From db78210a37a9b042a7d6148e57ed28ca2a6224b6 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sat, 27 Nov 2021 16:58:36 +0900 Subject: [PATCH] #136 hidden paths: select directory with custom picker instead of SAF one --- .../deckers/thibault/aves/MainActivity.kt | 8 +- .../streams/StorageAccessStreamHandler.kt | 21 -- lib/l10n/app_en.arb | 13 +- lib/l10n/app_fr.arb | 8 +- lib/l10n/app_ko.arb | 8 +- lib/l10n/app_ru.arb | 8 +- lib/model/settings/defaults.dart | 3 + lib/model/settings/settings.dart | 10 + lib/services/storage_service.dart | 23 -- lib/theme/icons.dart | 2 + .../privacy/file_picker/crumb_line.dart | 107 +++++++++ .../privacy/file_picker/file_picker.dart | 203 ++++++++++++++++++ .../settings/privacy/hidden_items.dart | 14 +- 13 files changed, 373 insertions(+), 55 deletions(-) create mode 100644 lib/widgets/settings/privacy/file_picker/crumb_line.dart create mode 100644 lib/widgets/settings/privacy/file_picker/file_picker.dart diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt index fb18fd93d..7e5871641 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt @@ -151,8 +151,7 @@ class MainActivity : FlutterActivity() { DELETE_SINGLE_PERMISSION_REQUEST, MEDIA_WRITE_BULK_PERMISSION_REQUEST -> onScopedStoragePermissionResult(resultCode) CREATE_FILE_REQUEST, - OPEN_FILE_REQUEST, - SELECT_DIRECTORY_REQUEST -> onStorageAccessResult(requestCode, data?.data) + OPEN_FILE_REQUEST -> onStorageAccessResult(requestCode, data?.data) } } @@ -290,9 +289,8 @@ class MainActivity : FlutterActivity() { const val OPEN_FROM_ANALYSIS_SERVICE = 2 const val CREATE_FILE_REQUEST = 3 const val OPEN_FILE_REQUEST = 4 - const val SELECT_DIRECTORY_REQUEST = 5 - const val DELETE_SINGLE_PERMISSION_REQUEST = 6 - const val MEDIA_WRITE_BULK_PERMISSION_REQUEST = 7 + const val DELETE_SINGLE_PERMISSION_REQUEST = 5 + const val MEDIA_WRITE_BULK_PERMISSION_REQUEST = 6 const val INTENT_DATA_KEY_ACTION = "action" const val INTENT_DATA_KEY_FILTERS = "filters" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt index 969423c40..9df3d5169 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt @@ -11,7 +11,6 @@ import deckers.thibault.aves.MainActivity import deckers.thibault.aves.PendingStorageAccessResultHandler import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.PermissionManager -import deckers.thibault.aves.utils.StorageUtils import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel.EventSink import kotlinx.coroutines.Dispatchers @@ -44,7 +43,6 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any? "requestMediaFileAccess" -> GlobalScope.launch(Dispatchers.IO) { requestMediaFileAccess() } "createFile" -> GlobalScope.launch(Dispatchers.IO) { createFile() } "openFile" -> GlobalScope.launch(Dispatchers.IO) { openFile() } - "selectDirectory" -> GlobalScope.launch(Dispatchers.IO) { selectDirectory() } else -> endOfStream() } } @@ -158,25 +156,6 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any? activity.startActivityForResult(intent, MainActivity.OPEN_FILE_REQUEST) } - private fun selectDirectory() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) - - MainActivity.pendingStorageAccessResultHandlers[MainActivity.SELECT_DIRECTORY_REQUEST] = PendingStorageAccessResultHandler(null, { uri -> - success(StorageUtils.convertTreeUriToDirPath(activity, uri)) - endOfStream() - }, { - success(null) - endOfStream() - }) - activity.startActivityForResult(intent, MainActivity.SELECT_DIRECTORY_REQUEST) - } else { - // TODO TLAD support KitKat - success(null) - endOfStream() - } - } - override fun onCancel(arguments: Any?) {} private fun success(result: Any?) { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 30118f540..8ac9fddae 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1064,5 +1064,16 @@ "@panoramaDisableSensorControl": {}, "sourceViewerPageTitle": "Source", - "@sourceViewerPageTitle": {} + "@sourceViewerPageTitle": {}, + + "filePickerShowHiddenFiles": "Show hidden files", + "@filePickerShowHiddenFiles": {}, + "filePickerDoNotShowHiddenFiles": "Don’t show hidden files", + "@filePickerDoNotShowHiddenFiles": {}, + "filePickerOpenFrom": "Open from", + "@filePickerOpenFrom": {}, + "filePickerNoItems": "No items", + "@filePickerNoItems": {}, + "filePickerUseThisFolder": "Use this folder", + "@filePickerUseThisFolder": {} } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 80fdbc987..4ebb4930f 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -513,5 +513,11 @@ "panoramaDisableSensorControl": "Désactiver le contrôle par capteurs", "sourceViewerPageTitle": "Code source", - "@sourceViewerPageTitle": {} + "@sourceViewerPageTitle": {}, + + "filePickerShowHiddenFiles": "Afficher les fichiers masqués", + "filePickerDoNotShowHiddenFiles": "Ne pas afficher les fichiers masqués", + "filePickerOpenFrom": "Ouvrir à partir de", + "filePickerNoItems": "Aucun élément", + "filePickerUseThisFolder": "Utiliser ce dossier" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index cea923f45..6cc8f05bb 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -508,5 +508,11 @@ "panoramaEnableSensorControl": "센서 제어 활성화", "panoramaDisableSensorControl": "센서 제어 비활성화", - "sourceViewerPageTitle": "소스 코드" + "sourceViewerPageTitle": "소스 코드", + + "filePickerShowHiddenFiles": "숨겨진 파일 표시", + "filePickerDoNotShowHiddenFiles": "숨겨진 파일 표시 안함", + "filePickerOpenFrom": "다음에서 열기:", + "filePickerNoItems": "항목 없음", + "filePickerUseThisFolder": "이 폴더 사용" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 0c7bd51c4..07442dbf1 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -503,5 +503,11 @@ "panoramaEnableSensorControl": "Включить сенсорное управление", "panoramaDisableSensorControl": "Отключить сенсорное управление", - "sourceViewerPageTitle": "Источник" + "sourceViewerPageTitle": "Источник", + + "filePickerShowHiddenFiles": "Показывать скрытые файлы", + "filePickerDoNotShowHiddenFiles": "Не показывать скрытые файлы", + "filePickerOpenFrom": "Открыть", + "filePickerNoItems": "Ничего нет.", + "filePickerUseThisFolder": "Использовать эту папку" } diff --git a/lib/model/settings/defaults.dart b/lib/model/settings/defaults.dart index 7f138b165..b7f37b315 100644 --- a/lib/model/settings/defaults.dart +++ b/lib/model/settings/defaults.dart @@ -99,4 +99,7 @@ class SettingsDefaults { // accessibility static const accessibilityAnimations = AccessibilityAnimations.system; static const timeToTakeAction = AccessibilityTimeout.appDefault; // `timeToTakeAction` has a contextual default value + + // file picker + static const filePickerShowHiddenFiles = false; } diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index 19f19585e..a70d23ad2 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -117,6 +117,9 @@ class Settings extends ChangeNotifier { // version static const lastVersionCheckDateKey = 'last_version_check_date'; + // file picker + static const filePickerShowHiddenFilesKey = 'file_picker_show_hidden_files'; + // platform settings // cf Android `Settings.System.ACCELEROMETER_ROTATION` static const platformAccelerometerRotationKey = 'accelerometer_rotation'; @@ -451,6 +454,12 @@ class Settings extends ChangeNotifier { set lastVersionCheckDate(DateTime newValue) => setAndNotify(lastVersionCheckDateKey, newValue.millisecondsSinceEpoch); + // file picker + + bool get filePickerShowHiddenFiles => getBoolOrDefault(filePickerShowHiddenFilesKey, SettingsDefaults.filePickerShowHiddenFiles); + + set filePickerShowHiddenFiles(bool newValue) => setAndNotify(filePickerShowHiddenFilesKey, newValue); + // convenience methods // ignore: avoid_positional_boolean_parameters @@ -597,6 +606,7 @@ class Settings extends ChangeNotifier { case enableVideoAutoPlayKey: case subtitleShowOutlineKey: case saveSearchHistoryKey: + case filePickerShowHiddenFilesKey: if (value is bool) { _prefs!.setBool(key, value); } else { diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 529623a39..e611679b6 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -37,8 +37,6 @@ abstract class StorageService { Future createFile(String name, String mimeType, Uint8List bytes); Future openFile(String mimeType); - - Future selectDirectory(); } class PlatformStorageService implements StorageService { @@ -255,25 +253,4 @@ class PlatformStorageService implements StorageService { } return Uint8List(0); } - - @override - Future selectDirectory() async { - try { - final completer = Completer(); - storageAccessChannel.receiveBroadcastStream({ - 'op': 'selectDirectory', - }).listen( - (data) => completer.complete(data as String?), - onError: completer.completeError, - onDone: () { - if (!completer.isCompleted) completer.complete(null); - }, - cancelOnError: true, - ); - return completer.future; - } on PlatformException catch (e, stack) { - await reportService.recordError(e, stack); - } - return null; - } } diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index daed92698..b54225f18 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -14,11 +14,13 @@ class AIcons { static const IconData date = Icons.calendar_today_outlined; static const IconData disc = Icons.fiber_manual_record; static const IconData error = Icons.error_outline; + static const IconData folder = Icons.folder_outlined; static const IconData grid = Icons.grid_on_outlined; static const IconData home = Icons.home_outlined; static const IconData language = Icons.translate_outlined; static const IconData location = Icons.place_outlined; static const IconData locationOff = Icons.location_off_outlined; + static const IconData mainStorage = Icons.smartphone_outlined; static const IconData privacy = MdiIcons.shieldAccountOutline; static const IconData raw = Icons.raw_on_outlined; static const IconData shooting = Icons.camera_outlined; diff --git a/lib/widgets/settings/privacy/file_picker/crumb_line.dart b/lib/widgets/settings/privacy/file_picker/crumb_line.dart new file mode 100644 index 000000000..7889ce085 --- /dev/null +++ b/lib/widgets/settings/privacy/file_picker/crumb_line.dart @@ -0,0 +1,107 @@ +import 'package:aves/theme/icons.dart'; +import 'package:aves/utils/android_file_utils.dart'; +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as p; + +class CrumbLine extends StatefulWidget { + final VolumeRelativeDirectory directory; + final void Function(String path) onTap; + + const CrumbLine({ + Key? key, + required this.directory, + required this.onTap, + }) : super(key: key); + + @override + _CrumbLineState createState() => _CrumbLineState(); +} + +class _CrumbLineState extends State { + final ScrollController _controller = ScrollController(); + + VolumeRelativeDirectory get directory => widget.directory; + + @override + void didUpdateWidget(covariant CrumbLine oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.directory.relativeDir.length > oldWidget.directory.relativeDir.length) { + // scroll to show last crumb + WidgetsBinding.instance!.addPostFrameCallback((_) { + final extent = _controller.position.maxScrollExtent; + _controller.animateTo( + extent, + duration: const Duration(milliseconds: 500), + curve: Curves.easeOutQuad, + ); + }); + } + } + + @override + Widget build(BuildContext context) { + List parts = [ + directory.getVolumeDescription(context), + ...p.split(directory.relativeDir), + ]; + final crumbStyle = Theme.of(context).textTheme.bodyText2; + final crumbColor = crumbStyle!.color!.withOpacity(.4); + return DefaultTextStyle( + style: crumbStyle.copyWith( + color: crumbColor, + fontWeight: FontWeight.w500, + ), + child: ListView.builder( + scrollDirection: Axis.horizontal, + controller: _controller, + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 8), + itemBuilder: (context, index) { + Widget _buildText(String text) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text(text), + ); + + if (index >= parts.length) return const SizedBox(); + final text = parts[index]; + if (index == parts.length - 1) { + return Center( + child: DefaultTextStyle.merge( + style: TextStyle( + color: Theme.of(context).colorScheme.secondary, + ), + child: _buildText(text), + ), + ); + } + return GestureDetector( + onTap: () { + final path = p.joinAll([ + directory.volumePath, + ...parts.skip(1).take(index), + ]); + widget.onTap(path); + }, + child: Container( + // use a `Container` with a dummy color to make it expand + // so that we can also detect taps around the title `Text` + color: Colors.transparent, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _buildText(text), + Icon( + AIcons.next, + color: crumbColor, + ), + ], + ), + ), + ); + }, + itemCount: parts.length, + ), + ); + } +} diff --git a/lib/widgets/settings/privacy/file_picker/file_picker.dart b/lib/widgets/settings/privacy/file_picker/file_picker.dart new file mode 100644 index 000000000..6a0eb82c9 --- /dev/null +++ b/lib/widgets/settings/privacy/file_picker/file_picker.dart @@ -0,0 +1,203 @@ +import 'dart:io'; + +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/widgets/common/basic/menu.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/buttons.dart'; +import 'package:aves/widgets/common/identity/empty.dart'; +import 'package:aves/widgets/settings/privacy/file_picker/crumb_line.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:path/path.dart' as p; + +class FilePicker extends StatefulWidget { + static const routeName = '/file_picker'; + + const FilePicker({Key? key}) : super(key: key); + + @override + _FilePickerState createState() => _FilePickerState(); +} + +class _FilePickerState extends State { + late VolumeRelativeDirectory _directory; + List? _contents; + + Set get volumes => androidFileUtils.storageVolumes; + + String get currentDirectoryPath => p.join(_directory.volumePath, _directory.relativeDir); + + @override + void initState() { + super.initState(); + final primaryVolume = volumes.firstWhere((v) => v.isPrimary); + _goTo(primaryVolume.path); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final showHidden = settings.filePickerShowHiddenFiles; + final visibleContents = _contents?.where((v) { + if (showHidden) { + return true; + } else { + final isHidden = p.split(v.path).last.startsWith('.'); + return !isHidden; + } + }).toList(); + return WillPopScope( + onWillPop: () { + if (_directory.relativeDir.isEmpty) { + return SynchronousFuture(true); + } + final parent = p.dirname(currentDirectoryPath); + _goTo(parent); + setState(() {}); + return SynchronousFuture(false); + }, + child: Scaffold( + appBar: AppBar( + title: Text(_getTitle(context)), + actions: [ + MenuIconTheme( + child: PopupMenuButton<_PickerAction>( + itemBuilder: (context) { + return [ + PopupMenuItem( + value: _PickerAction.toggleHiddenView, + child: MenuRow(text: showHidden ? l10n.filePickerDoNotShowHiddenFiles : l10n.filePickerShowHiddenFiles), + ), + ]; + }, + onSelected: (action) async { + // wait for the popup menu to hide before proceeding with the action + await Future.delayed(Durations.popupMenuAnimation * timeDilation); + switch (action) { + case _PickerAction.toggleHiddenView: + settings.filePickerShowHiddenFiles = !showHidden; + setState(() {}); + break; + } + }, + ), + ), + ], + ), + drawer: _buildDrawer(context), + body: SafeArea( + child: Column( + children: [ + SizedBox( + height: kMinInteractiveDimension, + child: CrumbLine( + directory: _directory, + onTap: (path) { + _goTo(path); + setState(() {}); + }, + ), + ), + const Divider(height: 0), + Expanded( + child: visibleContents == null + ? const SizedBox() + : visibleContents.isEmpty + ? Center( + child: EmptyContent( + icon: AIcons.folder, + text: l10n.filePickerNoItems, + ), + ) + : ListView.builder( + itemCount: visibleContents.length, + itemBuilder: (context, index) { + return index < visibleContents.length ? _buildContentLine(context, visibleContents[index]) : const SizedBox(); + }, + ), + ), + const Divider(height: 0), + Padding( + padding: const EdgeInsets.all(8), + child: AvesOutlinedButton( + label: l10n.filePickerUseThisFolder, + onPressed: () => Navigator.pop(context, currentDirectoryPath), + ), + ), + ], + ), + ), + ), + ); + } + + String _getTitle(BuildContext context) { + if (_directory.relativeDir.isEmpty) { + return _directory.getVolumeDescription(context); + } + return p.split(_directory.relativeDir).last; + } + + Widget _buildDrawer(BuildContext context) { + return Drawer( + child: ListView( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text( + context.l10n.filePickerOpenFrom, + style: Theme.of(context).textTheme.headline5, + ), + ), + ...volumes.map((v) { + final icon = v.isRemovable ? AIcons.removableStorage : AIcons.mainStorage; + return ListTile( + leading: Icon(icon), + title: Text(v.getDescription(context)), + onTap: () async { + Navigator.pop(context); + await Future.delayed(Durations.drawerTransitionAnimation); + _goTo(v.path); + setState(() {}); + }, + selected: _directory.volumePath == v.path, + ); + }) + ], + ), + ); + } + + Widget _buildContentLine(BuildContext context, FileSystemEntity content) { + return ListTile( + leading: const Icon(AIcons.folder), + title: Text(p.split(content.path).last), + onTap: () { + _goTo(content.path); + setState(() {}); + }, + ); + } + + void _goTo(String path) { + _directory = VolumeRelativeDirectory.fromPath(path)!; + _contents = null; + final contents = []; + Directory(currentDirectoryPath).list().listen((event) { + final entity = event.absolute; + if (entity is Directory) { + contents.add(entity); + } + }, onDone: () { + _contents = contents..sort((a, b) => compareAsciiUpperCase(p.split(a.path).last, p.split(b.path).last)); + setState(() {}); + }); + } +} + +enum _PickerAction { toggleHiddenView } diff --git a/lib/widgets/settings/privacy/hidden_items.dart b/lib/widgets/settings/privacy/hidden_items.dart index 2fb295f99..48e5a6d19 100644 --- a/lib/widgets/settings/privacy/hidden_items.dart +++ b/lib/widgets/settings/privacy/hidden_items.dart @@ -2,14 +2,16 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/path.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_source.dart'; -import 'package:aves/services/common/services.dart'; +import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/identity/buttons.dart'; import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/settings/privacy/file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; @@ -164,7 +166,15 @@ class _HiddenPaths extends StatelessWidget { icon: const Icon(AIcons.add), label: context.l10n.addPathTooltip, onPressed: () async { - final path = await storageService.selectDirectory(); + final path = await Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: FilePicker.routeName), + builder: (context) => const FilePicker(), + ), + ); + // wait for the dialog to hide as applying the change may block the UI + await Future.delayed(Durations.pageTransitionAnimation * timeDilation); if (path != null && path.isNotEmpty) { context.read().changeFilterVisibility({PathFilter(path)}, false); }