diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt b/mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt index 3cb231eaf605e..1e9b2502d10f8 100644 --- a/mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt +++ b/mobile/android/app/src/main/kotlin/com/example/mobile/BackgroundServicePlugin.kt @@ -54,7 +54,9 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler { val args = call.arguments>()!! val requireUnmeteredNetwork = args.get(0) as Boolean val requireCharging = args.get(1) as Boolean - ContentObserverWorker.configureWork(ctx, requireUnmeteredNetwork, requireCharging) + val triggerUpdateDelay = (args.get(2) as Number).toLong() + val triggerMaxDelay = (args.get(3) as Number).toLong() + ContentObserverWorker.configureWork(ctx, requireUnmeteredNetwork, requireCharging, triggerUpdateDelay, triggerMaxDelay) result.success(true) } "disable" -> { diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/ContentObserverWorker.kt b/mobile/android/app/src/main/kotlin/com/example/mobile/ContentObserverWorker.kt index a58ea14518c6e..59ca6d5638a56 100644 --- a/mobile/android/app/src/main/kotlin/com/example/mobile/ContentObserverWorker.kt +++ b/mobile/android/app/src/main/kotlin/com/example/mobile/ContentObserverWorker.kt @@ -37,6 +37,8 @@ class ContentObserverWorker(ctx: Context, params: WorkerParameters) : Worker(ctx const val SHARED_PREF_SERVICE_ENABLED = "serviceEnabled" const val SHARED_PREF_REQUIRE_WIFI = "requireWifi" const val SHARED_PREF_REQUIRE_CHARGING = "requireCharging" + const val SHARED_PREF_TRIGGER_UPDATE_DELAY = "triggerUpdateDelay" + const val SHARED_PREF_TRIGGER_MAX_DELAY = "triggerMaxDelay" private const val TASK_NAME_OBSERVER = "immich/ContentObserver" @@ -62,12 +64,16 @@ class ContentObserverWorker(ctx: Context, params: WorkerParameters) : Worker(ctx */ fun configureWork(context: Context, requireWifi: Boolean = false, - requireCharging: Boolean = false) { + requireCharging: Boolean = false, + triggerUpdateDelay: Long = 5000, + triggerMaxDelay: Long = 50000) { context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) .edit() .putBoolean(SHARED_PREF_SERVICE_ENABLED, true) .putBoolean(SHARED_PREF_REQUIRE_WIFI, requireWifi) .putBoolean(SHARED_PREF_REQUIRE_CHARGING, requireCharging) + .putLong(SHARED_PREF_TRIGGER_UPDATE_DELAY, triggerUpdateDelay) + .putLong(SHARED_PREF_TRIGGER_MAX_DELAY, triggerMaxDelay) .apply() BackupWorker.updateBackupWorker(context, requireWifi, requireCharging) } @@ -106,12 +112,14 @@ class ContentObserverWorker(ctx: Context, params: WorkerParameters) : Worker(ctx } private fun enqueueObserverWorker(context: Context, policy: ExistingWorkPolicy) { + val sp = context.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE) val constraints = Constraints.Builder() .addContentUriTrigger(MediaStore.Images.Media.INTERNAL_CONTENT_URI, true) .addContentUriTrigger(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true) .addContentUriTrigger(MediaStore.Video.Media.INTERNAL_CONTENT_URI, true) .addContentUriTrigger(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, true) - .setTriggerContentUpdateDelay(5000, TimeUnit.MILLISECONDS) + .setTriggerContentUpdateDelay(sp.getLong(SHARED_PREF_TRIGGER_UPDATE_DELAY, 5000), TimeUnit.MILLISECONDS) + .setTriggerContentMaxDelay(sp.getLong(SHARED_PREF_TRIGGER_MAX_DELAY, 50000), TimeUnit.MILLISECONDS) .build() val work = OneTimeWorkRequest.Builder(ContentObserverWorker::class.java) diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index b10b9b5e92cbc..b989253c054a3 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -41,6 +41,7 @@ "backup_controller_page_background_turn_off": "Turn off background service", "backup_controller_page_background_turn_on": "Turn on background service", "backup_controller_page_background_wifi": "Only on WiFi", + "backup_controller_page_background_delay": "Delay new assets backup: {}", "backup_controller_page_backup": "Backup", "backup_controller_page_backup_selected": "Selected: ", "backup_controller_page_backup_sub": "Backed up photos and videos", @@ -134,6 +135,7 @@ "setting_notifications_notify_hours": "{} hours", "setting_notifications_notify_immediately": "immediately", "setting_notifications_notify_minutes": "{} minutes", + "setting_notifications_notify_seconds": "{} seconds", "setting_notifications_notify_never": "never", "setting_notifications_subtitle": "Adjust your notification preferences", "setting_notifications_title": "Notifications", diff --git a/mobile/lib/constants/hive_box.dart b/mobile/lib/constants/hive_box.dart index 704be3586e84e..0c3b04bd7e67e 100644 --- a/mobile/lib/constants/hive_box.dart +++ b/mobile/lib/constants/hive_box.dart @@ -26,6 +26,7 @@ const String backgroundBackupInfoBox = "immichBackgroundBackupInfoBox"; // Box const String backupFailedSince = "immichBackupFailedSince"; // Key 1 const String backupRequireWifi = "immichBackupRequireWifi"; // Key 2 const String backupRequireCharging = "immichBackupRequireCharging"; // Key 3 +const String backupTriggerDelay = "immichBackupTriggerDelay"; // Key 4 // Duplicate asset const String duplicatedAssetsBox = "immichDuplicatedAssetsBox"; // Box diff --git a/mobile/lib/modules/backup/background_service/background.service.dart b/mobile/lib/modules/backup/background_service/background.service.dart index 8b48934917aa0..c05341627a33e 100644 --- a/mobile/lib/modules/backup/background_service/background.service.dart +++ b/mobile/lib/modules/backup/background_service/background.service.dart @@ -86,6 +86,8 @@ class BackgroundService { Future configureService({ bool requireUnmetered = true, bool requireCharging = false, + int triggerUpdateDelay = 5000, + int triggerMaxDelay = 50000, }) async { if (!Platform.isAndroid) { return true; @@ -93,7 +95,12 @@ class BackgroundService { try { final bool ok = await _foregroundChannel.invokeMethod( 'configure', - [requireUnmetered, requireCharging], + [ + requireUnmetered, + requireCharging, + triggerUpdateDelay, + triggerMaxDelay + ], ); return ok; } catch (error) { diff --git a/mobile/lib/modules/backup/models/backup_state.model.dart b/mobile/lib/modules/backup/models/backup_state.model.dart index d026be23fe682..ab08d1f4ba902 100644 --- a/mobile/lib/modules/backup/models/backup_state.model.dart +++ b/mobile/lib/modules/backup/models/backup_state.model.dart @@ -18,6 +18,7 @@ class BackUpState { final bool backgroundBackup; final bool backupRequireWifi; final bool backupRequireCharging; + final int backupTriggerDelay; /// All available albums on the device final List availableAlbums; @@ -42,6 +43,7 @@ class BackUpState { required this.backgroundBackup, required this.backupRequireWifi, required this.backupRequireCharging, + required this.backupTriggerDelay, required this.availableAlbums, required this.selectedBackupAlbums, required this.excludedBackupAlbums, @@ -59,6 +61,7 @@ class BackUpState { bool? backgroundBackup, bool? backupRequireWifi, bool? backupRequireCharging, + int? backupTriggerDelay, List? availableAlbums, Set? selectedBackupAlbums, Set? excludedBackupAlbums, @@ -76,6 +79,7 @@ class BackUpState { backupRequireWifi: backupRequireWifi ?? this.backupRequireWifi, backupRequireCharging: backupRequireCharging ?? this.backupRequireCharging, + backupTriggerDelay: backupTriggerDelay ?? this.backupTriggerDelay, availableAlbums: availableAlbums ?? this.availableAlbums, selectedBackupAlbums: selectedBackupAlbums ?? this.selectedBackupAlbums, excludedBackupAlbums: excludedBackupAlbums ?? this.excludedBackupAlbums, @@ -88,7 +92,7 @@ class BackUpState { @override String toString() { - return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)'; + return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)'; } @override @@ -105,6 +109,7 @@ class BackUpState { other.backgroundBackup == backgroundBackup && other.backupRequireWifi == backupRequireWifi && other.backupRequireCharging == backupRequireCharging && + other.backupTriggerDelay == backupTriggerDelay && collectionEquals(other.availableAlbums, availableAlbums) && collectionEquals(other.selectedBackupAlbums, selectedBackupAlbums) && collectionEquals(other.excludedBackupAlbums, excludedBackupAlbums) && @@ -126,6 +131,7 @@ class BackUpState { backgroundBackup.hashCode ^ backupRequireWifi.hashCode ^ backupRequireCharging.hashCode ^ + backupTriggerDelay.hashCode ^ availableAlbums.hashCode ^ selectedBackupAlbums.hashCode ^ excludedBackupAlbums.hashCode ^ diff --git a/mobile/lib/modules/backup/providers/backup.provider.dart b/mobile/lib/modules/backup/providers/backup.provider.dart index aabad64c1ee79..218d4db21b164 100644 --- a/mobile/lib/modules/backup/providers/backup.provider.dart +++ b/mobile/lib/modules/backup/providers/backup.provider.dart @@ -38,6 +38,7 @@ class BackupNotifier extends StateNotifier { backgroundBackup: false, backupRequireWifi: true, backupRequireCharging: false, + backupTriggerDelay: 5000, serverInfo: ServerInfoResponseDto( diskAvailable: "0", diskAvailableRaw: 0, @@ -119,18 +120,26 @@ class BackupNotifier extends StateNotifier { bool? enabled, bool? requireWifi, bool? requireCharging, + int? triggerDelay, required void Function(String msg) onError, required void Function() onBatteryInfo, }) async { - assert(enabled != null || requireWifi != null || requireCharging != null); + assert( + enabled != null || + requireWifi != null || + requireCharging != null || + triggerDelay != null, + ); if (Platform.isAndroid) { final bool wasEnabled = state.backgroundBackup; final bool wasWifi = state.backupRequireWifi; - final bool wasCharing = state.backupRequireCharging; + final bool wasCharging = state.backupRequireCharging; + final int oldTriggerDelay = state.backupTriggerDelay; state = state.copyWith( backgroundBackup: enabled, backupRequireWifi: requireWifi, backupRequireCharging: requireCharging, + backupTriggerDelay: triggerDelay, ); if (state.backgroundBackup) { @@ -145,17 +154,22 @@ class BackupNotifier extends StateNotifier { await _backgroundService.configureService( requireUnmetered: state.backupRequireWifi, requireCharging: state.backupRequireCharging, + triggerUpdateDelay: state.backupTriggerDelay, + triggerMaxDelay: state.backupTriggerDelay * 10, ); if (success) { - await Hive.box(backgroundBackupInfoBox) - .put(backupRequireWifi, state.backupRequireWifi); - await Hive.box(backgroundBackupInfoBox) - .put(backupRequireCharging, state.backupRequireCharging); + final box = Hive.box(backgroundBackupInfoBox); + await Future.wait([ + box.put(backupRequireWifi, state.backupRequireWifi), + box.put(backupRequireCharging, state.backupRequireCharging), + box.put(backupTriggerDelay, state.backupTriggerDelay), + ]); } else { state = state.copyWith( backgroundBackup: wasEnabled, backupRequireWifi: wasWifi, - backupRequireCharging: wasCharing, + backupRequireCharging: wasCharging, + backupTriggerDelay: oldTriggerDelay, ); onError("backup_controller_page_background_configure_error"); } @@ -602,6 +616,7 @@ class BackupNotifier extends StateNotifier { excludedBackupAlbums: excludedAlbums, backupRequireWifi: backgroundBox.get(backupRequireWifi), backupRequireCharging: backgroundBox.get(backupRequireCharging), + backupTriggerDelay: backgroundBox.get(backupTriggerDelay), ); } return _resumeBackup(); diff --git a/mobile/lib/modules/backup/views/backup_controller_page.dart b/mobile/lib/modules/backup/views/backup_controller_page.dart index 1ca866b0df7e5..c14f68d2f2992 100644 --- a/mobile/lib/modules/backup/views/backup_controller_page.dart +++ b/mobile/lib/modules/backup/views/backup_controller_page.dart @@ -198,6 +198,46 @@ class BackupControllerPage extends HookConsumerWidget { final bool isWifiRequired = backupState.backupRequireWifi; final bool isChargingRequired = backupState.backupRequireCharging; final Color activeColor = Theme.of(context).primaryColor; + + String formatBackupDelaySliderValue(double v) { + if (v == 0.0) { + return 'setting_notifications_notify_seconds'.tr(args: const ['5']); + } else if (v == 1.0) { + return 'setting_notifications_notify_seconds'.tr(args: const ['30']); + } else if (v == 2.0) { + return 'setting_notifications_notify_minutes'.tr(args: const ['2']); + } else { + return 'setting_notifications_notify_minutes'.tr(args: const ['10']); + } + } + + int backupDelayToMilliseconds(double v) { + if (v == 0.0) { + return 5000; + } else if (v == 1.0) { + return 30000; + } else if (v == 2.0) { + return 120000; + } else { + return 600000; + } + } + + double backupDelayToSliderValue(int ms) { + if (ms == 5000) { + return 0.0; + } else if (ms == 30000) { + return 1.0; + } else if (ms == 120000) { + return 2.0; + } else { + return 3.0; + } + } + + final triggerDelay = + useState(backupDelayToSliderValue(backupState.backupTriggerDelay)); + return ListTile( isThreeLine: true, leading: isBackgroundEnabled @@ -264,6 +304,35 @@ class BackupControllerPage extends HookConsumerWidget { ) : null, ), + if (isBackgroundEnabled) + ListTile( + isThreeLine: false, + dense: true, + enabled: hasExclusiveAccess, + title: const Text( + 'backup_controller_page_background_delay', + style: TextStyle( + fontWeight: FontWeight.bold, + ), + ).tr(args: [formatBackupDelaySliderValue(triggerDelay.value)]), + subtitle: Slider( + value: triggerDelay.value, + onChanged: hasExclusiveAccess + ? (double v) => triggerDelay.value = v + : null, + onChangeEnd: (double v) => ref + .read(backupProvider.notifier) + .configureBackgroundBackup( + triggerDelay: backupDelayToMilliseconds(v), + onError: showErrorToUser, + onBatteryInfo: showBatteryOptimizationInfoToUser, + ), + max: 3.0, + divisions: 3, + label: formatBackupDelaySliderValue(triggerDelay.value), + activeColor: Theme.of(context).primaryColor, + ), + ), ElevatedButton( onPressed: () => ref.read(backupProvider.notifier).configureBackgroundBackup(