diff --git a/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt b/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt index fb47da6a1d..6e2795a8fa 100644 --- a/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt +++ b/mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt @@ -138,6 +138,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct immediate = true, requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true), requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false), + initialDelayInMs = ONE_MINUTE, retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1) } engine?.destroy() @@ -169,6 +170,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct immediate = true, requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true), requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false), + initialDelayInMs = ONE_MINUTE, retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1) } } @@ -186,22 +188,27 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct val args = call.arguments>()!! val title = args.get(0) as String val content = args.get(1) as String - showError(title, content) + val individualTag = args.get(2) as String? + showError(title, content, individualTag) } + "clearErrorNotifications" -> clearErrorNotifications() else -> r.notImplemented() } } - private fun showError(title: String, content: String) { + private fun showError(title: String, content: String, individualTag: String?) { val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ERROR_ID) .setContentTitle(title) .setTicker(title) .setContentText(content) .setSmallIcon(R.mipmap.ic_launcher) - .setAutoCancel(true) + .setOnlyAlertOnce(true) .build() - val notificationId = SystemClock.uptimeMillis() as Int - notificationManager.notify(notificationId, notification) + notificationManager.notify(individualTag, NOTIFICATION_ERROR_ID, notification) + } + + private fun clearErrorNotifications() { + notificationManager.cancel(NOTIFICATION_ERROR_ID) } private fun createForegroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null): ForegroundInfo { @@ -212,14 +219,14 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct .setSmallIcon(R.mipmap.ic_launcher) .setOngoing(true) .build() - return ForegroundInfo(1, notification) + return ForegroundInfo(NOTIFICATION_ID, notification) } @RequiresApi(Build.VERSION_CODES.O) private fun createChannel() { val foreground = NotificationChannel(NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_ID, NotificationManager.IMPORTANCE_LOW) notificationManager.createNotificationChannel(foreground) - val error = NotificationChannel(NOTIFICATION_CHANNEL_ERROR_ID, NOTIFICATION_CHANNEL_ERROR_ID, NotificationManager.IMPORTANCE_HIGH) + val error = NotificationChannel(NOTIFICATION_CHANNEL_ERROR_ID, NOTIFICATION_CHANNEL_ERROR_ID, NotificationManager.IMPORTANCE_DEFAULT) notificationManager.createNotificationChannel(error) } @@ -236,6 +243,9 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService" private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError" private const val NOTIFICATION_DEFAULT_TITLE = "Immich" + private const val NOTIFICATION_ID = 1 + private const val NOTIFICATION_ERROR_ID = 2 + private const val ONE_MINUTE: Long = 60000 /** * Enqueues the `BackupWorker` to run when all constraints are met. @@ -262,6 +272,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct keepExisting: Boolean = false, requireUnmeteredNetwork: Boolean = false, requireCharging: Boolean = false, + initialDelayInMs: Long = 0, retries: Int = 0) { if (!isEnabled(context)) { return @@ -287,9 +298,10 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct val photoCheck = OneTimeWorkRequest.Builder(BackupWorker::class.java) .setConstraints(constraints.build()) .setInputData(inputData) + .setInitialDelay(initialDelayInMs, TimeUnit.MILLISECONDS) .setBackoffCriteria( BackoffPolicy.EXPONENTIAL, - OneTimeWorkRequest.MIN_BACKOFF_MILLIS, + ONE_MINUTE, TimeUnit.MILLISECONDS) .build() val policy = if (immediate) ExistingWorkPolicy.REPLACE else (if (keepExisting) ExistingWorkPolicy.KEEP else ExistingWorkPolicy.APPEND_OR_REPLACE) diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 54dfc2dbe5..db18dca740 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -21,6 +21,9 @@ "backup_background_service_upload_failure_notification": "Failed to upload {}", "backup_background_service_in_progress_notification": "Backing up your assets…", "backup_background_service_current_upload_notification": "Uploading {}", + "backup_background_service_error_title": "Backup error", + "backup_background_service_connection_failed_message": "Failed to connect to the server. Retrying…", + "backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…", "backup_controller_page_albums": "Backup Albums", "backup_controller_page_backup": "Backup", "backup_controller_page_backup_selected": "Selected: ", @@ -139,5 +142,12 @@ "asset_list_settings_title": "Photo Grid", "asset_list_settings_subtitle": "Photo grid layout settings", "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", - "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})" + "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})", + "setting_notifications_title": "Notifications", + "setting_notifications_subtitle": "Adjust your notification preferences", + "setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}", + "setting_notifications_notify_immediately": "immediately", + "setting_notifications_notify_minutes": "{} minutes", + "setting_notifications_notify_hours": "{} hours", + "setting_notifications_notify_never": "never" } diff --git a/mobile/lib/constants/hive_box.dart b/mobile/lib/constants/hive_box.dart index a82c7c9b97..d422af08f2 100644 --- a/mobile/lib/constants/hive_box.dart +++ b/mobile/lib/constants/hive_box.dart @@ -19,3 +19,7 @@ const String githubReleaseInfoKey = "immichGithubReleaseInfoKey"; // Key 1 // User Setting Info const String userSettingInfoBox = "immichUserSettingInfoBox"; + +// Background backup Info +const String backgroundBackupInfoBox = "immichBackgroundBackupInfoBox"; // Box +const String backupFailedSince = "immichBackupFailedSince"; // Key 1 \ No newline at end of file diff --git a/mobile/lib/modules/backup/background_service/background.service.dart b/mobile/lib/modules/backup/background_service/background.service.dart index 87acdbc40d..5581831513 100644 --- a/mobile/lib/modules/backup/background_service/background.service.dart +++ b/mobile/lib/modules/backup/background_service/background.service.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'dart:isolate'; import 'dart:ui' show IsolateNameServer, PluginUtilities; import 'package:cancellation_token_http/http.dart'; +import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; @@ -16,6 +17,7 @@ import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dar import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart'; import 'package:immich_mobile/modules/backup/services/backup.service.dart'; import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart'; +import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/shared/services/api.service.dart'; import 'package:photo_manager/photo_manager.dart'; @@ -39,6 +41,7 @@ class BackgroundService { bool _hasLock = false; SendPort? _waitingIsolate; ReceivePort? _rp; + bool _errorGracePeriodExceeded = true; bool get isForegroundInitialized { return _isForegroundInitialized; @@ -140,8 +143,8 @@ class BackgroundService { } /// Updates the notification shown by the background service - Future updateNotification({ - String title = "Immich", + Future _updateNotification({ + required String title, String? content, }) async { if (!Platform.isAndroid) { @@ -153,28 +156,44 @@ class BackgroundService { .invokeMethod('updateNotification', [title, content]); } } catch (error) { - debugPrint("[updateNotification] failed to communicate with plugin"); + debugPrint("[_updateNotification] failed to communicate with plugin"); } return Future.value(false); } /// Shows a new priority notification - Future showErrorNotification( - String title, - String content, - ) async { + Future _showErrorNotification({ + required String title, + String? content, + String? individualTag, + }) async { + if (!Platform.isAndroid) { + return true; + } + try { + if (_isBackgroundInitialized && _errorGracePeriodExceeded) { + return await _backgroundChannel + .invokeMethod('showError', [title, content, individualTag]); + } + } catch (error) { + debugPrint("[_showErrorNotification] failed to communicate with plugin"); + } + return false; + } + + Future _clearErrorNotifications() async { if (!Platform.isAndroid) { return true; } try { if (_isBackgroundInitialized) { - return await _backgroundChannel - .invokeMethod('showError', [title, content]); + return await _backgroundChannel.invokeMethod('clearErrorNotifications'); } } catch (error) { - debugPrint("[showErrorNotification] failed to communicate with plugin"); + debugPrint( + "[_clearErrorNotifications] failed to communicate with plugin"); } - return Future.value(false); + return false; } /// await to ensure this thread (foreground or background) has exclusive access @@ -278,7 +297,15 @@ class BackgroundService { return false; } await translationsLoaded; - return await _onAssetsChanged(); + final bool ok = await _onAssetsChanged(); + if (ok) { + Hive.box(backgroundBackupInfoBox).delete(backupFailedSince); + } else if (Hive.box(backgroundBackupInfoBox).get(backupFailedSince) == + null) { + Hive.box(backgroundBackupInfoBox) + .put(backupFailedSince, DateTime.now()); + } + return ok; } catch (error) { debugPrint(error.toString()); return false; @@ -303,6 +330,8 @@ class BackgroundService { Hive.registerAdapter(HiveBackupAlbumsAdapter()); await Hive.openBox(userInfoBox); await Hive.openBox(hiveLoginInfoBox); + await Hive.openBox(userSettingInfoBox); + await Hive.openBox(backgroundBackupInfoBox); ApiService apiService = ApiService(); apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey)); @@ -313,23 +342,36 @@ class BackgroundService { await Hive.openBox(hiveBackupInfoBox); final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey); if (backupAlbumInfo == null) { + _clearErrorNotifications(); return true; } await PhotoManager.setIgnorePermissionCheck(true); + _errorGracePeriodExceeded = _isErrorGracePeriodExceeded(); if (_canceledBySystem) { return false; } - final List toUpload = - await backupService.getAssetsToBackup(backupAlbumInfo); + List toUpload = + await backupService.buildUploadCandidates(backupAlbumInfo); + + try { + toUpload = await backupService.removeAlreadyUploadedAssets(toUpload); + } catch (e) { + _showErrorNotification( + title: "backup_background_service_error_title".tr(), + content: "backup_background_service_connection_failed_message".tr(), + ); + return false; + } if (_canceledBySystem) { return false; } if (toUpload.isEmpty) { + _clearErrorNotifications(); return true; } @@ -343,10 +385,16 @@ class BackgroundService { _onBackupError, ); if (ok) { + _clearErrorNotifications(); await box.put( backupInfoKey, backupAlbumInfo, ); + } else { + _showErrorNotification( + title: "backup_background_service_error_title".tr(), + content: "backup_background_service_backup_failed_message".tr(), + ); } return ok; } @@ -358,20 +406,48 @@ class BackgroundService { void _onProgress(int sent, int total) {} void _onBackupError(ErrorUploadAsset errorAssetInfo) { - showErrorNotification( - "backup_background_service_upload_failure_notification" + _showErrorNotification( + title: "Upload failed", + content: "backup_background_service_upload_failure_notification" .tr(args: [errorAssetInfo.fileName]), - errorAssetInfo.errorMessage, + individualTag: errorAssetInfo.id, ); } void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) { - updateNotification( + _updateNotification( title: "backup_background_service_in_progress_notification".tr(), content: "backup_background_service_current_upload_notification" .tr(args: [currentUploadAsset.fileName]), ); } + + bool _isErrorGracePeriodExceeded() { + final int value = AppSettingsService() + .getSetting(AppSettingsEnum.uploadErrorNotificationGracePeriod); + if (value == 0) { + return true; + } else if (value == 5) { + return false; + } + final DateTime? failedSince = + Hive.box(backgroundBackupInfoBox).get(backupFailedSince); + if (failedSince == null) { + return false; + } + final Duration duration = DateTime.now().difference(failedSince); + if (value == 1) { + return duration > const Duration(minutes: 30); + } else if (value == 2) { + return duration > const Duration(hours: 2); + } else if (value == 3) { + return duration > const Duration(hours: 8); + } else if (value == 4) { + return duration > const Duration(hours: 24); + } + assert(false, "Invalid value"); + return true; + } } /// entry point called by Kotlin/Java code; needs to be a top-level function diff --git a/mobile/lib/modules/backup/services/backup.service.dart b/mobile/lib/modules/backup/services/backup.service.dart index 8f4daff59b..fe39029016 100644 --- a/mobile/lib/modules/backup/services/backup.service.dart +++ b/mobile/lib/modules/backup/services/backup.service.dart @@ -41,21 +41,8 @@ class BackupService { } } - /// Returns all assets to backup from the backup info taking into account the - /// time of the last successfull backup per album - Future> getAssetsToBackup( - HiveBackupAlbums backupAlbumInfo, - ) async { - final List candidates = - await _buildUploadCandidates(backupAlbumInfo); - - final List toUpload = candidates.isEmpty - ? [] - : await _removeAlreadyUploadedAssets(candidates); - return toUpload; - } - - Future> _buildUploadCandidates( + /// Returns all assets newer than the last successful backup per album + Future> buildUploadCandidates( HiveBackupAlbums backupAlbums, ) async { final filter = FilterOptionGroup( @@ -147,7 +134,8 @@ class BackupService { return result; } - Future> _removeAlreadyUploadedAssets( + /// Returns a new list of assets not yet uploaded + Future> removeAlreadyUploadedAssets( List candidates, ) async { final String deviceId = Hive.box(userInfoBox).get(deviceIdKey); diff --git a/mobile/lib/modules/settings/services/app_settings.service.dart b/mobile/lib/modules/settings/services/app_settings.service.dart index 38f32822b6..918d18b7a0 100644 --- a/mobile/lib/modules/settings/services/app_settings.service.dart +++ b/mobile/lib/modules/settings/services/app_settings.service.dart @@ -5,6 +5,8 @@ enum AppSettingsEnum { threeStageLoading("threeStageLoading", false), themeMode("themeMode", "system"), // "light","dark","system" tilesPerRow("tilesPerRow", 4), + uploadErrorNotificationGracePeriod( + "uploadErrorNotificationGracePeriod", 2), storageIndicator("storageIndicator", true); const AppSettingsEnum(this.hiveKey, this.defaultValue); diff --git a/mobile/lib/modules/settings/ui/asset_list_settings/asset_list_tiles_per_row.dart b/mobile/lib/modules/settings/ui/asset_list_settings/asset_list_tiles_per_row.dart index 734bbf30cc..a99b3a11ec 100644 --- a/mobile/lib/modules/settings/ui/asset_list_settings/asset_list_tiles_per_row.dart +++ b/mobile/lib/modules/settings/ui/asset_list_settings/asset_list_tiles_per_row.dart @@ -56,6 +56,7 @@ class TilesPerRow extends HookConsumerWidget { max: 6, divisions: 4, label: "${itemsValue.value.toInt()}", + activeColor: Theme.of(context).primaryColor, ), ], ); diff --git a/mobile/lib/modules/settings/ui/notification_setting/notification_setting.dart b/mobile/lib/modules/settings/ui/notification_setting/notification_setting.dart new file mode 100644 index 0000000000..1643a830b5 --- /dev/null +++ b/mobile/lib/modules/settings/ui/notification_setting/notification_setting.dart @@ -0,0 +1,82 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; +import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; + +class NotificationSetting extends HookConsumerWidget { + const NotificationSetting({ + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final appSettingService = ref.watch(appSettingsServiceProvider); + + final sliderValue = useState(0.0); + + useEffect( + () { + sliderValue.value = appSettingService + .getSetting(AppSettingsEnum.uploadErrorNotificationGracePeriod) + .toDouble(); + return null; + }, + [], + ); + + final String formattedValue = _formatSliderValue(sliderValue.value); + return ExpansionTile( + textColor: Theme.of(context).primaryColor, + title: const Text( + 'setting_notifications_title', + style: TextStyle( + fontWeight: FontWeight.bold, + ), + ).tr(), + subtitle: const Text( + 'setting_notifications_subtitle', + style: TextStyle( + fontSize: 13, + ), + ).tr(), + children: [ + ListTile( + isThreeLine: false, + dense: true, + title: const Text( + 'setting_notifications_notify_failures_grace_period', + style: TextStyle(fontWeight: FontWeight.bold), + ).tr(args: [formattedValue]), + subtitle: Slider( + value: sliderValue.value, + onChanged: (double v) => sliderValue.value = v, + onChangeEnd: (double v) => appSettingService.setSetting( + AppSettingsEnum.uploadErrorNotificationGracePeriod, v.toInt()), + max: 5.0, + divisions: 5, + label: formattedValue, + activeColor: Theme.of(context).primaryColor, + ), + ), + ], + ); + } +} + +String _formatSliderValue(double v) { + if (v == 0.0) { + return 'setting_notifications_notify_immediately'.tr(); + } else if (v == 1.0) { + return 'setting_notifications_notify_minutes'.tr(args: const ['30']); + } else if (v == 2.0) { + return 'setting_notifications_notify_hours'.tr(args: const ['2']); + } else if (v == 3.0) { + return 'setting_notifications_notify_hours'.tr(args: const ['8']); + } else if (v == 4.0) { + return 'setting_notifications_notify_hours'.tr(args: const ['24']); + } else { + return 'setting_notifications_notify_never'.tr(); + } +} diff --git a/mobile/lib/modules/settings/views/settings_page.dart b/mobile/lib/modules/settings/views/settings_page.dart index 84563a2f76..8cbedd0efc 100644 --- a/mobile/lib/modules/settings/views/settings_page.dart +++ b/mobile/lib/modules/settings/views/settings_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/settings/ui/asset_list_settings/asset_list_settings.dart'; import 'package:immich_mobile/modules/settings/ui/image_viewer_quality_setting/image_viewer_quality_setting.dart'; +import 'package:immich_mobile/modules/settings/ui/notification_setting/notification_setting.dart'; import 'package:immich_mobile/modules/settings/ui/theme_setting/theme_setting.dart'; class SettingsPage extends HookConsumerWidget { @@ -37,7 +38,8 @@ class SettingsPage extends HookConsumerWidget { tiles: [ const ImageViewerQualitySetting(), const ThemeSetting(), - const AssetListSettings() + const AssetListSettings(), + const NotificationSetting(), ], ).toList(), ],