diff --git a/lib/image_providers/app_icon_image_provider.dart b/lib/image_providers/app_icon_image_provider.dart index 3628e7481..c9f70632e 100644 --- a/lib/image_providers/app_icon_image_provider.dart +++ b/lib/image_providers/app_icon_image_provider.dart @@ -1,6 +1,6 @@ import 'dart:ui' as ui show Codec; -import 'package:aves/services/android_app_service.dart'; +import 'package:aves/services/common/services.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; @@ -39,7 +39,7 @@ class AppIconImage extends ImageProvider { Future _loadAsync(AppIconImageKey key, DecoderCallback decode) async { try { - final bytes = await AndroidAppService.getAppIcon(key.packageName, key.size); + final bytes = await androidAppService.getAppIcon(key.packageName, key.size); return await decode(bytes.isEmpty ? kTransparentImage : bytes); } catch (error) { debugPrint('$runtimeType _loadAsync failed with packageName=$packageName, error=$error'); diff --git a/lib/services/android_app_service.dart b/lib/services/android_app_service.dart index a8883adf8..ef7defa72 100644 --- a/lib/services/android_app_service.dart +++ b/lib/services/android_app_service.dart @@ -10,10 +10,35 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:latlong2/latlong.dart'; -class AndroidAppService { +abstract class AndroidAppService { + 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); + + Future openMap(LatLng latLng); + + Future setAs(String uri, String mimeType); + + Future shareEntries(Iterable entries); + + Future shareSingle(String uri, String mimeType); + + Future canPinToHomeScreen(); + + Future pinToHomeScreen(String label, AvesEntry? entry, Set filters); +} + +class PlatformAndroidAppService implements AndroidAppService { static const platform = MethodChannel('deckers.thibault/aves/app'); - static Future> getPackages() async { + @override + Future> getPackages() async { try { final result = await platform.invokeMethod('getPackages'); final packages = (result as List).cast().map((map) => Package.fromMap(map)).toSet(); @@ -29,7 +54,8 @@ class AndroidAppService { return {}; } - static Future getAppIcon(String packageName, double size) async { + @override + Future getAppIcon(String packageName, double size) async { try { final result = await platform.invokeMethod('getAppIcon', { 'packageName': packageName, @@ -42,7 +68,8 @@ class AndroidAppService { return Uint8List(0); } - static Future copyToClipboard(String uri, String? label) async { + @override + Future copyToClipboard(String uri, String? label) async { try { final result = await platform.invokeMethod('copyToClipboard', { 'uri': uri, @@ -55,7 +82,8 @@ class AndroidAppService { return false; } - static Future edit(String uri, String mimeType) async { + @override + Future edit(String uri, String mimeType) async { try { final result = await platform.invokeMethod('edit', { 'uri': uri, @@ -68,7 +96,8 @@ class AndroidAppService { return false; } - static Future open(String uri, String mimeType) async { + @override + Future open(String uri, String mimeType) async { try { final result = await platform.invokeMethod('open', { 'uri': uri, @@ -81,7 +110,8 @@ class AndroidAppService { return false; } - static Future openMap(LatLng latLng) async { + @override + Future openMap(LatLng latLng) async { final latitude = roundToPrecision(latLng.latitude, decimals: 6); final longitude = roundToPrecision(latLng.longitude, decimals: 6); final geoUri = 'geo:$latitude,$longitude?q=$latitude,$longitude'; @@ -97,7 +127,8 @@ class AndroidAppService { return false; } - static Future setAs(String uri, String mimeType) async { + @override + Future setAs(String uri, String mimeType) async { try { final result = await platform.invokeMethod('setAs', { 'uri': uri, @@ -110,7 +141,8 @@ class AndroidAppService { return false; } - static Future shareEntries(Iterable entries) async { + @override + Future shareEntries(Iterable 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(entries, (e) => e.mimeTypeAnySubtype).map((k, v) => MapEntry(k, v.map((e) => e.uri).toList())); @@ -125,7 +157,8 @@ class AndroidAppService { return false; } - static Future shareSingle(String uri, String mimeType) async { + @override + Future shareSingle(String uri, String mimeType) async { try { final result = await platform.invokeMethod('share', { 'urisByMimeType': { @@ -142,9 +175,10 @@ class AndroidAppService { // app shortcuts // this ability will not change over the lifetime of the app - static bool? _canPin; + bool? _canPin; - static Future canPinToHomeScreen() async { + @override + Future canPinToHomeScreen() async { if (_canPin != null) return SynchronousFuture(_canPin!); try { @@ -159,7 +193,8 @@ class AndroidAppService { return false; } - static Future pinToHomeScreen(String label, AvesEntry? entry, Set filters) async { + @override + Future pinToHomeScreen(String label, AvesEntry? entry, Set filters) async { Uint8List? iconBytes; if (entry != null) { final size = entry.isVideo ? 0.0 : 256.0; diff --git a/lib/services/common/services.dart b/lib/services/common/services.dart index a97b32bf8..9963efef7 100644 --- a/lib/services/common/services.dart +++ b/lib/services/common/services.dart @@ -1,5 +1,6 @@ import 'package:aves/model/availability.dart'; import 'package:aves/model/metadata_db.dart'; +import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/device_service.dart'; import 'package:aves/services/media/embedded_data_service.dart'; import 'package:aves/services/media/media_file_service.dart'; @@ -18,6 +19,7 @@ final p.Context pContext = getIt(); final AvesAvailability availability = getIt(); final MetadataDb metadataDb = getIt(); +final AndroidAppService androidAppService = getIt(); final DeviceService deviceService = getIt(); final EmbeddedDataService embeddedDataService = getIt(); final MediaFileService mediaFileService = getIt(); @@ -33,6 +35,7 @@ void initPlatformServices() { getIt.registerLazySingleton(() => LiveAvesAvailability()); getIt.registerLazySingleton(() => SqfliteMetadataDb()); + getIt.registerLazySingleton(() => PlatformAndroidAppService()); getIt.registerLazySingleton(() => PlatformDeviceService()); getIt.registerLazySingleton(() => PlatformEmbeddedDataService()); getIt.registerLazySingleton(() => PlatformMediaFileService()); diff --git a/lib/utils/android_file_utils.dart b/lib/utils/android_file_utils.dart index 378b4fd22..7132003f7 100644 --- a/lib/utils/android_file_utils.dart +++ b/lib/utils/android_file_utils.dart @@ -1,4 +1,3 @@ -import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/utils/change_notifier.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; @@ -36,15 +35,15 @@ class AndroidFileUtils { // from Aves videoCapturesPath = pContext.join(dcimPath, 'Video Captures'); - _initialized = true; - } - - Future initAppNames() async { + // include package fetching in initialization + // to avoid app album color flickering if (_packages.isEmpty) { - _packages = await AndroidAppService.getPackages(); + _packages = await androidAppService.getPackages(); _potentialAppDirs = _launcherPackages.expand((package) => package.potentialDirs).toList(); appNameChangeNotifier.notifyListeners(); } + + _initialized = true; } bool isCameraPath(String path) => path.startsWith(dcimPath) && (path.endsWith('${separator}Camera') || path.endsWith('${separator}100ANDRO')); diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 91145e206..52699fbd2 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -9,7 +9,7 @@ import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/enums.dart'; -import 'package:aves/services/android_app_service.dart'; +import 'package:aves/services/common/services.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/collection/entry_set_action_delegate.dart'; import 'package:aves/widgets/collection/filter_bar.dart'; @@ -61,7 +61,7 @@ class _CollectionAppBarState extends State with SingleTickerPr vsync: this, ); _isSelectingNotifier.addListener(_onActivityChange); - _canAddShortcutsLoader = AndroidAppService.canPinToHomeScreen(); + _canAddShortcutsLoader = androidAppService.canPinToHomeScreen(); _registerWidget(widget); WidgetsBinding.instance!.addPostFrameCallback((_) => _onFilterChanged()); } @@ -363,7 +363,7 @@ class _CollectionAppBarState extends State with SingleTickerPr final name = result.item2; if (name.isEmpty) return; - unawaited(AndroidAppService.pinToHomeScreen(name, coverEntry, filters)); + unawaited(androidAppService.pinToHomeScreen(name, coverEntry, filters)); } void _goToSearch() { diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index bd1a284ae..e528c2e6c 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -9,7 +9,6 @@ import 'package:aves/model/highlight.dart'; import 'package:aves/model/selection.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; -import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/services/media/enums.dart'; @@ -66,7 +65,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware void _share(BuildContext context) { final selection = context.read>(); final selectedItems = _getExpandedSelectedItems(selection); - AndroidAppService.shareEntries(selectedItems).then((success) { + androidAppService.shareEntries(selectedItems).then((success) { if (!success) showNoMatchingAppDialog(context); }); } diff --git a/lib/widgets/debug/android_apps.dart b/lib/widgets/debug/android_apps.dart index eeaf09a44..c21c8e263 100644 --- a/lib/widgets/debug/android_apps.dart +++ b/lib/widgets/debug/android_apps.dart @@ -1,5 +1,5 @@ import 'package:aves/image_providers/app_icon_image_provider.dart'; -import 'package:aves/services/android_app_service.dart'; +import 'package:aves/services/common/services.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/viewer/info/common.dart'; @@ -21,7 +21,7 @@ class _DebugAndroidAppSectionState extends State with Au @override void initState() { super.initState(); - _loader = AndroidAppService.getPackages(); + _loader = androidAppService.getPackages(); } @override diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index 7c4c4e0a9..af3a0ebaf 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -66,7 +66,6 @@ class _HomePageState extends State { } await androidFileUtils.init(); - unawaited(androidFileUtils.initAppNames()); var appMode = AppMode.main; final intentData = widget.intentData ?? await ViewerService.getIntentData(); diff --git a/lib/widgets/viewer/embedded/embedded_data_opener.dart b/lib/widgets/viewer/embedded/embedded_data_opener.dart index f09a5f04d..6afea2433 100644 --- a/lib/widgets/viewer/embedded/embedded_data_opener.dart +++ b/lib/widgets/viewer/embedded/embedded_data_opener.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:aves/model/entry.dart'; import 'package:aves/ref/mime_types.dart'; -import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/behaviour/routes.dart'; @@ -56,10 +55,10 @@ class EmbeddedDataOpener extends StatelessWidget with FeedbackMixin { final uri = fields['uri']!; if (!MimeTypes.isImage(mimeType) && !MimeTypes.isVideo(mimeType)) { // open with another app - unawaited(AndroidAppService.open(uri, mimeType).then((success) { + 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) { + androidAppService.shareSingle(uri, mimeType).then((success) { if (!success) showNoMatchingAppDialog(context); }); } diff --git a/lib/widgets/viewer/entry_action_delegate.dart b/lib/widgets/viewer/entry_action_delegate.dart index 23b7ffe8e..0b0ddbdd6 100644 --- a/lib/widgets/viewer/entry_action_delegate.dart +++ b/lib/widgets/viewer/entry_action_delegate.dart @@ -10,7 +10,6 @@ import 'package:aves/model/highlight.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/ref/mime_types.dart'; -import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/services/media/enums.dart'; @@ -39,7 +38,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix entry.toggleFavourite(); break; case EntryAction.copyToClipboard: - AndroidAppService.copyToClipboard(entry.uri, entry.bestTitle).then((success) { + androidAppService.copyToClipboard(entry.uri, entry.bestTitle).then((success) { showFeedback(context, success ? context.l10n.genericSuccessFeedback : context.l10n.genericFailureFeedback); }); break; @@ -68,17 +67,17 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix _flip(context, entry); break; case EntryAction.edit: - AndroidAppService.edit(entry.uri, entry.mimeType).then((success) { + androidAppService.edit(entry.uri, entry.mimeType).then((success) { if (!success) showNoMatchingAppDialog(context); }); break; case EntryAction.open: - AndroidAppService.open(entry.uri, entry.mimeTypeAnySubtype).then((success) { + androidAppService.open(entry.uri, entry.mimeTypeAnySubtype).then((success) { if (!success) showNoMatchingAppDialog(context); }); break; case EntryAction.openMap: - AndroidAppService.openMap(entry.latLng!).then((success) { + androidAppService.openMap(entry.latLng!).then((success) { if (!success) showNoMatchingAppDialog(context); }); break; @@ -86,12 +85,12 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix _rotateScreen(context); break; case EntryAction.setAs: - AndroidAppService.setAs(entry.uri, entry.mimeType).then((success) { + androidAppService.setAs(entry.uri, entry.mimeType).then((success) { if (!success) showNoMatchingAppDialog(context); }); break; case EntryAction.share: - AndroidAppService.shareEntries({entry}).then((success) { + androidAppService.shareEntries({entry}).then((success) { if (!success) showNoMatchingAppDialog(context); }); break; diff --git a/lib/widgets/viewer/overlay/bottom/video.dart b/lib/widgets/viewer/overlay/bottom/video.dart index 6474e1d6c..03c055fca 100644 --- a/lib/widgets/viewer/overlay/bottom/video.dart +++ b/lib/widgets/viewer/overlay/bottom/video.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:aves/model/actions/video_actions.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/settings/settings.dart'; -import 'package:aves/services/android_app_service.dart'; +import 'package:aves/services/common/services.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/format.dart'; import 'package:aves/theme/icons.dart'; @@ -74,7 +74,7 @@ class _VideoControlOverlayState extends State with SingleTi scale: scale, child: IconButton( icon: const Icon(AIcons.openOutside), - onPressed: () => AndroidAppService.open(entry.uri, entry.mimeTypeAnySubtype), + onPressed: () => androidAppService.open(entry.uri, entry.mimeTypeAnySubtype), tooltip: context.l10n.viewerOpenTooltip, ), ), diff --git a/test/fake/android_app_service.dart b/test/fake/android_app_service.dart new file mode 100644 index 000000000..9ec20dcd7 --- /dev/null +++ b/test/fake/android_app_service.dart @@ -0,0 +1,9 @@ +import 'package:aves/services/android_app_service.dart'; +import 'package:aves/utils/android_file_utils.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class FakeAndroidAppService extends Fake implements AndroidAppService { + @override + Future> getPackages() => SynchronousFuture({}); +} diff --git a/test/model/collection_source_test.dart b/test/model/collection_source_test.dart index 373321c06..72fd17f64 100644 --- a/test/model/collection_source_test.dart +++ b/test/model/collection_source_test.dart @@ -8,6 +8,7 @@ import 'package:aves/model/metadata_db.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/media_store_source.dart'; +import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/services/device_service.dart'; import 'package:aves/services/media/media_file_service.dart'; @@ -21,6 +22,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; +import '../fake/android_app_service.dart'; import '../fake/availability.dart'; import '../fake/device_service.dart'; import '../fake/media_file_service.dart'; @@ -42,6 +44,7 @@ void main() { getIt.registerLazySingleton(() => FakeAvesAvailability()); getIt.registerLazySingleton(() => FakeMetadataDb()); + getIt.registerLazySingleton(() => FakeAndroidAppService()); getIt.registerLazySingleton(() => FakeDeviceService()); getIt.registerLazySingleton(() => FakeMediaFileService()); getIt.registerLazySingleton(() => FakeMediaStoreService());