Bläddra i källkod

feat(mobile): background backup progress notifications (#781)

* settings to configure upload progress notifications (none/standard/detailed)
* use native Android notifications to show progress information
* e.g. 50% (30/60) assets
* e.g. Uploading asset XYZ - 25% (2/8MB)
* no longer show errors if canceled by system (losing network)
Fynn Petersen-Frey 2 år sedan
förälder
incheckning
5dfce4db34

+ 57 - 37
mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt

@@ -1,5 +1,6 @@
 package app.alextran.immich
 
+import android.app.Notification
 import android.app.NotificationChannel
 import android.app.NotificationManager
 import android.content.Context
@@ -47,6 +48,8 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
     private val notificationManager = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
     private val isIgnoringBatteryOptimizations = isIgnoringBatteryOptimizations(applicationContext)
     private var timeBackupStarted: Long = 0L
+    private var notificationBuilder: NotificationCompat.Builder? = null
+    private var notificationDetailBuilder: NotificationCompat.Builder? = null
 
     override fun startWork(): ListenableFuture<ListenableWorker.Result> {
 
@@ -61,16 +64,14 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
             // Create a Notification channel if necessary
             createChannel()
         }
-        val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
-            .getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!!
         if (isIgnoringBatteryOptimizations) {
             // normal background services can only up to 10 minutes
             // foreground services are allowed to run indefinitely
             // requires battery optimizations to be disabled (either manually by the user
             // or by the system learning that immich is important to the user)
-            setForegroundAsync(createForegroundInfo(title))
-        } else {
-            showBackgroundInfo(title)
+            val title = ctx.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE)
+                .getString(SHARED_PREF_NOTIFICATION_TITLE, NOTIFICATION_DEFAULT_TITLE)!!
+            showInfo(getInfoBuilder(title, indeterminate=true).build())
         }
         engine = FlutterEngine(ctx)
 
@@ -154,18 +155,21 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
             }
             "updateNotification" -> {
                 val args = call.arguments<ArrayList<*>>()!!
-                val title = args.get(0) as String
-                val content = args.get(1) as String
-                if (isIgnoringBatteryOptimizations) {
-                    setForegroundAsync(createForegroundInfo(title, content))
-                } else {
-                    showBackgroundInfo(title, content)
+                val title = args.get(0) as String?
+                val content = args.get(1) as String?
+                val progress = args.get(2) as Int
+                val max = args.get(3) as Int
+                val indeterminate = args.get(4) as Boolean
+                val isDetail = args.get(5) as Boolean
+                val onlyIfFG = args.get(6) as Boolean
+                if (!onlyIfFG || isIgnoringBatteryOptimizations) {
+                    showInfo(getInfoBuilder(title, content, isDetail, progress, max, indeterminate).build(), isDetail)
                 }
             }
             "showError" -> {
                 val args = call.arguments<ArrayList<*>>()!!
                 val title = args.get(0) as String
-                val content = args.get(1) as String
+                val content = args.get(1) as String?
                 val individualTag = args.get(2) as String?
                 showError(title, content, individualTag)
             }
@@ -182,13 +186,12 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
         }
     }
 
-    private fun showError(title: String, content: String, individualTag: 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)
-           .setOnlyAlertOnce(true)
            .build()
         notificationManager.notify(individualTag, NOTIFICATION_ERROR_ID, notification)
     }
@@ -197,38 +200,54 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
         notificationManager.cancel(NOTIFICATION_ERROR_ID)
     }
 
-    private fun showBackgroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null) {
-        val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
-           .setContentTitle(title)
-           .setTicker(title)
-           .setContentText(content)
-           .setSmallIcon(R.mipmap.ic_launcher)
-           .setOnlyAlertOnce(true)
-           .setOngoing(true)
-           .build()
-        notificationManager.notify(NOTIFICATION_ID, notification)
-    }
-
     private fun clearBackgroundNotification() {
         notificationManager.cancel(NOTIFICATION_ID)
+        notificationManager.cancel(NOTIFICATION_DETAIL_ID)
     }
 
