diff --git a/CHANGELOG.md b/CHANGELOG.md index ec2bb2a24..0627fd23e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file. - Collection: thumbnail overlay tag icon - Collection: fast-scrolling shows breadcrumbs from groups - Settings: search +- Pick: allow selecting multiple items according to request intent - `huawei` app flavor (Petal Maps, no Crashlytics) ### Changed diff --git a/android/app/build.gradle b/android/app/build.gradle index 6ffdbec3c..fb84dde54 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -177,12 +177,12 @@ dependencies { android.productFlavors.each { flavor -> def tasks = gradle.startParameter.taskRequests.toString().toLowerCase() if (tasks.contains(flavor.name) && flavor.ext.useCrashlytics) { - println("Building flavor with Crashlytics [${flavor.name}] - applying plugin") + println("Building flavor [${flavor.name}] with Crashlytics plugin") apply plugin: 'com.google.gms.google-services' apply plugin: 'com.google.firebase.crashlytics' } if (tasks.contains(flavor.name) && flavor.ext.useHMS) { - println("Building flavor with HMS [${flavor.name}] - applying plugin") + println("Building flavor [${flavor.name}] with HMS plugin") apply plugin: 'com.huawei.agconnect' } } 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 75d1385a1..4093ed403 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt @@ -2,6 +2,7 @@ package deckers.thibault.aves import android.annotation.SuppressLint import android.app.SearchManager +import android.content.ClipData import android.content.Intent import android.net.Uri import android.os.Build @@ -16,7 +17,6 @@ import app.loup.streams_channel.StreamsChannel import deckers.thibault.aves.channel.calls.* import deckers.thibault.aves.channel.streams.* import deckers.thibault.aves.utils.LogUtils -import deckers.thibault.aves.utils.PermissionManager import io.flutter.embedding.android.FlutterActivity import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodCall @@ -222,6 +222,7 @@ class MainActivity : FlutterActivity() { return hashMapOf( INTENT_DATA_KEY_ACTION to INTENT_ACTION_PICK, INTENT_DATA_KEY_MIME_TYPE to intent.type, + INTENT_DATA_KEY_ALLOW_MULTIPLE to (intent.extras?.getBoolean(Intent.EXTRA_ALLOW_MULTIPLE) ?: false), ) } Intent.ACTION_SEARCH -> { @@ -246,10 +247,20 @@ class MainActivity : FlutterActivity() { } private fun pick(call: MethodCall) { - val pickedUri = call.argument("uri") - if (pickedUri != null) { + val pickedUris = call.argument>("uris") + if (pickedUris != null && pickedUris.isNotEmpty()) { + val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(context, Uri.parse(uriString)) } val intent = Intent().apply { - data = Uri.parse(pickedUri) + val firstUri = toUri(pickedUris.first()) + if (pickedUris.size == 1) { + data = firstUri + } else { + clipData = ClipData.newUri(contentResolver, null, firstUri).apply { + pickedUris.drop(1).forEach { + addItem(ClipData.Item(toUri(it))) + } + } + } addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } setResult(RESULT_OK, intent) @@ -307,6 +318,7 @@ class MainActivity : FlutterActivity() { const val INTENT_DATA_KEY_ACTION = "action" const val INTENT_DATA_KEY_FILTERS = "filters" const val INTENT_DATA_KEY_MIME_TYPE = "mimeType" + const val INTENT_DATA_KEY_ALLOW_MULTIPLE = "allowMultiple" const val INTENT_DATA_KEY_PAGE = "page" const val INTENT_DATA_KEY_URI = "uri" const val INTENT_DATA_KEY_QUERY = "query" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt index 195e9d4ab..9437745a3 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt @@ -216,7 +216,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { try { val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager if (clipboard != null) { - val clip = ClipData.newUri(context.contentResolver, label, getShareableUri(uri)) + val clip = ClipData.newUri(context.contentResolver, label, getShareableUri(context, uri)) clipboard.setPrimaryClip(clip) result.success(true) } else { @@ -239,7 +239,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { val intent = Intent(Intent.ACTION_EDIT) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) - .setDataAndType(getShareableUri(uri), mimeType) + .setDataAndType(getShareableUri(context, uri), mimeType) val started = safeStartActivityChooser(title, intent) result.success(started) @@ -256,7 +256,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { val intent = Intent(Intent.ACTION_VIEW) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - .setDataAndType(getShareableUri(uri), mimeType) + .setDataAndType(getShareableUri(context, uri), mimeType) val started = safeStartActivityChooser(title, intent) result.success(started) @@ -286,7 +286,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { val intent = Intent(Intent.ACTION_ATTACH_DATA) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - .setDataAndType(getShareableUri(uri), mimeType) + .setDataAndType(getShareableUri(context, uri), mimeType) val started = safeStartActivityChooser(title, intent) result.success(started) @@ -311,7 +311,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { val intent = Intent(Intent.ACTION_SEND) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .setType(mimeType) - .putExtra(Intent.EXTRA_STREAM, getShareableUri(uri)) + .putExtra(Intent.EXTRA_STREAM, getShareableUri(context, uri)) safeStartActivityChooser(title, intent) } else { var mimeType = "*/*" @@ -368,18 +368,6 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { return false } - private fun getShareableUri(uri: Uri): Uri? { - return when (uri.scheme?.lowercase(Locale.ROOT)) { - ContentResolver.SCHEME_FILE -> { - uri.path?.let { path -> - val authority = "${context.applicationContext.packageName}.file_provider" - FileProvider.getUriForFile(context, authority, File(path)) - } - } - else -> uri - } - } - // shortcuts private fun pinShortcut(call: MethodCall, result: MethodChannel.Result) { @@ -443,5 +431,17 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { companion object { private val LOG_TAG = LogUtils.createTag() const val CHANNEL = "deckers.thibault/aves/app" + + fun getShareableUri(context: Context, uri: Uri): Uri? { + return when (uri.scheme?.lowercase(Locale.ROOT)) { + ContentResolver.SCHEME_FILE -> { + uri.path?.let { path -> + val authority = "${context.applicationContext.packageName}.file_provider" + FileProvider.getUriForFile(context, authority, File(path)) + } + } + else -> uri + } + } } } \ No newline at end of file diff --git a/lib/app_mode.dart b/lib/app_mode.dart index 34f38a48b..70ed305da 100644 --- a/lib/app_mode.dart +++ b/lib/app_mode.dart @@ -1,11 +1,13 @@ -enum AppMode { main, pickMediaExternal, pickMediaInternal, pickFilterInternal, view } +enum AppMode { main, pickSingleMediaExternal, pickMultipleMediaExternal, pickMediaInternal, pickFilterInternal, view } extension ExtraAppMode on AppMode { - bool get canSearch => this == AppMode.main || this == AppMode.pickMediaExternal; + bool get canSearch => this == AppMode.main || this == AppMode.pickSingleMediaExternal || this == AppMode.pickMultipleMediaExternal; - bool get canSelect => this == AppMode.main; + bool get canSelectMedia => this == AppMode.main || this == AppMode.pickMultipleMediaExternal; - bool get hasDrawer => this == AppMode.main || this == AppMode.pickMediaExternal; + bool get canSelectFilter => this == AppMode.main; - bool get isPickingMedia => this == AppMode.pickMediaExternal || this == AppMode.pickMediaInternal; + bool get hasDrawer => this == AppMode.main || this == AppMode.pickSingleMediaExternal || this == AppMode.pickMultipleMediaExternal; + + bool get isPickingMedia => this == AppMode.pickSingleMediaExternal || this == AppMode.pickMultipleMediaExternal || this == AppMode.pickMediaInternal; } diff --git a/lib/services/viewer_service.dart b/lib/services/viewer_service.dart index 7956e3ae3..335bd7b71 100644 --- a/lib/services/viewer_service.dart +++ b/lib/services/viewer_service.dart @@ -15,10 +15,10 @@ class ViewerService { return {}; } - static Future pick(String uri) async { + static Future pick(List uris) async { try { await platform.invokeMethod('pick', { - 'uri': uri, + 'uris': uris, }); } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index c0e66ff4f..3bc9c645f 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -10,6 +10,7 @@ class AIcons { static const IconData accessibility = Icons.accessibility_new_outlined; static const IconData android = Icons.android; static const IconData app = Icons.apps_outlined; + static const IconData apply = Icons.done_outlined; static const IconData bin = Icons.delete_outlined; static const IconData broken = Icons.broken_image_outlined; static const IconData checked = Icons.done_outlined; diff --git a/lib/widgets/aves_app.dart b/lib/widgets/aves_app.dart index a06e74c9e..655268c5c 100644 --- a/lib/widgets/aves_app.dart +++ b/lib/widgets/aves_app.dart @@ -184,7 +184,8 @@ class _AvesAppState extends State with WidgetsBindingObserver { case AppLifecycleState.inactive: switch (appModeNotifier.value) { case AppMode.main: - case AppMode.pickMediaExternal: + case AppMode.pickSingleMediaExternal: + case AppMode.pickMultipleMediaExternal: _saveTopEntries(); break; case AppMode.pickMediaInternal: diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index f1a0087dd..2ef7861c9 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -250,7 +250,7 @@ class _CollectionAppBarState extends State with SingleTickerPr ...(isSelecting ? selectionMenuActions : browsingMenuActions).where(isVisible).map( (action) => _toMenuItem(action, enabled: canApply(action), selection: selection), ), - if (isSelecting && !isTrash) + if (isSelecting && !isTrash && appMode == AppMode.main) PopupMenuItem( enabled: canApplyEditActions, padding: EdgeInsets.zero, diff --git a/lib/widgets/collection/collection_grid.dart b/lib/widgets/collection/collection_grid.dart index 347e1acac..30a9c8da0 100644 --- a/lib/widgets/collection/collection_grid.dart +++ b/lib/widgets/collection/collection_grid.dart @@ -216,10 +216,9 @@ class _CollectionSectionedContentState extends State<_CollectionSectionedContent child: scrollView, ); - final isMainMode = context.select, bool>((vn) => vn.value == AppMode.main); final selector = GridSelectionGestureDetector( scrollableKey: scrollableKey, - selectable: isMainMode, + selectable: context.select, bool>((v) => v.value.canSelectMedia), items: collection.sortedEntries, scrollController: scrollController, appBarHeightNotifier: appBarHeightNotifier, diff --git a/lib/widgets/collection/collection_page.dart b/lib/widgets/collection/collection_page.dart index b057d89c6..3727d8b06 100644 --- a/lib/widgets/collection/collection_page.dart +++ b/lib/widgets/collection/collection_page.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:aves/app_mode.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/query.dart'; @@ -9,11 +10,14 @@ import 'package:aves/model/selection.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/services/viewer_service.dart'; import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/collection/collection_grid.dart'; import 'package:aves/widgets/common/basic/draggable_scrollbar.dart'; import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/behaviour/double_back_pop.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/providers/query_provider.dart'; import 'package:aves/widgets/common/providers/selection_provider.dart'; @@ -75,61 +79,78 @@ class _CollectionPageState extends State { @override Widget build(BuildContext context) { + final appMode = context.watch>().value; final liveFilter = _collection.filters.firstWhereOrNull((v) => v is QueryFilter && v.live) as QueryFilter?; return MediaQueryDataProvider( - child: Selector( - selector: (context, s) => s.showBottomNavigationBar, - builder: (context, showBottomNavigationBar, child) { - return NotificationListener( - onNotification: (notification) { - _draggableScrollBarEventStreamController.add(notification.event); - return false; - }, - child: Scaffold( - body: SelectionProvider( - child: QueryProvider( - initialQuery: liveFilter?.query, - child: Builder( - builder: (context) => WillPopScope( - onWillPop: () { - final selection = context.read>(); - if (selection.isSelecting) { - selection.browse(); - return SynchronousFuture(false); - } - return SynchronousFuture(true); - }, - child: DoubleBackPopScope( - child: GestureAreaProtectorStack( - child: SafeArea( - bottom: false, - child: ChangeNotifierProvider.value( - value: _collection, - child: const CollectionGrid( - // key is expected by test driver - key: Key('collection-grid'), - settingsRouteKey: CollectionPage.routeName, + child: SelectionProvider( + child: Selector, bool>( + selector: (context, selection) => selection.selectedItems.isNotEmpty, + builder: (context, hasSelection, child) { + return Selector( + selector: (context, s) => s.showBottomNavigationBar, + builder: (context, showBottomNavigationBar, child) { + return NotificationListener( + onNotification: (notification) { + _draggableScrollBarEventStreamController.add(notification.event); + return false; + }, + child: Scaffold( + body: QueryProvider( + initialQuery: liveFilter?.query, + child: Builder( + builder: (context) => WillPopScope( + onWillPop: () { + final selection = context.read>(); + if (selection.isSelecting) { + selection.browse(); + return SynchronousFuture(false); + } + return SynchronousFuture(true); + }, + child: DoubleBackPopScope( + child: GestureAreaProtectorStack( + child: SafeArea( + bottom: false, + child: ChangeNotifierProvider.value( + value: _collection, + child: const CollectionGrid( + // key is expected by test driver + key: Key('collection-grid'), + settingsRouteKey: CollectionPage.routeName, + ), + ), ), ), ), ), ), ), + floatingActionButton: appMode == AppMode.pickMultipleMediaExternal && hasSelection + ? FloatingActionButton( + tooltip: context.l10n.collectionPickPageTitle, + onPressed: () { + final items = context.read>().selectedItems; + final uris = items.map((entry) => entry.uri).toList(); + ViewerService.pick(uris); + }, + child: const Icon(AIcons.apply), + ) + : null, + drawer: AppDrawer(currentCollection: _collection), + bottomNavigationBar: showBottomNavigationBar + ? AppBottomNavBar( + events: _draggableScrollBarEventStreamController.stream, + currentCollection: _collection, + ) + : null, + resizeToAvoidBottomInset: false, + extendBody: true, ), - ), - ), - drawer: AppDrawer(currentCollection: _collection), - bottomNavigationBar: showBottomNavigationBar - ? AppBottomNavBar( - events: _draggableScrollBarEventStreamController.stream, - currentCollection: _collection, - ) - : null, - resizeToAvoidBottomInset: false, - extendBody: true, - ), - ); - }, + ); + }, + ); + }, + ), ), ); } diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index 57225c150..9467b3354 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -56,7 +56,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware case EntrySetAction.configureView: return true; case EntrySetAction.select: - return appMode.canSelect && !isSelecting; + return appMode.canSelectMedia && !isSelecting; case EntrySetAction.selectAll: return isSelecting && selectedItemCount < itemCount; case EntrySetAction.selectNone: @@ -69,7 +69,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware case EntrySetAction.addShortcut: return appMode == AppMode.main && !isSelecting && device.canPinShortcut && !isTrash; case EntrySetAction.emptyBin: - return isTrash; + return appMode == AppMode.main && isTrash; // browsing or selecting case EntrySetAction.map: case EntrySetAction.stats: diff --git a/lib/widgets/collection/grid/tile.dart b/lib/widgets/collection/grid/tile.dart index 637a2687a..bebcb6d70 100644 --- a/lib/widgets/collection/grid/tile.dart +++ b/lib/widgets/collection/grid/tile.dart @@ -43,8 +43,12 @@ class InteractiveTile extends StatelessWidget { _goToViewer(context); } break; - case AppMode.pickMediaExternal: - ViewerService.pick(entry.uri); + case AppMode.pickSingleMediaExternal: + ViewerService.pick([entry.uri]); + break; + case AppMode.pickMultipleMediaExternal: + final selection = context.read>(); + selection.toggleSelection(entry); break; case AppMode.pickMediaInternal: Navigator.pop(context, entry); diff --git a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart index cc94f0b1a..dbfd3fd88 100644 --- a/lib/widgets/filter_grids/common/action_delegates/chip_set.dart +++ b/lib/widgets/filter_grids/common/action_delegates/chip_set.dart @@ -52,7 +52,7 @@ abstract class ChipSetActionDelegate with FeedbackMi case ChipSetAction.configureView: return true; case ChipSetAction.select: - return appMode.canSelect && !isSelecting; + return appMode.canSelectFilter && !isSelecting; case ChipSetAction.selectAll: return isSelecting && selectedItemCount < itemCount; case ChipSetAction.selectNone: diff --git a/lib/widgets/filter_grids/common/filter_grid_page.dart b/lib/widgets/filter_grids/common/filter_grid_page.dart index b82954061..ad43876c1 100644 --- a/lib/widgets/filter_grids/common/filter_grid_page.dart +++ b/lib/widgets/filter_grids/common/filter_grid_page.dart @@ -401,10 +401,9 @@ class _FilterSectionedContentState extends State<_Fi child: scrollView, ); - final isMainMode = context.select, bool>((vn) => vn.value == AppMode.main); final selector = GridSelectionGestureDetector>( scrollableKey: scrollableKey, - selectable: isMainMode && widget.selectable, + selectable: context.select, bool>((v) => v.value.canSelectFilter) && widget.selectable, items: visibleSections.values.expand((v) => v).toList(), scrollController: scrollController, appBarHeightNotifier: appBarHeightNotifier, diff --git a/lib/widgets/filter_grids/common/filter_tile.dart b/lib/widgets/filter_grids/common/filter_tile.dart index 71b668f17..34ece3225 100644 --- a/lib/widgets/filter_grids/common/filter_tile.dart +++ b/lib/widgets/filter_grids/common/filter_tile.dart @@ -50,7 +50,8 @@ class _InteractiveFilterTileState extends State>().value; switch (appMode) { case AppMode.main: - case AppMode.pickMediaExternal: + case AppMode.pickSingleMediaExternal: + case AppMode.pickMultipleMediaExternal: final selection = context.read>>(); if (selection.isSelecting) { selection.toggleSelection(gridItem); diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index f605a1bc4..2d784dcea 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -95,11 +95,12 @@ class _HomePageState extends State { } break; case 'pick': - appMode = AppMode.pickMediaExternal; // TODO TLAD apply pick mimetype(s) // some apps define multiple types, separated by a space (maybe other signs too, like `,` `;`?) String? pickMimeTypes = intentData['mimeType']; - debugPrint('pick mimeType=$pickMimeTypes'); + final multiple = intentData['allowMultiple'] ?? false; + debugPrint('pick mimeType=$pickMimeTypes multiple=$multiple'); + appMode = multiple ? AppMode.pickMultipleMediaExternal : AppMode.pickSingleMediaExternal; break; case 'search': _shortcutRouteName = SearchPage.routeName; @@ -121,7 +122,8 @@ class _HomePageState extends State { switch (appMode) { case AppMode.main: - case AppMode.pickMediaExternal: + case AppMode.pickSingleMediaExternal: + case AppMode.pickMultipleMediaExternal: unawaited(GlobalSearch.registerCallback()); unawaited(AnalysisService.registerCallback()); final source = context.read(); @@ -226,11 +228,15 @@ class _HomePageState extends State { String routeName; Set? filters; - if (appMode == AppMode.pickMediaExternal) { - routeName = CollectionPage.routeName; - } else { - routeName = _shortcutRouteName ?? settings.homePage.routeName; - filters = (_shortcutFilters ?? {}).map(CollectionFilter.fromJson).toSet(); + switch (appMode) { + case AppMode.pickSingleMediaExternal: + case AppMode.pickMultipleMediaExternal: + routeName = CollectionPage.routeName; + break; + default: + routeName = _shortcutRouteName ?? settings.homePage.routeName; + filters = (_shortcutFilters ?? {}).map(CollectionFilter.fromJson).toSet(); + break; } final source = context.read(); switch (routeName) {