info: show owner app, if any

This commit is contained in:
Thibault Deckers 2021-01-28 17:16:06 +09:00
parent 2d893d4415
commit 24dcb5b021
4 changed files with 148 additions and 13 deletions

View file

@ -1,10 +1,15 @@
package deckers.thibault.aves.channel.calls package deckers.thibault.aves.channel.calls
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context import android.content.Context
import android.database.Cursor
import android.media.MediaExtractor import android.media.MediaExtractor
import android.media.MediaFormat import android.media.MediaFormat
import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever
import android.net.Uri import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.util.Log import android.util.Log
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import com.adobe.internal.xmp.XMPException 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) } "getOverlayMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getOverlayMetadata) }
"getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMultiPageInfo) } "getMultiPageInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getMultiPageInfo) }
"getPanoramaInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPanoramaInfo) } "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) } "getEmbeddedPictures" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getEmbeddedPictures) }
"getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getExifThumbnails) } "getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getExifThumbnails) }
"extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractXmpDataProp) } "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) result.error("getPanoramaInfo-empty", "failed to read XMP from uri=$uri", null)
} }
private fun getContentResolverProp(call: MethodCall, result: MethodChannel.Result) {
val mimeType = call.argument<String>("mimeType")
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
val prop = call.argument<String>("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) { private fun getEmbeddedPictures(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

@ -113,6 +113,19 @@ class MetadataService {
return null; return null;
} }
static Future<String> getContentResolverProp(AvesEntry entry, String prop) async {
try {
return await platform.invokeMethod('getContentResolverProp', <String, dynamic>{
'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<List<Uint8List>> getEmbeddedPictures(String uri) async { static Future<List<Uint8List>> getEmbeddedPictures(String uri) async {
try { try {
final result = await platform.invokeMethod('getEmbeddedPictures', <String, dynamic>{ final result = await platform.invokeMethod('getEmbeddedPictures', <String, dynamic>{

View file

@ -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/favourite_repo.dart';
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/filters/tag.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/model/source/collection_lens.dart';
import 'package:aves/ref/mime_types.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/constants.dart';
import 'package:aves/utils/file_utils.dart'; import 'package:aves/utils/file_utils.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
@ -55,6 +58,7 @@ class BasicSection extends StatelessWidget {
'URI': uri, 'URI': uri,
if (path != null) 'Path': path, if (path != null) 'Path': path,
}), }),
OwnerProp(entry: entry),
_buildChips(), _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<OwnerProp> {
Future<String> _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<String>(
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,
),
],
),
);
},
);
}
}

View file

@ -42,6 +42,12 @@ class InfoRowGroup extends StatefulWidget {
final int maxValueLength; final int maxValueLength;
final Map<String, InfoLinkHandler> linkHandlers; final Map<String, InfoLinkHandler> 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( const InfoRowGroup(
this.keyValues, { this.keyValues, {
this.maxValueLength = 0, this.maxValueLength = 0,
@ -61,20 +67,14 @@ class _InfoRowGroupState extends State<InfoRowGroup> {
Map<String, InfoLinkHandler> get linkHandlers => widget.linkHandlers; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (keyValues.isEmpty) return SizedBox.shrink(); if (keyValues.isEmpty) return SizedBox.shrink();
// compute the size of keys and space in order to align values // compute the size of keys and space in order to align values
final textScaleFactor = MediaQuery.textScaleFactorOf(context); final textScaleFactor = MediaQuery.textScaleFactorOf(context);
final keySizes = Map.fromEntries(keyValues.keys.map((key) => MapEntry(key, _getSpanWidth(TextSpan(text: '$key', style: keyStyle), 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: baseStyle), textScaleFactor); final baseSpaceWidth = _getSpanWidth(TextSpan(text: '\u200A' * 100, style: InfoRowGroup.baseStyle), textScaleFactor);
final lastKey = keyValues.keys.last; final lastKey = keyValues.keys.last;
return LayoutBuilder( return LayoutBuilder(
@ -100,7 +100,7 @@ class _InfoRowGroupState extends State<InfoRowGroup> {
value = handler.linkText; value = handler.linkText;
// open link on tap // open link on tap
recognizer = TapGestureRecognizer()..onTap = () => handler.onTap(context); recognizer = TapGestureRecognizer()..onTap = () => handler.onTap(context);
style = linkStyle; style = InfoRowGroup.linkStyle;
} else { } else {
value = kv.value; value = kv.value;
// long values are clipped, and made expandable by tapping them // long values are clipped, and made expandable by tapping them
@ -118,18 +118,18 @@ class _InfoRowGroupState extends State<InfoRowGroup> {
// as of Flutter v1.22.4, `SelectableText` cannot contain `WidgetSpan` // as of Flutter v1.22.4, `SelectableText` cannot contain `WidgetSpan`
// so we add padding using multiple hair spaces instead // 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(); final spaceCount = (100 * thisSpaceSize / baseSpaceWidth).round();
return [ return [
TextSpan(text: key, style: keyStyle), TextSpan(text: key, style: InfoRowGroup.keyStyle),
TextSpan(text: '\u200A' * spaceCount), TextSpan(text: '\u200A' * spaceCount),
TextSpan(text: value, style: style, recognizer: recognizer), TextSpan(text: value, style: style, recognizer: recognizer),
]; ];
}, },
).toList(), ).toList(),
), ),
style: baseStyle, style: InfoRowGroup.baseStyle,
), ),
], ],
); );