-    private fun createForegroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null): ForegroundInfo {
-       val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
-           .setContentTitle(title)
-           .setTicker(title)
-           .setContentText(content)
-           .setSmallIcon(R.mipmap.ic_launcher)
-           .setOngoing(true)
-           .build()
-       return ForegroundInfo(NOTIFICATION_ID, notification)
-   }
+    private fun showInfo(notification: Notification, isDetail: Boolean = false) {
+        val id = if(isDetail) NOTIFICATION_DETAIL_ID else NOTIFICATION_ID
+        if (isIgnoringBatteryOptimizations) {
+            setForegroundAsync(ForegroundInfo(id, notification))
+        } else {
+            notificationManager.notify(id, notification)
+        }
+    }
+
+    private fun getInfoBuilder(
+        title: String? = null,
+        content: String? = null,
+        isDetail: Boolean = false,
+        progress: Int = 0,
+        max: Int = 0,
+        indeterminate: Boolean = false,
+    ): NotificationCompat.Builder {
+        var builder = if(isDetail) notificationDetailBuilder else notificationBuilder
+        if (builder == null) {
+            builder = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
+                .setSmallIcon(R.mipmap.ic_launcher)
+                .setOnlyAlertOnce(true)
+                .setOngoing(true)
+            if (isDetail) {
+                notificationDetailBuilder = builder
+            } else {
+                notificationBuilder = builder
+            }
+        }
+        if (title != null) {
+            builder.setTicker(title).setContentTitle(title)
+        }
+        if (content != null) {
+            builder.setContentText(content)
+        }
+        return builder.setProgress(max, progress, indeterminate)
+    }
 
     @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_DEFAULT)
+        val error = NotificationChannel(NOTIFICATION_CHANNEL_ERROR_ID, NOTIFICATION_CHANNEL_ERROR_ID, NotificationManager.IMPORTANCE_HIGH)
         notificationManager.createNotificationChannel(error)
     }
 
@@ -244,6 +263,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
         private const val NOTIFICATION_DEFAULT_TITLE = "Immich"
         private const val NOTIFICATION_ID = 1
         private const val NOTIFICATION_ERROR_ID = 2 
