From 2832351710a48ef4873b0955cb588a2dfeab9a95 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 3 Dec 2020 21:25:26 +0900 Subject: [PATCH] info: open embedded GImage/GAudio/GDepth media --- .../aves/channel/calls/AppAdapterHandler.kt | 28 ++-- .../aves/channel/calls/DebugHandler.kt | 11 ++ .../aves/channel/calls/MetadataHandler.kt | 84 +++++++++++- .../deckers/thibault/aves/metadata/XMP.kt | 26 ++++ .../thibault/aves/model/SourceImageEntry.kt | 2 +- .../app/src/main/res/xml/provider_paths.xml | 6 + lib/model/image_entry.dart | 2 +- lib/ref/mime_types.dart | 4 + lib/ref/xmp.dart | 15 ++- lib/services/android_app_service.dart | 18 ++- lib/services/android_debug_service.dart | 10 ++ lib/services/metadata_service.dart | 15 +++ .../collection/entry_set_action_delegate.dart | 2 +- lib/widgets/debug/android_dirs.dart | 47 +++++++ lib/widgets/debug/app_debug_page.dart | 2 + .../fullscreen/entry_action_delegate.dart | 2 +- lib/widgets/fullscreen/fullscreen_page.dart | 2 +- lib/widgets/fullscreen/info/common.dart | 52 ++++++-- .../fullscreen/info/metadata/xmp_tile.dart | 121 +++++++++++++++--- 19 files changed, 392 insertions(+), 57 deletions(-) create mode 100644 lib/widgets/debug/android_dirs.dart 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 98f03e3fe..c134c5c16 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 @@ -153,7 +153,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(uri, mimeType) + .setDataAndType(getShareableUri(uri), mimeType) return safeStartActivityChooser(title, intent) } @@ -162,7 +162,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { val intent = Intent(Intent.ACTION_VIEW) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - .setDataAndType(uri, mimeType) + .setDataAndType(getShareableUri(uri), mimeType) return safeStartActivityChooser(title, intent) } @@ -178,7 +178,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { val intent = Intent(Intent.ACTION_ATTACH_DATA) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - .setDataAndType(uri, mimeType) + .setDataAndType(getShareableUri(uri), mimeType) return safeStartActivityChooser(title, intent) } @@ -186,15 +186,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { val intent = Intent(Intent.ACTION_SEND) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .setType(mimeType) - when (uri.scheme?.toLowerCase(Locale.ROOT)) { - ContentResolver.SCHEME_FILE -> { - val path = uri.path ?: return false - val applicationId = context.applicationContext.packageName - val apkUri = FileProvider.getUriForFile(context, "$applicationId.fileprovider", File(path)) - intent.putExtra(Intent.EXTRA_STREAM, apkUri) - } - else -> intent.putExtra(Intent.EXTRA_STREAM, uri) - } + .putExtra(Intent.EXTRA_STREAM, getShareableUri(uri)) return safeStartActivityChooser(title, intent) } @@ -251,6 +243,18 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { return false } + private fun getShareableUri(uri: Uri): Uri? { + return when (uri.scheme?.toLowerCase(Locale.ROOT)) { + ContentResolver.SCHEME_FILE -> { + uri.path?.let { path -> + val applicationId = context.applicationContext.packageName + FileProvider.getUriForFile(context, "$applicationId.fileprovider", File(path)) + } + } + else -> uri + } + } + companion object { private val LOG_TAG = createTag(AppAdapterHandler::class.java) const val CHANNEL = "deckers.thibault/aves/app" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt index b470f4875..dcdb1fecc 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt @@ -31,6 +31,7 @@ import java.util.* class DebugHandler(private val context: Context) : MethodCallHandler { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { + "getContextDirs" -> result.success(getContextDirs()) "getEnv" -> result.success(System.getenv()) "getBitmapFactoryInfo" -> GlobalScope.launch { getBitmapFactoryInfo(call, Coresult(result)) } "getContentResolverMetadata" -> GlobalScope.launch { getContentResolverMetadata(call, Coresult(result)) } @@ -41,6 +42,16 @@ class DebugHandler(private val context: Context) : MethodCallHandler { } } + private fun getContextDirs() = hashMapOf( + "dataDir" to context.dataDir, + "cacheDir" to context.cacheDir, + "codeCacheDir" to context.codeCacheDir, + "filesDir" to context.filesDir, + "noBackupFilesDir" to context.noBackupFilesDir, + "obbDir" to context.obbDir, + "externalCacheDir" to context.externalCacheDir, + ).mapValues { it.value?.path } + private fun getBitmapFactoryInfo(call: MethodCall, result: MethodChannel.Result) { val uri = call.argument("uri")?.let { Uri.parse(it) } if (uri == null) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index 8880ea2b2..3f42b6d31 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -42,11 +42,14 @@ import deckers.thibault.aves.metadata.MetadataExtractorHelper.isGeoTiff import deckers.thibault.aves.metadata.XMP import deckers.thibault.aves.metadata.XMP.getSafeDateMillis import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText +import deckers.thibault.aves.model.provider.FieldMap +import deckers.thibault.aves.model.provider.FileImageProvider +import deckers.thibault.aves.model.provider.ImageProvider import deckers.thibault.aves.utils.BitmapUtils import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes -import deckers.thibault.aves.utils.MimeTypes.TIFF +import deckers.thibault.aves.utils.MimeTypes.isImage import deckers.thibault.aves.utils.MimeTypes.isMultimedia import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor @@ -58,6 +61,7 @@ import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import java.io.File import java.util.* import kotlin.math.roundToLong @@ -70,6 +74,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { "getEmbeddedPictures" -> GlobalScope.launch { getEmbeddedPictures(call, Coresult(result)) } "getExifThumbnails" -> GlobalScope.launch { getExifThumbnails(call, Coresult(result)) } "getXmpThumbnails" -> GlobalScope.launch { getXmpThumbnails(call, Coresult(result)) } + "extractXmpDataProp" -> GlobalScope.launch { extractXmpDataProp(call, Coresult(result)) } else -> result.notImplemented() } } @@ -104,7 +109,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { metadataMap[dirName] = dirMap // tags - if (mimeType == TIFF && dir is ExifIFD0Directory) { + if (mimeType == MimeTypes.TIFF && dir is ExifIFD0Directory) { dirMap.putAll(dir.tags.map { val name = if (it.hasTagName()) { it.tagName @@ -118,13 +123,14 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { } if (dir is XmpDirectory) { try { - val xmpMeta = dir.xmpMeta.apply { sort() } - for (prop in xmpMeta) { + for (prop in dir.xmpMeta) { if (prop is XMPPropertyInfo) { val path = prop.path - val value = prop.value - if (path?.isNotEmpty() == true && value?.isNotEmpty() == true) { - dirMap[path] = value + if (path?.isNotEmpty() == true) { + val value = if (XMP.isDataPath(path)) "[skipped]" else prop.value + if (value?.isNotEmpty() == true) { + dirMap[path] = value + } } } } @@ -548,6 +554,70 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { result.success(thumbnails) } + private fun extractXmpDataProp(call: MethodCall, result: MethodChannel.Result) { + val mimeType = call.argument("mimeType") + val uri = call.argument("uri")?.let { Uri.parse(it) } + val sizeBytes = call.argument("sizeBytes")?.toLong() + val dataPropPath = call.argument("propPath") + if (mimeType == null || uri == null || dataPropPath == null) { + result.error("extractXmpDataProp-args", "failed because of missing arguments", null) + return + } + + if (isSupportedByMetadataExtractor(mimeType, sizeBytes)) { + try { + StorageUtils.openInputStream(context, uri)?.use { input -> + val metadata = ImageMetadataReader.readMetadata(input, sizeBytes ?: -1) + // data can be large and stored in "Extended XMP", + // which is returned as a second XMP directory + val xmpDirs = metadata.getDirectoriesOfType(XmpDirectory::class.java) + try { + val ns = XMP.namespaceForDataPath(dataPropPath) + val mimePropPath = XMP.mimeTypePathForDataPath(dataPropPath) + val embedMimeType = xmpDirs.map { it.xmpMeta.getPropertyString(ns, mimePropPath) }.first { it != null } + val embedBytes = xmpDirs.map { it.xmpMeta.getPropertyBase64(ns, dataPropPath) }.first { it != null } + val embedFile = File.createTempFile("aves", null, context.cacheDir).apply { + deleteOnExit() + outputStream().use { outputStream -> + embedBytes.inputStream().use { inputStream -> + inputStream.copyTo(outputStream) + } + } + } + val embedUri = Uri.fromFile(embedFile) + val embedFields: FieldMap = hashMapOf( + "uri" to embedUri.toString(), + "mimeType" to embedMimeType, + ) + if (isImage(embedMimeType) || isVideo(embedMimeType)) { + GlobalScope.launch { + FileImageProvider().fetchSingle(context, embedUri, embedMimeType, object : ImageProvider.ImageOpCallback { + override fun onSuccess(fields: FieldMap) { + embedFields.putAll(fields) + result.success(embedFields) + } + + override fun onFailure(throwable: Throwable) = result.error("extractXmpDataProp-failure", "failed to get entry for uri=$embedUri mime=$embedMimeType", throwable.message) + }) + } + } else { + result.success(embedFields) + } + return + } catch (e: XMPException) { + result.error("extractXmpDataProp-args", "failed to read XMP directory for uri=$uri prop=$dataPropPath", e.message) + return + } + } + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to extract file from XMP", e) + } catch (e: NoClassDefFoundError) { + Log.w(LOG_TAG, "failed to extract file from XMP", e) + } + } + result.error("extractXmpDataProp-empty", "failed to extract file from XMP uri=$uri prop=$dataPropPath", null) + } + companion object { private val LOG_TAG = LogUtils.createTag(MetadataHandler::class.java) const val CHANNEL = "deckers.thibault/aves/metadata" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt index 40ed49a7c..cd49d4ea2 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/XMP.kt @@ -12,15 +12,41 @@ object XMP { const val DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/" const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/" const val IMG_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/g/img/" + const val SUBJECT_PROP_NAME = "dc:subject" const val TITLE_PROP_NAME = "dc:title" const val DESCRIPTION_PROP_NAME = "dc:description" const val CREATE_DATE_PROP_NAME = "xmp:CreateDate" const val THUMBNAIL_PROP_NAME = "xmp:Thumbnails" const val THUMBNAIL_IMAGE_PROP_NAME = "xmpGImg:image" + private const val GENERIC_LANG = "" private const val SPECIFIC_LANG = "en-US" + // embedded media data properties + + private const val GAUDIO_SCHEMA_NS = "http://ns.google.com/photos/1.0/audio/" + private const val GDEPTH_SCHEMA_NS = "http://ns.google.com/photos/1.0/depthmap/" + private const val GIMAGE_SCHEMA_NS = "http://ns.google.com/photos/1.0/image/" + + private const val GAUDIO_DATA_PROP_NAME = "GAudio:Data" + private const val GDEPTH_DATA_PROP_NAME = "GDepth:Data" + private const val GIMAGE_DATA_PROP_NAME = "GImage:Data" + + private val dataProps = hashMapOf( + GAUDIO_DATA_PROP_NAME to GAUDIO_SCHEMA_NS, + GDEPTH_DATA_PROP_NAME to GDEPTH_SCHEMA_NS, + GIMAGE_DATA_PROP_NAME to GIMAGE_SCHEMA_NS, + ) + + fun isDataPath(path: String) = dataProps.containsKey(path) + + fun namespaceForDataPath(path: String) = dataProps[path] + + fun mimeTypePathForDataPath(dataPropPath: String) = dataPropPath.replace("Data", "Mime") + + // extensions + fun XMPMeta.getSafeLocalizedText(schema: String, propName: String, save: (value: String) -> Unit) { try { if (this.doesPropertyExist(schema, propName)) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt index 409966f68..87509a39e 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/SourceImageEntry.kt @@ -213,7 +213,7 @@ class SourceImageEntry { // finds: width, height, orientation, date private fun fillByExifInterface(context: Context) { - if (!MimeTypes.isSupportedByExifInterface(sourceMimeType, sizeBytes)) return; + if (!MimeTypes.isSupportedByExifInterface(sourceMimeType, sizeBytes)) return try { StorageUtils.openInputStream(context, uri)?.use { input -> diff --git a/android/app/src/main/res/xml/provider_paths.xml b/android/app/src/main/res/xml/provider_paths.xml index fafa14f89..2a7a21e70 100644 --- a/android/app/src/main/res/xml/provider_paths.xml +++ b/android/app/src/main/res/xml/provider_paths.xml @@ -3,4 +3,10 @@ + + + \ No newline at end of file diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index eb0fc7923..73d2d9c79 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -200,7 +200,7 @@ class ImageEntry { bool get isRaw => MimeTypes.rawImages.contains(mimeType); - bool get isVideo => mimeType.startsWith('video'); + bool get isVideo => MimeTypes.isVideo(mimeType); bool get isCatalogued => _catalogMetadata != null; diff --git a/lib/ref/mime_types.dart b/lib/ref/mime_types.dart index 537d81121..21af7155f 100644 --- a/lib/ref/mime_types.dart +++ b/lib/ref/mime_types.dart @@ -41,4 +41,8 @@ class MimeTypes { // groups static const List rawImages = [arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f]; + + static bool isImage(String mimeType) => mimeType.startsWith('image'); + + static bool isVideo(String mimeType) => mimeType.startsWith('video'); } diff --git a/lib/ref/xmp.dart b/lib/ref/xmp.dart index 725728c75..7874edbb5 100644 --- a/lib/ref/xmp.dart +++ b/lib/ref/xmp.dart @@ -1,5 +1,5 @@ class XMP { - static const namespaceSeparator = ':'; + static const propNamespaceSeparator = ':'; static const structFieldSeparator = '/'; // cf https://exiftool.org/TagNames/XMP.html @@ -15,7 +15,11 @@ class XMP { 'exifEX': 'Exif Ex', 'GettyImagesGIFT': 'Getty Images', 'GIMP': 'GIMP', - 'GPano': 'Google Photo Sphere', + 'GAudio': 'Google Audio', + 'GDepth': 'Google Depth', + 'GFocus': 'Google Focus', + 'GImage': 'Google Image', + 'GPano': 'Google Panorama', 'illustrator': 'Illustrator', 'Iptc4xmpCore': 'IPTC Core', 'lr': 'Lightroom', @@ -35,4 +39,11 @@ class XMP { 'xmpRights': 'Rights Management', 'xmpTPg': 'Paged-Text', }; + + // TODO TLAD 'xmp:Thumbnails[\d]/Image' + static const dataProps = [ + 'GAudio:Data', + 'GDepth:Data', + 'GImage:Data', + ]; } diff --git a/lib/services/android_app_service.dart b/lib/services/android_app_service.dart index bed486206..016cffb27 100644 --- a/lib/services/android_app_service.dart +++ b/lib/services/android_app_service.dart @@ -81,7 +81,7 @@ class AndroidAppService { return false; } - static Future share(Iterable entries) async { + static Future shareEntries(Iterable entries) async { // loosen mime type to a generic one, so we can share with badly defined apps // e.g. Google Lens declares receiving "image/jpeg" only, but it can actually handle more formats final urisByMimeType = groupBy(entries, (e) => e.mimeTypeAnySubtype).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList())); @@ -91,7 +91,21 @@ class AndroidAppService { 'urisByMimeType': urisByMimeType, }); } on PlatformException catch (e) { - debugPrint('share failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + debugPrint('shareEntries failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + return false; + } + + static Future shareSingle(String uri, String mimeType) async { + try { + return await platform.invokeMethod('share', { + 'title': 'Share via:', + 'urisByMimeType': { + mimeType: [uri] + }, + }); + } on PlatformException catch (e) { + debugPrint('shareSingle failed with code=${e.code}, exception=${e.message}, details=${e.details}'); } return false; } diff --git a/lib/services/android_debug_service.dart b/lib/services/android_debug_service.dart index e1dae2621..c1bd79575 100644 --- a/lib/services/android_debug_service.dart +++ b/lib/services/android_debug_service.dart @@ -5,6 +5,16 @@ import 'package:flutter/services.dart'; class AndroidDebugService { static const platform = MethodChannel('deckers.thibault/aves/debug'); + static Future getContextDirs() async { + try { + final result = await platform.invokeMethod('getContextDirs'); + return result as Map; + } on PlatformException catch (e) { + debugPrint('getContextDirs failed with code=${e.code}, exception=${e.message}, details=${e.details}}'); + } + return {}; + } + static Future getEnv() async { try { final result = await platform.invokeMethod('getEnv'); diff --git a/lib/services/metadata_service.dart b/lib/services/metadata_service.dart index 1a0cbcee3..c09845310 100644 --- a/lib/services/metadata_service.dart +++ b/lib/services/metadata_service.dart @@ -119,4 +119,19 @@ class MetadataService { } return []; } + + static Future extractXmpDataProp(ImageEntry entry, String propPath) async { + try { + final result = await platform.invokeMethod('extractXmpDataProp', { + 'mimeType': entry.mimeType, + 'uri': entry.uri, + 'sizeBytes': entry.sizeBytes, + 'propPath': propPath, + }); + return result; + } on PlatformException catch (e) { + debugPrint('extractXmpDataProp failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + return null; + } } diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index 9ecf90e61..2d132398d 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -34,7 +34,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware _showDeleteDialog(context); break; case EntryAction.share: - AndroidAppService.share(selection).then((success) { + AndroidAppService.shareEntries(selection).then((success) { if (!success) showNoMatchingAppDialog(context); }); break; diff --git a/lib/widgets/debug/android_dirs.dart b/lib/widgets/debug/android_dirs.dart new file mode 100644 index 000000000..9f2f02129 --- /dev/null +++ b/lib/widgets/debug/android_dirs.dart @@ -0,0 +1,47 @@ +import 'dart:collection'; + +import 'package:aves/services/android_debug_service.dart'; +import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; +import 'package:aves/widgets/fullscreen/info/common.dart'; +import 'package:flutter/material.dart'; + +class DebugAndroidDirSection extends StatefulWidget { + @override + _DebugAndroidDirSectionState createState() => _DebugAndroidDirSectionState(); +} + +class _DebugAndroidDirSectionState extends State with AutomaticKeepAliveClientMixin { + Future _loader; + + @override + void initState() { + super.initState(); + _loader = AndroidDebugService.getContextDirs(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + + return AvesExpansionTile( + title: 'Android Dir', + children: [ + Padding( + padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), + child: FutureBuilder( + future: _loader, + builder: (context, snapshot) { + if (snapshot.hasError) return Text(snapshot.error.toString()); + if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink(); + final data = SplayTreeMap.of(snapshot.data.map((k, v) => MapEntry(k.toString(), v?.toString() ?? 'null'))); + return InfoRowGroup(data); + }, + ), + ), + ], + ); + } + + @override + bool get wantKeepAlive => true; +} diff --git a/lib/widgets/debug/app_debug_page.dart b/lib/widgets/debug/app_debug_page.dart index a3c609ab7..dd4fe617e 100644 --- a/lib/widgets/debug/app_debug_page.dart +++ b/lib/widgets/debug/app_debug_page.dart @@ -2,6 +2,7 @@ import 'package:aves/model/image_entry.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; +import 'package:aves/widgets/debug/android_dirs.dart'; import 'package:aves/widgets/debug/android_env.dart'; import 'package:aves/widgets/debug/cache.dart'; import 'package:aves/widgets/debug/database.dart'; @@ -41,6 +42,7 @@ class AppDebugPageState extends State { padding: EdgeInsets.all(8), children: [ _buildGeneralTabView(), + DebugAndroidDirSection(), DebugAndroidEnvironmentSection(), DebugCacheSection(), DebugAppDatabaseSection(), diff --git a/lib/widgets/fullscreen/entry_action_delegate.dart b/lib/widgets/fullscreen/entry_action_delegate.dart index e77491913..cc7dbc506 100644 --- a/lib/widgets/fullscreen/entry_action_delegate.dart +++ b/lib/widgets/fullscreen/entry_action_delegate.dart @@ -77,7 +77,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin { }); break; case EntryAction.share: - AndroidAppService.share({entry}).then((success) { + AndroidAppService.shareEntries({entry}).then((success) { if (!success) showNoMatchingAppDialog(context); }); break; diff --git a/lib/widgets/fullscreen/fullscreen_page.dart b/lib/widgets/fullscreen/fullscreen_page.dart index 8691f2f3e..37abf6a17 100644 --- a/lib/widgets/fullscreen/fullscreen_page.dart +++ b/lib/widgets/fullscreen/fullscreen_page.dart @@ -48,7 +48,7 @@ class SingleFullscreenPage extends StatelessWidget { body: FullscreenBody( initialEntry: entry, ), - backgroundColor: Colors.black, + backgroundColor: Navigator.canPop(context) ? Colors.transparent : Colors.black, resizeToAvoidBottomInset: false, ), ); diff --git a/lib/widgets/fullscreen/info/common.dart b/lib/widgets/fullscreen/info/common.dart index d6d7acd4c..259c7d43f 100644 --- a/lib/widgets/fullscreen/info/common.dart +++ b/lib/widgets/fullscreen/info/common.dart @@ -40,10 +40,12 @@ class SectionRow extends StatelessWidget { class InfoRowGroup extends StatefulWidget { final Map keyValues; final int maxValueLength; + final Map linkHandlers; const InfoRowGroup( this.keyValues, { this.maxValueLength = 0, + this.linkHandlers, }); @override @@ -57,9 +59,13 @@ class _InfoRowGroupState extends State { int get maxValueLength => widget.maxValueLength; + Map get linkHandlers => widget.linkHandlers; + static const keyValuePadding = 16; + static const linkColor = Colors.blue; static final baseStyle = TextStyle(fontFamily: 'Concourse'); static final keyStyle = baseStyle.copyWith(color: Colors.white70, height: 1.7); + static final linkStyle = baseStyle.copyWith(color: linkColor, decoration: TextDecoration.underline); @override Widget build(BuildContext context) { @@ -85,11 +91,29 @@ class _InfoRowGroupState extends State { children: keyValues.entries.expand( (kv) { final key = kv.key; - var value = kv.value; - // long values are clipped, and made expandable by tapping them - final showPreviewOnly = maxValueLength > 0 && value.length > maxValueLength && !_expandedKeys.contains(key); - if (showPreviewOnly) { - value = '${value.substring(0, maxValueLength)}…'; + String value; + TextStyle style; + GestureRecognizer recognizer; + + if (linkHandlers?.containsKey(key) == true) { + final handler = linkHandlers[key]; + value = handler.linkText; + // open link on tap + recognizer = TapGestureRecognizer()..onTap = handler.onTap; + style = linkStyle; + } else { + value = kv.value; + // long values are clipped, and made expandable by tapping them + final showPreviewOnly = maxValueLength > 0 && value.length > maxValueLength && !_expandedKeys.contains(key); + if (showPreviewOnly) { + value = '${value.substring(0, maxValueLength)}…'; + // show full value on tap + recognizer = TapGestureRecognizer()..onTap = () => setState(() => _expandedKeys.add(key)); + } + } + + if (key != lastKey) { + value = '$value\n'; } // as of Flutter v1.22.4, `SelectableText` cannot contain `WidgetSpan` @@ -98,9 +122,9 @@ class _InfoRowGroupState extends State { final spaceCount = (100 * thisSpaceSize / baseSpaceWidth).round(); return [ - TextSpan(text: '$key', style: keyStyle), + TextSpan(text: key, style: keyStyle), TextSpan(text: '\u200A' * spaceCount), - TextSpan(text: '$value${key == lastKey ? '' : '\n'}', recognizer: showPreviewOnly ? _buildTapRecognizer(key) : null), + TextSpan(text: value, style: style, recognizer: recognizer), ]; }, ).toList(), @@ -121,8 +145,14 @@ class _InfoRowGroupState extends State { )..layout(BoxConstraints(), parentUsesSize: true); return para.getMaxIntrinsicWidth(double.infinity); } - - GestureRecognizer _buildTapRecognizer(String key) { - return TapGestureRecognizer()..onTap = () => setState(() => _expandedKeys.add(key)); - } +} + +class InfoLinkHandler { + final String linkText; + final VoidCallback onTap; + + const InfoLinkHandler({ + @required this.linkText, + @required this.onTap, + }); } diff --git a/lib/widgets/fullscreen/info/metadata/xmp_tile.dart b/lib/widgets/fullscreen/info/metadata/xmp_tile.dart index 3de2cdc5a..5d3226803 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_tile.dart +++ b/lib/widgets/fullscreen/info/metadata/xmp_tile.dart @@ -2,17 +2,25 @@ import 'dart:collection'; import 'package:aves/model/image_entry.dart'; import 'package:aves/ref/brand_colors.dart'; +import 'package:aves/ref/mime_types.dart'; import 'package:aves/ref/xmp.dart'; +import 'package:aves/services/android_app_service.dart'; +import 'package:aves/services/metadata_service.dart'; import 'package:aves/utils/constants.dart'; +import 'package:aves/widgets/common/action_mixins/feedback.dart'; +import 'package:aves/widgets/common/behaviour/routes.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/common/identity/highlight_title.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:aves/widgets/fullscreen/fullscreen_page.dart'; import 'package:aves/widgets/fullscreen/info/common.dart'; import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:pedantic/pedantic.dart'; -class XmpDirTile extends StatelessWidget { +class XmpDirTile extends StatefulWidget { final ImageEntry entry; final SplayTreeMap tags; final ValueNotifier expandedNotifier; @@ -23,52 +31,75 @@ class XmpDirTile extends StatelessWidget { @required this.expandedNotifier, }); + @override + _XmpDirTileState createState() => _XmpDirTileState(); +} + +class _XmpDirTileState extends State with FeedbackMixin { + ImageEntry get entry => widget.entry; + @override Widget build(BuildContext context) { final thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry); - final sections = SplayTreeMap.of( - groupBy, String>(tags.entries, (kv) { + final sections = SplayTreeMap<_XmpNamespace, List>>.of( + groupBy(widget.tags.entries, (kv) { final fullKey = kv.key; - final i = fullKey.indexOf(XMP.namespaceSeparator); - if (i == -1) return ''; + final i = fullKey.indexOf(XMP.propNamespaceSeparator); + if (i == -1) return _XmpNamespace(''); final namespace = fullKey.substring(0, i); - return XMP.namespaces[namespace] ?? namespace; + return _XmpNamespace(namespace); }), - compareAsciiUpperCase, + (a, b) => compareAsciiUpperCase(a.displayTitle, b.displayTitle), ); return AvesExpansionTile( title: 'XMP', - expandedNotifier: expandedNotifier, + expandedNotifier: widget.expandedNotifier, children: [ if (thumbnail != null) thumbnail, Padding( padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: sections.entries.expand((sectionEntry) { - final title = sectionEntry.key; + children: sections.entries.expand((namespaceProps) { + final namespace = namespaceProps.key; + final displayNamespace = namespace.displayTitle; + final linkHandlers = {}; - final entries = sectionEntry.value.map((kv) { - final key = kv.key.splitMapJoin(XMP.structFieldSeparator, onNonMatch: (s) { + final entries = namespaceProps.value.map((prop) { + final propPath = prop.key; + + final displayKey = propPath.splitMapJoin(XMP.structFieldSeparator, onNonMatch: (s) { // strip namespace - final key = s.split(XMP.namespaceSeparator).last; + final key = s.split(XMP.propNamespaceSeparator).last; // uppercase first letter return key.replaceFirstMapped(RegExp('.'), (m) => m.group(0).toUpperCase()); }); - return MapEntry(key, kv.value); + + var value = prop.value; + if (XMP.dataProps.contains(propPath)) { + linkHandlers.putIfAbsent( + displayKey, + () => InfoLinkHandler(linkText: 'Open', onTap: () => _openEmbeddedData(propPath)), + ); + } + return MapEntry(displayKey, value); }).toList() ..sort((a, b) => compareAsciiUpperCaseNatural(a.key, b.key)); return [ - if (title.isNotEmpty) + if (displayNamespace.isNotEmpty) Padding( padding: EdgeInsets.only(top: 8), child: HighlightTitle( - title, - color: BrandColors.get(title), + displayNamespace, + color: BrandColors.get(displayNamespace), selectable: true, ), ), - InfoRowGroup(Map.fromEntries(entries), maxValueLength: Constants.infoGroupMaxValueLength), + InfoRowGroup( + Map.fromEntries(entries), + maxValueLength: Constants.infoGroupMaxValueLength, + linkHandlers: linkHandlers, + ), ]; }).toList(), ), @@ -76,4 +107,58 @@ class XmpDirTile extends StatelessWidget { ], ); } + + Future _openEmbeddedData(String propPath) async { + final fields = await MetadataService.extractXmpDataProp(entry, propPath); + if (fields == null || !fields.containsKey('mimeType') || !fields.containsKey('uri')) { + showFeedback(context, 'Failed'); + return; + } + + final mimeType = fields['mimeType']; + final uri = fields['uri']; + if (!MimeTypes.isImage(mimeType) && !MimeTypes.isVideo(mimeType)) { + // open with another app + unawaited(AndroidAppService.open(uri, mimeType).then((success) { + if (!success) { + // fallback to sharing, so that the file can be saved somewhere + AndroidAppService.shareSingle(uri, mimeType).then((success) { + if (!success) showNoMatchingAppDialog(context); + }); + } + })); + return; + } + + final embedEntry = ImageEntry.fromMap(fields); + unawaited(Navigator.push( + context, + TransparentMaterialPageRoute( + settings: RouteSettings(name: SingleFullscreenPage.routeName), + pageBuilder: (c, a, sa) => SingleFullscreenPage(entry: embedEntry), + ), + )); + } +} + +class _XmpNamespace { + final String namespace; + + const _XmpNamespace(this.namespace); + + String get displayTitle => XMP.namespaces[namespace] ?? namespace; + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) return false; + return other is _XmpNamespace && other.namespace == namespace; + } + + @override + int get hashCode => namespace.hashCode; + + @override + String toString() { + return '$runtimeType#${shortHash(this)}{namespace=$namespace}'; + } }