Просмотр исходного кода

show notifications on background backup errors (#496)

* show notifications on background backup errors

* settings page to configure (background backup error) notifications

* persist time since failed background backup

* fix darkmode slider color
Fynn Petersen-Frey 2 лет назад
Родитель
Сommit
3125d04f32

+ 20 - 8
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<ArrayList<*>>()!!
                 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)

+ 11 - 1
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"
 }

+ 4 - 0
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

+ 94 - 18
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<bool> updateNotification({
-    String title = "Immich",
+  Future<bool> _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<bool> showErrorNotification(
-    String title,
-    String content,
-  ) async {
+  Future<bool> _showErrorNotification({
+    required String title,
+    String? content,
+    String? individualTag,
+  }) async {
     if (!Platform.isAndroid) {
       return true;
     }
     try {
-      if (_isBackgroundInitialized) {
+      if (_isBackgroundInitialized && _errorGracePeriodExceeded) {
         return await _backgroundChannel
-            .invokeMethod('showError', [title, content]);
+            .invokeMethod('showError', [title, content, individualTag]);
       }
     } catch (error) {
-      debugPrint("[showErrorNotification] failed to communicate with plugin");
+      debugPrint("[_showErrorNotification] failed to communicate with plugin");
     }
-    return Future.value(false);
+    return false;
+  }
+
+  Future<bool> _clearErrorNotifications() async {
+    if (!Platform.isAndroid) {
+      return true;
+    }
+    try {
+      if (_isBackgroundInitialized) {
+        return await _backgroundChannel.invokeMethod('clearErrorNotifications');
+      }
+    } catch (error) {
+      debugPrint(
+          "[_clearErrorNotifications] failed to communicate with plugin");
+    }
+    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<HiveSavedLoginInfo>(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<HiveBackupAlbums>(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<AssetEntity> toUpload =
-        await backupService.getAssetsToBackup(backupAlbumInfo);
+    List<AssetEntity> 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

+ 4 - 16
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<List<AssetEntity>> getAssetsToBackup(
-    HiveBackupAlbums backupAlbumInfo,
-  ) async {
-    final List<AssetEntity> candidates =
-        await _buildUploadCandidates(backupAlbumInfo);
-
-    final List<AssetEntity> toUpload = candidates.isEmpty
-        ? []
-        : await _removeAlreadyUploadedAssets(candidates);
-    return toUpload;
-  }
-
-  Future<List<AssetEntity>> _buildUploadCandidates(
+  /// Returns all assets newer than the last successful backup per album
+  Future<List<AssetEntity>> buildUploadCandidates(
     HiveBackupAlbums backupAlbums,
   ) async {
     final filter = FilterOptionGroup(
@@ -147,7 +134,8 @@ class BackupService {
     return result;
   }
 
-  Future<List<AssetEntity>> _removeAlreadyUploadedAssets(
+  /// Returns a new list of assets not yet uploaded
+  Future<List<AssetEntity>> removeAlreadyUploadedAssets(
     List<AssetEntity> candidates,
   ) async {
     final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);

+ 2 - 0
mobile/lib/modules/settings/services/app_settings.service.dart

@@ -5,6 +5,8 @@ enum AppSettingsEnum<T> {
   threeStageLoading<bool>("threeStageLoading", false),
   themeMode<String>("themeMode", "system"), // "light","dark","system"
   tilesPerRow<int>("tilesPerRow", 4),
+  uploadErrorNotificationGracePeriod<int>(
+      "uploadErrorNotificationGracePeriod", 2),
   storageIndicator<bool>("storageIndicator", true);
 
   const AppSettingsEnum(this.hiveKey, this.defaultValue);

+ 1 - 0
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,
         ),
       ],
     );

+ 82 - 0
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<int>(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();
+  }
+}

+ 3 - 1
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(),
         ],