Skip to content

Commit 3125d04

Browse files
authored
show notifications on background backup errors (immich-app#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
1 parent c436c57 commit 3125d04

File tree

9 files changed

+221
-44
lines changed

9 files changed

+221
-44
lines changed

mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
138138
immediate = true,
139139
requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true),
140140
requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false),
141+
initialDelayInMs = ONE_MINUTE,
141142
retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1)
142143
}
143144
engine?.destroy()
@@ -169,6 +170,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
169170
immediate = true,
170171
requireUnmeteredNetwork = inputData.getBoolean(DATA_KEY_UNMETERED, true),
171172
requireCharging = inputData.getBoolean(DATA_KEY_CHARGING, false),
173+
initialDelayInMs = ONE_MINUTE,
172174
retries = inputData.getInt(DATA_KEY_RETRIES, 0) + 1)
173175
}
174176
}
@@ -186,22 +188,27 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
186188
val args = call.arguments<ArrayList<*>>()!!
187189
val title = args.get(0) as String
188190
val content = args.get(1) as String
189-
showError(title, content)
191+
val individualTag = args.get(2) as String?
192+
showError(title, content, individualTag)
190193
}
194+
"clearErrorNotifications" -> clearErrorNotifications()
191195
else -> r.notImplemented()
192196
}
193197
}
194198

