From c47880be37eecbcdf6bee8902a07ed8190cb7b4a Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 10 Sep 2022 00:29:33 +0800 Subject: [PATCH] Support sharing a reduced quality preview --- .../nkming/nc_photos/ShareChannelHandler.kt | 17 +++++- .../app/src/main/res/xml/file_paths.xml | 1 + app/lib/cache_manager_util.dart | 1 + app/lib/l10n/app_en.arb | 13 ++-- app/lib/l10n/untranslated-messages.txt | 46 +++++++++++++- app/lib/share_handler.dart | 60 ++++++++++++++++++- app/lib/use_case/download_preview.dart | 26 ++++++++ app/lib/widget/share_method_dialog.dart | 25 ++++++-- .../plugin/ContentUriChannelHandler.kt | 23 +++++++ plugin/lib/src/content_uri.dart | 6 ++ 10 files changed, 202 insertions(+), 16 deletions(-) create mode 100644 app/lib/use_case/download_preview.dart diff --git a/app/android/app/src/main/kotlin/com/nkming/nc_photos/ShareChannelHandler.kt b/app/android/app/src/main/kotlin/com/nkming/nc_photos/ShareChannelHandler.kt index d2be2e97..6ed0a1c2 100644 --- a/app/android/app/src/main/kotlin/com/nkming/nc_photos/ShareChannelHandler.kt +++ b/app/android/app/src/main/kotlin/com/nkming/nc_photos/ShareChannelHandler.kt @@ -1,6 +1,7 @@ package com.nkming.nc_photos import android.app.Activity +import android.content.ClipData import android.content.Intent import android.net.Uri import io.flutter.plugin.common.MethodCall @@ -54,14 +55,26 @@ class ShareChannelHandler(activity: Activity) : val shareIntent = if (uris.size == 1) Intent().apply { action = Intent.ACTION_SEND putExtra(Intent.EXTRA_STREAM, uris[0]) + // setting clipdata is needed for FLAG_GRANT_READ_URI_PERMISSION to + // work, see: https://developer.android.com/reference/android/content/Intent#ACTION_SEND + clipData = + ClipData.newUri(_context.contentResolver, "Share", uris[0]) type = mimeTypes[0] ?: "*/*" addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) } else Intent().apply { action = Intent.ACTION_SEND_MULTIPLE putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris)) - type = - if (mimeTypes.all { it?.startsWith("image/") == true }) "image/*" else "*/*" + clipData = + ClipData.newUri(_context.contentResolver, "Share", uris[0]) + .apply { + for (uri in uris.subList(1, uris.size)) { + addItem(ClipData.Item(uri)) + } + } + type = if (mimeTypes.all { + it?.startsWith("image/") == true + }) "image/*" else "*/*" addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) } diff --git a/app/android/app/src/main/res/xml/file_paths.xml b/app/android/app/src/main/res/xml/file_paths.xml index dfb9258a..567fa808 100644 --- a/app/android/app/src/main/res/xml/file_paths.xml +++ b/app/android/app/src/main/res/xml/file_paths.xml @@ -1,4 +1,5 @@ + diff --git a/app/lib/cache_manager_util.dart b/app/lib/cache_manager_util.dart index 2c911dcb..d98bbc1d 100644 --- a/app/lib/cache_manager_util.dart +++ b/app/lib/cache_manager_util.dart @@ -51,6 +51,7 @@ class ThumbnailCacheManager { /// very large in size. Since large images are only viewed one by one (unlike /// thumbnails), they are less critical to the overall app responsiveness class LargeImageCacheManager { + // used in file_paths.xml, must not change static const key = "largeImageCache"; static CacheManager inst = CacheManager( Config( diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 31aaa44a..ebabd151 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -906,11 +906,16 @@ "@shareMethodDialogTitle": { "description": "Let the user pick how they want to share" }, - "shareMethodFileTitle": "File", - "@shareMethodFileTitle": { - "description": "Share the actual file" + "shareMethodPreviewTitle": "Preview", + "@shareMethodPreviewTitle": { + "description": "Share the preview of a file" }, - "shareMethodFileDescription": "Download the file and share it to other apps", + "shareMethodPreviewDescription": "Share a reduced quality preview to other apps (only support images)", + "shareMethodOriginalFileTitle": "Original file", + "@shareMethodOriginalFileTitle": { + "description": "Share the original file" + }, + "shareMethodOriginalFileDescription": "Download the original file and share it to other apps", "shareMethodPublicLinkTitle": "Public link", "@shareMethodPublicLinkTitle": { "description": "Create a share link on server and share it" diff --git a/app/lib/l10n/untranslated-messages.txt b/app/lib/l10n/untranslated-messages.txt index ea179029..6fd6c4e3 100644 --- a/app/lib/l10n/untranslated-messages.txt +++ b/app/lib/l10n/untranslated-messages.txt @@ -41,6 +41,10 @@ "sortOptionFilenameDescendingLabel", "sortOptionManualLabel", "helpButtonLabel", + "shareMethodPreviewTitle", + "shareMethodPreviewDescription", + "shareMethodOriginalFileTitle", + "shareMethodOriginalFileDescription", "collectionSharingLabel", "fileLastSharedDescription", "fileLastSharedByOthersDescription", @@ -210,8 +214,10 @@ "slideshowSetupDialogRepeatTitle", "linkCopiedNotification", "shareMethodDialogTitle", - "shareMethodFileTitle", - "shareMethodFileDescription", + "shareMethodPreviewTitle", + "shareMethodPreviewDescription", + "shareMethodOriginalFileTitle", + "shareMethodOriginalFileDescription", "shareMethodPublicLinkTitle", "shareMethodPublicLinkDescription", "shareMethodPasswordLinkTitle", @@ -346,6 +352,10 @@ "settingsMemoriesRangeTitle", "settingsMemoriesRangeValueText", "settingsDoubleTapExitTitle", + "shareMethodPreviewTitle", + "shareMethodPreviewDescription", + "shareMethodOriginalFileTitle", + "shareMethodOriginalFileDescription", "enhanceStyleTransferStyleDialogTitle", "doubleTapExitNotification", "imageEditDiscardDialogTitle", @@ -397,6 +407,10 @@ "settingsMemoriesRangeTitle", "settingsMemoriesRangeValueText", "rootPickerSkipConfirmationDialogContent2", + "shareMethodPreviewTitle", + "shareMethodPreviewDescription", + "shareMethodOriginalFileTitle", + "shareMethodOriginalFileDescription", "imageEditToolbarColorLabel", "imageEditToolbarTransformLabel", "imageEditTransformOrientation", @@ -415,6 +429,10 @@ "settingsPhotosPageTitle", "settingsMemoriesRangeTitle", "settingsMemoriesRangeValueText", + "shareMethodPreviewTitle", + "shareMethodPreviewDescription", + "shareMethodOriginalFileTitle", + "shareMethodOriginalFileDescription", "imageEditToolbarColorLabel", "imageEditToolbarTransformLabel", "imageEditTransformOrientation", @@ -451,6 +469,10 @@ "helpTooltip", "helpButtonLabel", "removeFromAlbumTooltip", + "shareMethodPreviewTitle", + "shareMethodPreviewDescription", + "shareMethodOriginalFileTitle", + "shareMethodOriginalFileDescription", "enhanceTooltip", "enhanceButtonLabel", "enhanceIntroDialogTitle", @@ -527,6 +549,10 @@ "settingsPhotosTabSortByNameTitle", "sortOptionFilenameAscendingLabel", "sortOptionFilenameDescendingLabel", + "shareMethodPreviewTitle", + "shareMethodPreviewDescription", + "shareMethodOriginalFileTitle", + "shareMethodOriginalFileDescription", "createCollectionTooltip", "createCollectionDialogAlbumLabel", "createCollectionDialogAlbumDescription", @@ -624,6 +650,10 @@ "settingsPhotosTabSortByNameTitle", "sortOptionFilenameAscendingLabel", "sortOptionFilenameDescendingLabel", + "shareMethodPreviewTitle", + "shareMethodPreviewDescription", + "shareMethodOriginalFileTitle", + "shareMethodOriginalFileDescription", "enhanceTooltip", "enhanceButtonLabel", "enhanceIntroDialogTitle", @@ -700,6 +730,10 @@ "settingsPhotosTabSortByNameTitle", "sortOptionFilenameAscendingLabel", "sortOptionFilenameDescendingLabel", + "shareMethodPreviewTitle", + "shareMethodPreviewDescription", + "shareMethodOriginalFileTitle", + "shareMethodOriginalFileDescription", "enhanceTooltip", "enhanceButtonLabel", "enhanceIntroDialogTitle", @@ -776,6 +810,10 @@ "settingsPhotosTabSortByNameTitle", "sortOptionFilenameAscendingLabel", "sortOptionFilenameDescendingLabel", + "shareMethodPreviewTitle", + "shareMethodPreviewDescription", + "shareMethodOriginalFileTitle", + "shareMethodOriginalFileDescription", "enhanceTooltip", "enhanceButtonLabel", "enhanceIntroDialogTitle", @@ -852,6 +890,10 @@ "settingsPhotosTabSortByNameTitle", "sortOptionFilenameAscendingLabel", "sortOptionFilenameDescendingLabel", + "shareMethodPreviewTitle", + "shareMethodPreviewDescription", + "shareMethodOriginalFileTitle", + "shareMethodOriginalFileDescription", "enhanceTooltip", "enhanceButtonLabel", "enhanceIntroDialogTitle", diff --git a/app/lib/share_handler.dart b/app/lib/share_handler.dart index 84373dcf..5848a9b5 100644 --- a/app/lib/share_handler.dart +++ b/app/lib/share_handler.dart @@ -10,6 +10,7 @@ import 'package:nc_photos/app_localizations.dart'; import 'package:nc_photos/debug_util.dart'; import 'package:nc_photos/di_container.dart'; import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/entity/local_file.dart'; import 'package:nc_photos/entity/share.dart'; import 'package:nc_photos/entity/share/data_source.dart'; @@ -24,6 +25,7 @@ import 'package:nc_photos/use_case/copy.dart'; import 'package:nc_photos/use_case/create_dir.dart'; import 'package:nc_photos/use_case/create_share.dart'; import 'package:nc_photos/use_case/download_file.dart'; +import 'package:nc_photos/use_case/download_preview.dart'; import 'package:nc_photos/use_case/share_local.dart'; import 'package:nc_photos/widget/processing_dialog.dart'; import 'package:nc_photos/widget/share_link_multiple_files_dialog.dart'; @@ -67,7 +69,7 @@ class ShareHandler { Future shareFiles(Account account, List files) async { try { - final method = await _askShareMethod(); + final method = await _askShareMethod(files); if (method == null) { // user canceled return; @@ -75,6 +77,8 @@ class ShareHandler { return await _shareAsLink(account, files, false); } else if (method == ShareMethod.passwordLink) { return await _shareAsLink(account, files, true); + } else if (method == ShareMethod.preview) { + return await _shareAsPreview(account, files); } else { return await _shareAsFile(account, files); } @@ -91,9 +95,59 @@ class ShareHandler { } } - Future _askShareMethod() { + Future _askShareMethod(List files) { return showDialog( - context: context, builder: (context) => const ShareMethodDialog()); + context: context, + builder: (context) => ShareMethodDialog( + isSupportPerview: files.any((f) => file_util.isSupportedImageFormat(f)), + ), + ); + } + + Future _shareAsPreview(Account account, List files) async { + assert(platform_k.isAndroid); + final controller = StreamController(); + unawaited( + showDialog( + context: context, + builder: (context) => StreamBuilder( + stream: controller.stream, + builder: (context, snapshot) => ProcessingDialog( + text: L10n.global().shareDownloadingDialogContent + + (snapshot.hasData ? " ${snapshot.data}" : ""), + ), + ), + ), + ); + final results = >[]; + for (final pair in files.withIndex()) { + final i = pair.item1, f = pair.item2; + controller.add("($i/${files.length})"); + try { + final dynamic uri; + if (file_util.isSupportedImageFormat(f) && + f.contentType != "image/gif") { + uri = await DownloadPreview()(account, f); + } else { + uri = await DownloadFile()(account, f); + } + results.add(Tuple2(f, uri)); + } catch (e, stacktrace) { + _log.shout( + "[_shareAsPreview] Failed while DownloadPreview", e, stacktrace); + SnackBarManager().showSnackBar(SnackBar( + content: Text(exception_util.toUserString(e)), + duration: k.snackBarDurationNormal, + )); + } + } + // dismiss the dialog + Navigator.of(context).pop(); + + final share = AndroidFileShare( + results.map((e) => e.item2 as String).toList(), + results.map((e) => e.item1.contentType).toList()); + return share.share(); } Future _shareAsFile(Account account, List files) async { diff --git a/app/lib/use_case/download_preview.dart b/app/lib/use_case/download_preview.dart new file mode 100644 index 00000000..9cba41f1 --- /dev/null +++ b/app/lib/use_case/download_preview.dart @@ -0,0 +1,26 @@ +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/api/api.dart'; +import 'package:nc_photos/api/api_util.dart' as api_util; +import 'package:nc_photos/cache_manager_util.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/k.dart' as k; +import 'package:nc_photos/platform/k.dart' as platform_k; +import 'package:nc_photos_plugin/nc_photos_plugin.dart'; + +class DownloadPreview { + Future call(Account account, File file) async { + assert(platform_k.isAndroid); + final previewUrl = api_util.getFilePreviewUrl( + account, + file, + width: k.photoLargeSize, + height: k.photoLargeSize, + a: true, + ); + final fileInfo = + await LargeImageCacheManager.inst.getSingleFile(previewUrl, headers: { + "authorization": Api.getAuthorizationHeaderValue(account), + }); + return ContentUri.getUriForFile(fileInfo.absolute.path); + } +} diff --git a/app/lib/widget/share_method_dialog.dart b/app/lib/widget/share_method_dialog.dart index 660a982f..99f260be 100644 --- a/app/lib/widget/share_method_dialog.dart +++ b/app/lib/widget/share_method_dialog.dart @@ -4,30 +4,43 @@ import 'package:nc_photos/platform/k.dart' as platform_k; enum ShareMethod { file, + preview, publicLink, passwordLink, } class ShareMethodDialog extends StatelessWidget { const ShareMethodDialog({ - Key? key, - }) : super(key: key); + super.key, + required this.isSupportPerview, + }); @override build(BuildContext context) { return SimpleDialog( title: Text(L10n.global().shareMethodDialogTitle), children: [ - if (platform_k.isAndroid) + if (platform_k.isAndroid) ...[ + if (isSupportPerview) + SimpleDialogOption( + child: ListTile( + title: Text(L10n.global().shareMethodPreviewTitle), + subtitle: Text(L10n.global().shareMethodPreviewDescription), + ), + onPressed: () { + Navigator.of(context).pop(ShareMethod.preview); + }, + ), SimpleDialogOption( child: ListTile( - title: Text(L10n.global().shareMethodFileTitle), - subtitle: Text(L10n.global().shareMethodFileDescription), + title: Text(L10n.global().shareMethodOriginalFileTitle), + subtitle: Text(L10n.global().shareMethodOriginalFileDescription), ), onPressed: () { Navigator.of(context).pop(ShareMethod.file); }, ), + ], SimpleDialogOption( child: ListTile( title: Text(L10n.global().shareMethodPublicLinkTitle), @@ -49,4 +62,6 @@ class ShareMethodDialog extends StatelessWidget { ], ); } + + final bool isSupportPerview; } diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ContentUriChannelHandler.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ContentUriChannelHandler.kt index f04c0107..3bea17cb 100644 --- a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ContentUriChannelHandler.kt +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/ContentUriChannelHandler.kt @@ -2,8 +2,10 @@ package com.nkming.nc_photos.plugin import android.content.Context import android.net.Uri +import androidx.core.content.FileProvider import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel +import java.io.File import java.io.FileNotFoundException class ContentUriChannelHandler(context: Context) : @@ -24,6 +26,14 @@ class ContentUriChannelHandler(context: Context) : } } + "getUriForFile" -> { + try { + getUriForFile(call.argument("filePath")!!, result) + } catch (e: Throwable) { + result.error("systemException", e.toString(), null) + } + } + else -> result.notImplemented() } } @@ -46,5 +56,18 @@ class ContentUriChannelHandler(context: Context) : } } + private fun getUriForFile(filePath: String, result: MethodChannel.Result) { + try { + val file = File(filePath) + val contentUri = FileProvider.getUriForFile( + context, "${context.packageName}.fileprovider", file + ) + result.success(contentUri.toString()) + } catch (e: IllegalArgumentException) { + logE(TAG, "[getUriForFile] Unsupported file path: $filePath") + throw e + } + } + private val context = context } diff --git a/plugin/lib/src/content_uri.dart b/plugin/lib/src/content_uri.dart index 74c953ea..78111f62 100644 --- a/plugin/lib/src/content_uri.dart +++ b/plugin/lib/src/content_uri.dart @@ -20,6 +20,12 @@ class ContentUri { } } + static Future getUriForFile(String filePath) async { + return await _methodChannel.invokeMethod("getUriForFile", { + "filePath": filePath, + }); + } + static const _methodChannel = MethodChannel("${k.libId}/content_uri_method"); static const _exceptionFileNotFound = "fileNotFoundException";