From 996bebfdd27624a6ea6f1883fffebf0b6317a191 Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Mon, 28 Mar 2022 01:46:40 +0800 Subject: [PATCH 1/2] (plugin) Add native event stream --- .../plugin/NativeEventChannelHandler.kt | 52 +++++++++++++++++++ .../nkming/nc_photos/plugin/NcPhotosPlugin.kt | 18 +++++++ plugin/lib/nc_photos_plugin.dart | 1 + plugin/lib/src/native_event.dart | 34 ++++++++++++ 4 files changed, 105 insertions(+) create mode 100644 plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NativeEventChannelHandler.kt create mode 100644 plugin/lib/src/native_event.dart diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NativeEventChannelHandler.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NativeEventChannelHandler.kt new file mode 100644 index 00000000..07d12957 --- /dev/null +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NativeEventChannelHandler.kt @@ -0,0 +1,52 @@ +package com.nkming.nc_photos.plugin + +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +class NativeEventChannelHandler : MethodChannel.MethodCallHandler, + EventChannel.StreamHandler { + companion object { + const val EVENT_CHANNEL = "${K.LIB_ID}/native_event" + const val METHOD_CHANNEL = "${K.LIB_ID}/native_event_method" + + private val eventSinks = mutableMapOf() + private var nextId = 0 + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "fire" -> { + try { + fire( + call.argument("event")!!, call.argument("data"), result + ) + } catch (e: Throwable) { + result.error("systemException", e.toString(), null) + } + } + } + } + + override fun onListen(arguments: Any?, events: EventChannel.EventSink) { + eventSinks[id] = events + } + + override fun onCancel(arguments: Any?) { + eventSinks.remove(id) + } + + private fun fire( + event: String, data: String?, result: MethodChannel.Result + ) { + for (s in eventSinks.values) { + s.success(buildMap { + put("event", event) + if (data != null) put("data", data) + }) + } + result.success(null) + } + + private val id = nextId++ +} diff --git a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NcPhotosPlugin.kt b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NcPhotosPlugin.kt index 500256dd..67148ef1 100644 --- a/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NcPhotosPlugin.kt +++ b/plugin/android/src/main/kotlin/com/nkming/nc_photos/plugin/NcPhotosPlugin.kt @@ -2,12 +2,14 @@ package com.nkming.nc_photos.plugin import androidx.annotation.NonNull import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodChannel class NcPhotosPlugin : FlutterPlugin { override fun onAttachedToEngine( @NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding ) { + // this may get called more than once lockChannel = MethodChannel( flutterPluginBinding.binaryMessenger, LockChannelHandler.CHANNEL ) @@ -22,6 +24,18 @@ class NcPhotosPlugin : FlutterPlugin { flutterPluginBinding.applicationContext ) ) + + val nativeEventHandler = NativeEventChannelHandler() + nativeEventChannel = EventChannel( + flutterPluginBinding.binaryMessenger, + NativeEventChannelHandler.EVENT_CHANNEL + ) + nativeEventChannel.setStreamHandler(nativeEventHandler) + nativeEventMethodChannel = MethodChannel( + flutterPluginBinding.binaryMessenger, + NativeEventChannelHandler.METHOD_CHANNEL + ) + nativeEventMethodChannel.setMethodCallHandler(nativeEventHandler) } override fun onDetachedFromEngine( @@ -29,8 +43,12 @@ class NcPhotosPlugin : FlutterPlugin { ) { lockChannel.setMethodCallHandler(null) notificationChannel.setMethodCallHandler(null) + nativeEventChannel.setStreamHandler(null) + nativeEventMethodChannel.setMethodCallHandler(null) } private lateinit var lockChannel: MethodChannel private lateinit var notificationChannel: MethodChannel + private lateinit var nativeEventChannel: EventChannel + private lateinit var nativeEventMethodChannel: MethodChannel } diff --git a/plugin/lib/nc_photos_plugin.dart b/plugin/lib/nc_photos_plugin.dart index 823d1aa7..29afa79d 100644 --- a/plugin/lib/nc_photos_plugin.dart +++ b/plugin/lib/nc_photos_plugin.dart @@ -1,4 +1,5 @@ library nc_photos_plugin; export 'src/lock.dart'; +export 'src/native_event.dart'; export 'src/notification.dart'; diff --git a/plugin/lib/src/native_event.dart b/plugin/lib/src/native_event.dart new file mode 100644 index 00000000..90002537 --- /dev/null +++ b/plugin/lib/src/native_event.dart @@ -0,0 +1,34 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:nc_photos_plugin/src/k.dart' as k; + +class NativeEventObject { + NativeEventObject(this.event, this.data); + + final String event; + final String? data; +} + +class NativeEvent { + static Future fire(NativeEventObject ev) => + _methodChannel.invokeMethod("fire", { + "event": ev.event, + if (ev.data != null) "data": ev.data, + }); + + static Stream get stream => _eventStream; + + static const _eventChannel = EventChannel("${k.libId}/native_event"); + static const _methodChannel = MethodChannel("${k.libId}/native_event_method"); + + static late final _eventStream = _eventChannel + .receiveBroadcastStream() + .map((event) { + if (event is Map) { + return NativeEventObject(event["event"], event["data"]); + } else { + return event; + } + }); +} From 3f8832c0e14e04625f3f9e80ee54d2bbbefe7a3e Mon Sep 17 00:00:00 2001 From: Ming Ming Date: Sat, 2 Apr 2022 00:59:18 +0800 Subject: [PATCH 2/2] Run metadata task in a service on Android --- app/lib/bloc/scan_account_dir.dart | 17 + app/lib/event/native_event.dart | 67 ++++ app/lib/l10n/app_en.arb | 4 + app/lib/l10n/untranslated-messages.txt | 15 +- app/lib/service.dart | 266 +++++++++++++++ app/lib/use_case/update_missing_metadata.dart | 3 + app/lib/widget/home_photos.dart | 307 ++++++++++-------- app/pubspec.lock | 16 + app/pubspec.yaml | 5 + 9 files changed, 571 insertions(+), 129 deletions(-) create mode 100644 app/lib/event/native_event.dart create mode 100644 app/lib/service.dart diff --git a/app/lib/bloc/scan_account_dir.dart b/app/lib/bloc/scan_account_dir.dart index 93f6fa1d..a597b619 100644 --- a/app/lib/bloc/scan_account_dir.dart +++ b/app/lib/bloc/scan_account_dir.dart @@ -12,7 +12,9 @@ import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file/data_source.dart'; import 'package:nc_photos/entity/file_util.dart' as file_util; import 'package:nc_photos/event/event.dart'; +import 'package:nc_photos/event/native_event.dart'; import 'package:nc_photos/exception_event.dart'; +import 'package:nc_photos/platform/k.dart' as platform_k; import 'package:nc_photos/pref.dart'; import 'package:nc_photos/throttler.dart'; import 'package:nc_photos/touch_token_manager.dart'; @@ -112,6 +114,8 @@ class ScanAccountDirBloc _favoriteResyncedEventListener.begin(); _prefUpdatedEventListener.begin(); _accountPrefUpdatedEventListener.begin(); + + _nativeFileExifUpdatedListener?.begin(); } static ScanAccountDirBloc of(Account account) { @@ -164,6 +168,8 @@ class ScanAccountDirBloc _prefUpdatedEventListener.end(); _accountPrefUpdatedEventListener.end(); + _nativeFileExifUpdatedListener?.end(); + _refreshThrottler.clear(); return super.close(); } @@ -304,6 +310,13 @@ class ScanAccountDirBloc } } + void _onNativeFileExifUpdated(FileExifUpdatedEvent ev) { + _refreshThrottler.trigger( + maxResponceTime: const Duration(seconds: 3), + maxPendingCount: 10, + ); + } + Future> _queryOffline(ScanAccountDirBlocQueryBase ev) async { final c = KiwiContainer().resolve(); final files = []; @@ -484,6 +497,10 @@ class ScanAccountDirBloc late final _accountPrefUpdatedEventListener = AppEventListener(_onAccountPrefUpdatedEvent); + late final _nativeFileExifUpdatedListener = platform_k.isWeb + ? null + : NativeEventListener(_onNativeFileExifUpdated); + late final _refreshThrottler = Throttler( onTriggered: (_) { add(const _ScanAccountDirBlocExternalEvent()); diff --git a/app/lib/event/native_event.dart b/app/lib/event/native_event.dart new file mode 100644 index 00000000..f1069ba4 --- /dev/null +++ b/app/lib/event/native_event.dart @@ -0,0 +1,67 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:logging/logging.dart'; +import 'package:nc_photos_plugin/nc_photos_plugin.dart'; + +class NativeEventListener { + NativeEventListener(this.listener); + + void begin() { + if (_subscription != null) { + _log.warning("[begin] Already listening"); + return; + } + _subscription = _mappedStream + .where((event) => event is T) + .cast() + .listen(listener); + } + + void end() { + if (_subscription == null) { + _log.warning("[end] Already not listening"); + return; + } + _subscription?.cancel(); + _subscription = null; + } + + static late final _mappedStream = + NativeEvent.stream.where((event) => event is NativeEventObject).map((ev) { + switch (ev.event) { + case FileExifUpdatedEvent._id: + return FileExifUpdatedEvent.fromEvent(ev); + + default: + throw ArgumentError("Invalid event: ${ev.event}"); + } + }); + + final void Function(T) listener; + StreamSubscription? _subscription; + + final _log = Logger("native_event.NativeEventListener<${T.runtimeType}>"); +} + +class FileExifUpdatedEvent { + const FileExifUpdatedEvent(this.fileIds); + + factory FileExifUpdatedEvent.fromEvent(NativeEventObject ev) { + assert(ev.event == _id); + assert(ev.data != null); + final dataJson = jsonDecode(ev.data!) as Map; + return FileExifUpdatedEvent((dataJson["fileIds"] as List).cast()); + } + + NativeEventObject toEvent() => NativeEventObject( + _id, + jsonEncode({ + "fileIds": fileIds, + }), + ); + + static const _id = "FileExifUpdatedEvent"; + + final List fileIds; +} diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 893482b0..62396e05 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -1167,6 +1167,10 @@ "@tagPickerNoTagSelectedNotification": { "description": "At least 1 tag is required to create a tag collection. This error is shown when user try to create one without selecting any tags" }, + "backgroundServiceStopping": "Stopping service", + "@backgroundServiceStopping": { + "description": "The background service is stopping itself" + }, "errorUnauthenticated": "Unauthenticated access. Please sign-in again if the problem continues", "@errorUnauthenticated": { diff --git a/app/lib/l10n/untranslated-messages.txt b/app/lib/l10n/untranslated-messages.txt index bdbdb90d..4a65f9a1 100644 --- a/app/lib/l10n/untranslated-messages.txt +++ b/app/lib/l10n/untranslated-messages.txt @@ -82,6 +82,7 @@ "createCollectionDialogTagDescription", "addTagInputHint", "tagPickerNoTagSelectedNotification", + "backgroundServiceStopping", "errorAlbumDowngrade" ], @@ -182,6 +183,7 @@ "createCollectionDialogTagDescription", "addTagInputHint", "tagPickerNoTagSelectedNotification", + "backgroundServiceStopping", "errorAlbumDowngrade" ], @@ -337,12 +339,18 @@ "createCollectionDialogTagDescription", "addTagInputHint", "tagPickerNoTagSelectedNotification", + "backgroundServiceStopping", "errorAlbumDowngrade" ], "es": [ "rootPickerSkipConfirmationDialogContent2", - "helpButtonLabel" + "helpButtonLabel", + "backgroundServiceStopping" + ], + + "fi": [ + "backgroundServiceStopping" ], "fr": [ @@ -477,6 +485,7 @@ "createCollectionDialogTagDescription", "addTagInputHint", "tagPickerNoTagSelectedNotification", + "backgroundServiceStopping", "errorAlbumDowngrade" ], @@ -499,7 +508,8 @@ "createCollectionDialogTagLabel", "createCollectionDialogTagDescription", "addTagInputHint", - "tagPickerNoTagSelectedNotification" + "tagPickerNoTagSelectedNotification", + "backgroundServiceStopping" ], "ru": [ @@ -607,6 +617,7 @@ "createCollectionDialogTagDescription", "addTagInputHint", "tagPickerNoTagSelectedNotification", + "backgroundServiceStopping", "errorAlbumDowngrade" ] } diff --git a/app/lib/service.dart b/app/lib/service.dart new file mode 100644 index 00000000..5dad7026 --- /dev/null +++ b/app/lib/service.dart @@ -0,0 +1,266 @@ +import 'package:devicelocale/devicelocale.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_background_service/flutter_background_service.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations_en.dart'; +import 'package:logging/logging.dart'; +import 'package:nc_photos/account.dart'; +import 'package:nc_photos/app_db.dart'; +import 'package:nc_photos/app_init.dart' as app_init; +import 'package:nc_photos/app_localizations.dart'; +import 'package:nc_photos/entity/file.dart'; +import 'package:nc_photos/entity/file/data_source.dart'; +import 'package:nc_photos/entity/file_util.dart' as file_util; +import 'package:nc_photos/event/event.dart'; +import 'package:nc_photos/event/native_event.dart'; +import 'package:nc_photos/language_util.dart' as language_util; +import 'package:nc_photos/pref.dart'; +import 'package:nc_photos/use_case/update_missing_metadata.dart'; +import 'package:nc_photos_plugin/nc_photos_plugin.dart'; + +/// Start the background service +Future startService() async { + _log.info("[startService] Starting service"); + final service = FlutterBackgroundService(); + await service.configure( + androidConfiguration: AndroidConfiguration( + onStart: serviceMain, + autoStart: false, + isForegroundMode: true, + foregroundServiceNotificationTitle: + L10n.global().metadataTaskProcessingNotification, + ), + iosConfiguration: IosConfiguration( + onForeground: () => throw UnimplementedError(), + onBackground: () => throw UnimplementedError(), + ), + ); + await service.start(); +} + +/// Ask the background service to stop ASAP +void stopService() { + _log.info("[stopService] Stopping service"); + FlutterBackgroundService().sendData({ + "stop": true, + }); +} + +@visibleForTesting +void serviceMain() async { + _Service._shouldRun = true; + WidgetsFlutterBinding.ensureInitialized(); + + await _Service()(); +} + +class _Service { + Future call() async { + await app_init.initAppLaunch(); + await _L10n().init(); + + _log.info("[call] Service started"); + final service = FlutterBackgroundService(); + service.setForegroundMode(true); + final onCancelSubscription = service.onCancel.listen((_) { + _log.info("[call] User canceled"); + _stopSelf(); + }); + final onDataSubscription = + service.onDataReceived.listen((event) => _onReceiveData(event ?? {})); + + try { + await _doWork(); + } catch (e, stackTrace) { + _log.shout("[call] Uncaught exception", e, stackTrace); + } + onCancelSubscription.cancel(); + onDataSubscription.cancel(); + service.stopBackgroundService(); + _log.info("[call] Service stopped"); + } + + Future _doWork() async { + final account = Pref().getCurrentAccount(); + if (account == null) { + _log.shout("[_doWork] account == null"); + return; + } + final accountPref = AccountPref.of(account); + + final service = FlutterBackgroundService(); + final metadataTask = _MetadataTask(service, account, accountPref); + _metadataTaskStateChangedListener.begin(); + try { + await metadataTask(); + } finally { + _metadataTaskStateChangedListener.end(); + } + } + + void _onReceiveData(Map data) { + try { + for (final e in data.entries) { + switch (e.key) { + case "stop": + _stopSelf(); + break; + } + } + } catch (e, stackTrace) { + _log.shout("[_onReceiveData] Uncaught exception", e, stackTrace); + } + } + + void _onMetadataTaskStateChanged(MetadataTaskStateChangedEvent ev) { + if (ev.state == _metadataTaskState) { + return; + } + _metadataTaskState = ev.state; + if (ev.state == MetadataTaskState.waitingForWifi) { + final service = FlutterBackgroundService(); + service.setNotificationInfo( + title: _L10n.global().metadataTaskPauseNoWiFiNotification, + ); + } + } + + void _stopSelf() { + _log.info("[_stopSelf] Stopping service"); + FlutterBackgroundService().setNotificationInfo( + title: _L10n.global().backgroundServiceStopping, + ); + _shouldRun = false; + } + + var _metadataTaskState = MetadataTaskState.idle; + late final _metadataTaskStateChangedListener = + AppEventListener( + _onMetadataTaskStateChanged); + + static var _shouldRun = true; + static final _log = Logger("service._Service"); +} + +/// Access localized string out of the main isolate +class _L10n { + factory _L10n() => _inst; + + _L10n._(); + + Future init() async { + try { + final locale = language_util.getSelectedLocale(); + if (locale == null) { + _l10n = await _queryL10n(); + } else { + _l10n = lookupAppLocalizations(locale); + } + } catch (e, stackTrace) { + _log.shout("[init] Uncaught exception", e, stackTrace); + _l10n = AppLocalizationsEn(); + } + } + + static AppLocalizations global() => _L10n()._l10n; + + Future _queryL10n() async { + try { + final locale = await Devicelocale.currentAsLocale; + return lookupAppLocalizations(locale!); + } on FlutterError catch (_) { + // unsupported locale, use default (en) + return AppLocalizationsEn(); + } catch (e, stackTrace) { + _log.shout( + "[_queryL10n] Failed while lookupAppLocalizations", e, stackTrace); + return AppLocalizationsEn(); + } + } + + static late final _inst = _L10n._(); + late AppLocalizations _l10n; + + static final _log = Logger("service._L10n"); +} + +class _MetadataTask { + _MetadataTask(this.service, this.account, this.accountPref); + + Future call() async { + try { + await _updateMetadata(); + } catch (e, stackTrace) { + _log.shout("[call] Uncaught exception", e, stackTrace); + } + if (_processedIds.isNotEmpty) { + NativeEvent.fire(FileExifUpdatedEvent(_processedIds).toEvent()); + _processedIds = []; + } + } + + Future _updateMetadata() async { + final shareFolder = File( + path: file_util.unstripPath(account, accountPref.getShareFolderOr())); + bool hasScanShareFolder = false; + final fileRepo = FileRepo(FileCachedDataSource(AppDb())); + for (final r in account.roots) { + final dir = File(path: file_util.unstripPath(account, r)); + hasScanShareFolder |= file_util.isOrUnderDir(shareFolder, dir); + final updater = UpdateMissingMetadata(fileRepo); + await for (final ev in updater(account, dir)) { + if (!_Service._shouldRun) { + _log.info("[_updateMetadata] Stopping task: user canceled"); + updater.stop(); + return; + } + if (ev is File) { + _onFileProcessed(ev); + } + } + } + if (!hasScanShareFolder) { + final shareUpdater = UpdateMissingMetadata(fileRepo); + await for (final ev in shareUpdater( + account, + shareFolder, + isRecursive: false, + filter: (f) => f.ownerId != account.username, + )) { + if (!_Service._shouldRun) { + _log.info("[_updateMetadata] Stopping task: user canceled"); + shareUpdater.stop(); + return; + } + if (ev is File) { + _onFileProcessed(ev); + } + } + } + } + + void _onFileProcessed(File file) { + ++_count; + service.setNotificationInfo( + title: _L10n.global().metadataTaskProcessingNotification, + content: file.strippedPath, + ); + + _processedIds.add(file.fileId!); + if (_processedIds.length >= 10) { + NativeEvent.fire(FileExifUpdatedEvent(_processedIds).toEvent()); + _processedIds = []; + } + } + + final FlutterBackgroundService service; + final Account account; + final AccountPref accountPref; + + var _count = 0; + var _processedIds = []; + + static final _log = Logger("service._MetadataTask"); +} + +final _log = Logger("service"); diff --git a/app/lib/use_case/update_missing_metadata.dart b/app/lib/use_case/update_missing_metadata.dart index c82bf5a5..cc69d9b3 100644 --- a/app/lib/use_case/update_missing_metadata.dart +++ b/app/lib/use_case/update_missing_metadata.dart @@ -86,6 +86,9 @@ class UpdateMissingMetadata { metadata: OrNull(metadataObj), ); yield file; + + // slow down a bit to give some space for the main isolate + await Future.delayed(const Duration(milliseconds: 10)); } catch (e, stackTrace) { _log.severe("[call] Failed while updating metadata: ${file.path}", e, stackTrace); diff --git a/app/lib/widget/home_photos.dart b/app/lib/widget/home_photos.dart index c1489f96..78f0270f 100644 --- a/app/lib/widget/home_photos.dart +++ b/app/lib/widget/home_photos.dart @@ -22,8 +22,10 @@ import 'package:nc_photos/exception_util.dart' as exception_util; import 'package:nc_photos/iterable_extension.dart'; import 'package:nc_photos/k.dart' as k; import 'package:nc_photos/metadata_task_manager.dart'; +import 'package:nc_photos/platform/k.dart' as platform_k; import 'package:nc_photos/pref.dart'; import 'package:nc_photos/primitive.dart'; +import 'package:nc_photos/service.dart' as service; import 'package:nc_photos/share_handler.dart'; import 'package:nc_photos/snack_bar_manager.dart'; import 'package:nc_photos/theme.dart'; @@ -66,17 +68,14 @@ class _HomePhotosState extends State super.initState(); _thumbZoomLevel = Pref().getHomePhotosZoomLevelOr(0); _initBloc(); - _metadataTaskStateChangedListener.begin(); + _web?.onInitState(); _prefUpdatedListener.begin(); - _filePropertyUpdatedListener.begin(); } @override dispose() { - _metadataTaskIconController.stop(); - _metadataTaskStateChangedListener.end(); _prefUpdatedListener.end(); - _filePropertyUpdatedListener.end(); + _web?.onDispose(); super.dispose(); } @@ -137,8 +136,7 @@ class _HomePhotosState extends State controller: _scrollController, slivers: [ _buildAppBar(context), - if (_metadataTaskState != MetadataTaskState.idle) - _buildMetadataTaskHeader(context), + _web?.buildContent(context), if (AccountPref.of(widget.account) .isEnableMemoryAlbumOr(true) && _smartAlbums.isNotEmpty) @@ -151,7 +149,7 @@ class _HomePhotosState extends State }); }, ), - ], + ].whereType().toList(), ), ), ), @@ -260,81 +258,6 @@ class _HomePhotosState extends State ); } - Widget _buildMetadataTaskHeader(BuildContext context) { - return SliverPersistentHeader( - pinned: true, - floating: false, - delegate: _MetadataTaskHeaderDelegate( - extent: _metadataTaskHeaderHeight, - builder: (context) => Container( - height: double.infinity, - color: Theme.of(context).scaffoldBackgroundColor, - alignment: AlignmentDirectional.centerStart, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Row( - children: [ - if (_metadataTaskState == MetadataTaskState.prcoessing) - Row( - mainAxisSize: MainAxisSize.min, - children: [ - _MetadataTaskLoadingIcon( - controller: _metadataTaskIconController, - ), - const SizedBox(width: 4), - Text( - L10n.global().metadataTaskProcessingNotification + - _getMetadataTaskProgressString(), - style: const TextStyle(fontSize: 12), - ), - ], - ) - else if (_metadataTaskState == MetadataTaskState.waitingForWifi) - Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.sync_problem, - size: 16, - ), - const SizedBox(width: 4), - Text( - L10n.global().metadataTaskPauseNoWiFiNotification, - style: const TextStyle(fontSize: 12), - ), - ], - ), - Expanded( - child: Container(), - ), - Material( - type: MaterialType.transparency, - child: InkWell( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 4), - child: Text( - L10n.global().configButtonLabel, - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontSize: 12, - ), - ), - ), - onTap: () { - Navigator.of(context).pushNamed(Settings.routeName, - arguments: SettingsArguments(widget.account)); - }, - ), - ), - ], - ), - ), - ), - ), - ); - } - Widget _buildSmartAlbumList(BuildContext context) { return SliverToBoxAdapter( child: SizedBox( @@ -487,48 +410,44 @@ class _HomePhotosState extends State ); } - void _onMetadataTaskStateChanged(MetadataTaskStateChangedEvent ev) { - if (ev.state == MetadataTaskState.idle) { - _metadataTaskProcessCount = 0; - } - if (ev.state != _metadataTaskState) { - setState(() { - _metadataTaskState = ev.state; - }); - } - } - void _onPrefUpdated(PrefUpdatedEvent ev) { - if (ev.key == PrefKey.enableExif && ev.value == true) { - _tryStartMetadataTask(ignoreFired: true); + if (ev.key == PrefKey.enableExif) { + if (ev.value == true) { + _tryStartMetadataTask(ignoreFired: true); + } else { + _stopMetadataTask(); + } } } - void _onFilePropertyUpdated(FilePropertyUpdatedEvent ev) { - if (!ev.hasAnyProperties([FilePropertyUpdatedEvent.propMetadata])) { - return; - } - setState(() { - ++_metadataTaskProcessCount; - }); - } - void _tryStartMetadataTask({ bool ignoreFired = false, }) { if (_bloc.state is ScanAccountDirBlocSuccess && Pref().isEnableExifOr() && (!_hasFiredMetadataTask.value || ignoreFired)) { - MetadataTaskManager().addTask( - MetadataTask(widget.account, AccountPref.of(widget.account))); - _metadataTaskProcessTotalCount = _backingFiles + final missingMetadataCount = _backingFiles .where( (f) => file_util.isSupportedImageFormat(f) && f.metadata == null) .length; + if (missingMetadataCount > 0) { + if (_web != null) { + _web!.startMetadataTask(missingMetadataCount); + } else { + service.startService(); + } + } + _hasFiredMetadataTask.value = true; } } + void _stopMetadataTask() { + if (_web == null) { + service.stopService(); + } + } + Future _syncFavorite() async { if (!_hasResyncedFavorites.value) { final c = KiwiContainer().resolve(); @@ -622,10 +541,7 @@ class _HomePhotosState extends State if (_itemListMaxExtent != null && constraints.hasBoundedHeight && _appBarExtent != null) { - final metadataTaskHeaderExtent = - _metadataTaskState == MetadataTaskState.idle - ? 0 - : _metadataTaskHeaderHeight; + final metadataTaskHeaderExtent = _web?.getHeaderHeight() ?? 0; final smartAlbumListHeight = AccountPref.of(widget.account).isEnableMemoryAlbumOr(true) && _smartAlbums.isNotEmpty @@ -647,15 +563,6 @@ class _HomePhotosState extends State } } - String _getMetadataTaskProgressString() { - if (_metadataTaskProcessTotalCount == 0) { - return ""; - } - final clippedCount = - math.min(_metadataTaskProcessCount, _metadataTaskProcessTotalCount - 1); - return " ($clippedCount/$_metadataTaskProcessTotalCount)"; - } - Primitive get _hasFiredMetadataTask { final name = bloc_util.getInstNameForRootAwareAccount( "HomePhotosState.hasFiredMetadataTask", widget.account); @@ -699,12 +606,161 @@ class _HomePhotosState extends State double? _appBarExtent; double? _itemListMaxExtent; + late final _prefUpdatedListener = + AppEventListener(_onPrefUpdated); + + late final _Web? _web = platform_k.isWeb ? _Web(this) : null; + + static final _log = Logger("widget.home_photos._HomePhotosState"); + static const _menuValueRefresh = 0; +} + +class _Web { + _Web(this.state); + + void onInitState() { + _metadataTaskStateChangedListener.begin(); + _filePropertyUpdatedListener.begin(); + } + + void onDispose() { + _metadataTaskIconController.stop(); + _metadataTaskStateChangedListener.end(); + _filePropertyUpdatedListener.end(); + } + + Widget? buildContent(BuildContext context) { + if (_metadataTaskState != MetadataTaskState.idle) { + return _buildMetadataTaskHeader(context); + } else { + return null; + } + } + + void startMetadataTask(int missingMetadataCount) { + MetadataTaskManager().addTask(MetadataTask( + state.widget.account, AccountPref.of(state.widget.account))); + _metadataTaskProcessTotalCount = missingMetadataCount; + } + + double getHeaderHeight() { + return _metadataTaskState == MetadataTaskState.idle + ? 0 + : _metadataTaskHeaderHeight; + } + + Widget _buildMetadataTaskHeader(BuildContext context) { + return SliverPersistentHeader( + pinned: true, + floating: false, + delegate: _MetadataTaskHeaderDelegate( + extent: _metadataTaskHeaderHeight, + builder: (context) => Container( + height: double.infinity, + color: Theme.of(context).scaffoldBackgroundColor, + alignment: AlignmentDirectional.centerStart, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + if (_metadataTaskState == MetadataTaskState.prcoessing) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _MetadataTaskLoadingIcon( + controller: _metadataTaskIconController, + ), + const SizedBox(width: 4), + Text( + L10n.global().metadataTaskProcessingNotification + + _getMetadataTaskProgressString(), + style: const TextStyle(fontSize: 12), + ), + ], + ) + else if (_metadataTaskState == MetadataTaskState.waitingForWifi) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.sync_problem, + size: 16, + ), + const SizedBox(width: 4), + Text( + L10n.global().metadataTaskPauseNoWiFiNotification, + style: const TextStyle(fontSize: 12), + ), + ], + ), + Expanded( + child: Container(), + ), + Material( + type: MaterialType.transparency, + child: InkWell( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + child: Text( + L10n.global().configButtonLabel, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontSize: 12, + ), + ), + ), + onTap: () { + Navigator.of(context).pushNamed(Settings.routeName, + arguments: SettingsArguments(state.widget.account)); + }, + ), + ), + ], + ), + ), + ), + ), + ); + } + + void _onMetadataTaskStateChanged(MetadataTaskStateChangedEvent ev) { + if (ev.state == MetadataTaskState.idle) { + _metadataTaskProcessCount = 0; + } + if (ev.state != _metadataTaskState) { + // ignore: invalid_use_of_protected_member + state.setState(() { + _metadataTaskState = ev.state; + }); + } + } + + void _onFilePropertyUpdated(FilePropertyUpdatedEvent ev) { + if (!ev.hasAnyProperties([FilePropertyUpdatedEvent.propMetadata])) { + return; + } + // ignore: invalid_use_of_protected_member + state.setState(() { + ++_metadataTaskProcessCount; + }); + } + + String _getMetadataTaskProgressString() { + if (_metadataTaskProcessTotalCount == 0) { + return ""; + } + final clippedCount = + math.min(_metadataTaskProcessCount, _metadataTaskProcessTotalCount - 1); + return " ($clippedCount/$_metadataTaskProcessTotalCount)"; + } + + final _HomePhotosState state; + late final _metadataTaskStateChangedListener = AppEventListener( _onMetadataTaskStateChanged); var _metadataTaskState = MetadataTaskManager().state; - late final _prefUpdatedListener = - AppEventListener(_onPrefUpdated); late final _filePropertyUpdatedListener = AppEventListener(_onFilePropertyUpdated); var _metadataTaskProcessCount = 0; @@ -712,12 +768,9 @@ class _HomePhotosState extends State late final _metadataTaskIconController = AnimationController( upperBound: 2 * math.pi, duration: const Duration(seconds: 10), - vsync: this, + vsync: state, )..repeat(); - static final _log = Logger("widget.home_photos._HomePhotosState"); - static const _menuValueRefresh = 0; - static const _metadataTaskHeaderHeight = 32.0; } diff --git a/app/pubspec.lock b/app/pubspec.lock index 7fde0b69..2edbf446 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -232,6 +232,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.1" + devicelocale: + dependency: "direct main" + description: + name: devicelocale + url: "https://pub.dartlang.org" + source: hosted + version: "0.5.0" diff_match_patch: dependency: transitive description: @@ -297,6 +304,15 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_background_service: + dependency: "direct main" + description: + path: "." + ref: "v0.2.6-nc-photos-1" + resolved-ref: c63c7ed595d39897db4791266e6208abd7d688e5 + url: "https://gitlab.com/nc-photos/flutter_background_service.git" + source: git + version: "0.2.6" flutter_bloc: dependency: "direct main" description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 8dd07518..be3f258b 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -32,6 +32,7 @@ dependencies: cached_network_image: ^3.0.0 collection: ^1.15.0 connectivity_plus: ^2.0.2 + devicelocale: ^0.5.0 device_info_plus: ^3.1.0 draggable_scrollbar: git: @@ -43,6 +44,10 @@ dependencies: git: url: https://gitlab.com/nc-photos/exifdart.git ref: 1.1.0 + flutter_background_service: + git: + url: https://gitlab.com/nc-photos/flutter_background_service.git + ref: v0.2.6-nc-photos-1 flutter_bloc: ^7.0.0 flutter_map: ^0.14.0 flutter_staggered_grid_view: