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) val intent = Intent(Intent.ACTION_EDIT)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) .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) return safeStartActivityChooser(title, intent)
} }
@ -162,7 +162,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
val intent = Intent(Intent.ACTION_VIEW) val intent = Intent(Intent.ACTION_VIEW)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.setDataAndType(uri, mimeType) .setDataAndType(getShareableUri(uri), mimeType)
return safeStartActivityChooser(title, intent) return safeStartActivityChooser(title, intent)
} }
@ -178,7 +178,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
val intent = Intent(Intent.ACTION_ATTACH_DATA) val intent = Intent(Intent.ACTION_ATTACH_DATA)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.setDataAndType(uri, mimeType) .setDataAndType(getShareableUri(uri), mimeType)
return safeStartActivityChooser(title, intent) return safeStartActivityChooser(title, intent)
} }
@ -186,15 +186,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
val intent = Intent(Intent.ACTION_SEND) val intent = Intent(Intent.ACTION_SEND)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.setType(mimeType) .setType(mimeType)
when (uri.scheme?.toLowerCase(Locale.ROOT)) { .putExtra(Intent.EXTRA_STREAM, getShareableUri(uri))
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)
}
return safeStartActivityChooser(title, intent) return safeStartActivityChooser(title, intent)
} }
@ -251,6 +243,18 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
return false 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 { companion object {
private val LOG_TAG = createTag(AppAdapterHandler::class.java) private val LOG_TAG = createTag(AppAdapterHandler::class.java)
const val CHANNEL = "deckers.thibault/aves/app" const val CHANNEL = "deckers.thibault/aves/app"

View file

@ -31,6 +31,7 @@ import java.util.*
class DebugHandler(private val context: Context) : MethodCallHandler { class DebugHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { when (call.method) {
"getContextDirs" -> result.success(getContextDirs())
"getEnv" -> result.success(System.getenv()) "getEnv" -> result.success(System.getenv())
"getBitmapFactoryInfo" -> GlobalScope.launch { getBitmapFactoryInfo(call, Coresult(result)) } "getBitmapFactoryInfo" -> GlobalScope.launch { getBitmapFactoryInfo(call, Coresult(result)) }
"getContentResolverMetadata" -> GlobalScope.launch { getContentResolverMetadata(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) { private fun getBitmapFactoryInfo(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>("uri")?.let { Uri.parse(it) } val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
if (uri == null) { 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
import deckers.thibault.aves.metadata.XMP.getSafeDateMillis import deckers.thibault.aves.metadata.XMP.getSafeDateMillis
import deckers.thibault.aves.metadata.XMP.getSafeLocalizedText 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
import deckers.thibault.aves.utils.BitmapUtils.getBytes import deckers.thibault.aves.utils.BitmapUtils.getBytes
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes 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.isMultimedia
import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface import deckers.thibault.aves.utils.MimeTypes.isSupportedByExifInterface
import deckers.thibault.aves.utils.MimeTypes.isSupportedByMetadataExtractor 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 io.flutter.plugin.common.MethodChannel.MethodCallHandler
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File
import java.util.* import java.util.*
import kotlin.math.roundToLong import kotlin.math.roundToLong
@ -70,6 +74,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
"getEmbeddedPictures" -> GlobalScope.launch { getEmbeddedPictures(call, Coresult(result)) } "getEmbeddedPictures" -> GlobalScope.launch { getEmbeddedPictures(call, Coresult(result)) }
"getExifThumbnails" -> GlobalScope.launch { getExifThumbnails(call, Coresult(result)) } "getExifThumbnails" -> GlobalScope.launch { getExifThumbnails(call, Coresult(result)) }
"getXmpThumbnails" -> GlobalScope.launch { getXmpThumbnails(call, Coresult(result)) } "getXmpThumbnails" -> GlobalScope.launch { getXmpThumbnails(call, Coresult(result)) }
"extractXmpDataProp" -> GlobalScope.launch { extractXmpDataProp(call, Coresult(result)) }
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
@ -104,7 +109,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
metadataMap[dirName] = dirMap metadataMap[dirName] = dirMap
// tags // tags
if (mimeType == TIFF && dir is ExifIFD0Directory) { if (mimeType == MimeTypes.TIFF && dir is ExifIFD0Directory) {
dirMap.putAll(dir.tags.map { dirMap.putAll(dir.tags.map {
val name = if (it.hasTagName()) { val name = if (it.hasTagName()) {
it.tagName it.tagName
@ -118,13 +123,14 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
} }
if (dir is XmpDirectory) { if (dir is XmpDirectory) {
try { try {
val xmpMeta = dir.xmpMeta.apply { sort() } for (prop in dir.xmpMeta) {
for (prop in xmpMeta) {
if (prop is XMPPropertyInfo) { if (prop is XMPPropertyInfo) {
val path = prop.path val path = prop.path
val value = prop.value if (path?.isNotEmpty() == true) {
if (path?.isNotEmpty() == true && value?.isNotEmpty() == true) { val value = if (XMP.isDataPath(path)) "[skipped]" else prop.value
dirMap[path] = value if (value?.isNotEmpty() == true) {
dirMap[path] = value
}
} }
} }
} }
@ -548,6 +554,70 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
result.success(thumbnails) 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 { companion object {
private val LOG_TAG = LogUtils.createTag(MetadataHandler::class.java) private val LOG_TAG = LogUtils.createTag(MetadataHandler::class.java)
const val CHANNEL = "deckers.thibault/aves/metadata" 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 DC_SCHEMA_NS = "http://purl.org/dc/elements/1.1/"
const val XMP_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/" 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 IMG_SCHEMA_NS = "http://ns.adobe.com/xap/1.0/g/img/"
const val SUBJECT_PROP_NAME = "dc:subject" const val SUBJECT_PROP_NAME = "dc:subject"
const val TITLE_PROP_NAME = "dc:title" const val TITLE_PROP_NAME = "dc:title"
const val DESCRIPTION_PROP_NAME = "dc:description" const val DESCRIPTION_PROP_NAME = "dc:description"
const val CREATE_DATE_PROP_NAME = "xmp:CreateDate" const val CREATE_DATE_PROP_NAME = "xmp:CreateDate"
const val THUMBNAIL_PROP_NAME = "xmp:Thumbnails" const val THUMBNAIL_PROP_NAME = "xmp:Thumbnails"
const val THUMBNAIL_IMAGE_PROP_NAME = "xmpGImg:image" const val THUMBNAIL_IMAGE_PROP_NAME = "xmpGImg:image"
private const val GENERIC_LANG = "" private const val GENERIC_LANG = ""
private const val SPECIFIC_LANG = "en-US" 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) { fun XMPMeta.getSafeLocalizedText(schema: String, propName: String, save: (value: String) -> Unit) {
try { try {
if (this.doesPropertyExist(schema, propName)) { if (this.doesPropertyExist(schema, propName)) {

View file

@ -213,7 +213,7 @@ class SourceImageEntry {
// finds: width, height, orientation, date // finds: width, height, orientation, date
private fun fillByExifInterface(context: Context) { private fun fillByExifInterface(context: Context) {
if (!MimeTypes.isSupportedByExifInterface(sourceMimeType, sizeBytes)) return; if (!MimeTypes.isSupportedByExifInterface(sourceMimeType, sizeBytes)) return
try { try {
StorageUtils.openInputStream(context, uri)?.use { input -> StorageUtils.openInputStream(context, uri)?.use { input ->

View file

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

View file

@ -200,7 +200,7 @@ class ImageEntry {
bool get isRaw => MimeTypes.rawImages.contains(mimeType); bool get isRaw => MimeTypes.rawImages.contains(mimeType);
bool get isVideo => mimeType.startsWith('video'); bool get isVideo => MimeTypes.isVideo(mimeType);
bool get isCatalogued => _catalogMetadata != null; bool get isCatalogued => _catalogMetadata != null;

View file

@ -41,4 +41,8 @@ class MimeTypes {
// groups // 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 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 { class XMP {
static const namespaceSeparator = ':'; static const propNamespaceSeparator = ':';
static const structFieldSeparator = '/'; static const structFieldSeparator = '/';
// cf https://exiftool.org/TagNames/XMP.html // cf https://exiftool.org/TagNames/XMP.html
@ -15,7 +15,11 @@ class XMP {
'exifEX': 'Exif Ex', 'exifEX': 'Exif Ex',
'GettyImagesGIFT': 'Getty Images', 'GettyImagesGIFT': 'Getty Images',
'GIMP': 'GIMP', 'GIMP': 'GIMP',
'GPano': 'Google Photo Sphere', 'GAudio': 'Google Audio',
'GDepth': 'Google Depth',
'GFocus': 'Google Focus',
'GImage': 'Google Image',
'GPano': 'Google Panorama',
'illustrator': 'Illustrator', 'illustrator': 'Illustrator',
'Iptc4xmpCore': 'IPTC Core', 'Iptc4xmpCore': 'IPTC Core',
'lr': 'Lightroom', 'lr': 'Lightroom',
@ -35,4 +39,11 @@ class XMP {
'xmpRights': 'Rights Management', 'xmpRights': 'Rights Management',
'xmpTPg': 'Paged-Text', '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; 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 // 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 // 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())); 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, 'urisByMimeType': urisByMimeType,
}); });
} on PlatformException catch (e) { } 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; return false;
} }

View file

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

View file

@ -119,4 +119,19 @@ class MetadataService {
} }
return []; 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); _showDeleteDialog(context);
break; break;
case EntryAction.share: case EntryAction.share:
AndroidAppService.share(selection).then((success) { AndroidAppService.shareEntries(selection).then((success) {
if (!success) showNoMatchingAppDialog(context); if (!success) showNoMatchingAppDialog(context);
}); });
break; 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/model/source/collection_source.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.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/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/android_env.dart';
import 'package:aves/widgets/debug/cache.dart'; import 'package:aves/widgets/debug/cache.dart';
import 'package:aves/widgets/debug/database.dart'; import 'package:aves/widgets/debug/database.dart';
@ -41,6 +42,7 @@ class AppDebugPageState extends State<AppDebugPage> {
padding: EdgeInsets.all(8), padding: EdgeInsets.all(8),
children: [ children: [
_buildGeneralTabView(), _buildGeneralTabView(),
DebugAndroidDirSection(),
DebugAndroidEnvironmentSection(), DebugAndroidEnvironmentSection(),
DebugCacheSection(), DebugCacheSection(),
DebugAppDatabaseSection(), DebugAppDatabaseSection(),

View file

@ -77,7 +77,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
}); });
break; break;
case EntryAction.share: case EntryAction.share:
AndroidAppService.share({entry}).then((success) { AndroidAppService.shareEntries({entry}).then((success) {
if (!success) showNoMatchingAppDialog(context); if (!success) showNoMatchingAppDialog(context);
}); });
break; break;

View file

@ -48,7 +48,7 @@ class SingleFullscreenPage extends StatelessWidget {
body: FullscreenBody( body: FullscreenBody(
initialEntry: entry, initialEntry: entry,
), ),
backgroundColor: Colors.black, backgroundColor: Navigator.canPop(context) ? Colors.transparent : Colors.black,
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
), ),
); );

View file

@ -40,10 +40,12 @@ class SectionRow extends StatelessWidget {
class InfoRowGroup extends StatefulWidget { class InfoRowGroup extends StatefulWidget {
final Map<String, String> keyValues; final Map<String, String> keyValues;
final int maxValueLength; final int maxValueLength;
final Map<String, InfoLinkHandler> linkHandlers;
const InfoRowGroup( const InfoRowGroup(
this.keyValues, { this.keyValues, {
this.maxValueLength = 0, this.maxValueLength = 0,
this.linkHandlers,
}); });
@override @override
@ -57,9 +59,13 @@ class _InfoRowGroupState extends State<InfoRowGroup> {
int get maxValueLength => widget.maxValueLength; int get maxValueLength => widget.maxValueLength;
Map<String, InfoLinkHandler> get linkHandlers => widget.linkHandlers;
static const keyValuePadding = 16; static const keyValuePadding = 16;
static const linkColor = Colors.blue;
static final baseStyle = TextStyle(fontFamily: 'Concourse'); static final baseStyle = TextStyle(fontFamily: 'Concourse');
static final keyStyle = baseStyle.copyWith(color: Colors.white70, height: 1.7); static final keyStyle = baseStyle.copyWith(color: Colors.white70, height: 1.7);
static final linkStyle = baseStyle.copyWith(color: linkColor, decoration: TextDecoration.underline);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -85,11 +91,29 @@ class _InfoRowGroupState extends State<InfoRowGroup> {
children: keyValues.entries.expand( children: keyValues.entries.expand(
(kv) { (kv) {
final key = kv.key; final key = kv.key;
var value = kv.value; String value;
// long values are clipped, and made expandable by tapping them TextStyle style;
final showPreviewOnly = maxValueLength > 0 && value.length > maxValueLength && !_expandedKeys.contains(key); GestureRecognizer recognizer;
if (showPreviewOnly) {
value = '${value.substring(0, maxValueLength)}'; 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` // 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(); final spaceCount = (100 * thisSpaceSize / baseSpaceWidth).round();
return [ return [
TextSpan(text: '$key', style: keyStyle), TextSpan(text: key, style: keyStyle),
TextSpan(text: '\u200A' * spaceCount), TextSpan(text: '\u200A' * spaceCount),
TextSpan(text: '$value${key == lastKey ? '' : '\n'}', recognizer: showPreviewOnly ? _buildTapRecognizer(key) : null), TextSpan(text: value, style: style, recognizer: recognizer),
]; ];
}, },
).toList(), ).toList(),
@ -121,8 +145,14 @@ class _InfoRowGroupState extends State<InfoRowGroup> {
)..layout(BoxConstraints(), parentUsesSize: true); )..layout(BoxConstraints(), parentUsesSize: true);
return para.getMaxIntrinsicWidth(double.infinity); 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/model/image_entry.dart';
import 'package:aves/ref/brand_colors.dart'; import 'package:aves/ref/brand_colors.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/ref/xmp.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/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/aves_expansion_tile.dart';
import 'package:aves/widgets/common/identity/highlight_title.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/common.dart';
import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.dart'; import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pedantic/pedantic.dart';
class XmpDirTile extends StatelessWidget { class XmpDirTile extends StatefulWidget {
final ImageEntry entry; final ImageEntry entry;
final SplayTreeMap<String, String> tags; final SplayTreeMap<String, String> tags;
final ValueNotifier<String> expandedNotifier; final ValueNotifier<String> expandedNotifier;
@ -23,52 +31,75 @@ class XmpDirTile extends StatelessWidget {
@required this.expandedNotifier, @required this.expandedNotifier,
}); });
@override
_XmpDirTileState createState() => _XmpDirTileState();
}
class _XmpDirTileState extends State<XmpDirTile> with FeedbackMixin {
ImageEntry get entry => widget.entry;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry); final thumbnail = MetadataThumbnails(source: MetadataThumbnailSource.xmp, entry: entry);
final sections = SplayTreeMap.of( final sections = SplayTreeMap<_XmpNamespace, List<MapEntry<String, String>>>.of(
groupBy<MapEntry<String, String>, String>(tags.entries, (kv) { groupBy(widget.tags.entries, (kv) {
final fullKey = kv.key; final fullKey = kv.key;
final i = fullKey.indexOf(XMP.namespaceSeparator); final i = fullKey.indexOf(XMP.propNamespaceSeparator);
if (i == -1) return ''; if (i == -1) return _XmpNamespace('');
final namespace = fullKey.substring(0, i); final namespace = fullKey.substring(0, i);
return XMP.namespaces[namespace] ?? namespace; return _XmpNamespace(namespace);
}), }),
compareAsciiUpperCase, (a, b) => compareAsciiUpperCase(a.displayTitle, b.displayTitle),
); );
return AvesExpansionTile( return AvesExpansionTile(
title: 'XMP', title: 'XMP',
expandedNotifier: expandedNotifier, expandedNotifier: widget.expandedNotifier,
children: [ children: [
if (thumbnail != null) thumbnail, if (thumbnail != null) thumbnail,
Padding( Padding(
padding: EdgeInsets.only(left: 8, right: 8, bottom: 8), padding: EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: sections.entries.expand((sectionEntry) { children: sections.entries.expand((namespaceProps) {
final title = sectionEntry.key; final namespace = namespaceProps.key;
final displayNamespace = namespace.displayTitle;
final linkHandlers = <String, InfoLinkHandler>{};
final entries = sectionEntry.value.map((kv) { final entries = namespaceProps.value.map((prop) {
final key = kv.key.splitMapJoin(XMP.structFieldSeparator, onNonMatch: (s) { final propPath = prop.key;
final displayKey = propPath.splitMapJoin(XMP.structFieldSeparator, onNonMatch: (s) {
// strip namespace // strip namespace
final key = s.split(XMP.namespaceSeparator).last; final key = s.split(XMP.propNamespaceSeparator).last;
// uppercase first letter // uppercase first letter
return key.replaceFirstMapped(RegExp('.'), (m) => m.group(0).toUpperCase()); 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() }).toList()
..sort((a, b) => compareAsciiUpperCaseNatural(a.key, b.key)); ..sort((a, b) => compareAsciiUpperCaseNatural(a.key, b.key));
return [ return [
if (title.isNotEmpty) if (displayNamespace.isNotEmpty)
Padding( Padding(
padding: EdgeInsets.only(top: 8), padding: EdgeInsets.only(top: 8),
child: HighlightTitle( child: HighlightTitle(
title, displayNamespace,
color: BrandColors.get(title), color: BrandColors.get(displayNamespace),
selectable: true, selectable: true,
), ),
), ),
InfoRowGroup(Map.fromEntries(entries), maxValueLength: Constants.infoGroupMaxValueLength), InfoRowGroup(
Map.fromEntries(entries),
maxValueLength: Constants.infoGroupMaxValueLength,
linkHandlers: linkHandlers,
),
]; ];
}).toList(), }).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}';
}
} }