195-
private fun showError(title: String, content: String) {
199+
private fun showError(title: String, content: String, individualTag: String?) {
196200
val notification = NotificationCompat.Builder(applicationContext, NOTIFICATION_CHANNEL_ERROR_ID)
197201
.setContentTitle(title)
198202
.setTicker(title)
199203
.setContentText(content)
200204
.setSmallIcon(R.mipmap.ic_launcher)
201-
.setAutoCancel(true)
205+
.setOnlyAlertOnce(true)
202206
.build()
203-
val notificationId = SystemClock.uptimeMillis() as Int
204-
notificationManager.notify(notificationId, notification)
207+
notificationManager.notify(individualTag, NOTIFICATION_ERROR_ID, notification)
208+
}
209+
210+
private fun clearErrorNotifications() {
211+
notificationManager.cancel(NOTIFICATION_ERROR_ID)
205212
}
206213

207214
private fun createForegroundInfo(title: String = NOTIFICATION_DEFAULT_TITLE, content: String? = null): ForegroundInfo {
@@ -212,14 +219,14 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
212219
.setSmallIcon(R.mipmap.ic_launcher)
213220
.setOngoing(true)
214221
.build()
215-
return ForegroundInfo(1, notification)
222+
return ForegroundInfo(NOTIFICATION_ID, notification)
216223
}
217224

218225
@RequiresApi(Build.VERSION_CODES.O)
219226
private fun createChannel() {
220227
val foreground = NotificationChannel(NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_ID, NotificationManager.IMPORTANCE_LOW)
221228
notificationManager.createNotificationChannel(foreground)
222-
val error = NotificationChannel(NOTIFICATION_CHANNEL_ERROR_ID, NOTIFICATION_CHANNEL_ERROR_ID, NotificationManager.IMPORTANCE_HIGH)
229+
val error = NotificationChannel(NOTIFICATION_CHANNEL_ERROR_ID, NOTIFICATION_CHANNEL_ERROR_ID, NotificationManager.IMPORTANCE_DEFAULT)
223230
notificationManager.createNotificationChannel(error)
224231
}
225232

@@ -236,6 +243,9 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
236243
private const val NOTIFICATION_CHANNEL_ID = "immich/backgroundService"
237244
private const val NOTIFICATION_CHANNEL_ERROR_ID = "immich/backgroundServiceError"
238245
private const val NOTIFICATION_DEFAULT_TITLE = "Immich"
246+
private const val NOTIFICATION_ID = 1
247+
private const val NOTIFICATION_ERROR_ID = 2
248+
private const val ONE_MINUTE: Long = 60000
239249

240250
/**
241251
* Enqueues the `BackupWorker` to run when all constraints are met.
@@ -262,6 +272,7 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
262272
keepExisting: Boolean = false,
263273
requireUnmeteredNetwork: Boolean = false,
264274
requireCharging: Boolean = false,
275+
initialDelayInMs: Long = 0,
265276
retries: Int = 0) {
266277
if (!isEnabled(context)) {
267278
return
@@ -287,9 +298,10 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
287298
val photoCheck = OneTimeWorkRequest.Builder(BackupWorker::class.java)
288299
.setConstraints(constraints.build())
289300
.setInputData(inputData)
301+
.setInitialDelay(initialDelayInMs, TimeUnit.MILLISECONDS)
290302
.setBackoffCriteria(
291303
BackoffPolicy.EXPONENTIAL,
292-
OneTimeWorkRequest.MIN_BACKOFF_MILLIS,
304+
ONE_MINUTE,
293305
TimeUnit.MILLISECONDS)
294306
.build()
295307
val policy = if (immediate) ExistingWorkPolicy.REPLACE else (if (keepExisting) ExistingWorkPolicy.KEEP else ExistingWorkPolicy.APPEND_OR_REPLACE)

mobile/assets/i18n/en-US.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
"backup_background_service_upload_failure_notification": "Failed to upload {}",
2222
"backup_background_service_in_progress_notification": "Backing up your assets…",
2323
"backup_background_service_current_upload_notification": "Uploading {}",
24+
"backup_background_service_error_title": "Backup error",
25+
"backup_background_service_connection_failed_message": "Failed to connect to the server. Retrying…",
26+
"backup_background_service_backup_failed_message": "Failed to backup assets. Retrying…",
2427
"backup_controller_page_albums": "Backup Albums",
2528
"backup_controller_page_backup": "Backup",
2629
"backup_controller_page_backup_selected": "Selected: ",
@@ -139,5 +142,12 @@
139142
"asset_list_settings_title": "Photo Grid",
140143
"asset_list_settings_subtitle": "Photo grid layout settings",
141144
"theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles",
142-
"theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})"
145+
"theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})",
146+
"setting_notifications_title": "Notifications",
147+
"setting_notifications_subtitle": "Adjust your notification preferences",
148+
"setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}",
149+
"setting_notifications_notify_immediately": "immediately",
150+
"setting_notifications_notify_minutes": "{} minutes",
151+
"setting_notifications_notify_hours": "{} hours",
152+
"setting_notifications_notify_never": "never"
143153
}

mobile/lib/constants/hive_box.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,7 @@ const String githubReleaseInfoKey = "immichGithubReleaseInfoKey"; // Key 1
1919

2020
// User Setting Info
2121
const String userSettingInfoBox = "immichUserSettingInfoBox";
22+
23+
// Background backup Info
24+
const String backgroundBackupInfoBox = "immichBackgroundBackupInfoBox"; // Box
25+
const String backupFailedSince = "immichBackupFailedSince"; // Key 1

mobile/lib/modules/backup/background_service/background.service.dart

Lines changed: 94 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'dart:io';
44
import 'dart:isolate';
55
import 'dart:ui' show IsolateNameServer, PluginUtilities;
66
import 'package:cancellation_token_http/http.dart';
7+
import 'package:collection/collection.dart';
78
import 'package:easy_localization/easy_localization.dart';
89
import 'package:flutter/services.dart';
910
import 'package:flutter/widgets.dart';
@@ -16,6 +17,7 @@ import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dar
1617
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
1718
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
1819
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
20+
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
1921
import 'package:immich_mobile/shared/services/api.service.dart';
2022
import 'package:photo_manager/photo_manager.dart';
2123

@@ -39,6 +41,7 @@ class BackgroundService {
3941
bool _hasLock = false;
4042
SendPort? _waitingIsolate;
4143
ReceivePort? _rp;
44+
bool _errorGracePeriodExceeded = true;
4245

4346
bool get isForegroundInitialized {
4447
return _isForegroundInitialized;
@@ -140,8 +143,8 @@ class BackgroundService {
140143
}
141144

142145
/// Updates the notification shown by the background service
143-
Future<bool> updateNotification({
144-
String title = "Immich",
146+
Future<bool> _updateNotification({
147+
required String title,
145148
String? content,
146149
}) async {
147150
if (!Platform.isAndroid) {
@@ -153,28 +156,44 @@ class BackgroundService {
153156
.invokeMethod('updateNotification', [title, content]);
154157
}
155158
} catch (error) {
156-
debugPrint("[updateNotification] failed to communicate with plugin");
159+
debugPrint("[_updateNotification] failed to communicate with plugin");
157160
}
158161
return Future.value(false);
159162
}
160163

161164
/// Shows a new priority notification
162-
Future<bool> showErrorNotification(
163-
String title,
164-
String content,
165-
) async {
165+
Future<bool> _showErrorNotification({
166+
required String title,
167+
String? content,
168+
String? individualTag,
169+
}) async {
166170
if (!Platform.isAndroid) {
167171
return true;
168172
}
169173
try {
170-
if (_isBackgroundInitialized) {
174+
if (_isBackgroundInitialized && _errorGracePeriodExceeded) {
171175
return await _backgroundChannel
172-
.invokeMethod('showError', [title, content]);
176+
.invokeMethod('showError', [title, content, individualTag]);
173177
}
174178
} catch (error) {
175-
debugPrint("[showErrorNotification] failed to communicate with plugin");
179+
debugPrint("[_showErrorNotification] failed to communicate with plugin");
176180
}
177-
return Future.value(false);
181+
return false;
182+
}
183+
184+
Future<bool> _clearErrorNotifications() async {
185+
if (!Platform.isAndroid) {
186+
return true;
187+
}
188+
try {
189+
if (_isBackgroundInitialized) {
190+
return await _backgroundChannel.invokeMethod('clearErrorNotifications');
191+
}
192+
} catch (error) {
193+
debugPrint(
194+
"[_clearErrorNotifications] failed to communicate with plugin");
195+
}
196+
return false;
178197
}
179198

180199
/// await to ensure this thread (foreground or background) has exclusive access
@@ -278,7 +297,15 @@ class BackgroundService {
278297
return false;
279298
}
280299
await translationsLoaded;
281-
return await _onAssetsChanged();
300+
final bool ok = await _onAssetsChanged();
301+
if (ok) {
302+
Hive.box(backgroundBackupInfoBox).delete(backupFailedSince);
303+
} else if (Hive.box(backgroundBackupInfoBox).get(backupFailedSince) ==
304+
null) {
305+
Hive.box(backgroundBackupInfoBox)
306+
.put(backupFailedSince, DateTime.now());
307+
}
308+
return ok;
282309
} catch (error) {
283310
debugPrint(error.toString());
284311
return false;
@@ -303,6 +330,8 @@ class BackgroundService {
303330
Hive.registerAdapter(HiveBackupAlbumsAdapter());
304331
await Hive.openBox(userInfoBox);
305332
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
333+
await Hive.openBox(userSettingInfoBox);
334+
await Hive.openBox(backgroundBackupInfoBox);
306335

307336
ApiService apiService = ApiService();
308337
apiService.setEndpoint(Hive.box(userInfoBox).get(serverEndpointKey));
@@ -313,23 +342,36 @@ class BackgroundService {
313342
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
314343
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
315344
if (backupAlbumInfo == null) {
345+
_clearErrorNotifications();
316346
return true;
317347
}
318348

319349
await PhotoManager.setIgnorePermissionCheck(true);
350+
_errorGracePeriodExceeded = _isErrorGracePeriodExceeded();
320351

321352
if (_canceledBySystem) {
322353
return false;
323354
}
324355

325-
final List<AssetEntity> toUpload =
326-
await backupService.getAssetsToBackup(backupAlbumInfo);
356+
List<AssetEntity> toUpload =
357+
await backupService.buildUploadCandidates(backupAlbumInfo);
358+
359+
try {
360+
toUpload = await backupService.removeAlreadyUploadedAssets(toUpload);
361+
} catch (e) {
362+
_showErrorNotification(
363+
title: "backup_background_service_error_title".tr(),
364+
content: "backup_background_service_connection_failed_message".tr(),
365+
);
366+
return false;
367+
}
327368

328369
if (_canceledBySystem) {
329370
return false;
330371
}
331372

332373
if (toUpload.isEmpty) {
374+
_clearErrorNotifications();
333375
return true;
334376
}
335377

@@ -343,10 +385,16 @@ class BackgroundService {
343385
_onBackupError,
344386
);
345387
if (ok) {
388+
_clearErrorNotifications();
346389
await box.put(
347390
backupInfoKey,
348391
backupAlbumInfo,
349392
);
393+
} else {
394+
_showErrorNotification(
395+
title: "backup_background_service_error_title".tr(),
396+
content: "backup_background_service_backup_failed_message".tr(),
397+
);
350398
}
351399
return ok;
352400
}
@@ -358,20 +406,48 @@ class BackgroundService {
358406
void _onProgress(int sent, int total) {}
359407

360408
void _onBackupError(ErrorUploadAsset errorAssetInfo) {
361-
showErrorNotification(
362-
"backup_background_service_upload_failure_notification"
409+
_showErrorNotification(
410+
title: "Upload failed",
411+
content: "backup_background_service_upload_failure_notification"
363412
.tr(args: [errorAssetInfo.fileName]),
364-
errorAssetInfo.errorMessage,
413+
individualTag: errorAssetInfo.id,
365414
);
366415
}
367416

368417
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
369-
updateNotification(
418+
_updateNotification(
370419
title: "backup_background_service_in_progress_notification".tr(),
371420
content: "backup_background_service_current_upload_notification"
372421
.tr(args: [currentUploadAsset.fileName]),
373422
);
374423
}
424+
425+
bool _isErrorGracePeriodExceeded() {
426+
final int value = AppSettingsService()
427+
.getSetting(AppSettingsEnum.uploadErrorNotificationGracePeriod);
428+
if (value == 0) {
429+
return true;
430+
} else if (value == 5) {
431+
return false;
432+
}
433+
final DateTime? failedSince =
434+
Hive.box(backgroundBackupInfoBox).get(backupFailedSince);
435+
if (failedSince == null) {
436+
return false;
437+
}
438+
final Duration duration = DateTime.now().difference(failedSince);
439+
if (value == 1) {
440+
return duration > const Duration(minutes: 30);
441+
} else if (value == 2) {
442+
return duration > const Duration(hours: 2);
443+
} else if (value == 3) {
444+
return duration > const Duration(hours: 8);
445+
} else if (value == 4) {
446+
return duration > const Duration(hours: 24);
447+
}
448+
assert(false, "Invalid value");
449+
return true;
450+
}
375451
}
376452

377453
/// entry point called by Kotlin/Java code; needs to be a top-level function

mobile/lib/modules/backup/services/backup.service.dart

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -41,21 +41,8 @@ class BackupService {
4141
}
4242
}
4343

44-
/// Returns all assets to backup from the backup info taking into account the
45-
/// time of the last successfull backup per album
46-
Future<List<AssetEntity>> getAssetsToBackup(
47-
HiveBackupAlbums backupAlbumInfo,
48-
) async {
49-
final List<AssetEntity> candidates =
50-
await _buildUploadCandidates(backupAlbumInfo);
51-
52-
final List<AssetEntity> toUpload = candidates.isEmpty
53-
? []
54-
: await _removeAlreadyUploadedAssets(candidates);
55-
return toUpload;
56-
}
57-
58-
Future<List<AssetEntity>> _buildUploadCandidates(
44+
/// Returns all assets newer than the last successful backup per album
45+
Future<List<AssetEntity>> buildUploadCandidates(
5946
HiveBackupAlbums backupAlbums,
6047
) async {
6148
final filter = FilterOptionGroup(
@@ -147,7 +134,8 @@ class BackupService {
147134
return result;
148135
}
149136

150-
Future<List<AssetEntity>> _removeAlreadyUploadedAssets(
137+
/// Returns a new list of assets not yet uploaded
138+
Future<List<AssetEntity>> removeAlreadyUploadedAssets(
151139
List<AssetEntity> candidates,
152140
) async {
153141
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);

mobile/lib/modules/settings/services/app_settings.service.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ enum AppSettingsEnum<T> {
55
threeStageLoading<bool>("threeStageLoading", false),
66
themeMode<String>("themeMode", "system"), // "light","dark","system"
77
tilesPerRow<int>("tilesPerRow", 4),
8+
uploadErrorNotificationGracePeriod<int>(
9+
"uploadErrorNotificationGracePeriod", 2),
810
storageIndicator<bool>("storageIndicator", true);
911

1012
const AppSettingsEnum(this.hiveKey, this.defaultValue);

0 commit comments

Comments
 (0)