From 5dfce4db341ce921ae41045f435e216eebde4a85 Mon Sep 17 00:00:00 2001 From: Fynn Petersen-Frey Date: Wed, 5 Oct 2022 16:59:35 +0200 Subject: [PATCH] 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) --- .../kotlin/com/example/mobile/BackupWorker.kt | 94 ++++++++----- mobile/assets/i18n/en-US.json | 4 + .../background.service.dart | 128 ++++++++++++++---- .../services/app_settings.service.dart | 6 +- .../notification_setting.dart | 50 ++++++- 5 files changed, 218 insertions(+), 64 deletions(-) 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 24bbd1785d..116422634c 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 @@ -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 { @@ -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>()!! - 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>()!! 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 /** diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index c29e842faf..ff313fcbc8 100644 --- a/mobile/assets/i18n/en-US.json +++ b/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", diff --git a/mobile/lib/modules/backup/background_service/background.service.dart b/mobile/lib/modules/backup/background_service/background.service.dart index 1af6dc9816..f5a0086b5b 100644 --- a/mobile/lib/modules/backup/background_service/background.service.dart +++ b/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 _updateNotification({ - required String title, + Future _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( + '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 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 box = await Hive.openBox(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 _runBackup( BackupService backupService, + AppSettingsService settingsService, HiveBackupAlbums backupAlbumInfo, ) async { - _errorGracePeriodExceeded = _isErrorGracePeriodExceeded(); + _errorGracePeriodExceeded = _isErrorGracePeriodExceeded(settingsService); + final bool notifyTotalProgress = settingsService + .getSetting(AppSettingsEnum.backgroundBackupTotalProgress); + final bool notifySingleProgress = settingsService + .getSetting(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; } - void _onAssetUploaded(String deviceAssetId, String deviceId) { - debugPrint("Uploaded $deviceAssetId from $deviceId"); + String _formatAssetBackupProgress() { + final int percent = (_uploadedAssetsCount * 100) ~/ _assetsToUploadCount; + return "$percent% ($_uploadedAssetsCount/$_assetsToUploadCount)"; } - void _onProgress(int sent, int total) {} + 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) { + 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 diff --git a/mobile/lib/modules/settings/services/app_settings.service.dart b/mobile/lib/modules/settings/services/app_settings.service.dart index 469e6a0d43..292c40c210 100644 --- a/mobile/lib/modules/settings/services/app_settings.service.dart +++ b/mobile/lib/modules/settings/services/app_settings.service.dart @@ -6,7 +6,11 @@ enum AppSettingsEnum { themeMode("themeMode", "system"), // "light","dark","system" tilesPerRow("tilesPerRow", 4), uploadErrorNotificationGracePeriod( - "uploadErrorNotificationGracePeriod", 2), + "uploadErrorNotificationGracePeriod", + 2, + ), + backgroundBackupTotalProgress("backgroundBackupTotalProgress", true), + backgroundBackupSingleProgress("backgroundBackupSingleProgress", false), storageIndicator("storageIndicator", true), thumbnailCacheSize("thumbnailCacheSize", 10000), imageCacheSize("imageCacheSize", 350), diff --git a/mobile/lib/modules/settings/ui/notification_setting/notification_setting.dart b/mobile/lib/modules/settings/ui/notification_setting/notification_setting.dart index 1643a830b5..be988e01cb 100644 --- a/mobile/lib/modules/settings/ui/notification_setting/notification_setting.dart +++ b/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(AppSettingsEnum.uploadErrorNotificationGracePeriod) .toDouble(); + totalProgressValue.value = appSettingService + .getSetting(AppSettingsEnum.backgroundBackupTotalProgress); + singleProgressValue.value = appSettingService + .getSetting(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 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();