1
0
mirror of https://github.com/immich-app/immich.git synced 2024-11-28 09:33:27 +02:00

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
This commit is contained in:
Fynn Petersen-Frey 2022-08-21 18:29:24 +02:00 committed by GitHub
parent c436c57cc9
commit 3125d04f32
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 221 additions and 44 deletions

View File

@ -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)

View File

@ -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"
}

View File

@ -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

View File

@ -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 && _errorGracePeriodExceeded) {
return await _backgroundChannel
.invokeMethod('showError', [title, content, individualTag]);
}
} catch (error) {
debugPrint("[_showErrorNotification] failed to communicate with plugin");
}
return false;
}
Future<bool> _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<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

View File

@ -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);

View File

@ -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);

View File

@ -56,6 +56,7 @@ class TilesPerRow extends HookConsumerWidget {
max: 6,
divisions: 4,
label: "${itemsValue.value.toInt()}",
activeColor: Theme.of(context).primaryColor,
),
],
);

View File

@ -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();
}
}

View File

@ -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(),
],