info: open embedded GImage/GAudio/GDepth media
This commit is contained in:
parent
556798dd7b
commit
2832351710
19 changed files with 392 additions and 57 deletions
|
@ -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"
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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<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"
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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 ->
|
||||
|
|
|
@ -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>
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
47
lib/widgets/debug/android_dirs.dart
Normal file
47
lib/widgets/debug/android_dirs.dart
Normal 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;
|
||||
}
|
|
@ -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(),
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
// 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<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,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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}';
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue