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 57fdb11b2..a3f64479d 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 @@ -1,10 +1,15 @@ package deckers.thibault.aves.channel.calls +import android.content.ContentResolver +import android.content.ContentUris import android.content.Context +import android.database.Cursor import android.media.MediaExtractor import android.media.MediaFormat import android.media.MediaMetadataRetriever import android.net.Uri +import android.os.Build +import android.provider.MediaStore import android.util.Log import androidx.exifinterface.media.ExifInterface import com.adobe.internal.xmp.XMPException @@ -75,6 +80,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { "getOverlayMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getOverlayMetadata) } "getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMultiPageInfo) } "getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPanoramaInfo) } + "getContentResolverProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverProp) } "getEmbeddedPictures" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getEmbeddedPictures) } "getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getExifThumbnails) } "extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractXmpDataProp) } @@ -622,6 +628,55 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { result.error("getPanoramaInfo-empty", "failed to read XMP from uri=$uri", null) } + private fun getContentResolverProp(call: MethodCall, result: MethodChannel.Result) { + val mimeType = call.argument("mimeType") + val uri = call.argument("uri")?.let { Uri.parse(it) } + val prop = call.argument("prop") + if (mimeType == null || uri == null || prop == null) { + result.error("getContentResolverProp-args", "failed because of missing arguments", null) + return + } + + var contentUri: Uri = uri + if (uri.scheme == ContentResolver.SCHEME_CONTENT && MediaStore.AUTHORITY.equals(uri.host, ignoreCase = true)) { + try { + val id = ContentUris.parseId(uri) + contentUri = when { + isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id) + isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id) + else -> uri + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + contentUri = MediaStore.setRequireOriginal(contentUri) + } + } catch (e: NumberFormatException) { + // ignore + } + } + + val projection = arrayOf(prop) + val cursor = context.contentResolver.query(contentUri, projection, null, null, null) + if (cursor != null && cursor.moveToFirst()) { + var value: Any? = null + try { + value = when (cursor.getType(0)) { + Cursor.FIELD_TYPE_NULL -> null + Cursor.FIELD_TYPE_INTEGER -> cursor.getLong(0) + Cursor.FIELD_TYPE_FLOAT -> cursor.getFloat(0) + Cursor.FIELD_TYPE_STRING -> cursor.getString(0) + Cursor.FIELD_TYPE_BLOB -> cursor.getBlob(0) + else -> null + } + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to get value for key=$prop", e) + } + cursor.close() + result.success(value?.toString()) + } else { + result.error("getContentResolverProp-null", "failed to get cursor for contentUri=$contentUri", null) + } + } + private fun getEmbeddedPictures(call: MethodCall, result: MethodChannel.Result) { val uri = call.argument("uri")?.let { Uri.parse(it) } if (uri == null) { diff --git a/lib/services/metadata_service.dart b/lib/services/metadata_service.dart index e4a2e0b90..d55799255 100644 --- a/lib/services/metadata_service.dart +++ b/lib/services/metadata_service.dart @@ -113,6 +113,19 @@ class MetadataService { return null; } + static Future getContentResolverProp(AvesEntry entry, String prop) async { + try { + return await platform.invokeMethod('getContentResolverProp', { + 'mimeType': entry.mimeType, + 'uri': entry.uri, + 'prop': prop, + }); + } on PlatformException catch (e) { + debugPrint('getContentResolverProp failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + return null; + } + static Future> getEmbeddedPictures(String uri) async { try { final result = await platform.invokeMethod('getEmbeddedPictures', { diff --git a/lib/widgets/viewer/info/basic_section.dart b/lib/widgets/viewer/info/basic_section.dart index 26cedfa6a..3e3cf4931 100644 --- a/lib/widgets/viewer/info/basic_section.dart +++ b/lib/widgets/viewer/info/basic_section.dart @@ -1,11 +1,14 @@ +import 'package:aves/image_providers/app_icon_image_provider.dart'; +import 'package:aves/model/entry.dart'; import 'package:aves/model/favourite_repo.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/tag.dart'; -import 'package:aves/model/entry.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/ref/mime_types.dart'; +import 'package:aves/services/metadata_service.dart'; +import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/constants.dart'; import 'package:aves/utils/file_utils.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; @@ -55,6 +58,7 @@ class BasicSection extends StatelessWidget { 'URI': uri, if (path != null) 'Path': path, }), + OwnerProp(entry: entry), _buildChips(), ], ); @@ -102,3 +106,66 @@ class BasicSection extends StatelessWidget { }; } } + +class OwnerProp extends StatefulWidget { + final AvesEntry entry; + + const OwnerProp({ + @required this.entry, + }); + + @override + _OwnerPropState createState() => _OwnerPropState(); +} + +class _OwnerPropState extends State { + Future _loader; + + static const iconSize = 20.0; + + @override + void initState() { + super.initState(); + _loader = MetadataService.getContentResolverProp(widget.entry, 'owner_package_name'); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _loader, + builder: (context, snapshot) { + final packageName = snapshot.data; + if (packageName == null) return SizedBox(); + final appName = androidFileUtils.appNameMap.entries.firstWhere((kv) => kv.value == packageName, orElse: () => null)?.key ?? packageName; + return Text.rich( + TextSpan( + children: [ + TextSpan( + text: 'Owned by', + style: InfoRowGroup.keyStyle, + ), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 4), + child: Image( + image: AppIconImage( + packageName: packageName, + size: iconSize, + ), + width: iconSize, + height: iconSize, + ), + ), + ), + TextSpan( + text: appName, + style: InfoRowGroup.baseStyle, + ), + ], + ), + ); + }, + ); + } +} diff --git a/lib/widgets/viewer/info/common.dart b/lib/widgets/viewer/info/common.dart index 4002d1571..5b0ff8243 100644 --- a/lib/widgets/viewer/info/common.dart +++ b/lib/widgets/viewer/info/common.dart @@ -42,6 +42,12 @@ class InfoRowGroup extends StatefulWidget { final int maxValueLength; final Map 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); + const InfoRowGroup( this.keyValues, { this.maxValueLength = 0, @@ -61,20 +67,14 @@ class _InfoRowGroupState extends State { 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) { if (keyValues.isEmpty) return SizedBox.shrink(); // compute the size of keys and space in order to align values final textScaleFactor = MediaQuery.textScaleFactorOf(context); - final keySizes = Map.fromEntries(keyValues.keys.map((key) => MapEntry(key, _getSpanWidth(TextSpan(text: '$key', style: keyStyle), textScaleFactor)))); - final baseSpaceWidth = _getSpanWidth(TextSpan(text: '\u200A' * 100, style: baseStyle), textScaleFactor); + final keySizes = Map.fromEntries(keyValues.keys.map((key) => MapEntry(key, _getSpanWidth(TextSpan(text: '$key', style: InfoRowGroup.keyStyle), textScaleFactor)))); + final baseSpaceWidth = _getSpanWidth(TextSpan(text: '\u200A' * 100, style: InfoRowGroup.baseStyle), textScaleFactor); final lastKey = keyValues.keys.last; return LayoutBuilder( @@ -100,7 +100,7 @@ class _InfoRowGroupState extends State { value = handler.linkText; // open link on tap recognizer = TapGestureRecognizer()..onTap = () => handler.onTap(context); - style = linkStyle; + style = InfoRowGroup.linkStyle; } else { value = kv.value; // long values are clipped, and made expandable by tapping them @@ -118,18 +118,18 @@ class _InfoRowGroupState extends State { // as of Flutter v1.22.4, `SelectableText` cannot contain `WidgetSpan` // so we add padding using multiple hair spaces instead - final thisSpaceSize = max(0.0, (baseValueX - keySizes[key])) + keyValuePadding; + final thisSpaceSize = max(0.0, (baseValueX - keySizes[key])) + InfoRowGroup.keyValuePadding; final spaceCount = (100 * thisSpaceSize / baseSpaceWidth).round(); return [ - TextSpan(text: key, style: keyStyle), + TextSpan(text: key, style: InfoRowGroup.keyStyle), TextSpan(text: '\u200A' * spaceCount), TextSpan(text: value, style: style, recognizer: recognizer), ]; }, ).toList(), ), - style: baseStyle, + style: InfoRowGroup.baseStyle, ), ], );