+        private const val NOTIFICATION_DETAIL_ID = 3
         private const val ONE_MINUTE = 60000L
 
         /**

+ 4 - 0
mobile/assets/i18n/en-US.json

@@ -134,6 +134,10 @@
   "setting_notifications_notify_never": "never",
   "setting_notifications_subtitle": "Adjust your notification preferences",
   "setting_notifications_title": "Notifications",
+  "setting_notifications_total_progress_title": "Show background backup total progress",
+  "setting_notifications_total_progress_subtitle": "Overall upload progress (done/total assets)",
+  "setting_notifications_single_progress_title": "Show background backup detail progress",
+  "setting_notifications_single_progress_subtitle": "Detailed upload progress information per asset",
   "setting_pages_app_bar_settings": "Settings",
   "share_add": "Add",
   "share_add_photos": "Add photos",

+ 101 - 23
mobile/lib/modules/backup/background_service/background.service.dart

@@ -27,11 +27,11 @@ final backgroundServiceProvider = Provider(
 /// Background backup service
 class BackgroundService {
   static const String _portNameLock = "immichLock";
-  BackgroundService();
   static const MethodChannel _foregroundChannel =
       MethodChannel('immich/foregroundChannel');
   static const MethodChannel _backgroundChannel =
       MethodChannel('immich/backgroundChannel');
+  static final NumberFormat numberFormat = NumberFormat("###0.##");
   bool _isBackgroundInitialized = false;
   CancellationToken? _cancellationToken;
   bool _canceledBySystem = false;
@@ -40,6 +40,10 @@ class BackgroundService {
   SendPort? _waitingIsolate;
   ReceivePort? _rp;
   bool _errorGracePeriodExceeded = true;
+  int _uploadedAssetsCount = 0;
+  int _assetsToUploadCount = 0;
+  int _lastDetailProgressUpdate = 0;
+  String _lastPrintedProgress = "";
 
   bool get isBackgroundInitialized {
     return _isBackgroundInitialized;
@@ -125,22 +129,29 @@ class BackgroundService {
   }
 
   /// Updates the notification shown by the background service
-  Future<bool> _updateNotification({
-    required String title,
+  Future<bool?> _updateNotification({
+    String? title,
     String? content,
+    int progress = 0,
+    int max = 0,
+    bool indeterminate = false,
+    bool isDetail = false,
+    bool onlyIfFG = false,
   }) async {
     if (!Platform.isAndroid) {
       return true;
     }
     try {
       if (_isBackgroundInitialized) {
-        return await _backgroundChannel
-            .invokeMethod('updateNotification', [title, content]);
+        return _backgroundChannel.invokeMethod<bool>(
+          'updateNotification',
+          [title, content, progress, max, indeterminate, isDetail, onlyIfFG],
+        );
       }
     } catch (error) {
       debugPrint("[_updateNotification] failed to communicate with plugin");
     }
-    return Future.value(false);
+    return false;
   }
 
   /// Shows a new priority notification
@@ -274,6 +285,7 @@ class BackgroundService {
       case "onAssetsChanged":
         final Future<bool> translationsLoaded = loadTranslations();
         try {
+          _clearErrorNotifications();
           final bool hasAccess = await acquireLock();
           if (!hasAccess) {
             debugPrint("[_callHandler] could not acquire lock, exiting");
@@ -313,19 +325,23 @@ class BackgroundService {
     apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
     apiService.setAccessToken(Hive.box(userInfoBox).get(accessTokenKey));
     BackupService backupService = BackupService(apiService);
+    AppSettingsService settingsService = AppSettingsService();
 
     final Box<HiveBackupAlbums> box =
         await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
     final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
     if (backupAlbumInfo == null) {
-      _clearErrorNotifications();
       return true;
     }
 
     await PhotoManager.setIgnorePermissionCheck(true);
 
     do {
-      final bool backupOk = await _runBackup(backupService, backupAlbumInfo);
+      final bool backupOk = await _runBackup(
+        backupService,
+        settingsService,
+        backupAlbumInfo,
+      );
       if (backupOk) {
         await Hive.box(backgroundBackupInfoBox).delete(backupFailedSince);
         await box.put(
@@ -346,9 +362,14 @@ class BackgroundService {
 
   Future<bool> _runBackup(
     BackupService backupService,
+    AppSettingsService settingsService,
     HiveBackupAlbums backupAlbumInfo,
   ) async {
-    _errorGracePeriodExceeded = _isErrorGracePeriodExceeded();
+    _errorGracePeriodExceeded = _isErrorGracePeriodExceeded(settingsService);
+    final bool notifyTotalProgress = settingsService
+        .getSetting<bool>(AppSettingsEnum.backgroundBackupTotalProgress);
+    final bool notifySingleProgress = settingsService
+        .getSetting<bool>(AppSettingsEnum.backgroundBackupSingleProgress);
 
     if (_canceledBySystem) {
       return false;
@@ -372,22 +393,29 @@ class BackgroundService {
     }
 
     if (toUpload.isEmpty) {
-      _clearErrorNotifications();
       return true;
     }
+    _assetsToUploadCount = toUpload.length;
+    _uploadedAssetsCount = 0;
+    _updateNotification(
+      title: "backup_background_service_in_progress_notification".tr(),
+      content: notifyTotalProgress ? _formatAssetBackupProgress() : null,
+      progress: 0,
+      max: notifyTotalProgress ? _assetsToUploadCount : 0,
+      indeterminate: !notifyTotalProgress,
+      onlyIfFG: !notifyTotalProgress,
+    );
 
     _cancellationToken = CancellationToken();
     final bool ok = await backupService.backupAsset(
       toUpload,
       _cancellationToken!,
-      _onAssetUploaded,
-      _onProgress,
-      _onSetCurrentBackupAsset,
+      notifyTotalProgress ? _onAssetUploaded : (assetId, deviceId) {},
+      notifySingleProgress ? _onProgress : (sent, total) {},
+      notifySingleProgress ? _onSetCurrentBackupAsset : (asset) {},
       _onBackupError,
     );
-    if (ok) {
-      _clearErrorNotifications();
-    } else {
+    if (!ok && !_cancellationToken!.isCancelled) {
       _showErrorNotification(
         title: "backup_background_service_error_title".tr(),
         content: "backup_background_service_backup_failed_message".tr(),
@@ -396,16 +424,43 @@ class BackgroundService {
     return ok;
   }
 
+  String _formatAssetBackupProgress() {
+    final int percent = (_uploadedAssetsCount * 100) ~/ _assetsToUploadCount;
+    return "$percent% ($_uploadedAssetsCount/$_assetsToUploadCount)";
+  }
+
   void _onAssetUploaded(String deviceAssetId, String deviceId) {
     debugPrint("Uploaded $deviceAssetId from $deviceId");
+    _uploadedAssetsCount++;
+    _updateNotification(
+      progress: _uploadedAssetsCount,
+      max: _assetsToUploadCount,
+      content: _formatAssetBackupProgress(),
+    );
   }
 
-  void _onProgress(int sent, int total) {}
+  void _onProgress(int sent, int total) {
+    final int now = Timeline.now;
+    // limit updates to 10 per second (or Android drops important notifications)
+    if (now > _lastDetailProgressUpdate + 100000) {
+      final String msg = _humanReadableBytesProgress(sent, total);
+      // only update if message actually differs (to stop many useless notification updates on large assets or slow connections)
+      if (msg != _lastPrintedProgress) {
+        _lastDetailProgressUpdate = now;
+        _lastPrintedProgress = msg;
+        _updateNotification(
+          progress: sent,
+          max: total,
+          isDetail: true,
+          content: msg,
+        );
+      }
+    }
+  }
 
   void _onBackupError(ErrorUploadAsset errorAssetInfo) {
     _showErrorNotification(
-      title: "Upload failed",
-      content: "backup_background_service_upload_failure_notification"
+      title: "backup_background_service_upload_failure_notification"
           .tr(args: [errorAssetInfo.fileName]),
       individualTag: errorAssetInfo.id,
     );
@@ -413,14 +468,17 @@ class BackgroundService {
 
   void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
     _updateNotification(
-      title: "backup_background_service_in_progress_notification".tr(),
-      content: "backup_background_service_current_upload_notification"
+      title: "backup_background_service_current_upload_notification"
           .tr(args: [currentUploadAsset.fileName]),
+      content: "",
+      isDetail: true,
+      progress: 0,
+      max: 0,
     );
   }
 
-  bool _isErrorGracePeriodExceeded() {
-    final int value = AppSettingsService()
+  bool _isErrorGracePeriodExceeded(AppSettingsService appSettingsService) {
+    final int value = appSettingsService
         .getSetting(AppSettingsEnum.uploadErrorNotificationGracePeriod);
     if (value == 0) {
       return true;
@@ -445,6 +503,26 @@ class BackgroundService {
     assert(false, "Invalid value");
     return true;
   }
+
+  /// prints percentage and absolute progress in useful (kilo/mega/giga)bytes
+  static String _humanReadableBytesProgress(int bytes, int bytesTotal) {
+    String unit = "KB"; // Kilobyte
+    if (bytesTotal >= 0x40000000) {
+      unit = "GB"; // Gigabyte
+      bytes >>= 20;
+      bytesTotal >>= 20;
+    } else if (bytesTotal >= 0x100000) {
+      unit = "MB"; // Megabyte
+      bytes >>= 10;
+      bytesTotal >>= 10;
+    } else if (bytesTotal < 0x400) {
+      return "$bytes / $bytesTotal B";
+    }
+    final int percent = (bytes * 100) ~/ bytesTotal;
+    final String done = numberFormat.format(bytes / 1024.0);
+    final String total = numberFormat.format(bytesTotal / 1024.0);
+    return "$percent% ($done/$total$unit)";
+  }
 }
 
 /// entry point called by Kotlin/Java code; needs to be a top-level function

+ 5 - 1
mobile/lib/modules/settings/services/app_settings.service.dart

@@ -6,7 +6,11 @@ enum AppSettingsEnum<T> {
   themeMode<String>("themeMode", "system"), // "light","dark","system"
   tilesPerRow<int>("tilesPerRow", 4),
   uploadErrorNotificationGracePeriod<int>(
-      "uploadErrorNotificationGracePeriod", 2),
+    "uploadErrorNotificationGracePeriod",
+    2,
+  ),
+  backgroundBackupTotalProgress<bool>("backgroundBackupTotalProgress", true),
+  backgroundBackupSingleProgress<bool>("backgroundBackupSingleProgress", false),
   storageIndicator<bool>("storageIndicator", true),
   thumbnailCacheSize<int>("thumbnailCacheSize", 10000),
   imageCacheSize<int>("imageCacheSize", 350),

+ 49 - 1
mobile/lib/modules/settings/ui/notification_setting/notification_setting.dart

@@ -15,12 +15,20 @@ class NotificationSetting extends HookConsumerWidget {
     final appSettingService = ref.watch(appSettingsServiceProvider);
 
     final sliderValue = useState(0.0);
+    final totalProgressValue =
+        useState(AppSettingsEnum.backgroundBackupTotalProgress.defaultValue);
+    final singleProgressValue =
+        useState(AppSettingsEnum.backgroundBackupSingleProgress.defaultValue);
 
     useEffect(
       () {
         sliderValue.value = appSettingService
             .getSetting<int>(AppSettingsEnum.uploadErrorNotificationGracePeriod)
             .toDouble();
+        totalProgressValue.value = appSettingService
+            .getSetting<bool>(AppSettingsEnum.backgroundBackupTotalProgress);
+        singleProgressValue.value = appSettingService
+            .getSetting<bool>(AppSettingsEnum.backgroundBackupSingleProgress);
         return null;
       },
       [],
@@ -42,6 +50,22 @@ class NotificationSetting extends HookConsumerWidget {
         ),
       ).tr(),
       children: [
+        _buildSwitchListTile(
+          context,
+          appSettingService,
+          totalProgressValue,
+          AppSettingsEnum.backgroundBackupTotalProgress,
+          title: 'setting_notifications_total_progress_title'.tr(),
+          subtitle: 'setting_notifications_total_progress_subtitle'.tr(),
+        ),
+        _buildSwitchListTile(
+          context,
+          appSettingService,
+          singleProgressValue,
+          AppSettingsEnum.backgroundBackupSingleProgress,
+          title: 'setting_notifications_single_progress_title'.tr(),
+          subtitle: 'setting_notifications_single_progress_subtitle'.tr(),
+        ),
         ListTile(
           isThreeLine: false,
           dense: true,
@@ -53,7 +77,9 @@ class NotificationSetting extends HookConsumerWidget {
             value: sliderValue.value,
             onChanged: (double v) => sliderValue.value = v,
             onChangeEnd: (double v) => appSettingService.setSetting(
-                AppSettingsEnum.uploadErrorNotificationGracePeriod, v.toInt()),
+              AppSettingsEnum.uploadErrorNotificationGracePeriod,
+              v.toInt(),
+            ),
             max: 5.0,
             divisions: 5,
             label: formattedValue,
@@ -65,6 +91,28 @@ class NotificationSetting extends HookConsumerWidget {
   }
 }
 
+SwitchListTile _buildSwitchListTile(
+  BuildContext context,
+  AppSettingsService appSettingService,
+  ValueNotifier<bool> valueNotifier,
+  AppSettingsEnum settingsEnum, {
+  required String title,
+  String? subtitle,
+}) {
+  return SwitchListTile(
+    key: Key(settingsEnum.name),
+    value: valueNotifier.value,
+    onChanged: (value) {
+      valueNotifier.value = value;
+      appSettingService.setSetting(settingsEnum, value);
+    },
+    activeColor: Theme.of(context).primaryColor,
+    dense: true,
+    title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
+    subtitle: subtitle != null ? Text(subtitle) : null,
+  );
+}
+
 String _formatSliderValue(double v) {
   if (v == 0.0) {
     return 'setting_notifications_notify_immediately'.tr();