info: open embedded GImage/GAudio/GDepth media

This commit is contained in:
Thibault Deckers 2020-12-03 21:25:26 +09:00
parent 556798dd7b
commit 2832351710
19 changed files with 392 additions and 57 deletions

View file

@ -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"

View file

@ -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<String>("uri")?.let { Uri.parse(it) }
if (uri == null) {

View file

@ -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,16 +123,17 @@ 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) {
if (path?.isNotEmpty() == true) {
val value = if (XMP.isDataPath(path)) "[skipped]" else prop.value
if (value?.isNotEmpty() == true) {
dirMap[path] = value
}
}
}
}
} catch (e: XMPException) {
Log.w(LOG_TAG, "failed to read XMP directory for uri=$uri", e)
}
@ -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<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val sizeBytes = call.argument<Number>("sizeBytes")?.toLong()
val dataPropPath = call.argument<String>("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"

View file

@ -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)) {

View file

@ -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 ->

View file

@ -3,4 +3,10 @@
<external-path
name="external_files"
path="." />
<!-- for images & other media embedded in XMP
and exported for viewing and sharing -->
<cache-path
name="xmp_props"
path="." />
</paths>

View file

@ -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;

View file

@ -41,4 +41,8 @@ class MimeTypes {
// groups
static const List<String> 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');
}

View file

@ -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',
];
}

View file

@ -81,7 +81,7 @@ class AndroidAppService {
return false;
}
static Future<bool> share(Iterable<ImageEntry> entries) async {
static Future<bool> shareEntries(Iterable<ImageEntry> 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<ImageEntry, String>(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<bool> shareSingle(String uri, String mimeType) async {
try {
return await platform.invokeMethod('share', <String, dynamic>{
'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;
}

View file

@ -5,6 +5,16 @@ import 'package:flutter/services.dart';
class AndroidDebugService {
static const platform = MethodChannel('deckers.thibault/aves/debug');
static Future<Map> 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<Map> getEnv() async {
try {
final result = await platform.invokeMethod('getEnv');

View file

@ -119,4 +119,19 @@ class MetadataService {
}
return [];
}
static Future<Map> extractXmpDataProp(ImageEntry entry, String propPath) async {
try {
final result = await platform.invokeMethod('extractXmpDataProp', <String, dynamic>{
'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;
}
}

View file

@ -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;

View file

@ -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<DebugAndroidDirSection> with AutomaticKeepAliveClientMixin {
Future<Map> _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<Map>(
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;
}

View file

@ -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<AppDebugPage> {
padding: EdgeInsets.all(8),
children: [
_buildGeneralTabView(),
DebugAndroidDirSection(),
DebugAndroidEnvironmentSection(),
DebugCacheSection(),
DebugAppDatabaseSection(),

View file

@ -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;

View file

@ -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,
),
);

View file

@ -40,10 +40,12 @@ class SectionRow extends StatelessWidget {
class InfoRowGroup extends StatefulWidget {
final Map<String, String> keyValues;
final int maxValueLength;
final Map<String, InfoLinkHandler> linkHandlers;
const InfoRowGroup(
this.keyValues, {
this.maxValueLength = 0,
this.linkHandlers,
});
@override
@ -57,9 +59,13 @@ class _InfoRowGroupState extends State<InfoRowGroup> {
int get maxValueLength => widget.maxValueLength;
Map<String, InfoLinkHandler> 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<InfoRowGroup> {
children: keyValues.entries.expand(
(kv) {
final key = kv.key;
var value = kv.value;
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<InfoRowGroup> {
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<InfoRowGroup> {
)..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,
});
}

View file

@ -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<String, String> tags;
final ValueNotifier<String> expandedNotifier;
@ -23,52 +31,75 @@ class XmpDirTile extends StatelessWidget {
@required this.expandedNotifier,
});
@override
_XmpDirTileState createState() => _XmpDirTileState();
}
class _XmpDirTileState extends State<XmpDirTile> 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<MapEntry<String, String>, String>(tags.entries, (kv) {
final sections = SplayTreeMap<_XmpNamespace, List<MapEntry<String, String>>>.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 = <String, InfoLinkHandler>{};
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<void> _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}';
}
}