import 'dart:async'; import 'dart:ui' as ui; import 'dart:ui'; import 'package:aves/geo/uri.dart'; import 'package:aves/model/app_inventory.dart'; import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/extensions/props.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/services/common/decoding.dart'; import 'package:aves/services/common/services.dart'; import 'package:collection/collection.dart'; import 'package:flutter/services.dart'; import 'package:latlong2/latlong.dart'; import 'package:streams_channel/streams_channel.dart'; abstract class AppService { Future> getPackages(); Future getAppIcon(String packageName, double size); Future copyToClipboard(String uri, String? label); Future> edit(String uri, String mimeType); Future open(String uri, String mimeType, {required bool forceChooser}); Future openMap(LatLng latLng); Future setAs(String uri, String mimeType); Future shareEntries(Iterable entries); Future shareSingle(String uri, String mimeType); Future pinToHomeScreen( String label, AvesEntry? coverEntry, { required String route, Set? filters, String? path, String? viewUri, String? geoUri, }); } class PlatformAppService implements AppService { static const _platform = MethodChannel('deckers.thibault/aves/app'); static final _stream = StreamsChannel('deckers.thibault/aves/activity_result_stream'); static final _knownAppDirs = { 'com.google.android.apps.photos': {'Google Photos'}, 'com.kakao.talk': {'KakaoTalkDownload'}, 'com.sony.playmemories.mobile': {'Imaging Edge Mobile'}, 'nekox.messenger': {'NekoX'}, 'org.telegram.messenger': {'Telegram Images', 'Telegram Video'}, 'com.whatsapp': {'WhatsApp Animated Gifs', 'WhatsApp Documents', 'WhatsApp Images', 'WhatsApp Video'} }; @override Future> getPackages() async { try { final result = await _platform.invokeMethod('getPackages'); final packages = (result as List).cast().map(Package.fromMap).toSet(); // additional info for known directories _knownAppDirs.forEach((packageName, dirs) { final package = packages.firstWhereOrNull((package) => package.packageName == packageName); if (package != null) { package.ownedDirs.addAll(dirs); } }); return packages; } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } return {}; } @override Future getAppIcon(String packageName, double size) async { try { final result = await _platform.invokeMethod('getAppIcon', { 'packageName': packageName, 'sizeDip': size, }); if (result != null) { final bytes = result as Uint8List; return InteropDecoding.bytesToCodec(bytes); } } on PlatformException catch (_) { // ignore, as some packages legitimately do not have icons } return null; } @override Future copyToClipboard(String uri, String? label) async { try { final result = await _platform.invokeMethod('copyToClipboard', { 'uri': uri, 'label': label, }); if (result != null) return result as bool; } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } return false; } @override Future> edit(String uri, String mimeType) async { try { final opCompleter = Completer(); _stream.receiveBroadcastStream({ 'op': 'edit', 'uri': uri, 'mimeType': mimeType, }).listen( (data) => opCompleter.complete(data as Map?), onError: opCompleter.completeError, onDone: () { if (!opCompleter.isCompleted) opCompleter.complete({'error': 'cancelled'}); }, cancelOnError: true, ); // `await` here, so that `completeError` will be caught below final result = await opCompleter.future; if (result == null) return {'error': 'cancelled'}; return result.cast(); } on PlatformException catch (e, stack) { if (e.code != 'edit-resolve') { await reportService.recordError(e, stack); } return {'error': e.code}; } } @override Future open(String uri, String mimeType, {required bool forceChooser}) async { try { final result = await _platform.invokeMethod('open', { 'uri': uri, 'mimeType': mimeType, 'forceChooser': forceChooser, }); if (result != null) return result as bool; } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } return false; } @override Future openMap(LatLng latLng) async { try { final result = await _platform.invokeMethod('openMap', { 'geoUri': toGeoUri(latLng), }); if (result != null) return result as bool; } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } return false; } @override Future setAs(String uri, String mimeType) async { try { final result = await _platform.invokeMethod('setAs', { 'uri': uri, 'mimeType': mimeType, }); if (result != null) return result as bool; } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } return false; } @override Future shareEntries(Iterable entries) { return _share(groupBy( entries, // 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) => e.mimeTypeAnySubtype, ).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList()))); } @override Future shareSingle(String uri, String mimeType) { return _share({ mimeType: [uri] }); } Future _share(Map> urisByMimeType) async { try { final result = await _platform.invokeMethod('share', { 'urisByMimeType': urisByMimeType, }); if (result != null) return result as bool; } on PlatformException catch (e, stack) { if (e.code == 'share-large') { throw TooManyItemsException(); } else { await reportService.recordError(e, stack); } } return false; } // app shortcuts @override Future pinToHomeScreen( String label, AvesEntry? coverEntry, { required String route, Set? filters, String? path, String? viewUri, String? geoUri, }) async { Uint8List? iconBytes; if (coverEntry != null) { final size = coverEntry.isVideo ? 0.0 : 256.0; final iconDescriptor = await mediaFetchService.getThumbnail( uri: coverEntry.uri, mimeType: coverEntry.mimeType, pageId: coverEntry.pageId, rotationDegrees: coverEntry.rotationDegrees, isFlipped: coverEntry.isFlipped, dateModifiedMillis: coverEntry.dateModifiedMillis, extent: size, ); if (iconDescriptor != null) { final codec = await iconDescriptor.instantiateCodec(); final frameInfo = await codec.getNextFrame(); final byteData = await frameInfo.image.toByteData(format: ImageByteFormat.png); iconBytes = byteData?.buffer.asUint8List(); } } try { await _platform.invokeMethod('pinShortcut', { 'label': label, 'iconBytes': iconBytes, 'route': route, 'filters': filters?.map((filter) => filter.toJson()).toList(), 'path': path, 'viewUri': viewUri, 'geoUri': geoUri, }); } on PlatformException catch (e, stack) { await reportService.recordError(e, stack); } } } class TooManyItemsException implements Exception {}