mirror of
https://github.com/immich-app/immich
synced 2025-11-07 17:27:20 +00:00
upload new photos in background with a service (#382)
* properly done background backup service * new concurrency/locking management with heartbeat fix communication erros with Kotlin plugin on start/stop service methods better error handling for BackgroundService public methods Add default notification message when service is running * configurable WiFi & charging requirement for service * use translations in background service
This commit is contained in:
parent
f35ebec7c6
commit
33b1410d82
21 changed files with 1462 additions and 79 deletions
|
|
@ -1,3 +1,5 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:cancellation_token_http/http.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
|
|
@ -9,9 +11,11 @@ import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.d
|
|||
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
|
||||
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
|
||||
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
|
||||
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
||||
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
|
||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/app_state.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/server_info.service.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
|
@ -21,6 +25,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||
this._backupService,
|
||||
this._serverInfoService,
|
||||
this._authState,
|
||||
this._backgroundService,
|
||||
this.ref,
|
||||
) : super(
|
||||
BackUpState(
|
||||
|
|
@ -28,6 +33,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||
allAssetsInDatabase: const [],
|
||||
progressInPercentage: 0,
|
||||
cancelToken: CancellationToken(),
|
||||
backgroundBackup: false,
|
||||
backupRequireWifi: true,
|
||||
backupRequireCharging: false,
|
||||
serverInfo: ServerInfoResponseDto(
|
||||
diskAvailable: "0",
|
||||
diskAvailableRaw: 0,
|
||||
|
|
@ -56,6 +64,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||
final BackupService _backupService;
|
||||
final ServerInfoService _serverInfoService;
|
||||
final AuthenticationState _authState;
|
||||
final BackgroundService _backgroundService;
|
||||
final Ref ref;
|
||||
|
||||
///
|
||||
|
|
@ -66,7 +75,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||
/// We have method to include and exclude albums
|
||||
/// The total unique assets will be used for backing mechanism
|
||||
///
|
||||
void addAlbumForBackup(AssetPathEntity album) {
|
||||
void addAlbumForBackup(AvailableAlbum album) {
|
||||
if (state.excludedBackupAlbums.contains(album)) {
|
||||
removeExcludedAlbumForBackup(album);
|
||||
}
|
||||
|
|
@ -76,7 +85,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||
_updateBackupAssetCount();
|
||||
}
|
||||
|
||||
void addExcludedAlbumForBackup(AssetPathEntity album) {
|
||||
void addExcludedAlbumForBackup(AvailableAlbum album) {
|
||||
if (state.selectedBackupAlbums.contains(album)) {
|
||||
removeAlbumForBackup(album);
|
||||
}
|
||||
|
|
@ -85,8 +94,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||
_updateBackupAssetCount();
|
||||
}
|
||||
|
||||
void removeAlbumForBackup(AssetPathEntity album) {
|
||||
Set<AssetPathEntity> currentSelectedAlbums = state.selectedBackupAlbums;
|
||||
void removeAlbumForBackup(AvailableAlbum album) {
|
||||
Set<AvailableAlbum> currentSelectedAlbums = state.selectedBackupAlbums;
|
||||
|
||||
currentSelectedAlbums.removeWhere((a) => a == album);
|
||||
|
||||
|
|
@ -94,8 +103,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||
_updateBackupAssetCount();
|
||||
}
|
||||
|
||||
void removeExcludedAlbumForBackup(AssetPathEntity album) {
|
||||
Set<AssetPathEntity> currentExcludedAlbums = state.excludedBackupAlbums;
|
||||
void removeExcludedAlbumForBackup(AvailableAlbum album) {
|
||||
Set<AvailableAlbum> currentExcludedAlbums = state.excludedBackupAlbums;
|
||||
|
||||
currentExcludedAlbums.removeWhere((a) => a == album);
|
||||
|
||||
|
|
@ -103,6 +112,50 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||
_updateBackupAssetCount();
|
||||
}
|
||||
|
||||
void configureBackgroundBackup({
|
||||
bool? enabled,
|
||||
bool? requireWifi,
|
||||
bool? requireCharging,
|
||||
required void Function(String msg) onError,
|
||||
}) async {
|
||||
assert(enabled != null || requireWifi != null || requireCharging != null);
|
||||
if (Platform.isAndroid) {
|
||||
final bool wasEnabled = state.backgroundBackup;
|
||||
final bool wasWifi = state.backupRequireWifi;
|
||||
final bool wasCharing = state.backupRequireCharging;
|
||||
state = state.copyWith(
|
||||
backgroundBackup: enabled,
|
||||
backupRequireWifi: requireWifi,
|
||||
backupRequireCharging: requireCharging,
|
||||
);
|
||||
|
||||
if (state.backgroundBackup) {
|
||||
if (!wasEnabled) {
|
||||
await _backgroundService.disableBatteryOptimizations();
|
||||
}
|
||||
final bool success = await _backgroundService.stopService() &&
|
||||
await _backgroundService.startService(
|
||||
requireUnmetered: state.backupRequireWifi,
|
||||
requireCharging: state.backupRequireCharging,
|
||||
);
|
||||
if (!success) {
|
||||
state = state.copyWith(
|
||||
backgroundBackup: wasEnabled,
|
||||
backupRequireWifi: wasWifi,
|
||||
backupRequireCharging: wasCharing,
|
||||
);
|
||||
onError("backup_controller_page_background_configure_error");
|
||||
}
|
||||
} else {
|
||||
final bool success = await _backgroundService.stopService();
|
||||
if (!success) {
|
||||
state = state.copyWith(backgroundBackup: wasEnabled);
|
||||
onError("backup_controller_page_background_configure_error");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
///
|
||||
/// Get all album on the device
|
||||
/// Get all selected and excluded album from the user's persistent storage
|
||||
|
|
@ -144,6 +197,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||
defaultValue: HiveBackupAlbums(
|
||||
selectedAlbumIds: [],
|
||||
excludedAlbumsIds: [],
|
||||
lastSelectedBackupTime: [],
|
||||
lastExcludedBackupTime: [],
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -173,6 +228,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||
HiveBackupAlbums(
|
||||
selectedAlbumIds: [albumHasAllAssets.id],
|
||||
excludedAlbumsIds: [],
|
||||
lastSelectedBackupTime: [
|
||||
DateTime.fromMillisecondsSinceEpoch(0, isUtc: true)
|
||||
],
|
||||
lastExcludedBackupTime: [],
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -181,19 +240,37 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||
|
||||
// Generate AssetPathEntity from id to add to local state
|
||||
try {
|
||||
for (var selectedAlbumId in backupAlbumInfo!.selectedAlbumIds) {
|
||||
var albumAsset = await AssetPathEntity.fromId(selectedAlbumId);
|
||||
state = state.copyWith(
|
||||
selectedBackupAlbums: {...state.selectedBackupAlbums, albumAsset},
|
||||
Set<AvailableAlbum> selectedAlbums = {};
|
||||
for (var i = 0; i < backupAlbumInfo!.selectedAlbumIds.length; i++) {
|
||||
var albumAsset =
|
||||
await AssetPathEntity.fromId(backupAlbumInfo.selectedAlbumIds[i]);
|
||||
selectedAlbums.add(
|
||||
AvailableAlbum(
|
||||
albumEntity: albumAsset,
|
||||
lastBackup: backupAlbumInfo.lastSelectedBackupTime.length > i
|
||||
? backupAlbumInfo.lastSelectedBackupTime[i]
|
||||
: DateTime.fromMillisecondsSinceEpoch(0, isUtc: true),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (var excludedAlbumId in backupAlbumInfo.excludedAlbumsIds) {
|
||||
var albumAsset = await AssetPathEntity.fromId(excludedAlbumId);
|
||||
state = state.copyWith(
|
||||
excludedBackupAlbums: {...state.excludedBackupAlbums, albumAsset},
|
||||
Set<AvailableAlbum> excludedAlbums = {};
|
||||
for (var i = 0; i < backupAlbumInfo.excludedAlbumsIds.length; i++) {
|
||||
var albumAsset =
|
||||
await AssetPathEntity.fromId(backupAlbumInfo.excludedAlbumsIds[i]);
|
||||
excludedAlbums.add(
|
||||
AvailableAlbum(
|
||||
albumEntity: albumAsset,
|
||||
lastBackup: backupAlbumInfo.lastExcludedBackupTime.length > i
|
||||
? backupAlbumInfo.lastExcludedBackupTime[i]
|
||||
: DateTime.fromMillisecondsSinceEpoch(0, isUtc: true),
|
||||
),
|
||||
);
|
||||
}
|
||||
state = state.copyWith(
|
||||
selectedBackupAlbums: selectedAlbums,
|
||||
excludedBackupAlbums: excludedAlbums,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint("[ERROR] Failed to generate album from id $e");
|
||||
}
|
||||
|
|
@ -209,14 +286,14 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||
Set<AssetEntity> assetsFromExcludedAlbums = {};
|
||||
|
||||
for (var album in state.selectedBackupAlbums) {
|
||||
var assets =
|
||||
await album.getAssetListRange(start: 0, end: album.assetCount);
|
||||
var assets = await album.albumEntity
|
||||
.getAssetListRange(start: 0, end: album.assetCount);
|
||||
assetsFromSelectedAlbums.addAll(assets);
|
||||
}
|
||||
|
||||
for (var album in state.excludedBackupAlbums) {
|
||||
var assets =
|
||||
await album.getAssetListRange(start: 0, end: album.assetCount);
|
||||
var assets = await album.albumEntity
|
||||
.getAssetListRange(start: 0, end: album.assetCount);
|
||||
assetsFromExcludedAlbums.addAll(assets);
|
||||
}
|
||||
|
||||
|
|
@ -263,12 +340,16 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||
/// and then update the UI according to those information
|
||||
///
|
||||
Future<void> getBackupInfo() async {
|
||||
await Future.wait([
|
||||
_getBackupAlbumsInfo(),
|
||||
_updateServerInfo(),
|
||||
]);
|
||||
final bool isEnabled = await _backgroundService.isBackgroundBackupEnabled();
|
||||
state = state.copyWith(backgroundBackup: isEnabled);
|
||||
if (state.backupProgress != BackUpProgressEnum.inBackground) {
|
||||
await Future.wait([
|
||||
_getBackupAlbumsInfo(),
|
||||
_updateServerInfo(),
|
||||
]);
|
||||
|
||||
await _updateBackupAssetCount();
|
||||
await _updateBackupAssetCount();
|
||||
}
|
||||
}
|
||||
|
||||
///
|
||||
|
|
@ -276,6 +357,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||
/// Hive database
|
||||
///
|
||||
void _updatePersistentAlbumsSelection() {
|
||||
final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
|
||||
Box<HiveBackupAlbums> backupAlbumInfoBox =
|
||||
Hive.box<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||
backupAlbumInfoBox.put(
|
||||
|
|
@ -283,6 +365,12 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||
HiveBackupAlbums(
|
||||
selectedAlbumIds: state.selectedBackupAlbums.map((e) => e.id).toList(),
|
||||
excludedAlbumsIds: state.excludedBackupAlbums.map((e) => e.id).toList(),
|
||||
lastSelectedBackupTime: state.selectedBackupAlbums
|
||||
.map((e) => e.lastBackup ?? epoch)
|
||||
.toList(),
|
||||
lastExcludedBackupTime: state.excludedBackupAlbums
|
||||
.map((e) => e.lastBackup ?? epoch)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -290,7 +378,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||
///
|
||||
/// Invoke backup process
|
||||
///
|
||||
void startBackupProcess() async {
|
||||
Future<void> startBackupProcess() async {
|
||||
assert(state.backupProgress == BackUpProgressEnum.idle);
|
||||
state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress);
|
||||
|
||||
await getBackupInfo();
|
||||
|
|
@ -318,7 +407,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||
|
||||
// Perform Backup
|
||||
state = state.copyWith(cancelToken: CancellationToken());
|
||||
_backupService.backupAsset(
|
||||
await _backupService.backupAsset(
|
||||
assetsWillBeBackup,
|
||||
state.cancelToken,
|
||||
_onAssetUploaded,
|
||||
|
|
@ -326,6 +415,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||
_onSetCurrentBackupAsset,
|
||||
_onBackupError,
|
||||
);
|
||||
await _notifyBackgroundServiceCanRun();
|
||||
} else {
|
||||
PhotoManager.openSetting();
|
||||
}
|
||||
|
|
@ -340,6 +430,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||
}
|
||||
|
||||
void cancelBackup() {
|
||||
if (state.backupProgress != BackUpProgressEnum.inProgress) {
|
||||
_notifyBackgroundServiceCanRun();
|
||||
}
|
||||
state.cancelToken.cancel();
|
||||
state = state.copyWith(
|
||||
backupProgress: BackUpProgressEnum.idle,
|
||||
|
|
@ -359,10 +452,21 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||
if (state.allUniqueAssets.length -
|
||||
state.selectedAlbumsBackupAssetsIds.length ==
|
||||
0) {
|
||||
final latestAssetBackup =
|
||||
state.allUniqueAssets.map((e) => e.modifiedDateTime).reduce(
|
||||
(v, e) => e.isAfter(v) ? e : v,
|
||||
);
|
||||
state = state.copyWith(
|
||||
selectedBackupAlbums: state.selectedBackupAlbums
|
||||
.map((e) => e.copyWith(lastBackup: latestAssetBackup))
|
||||
.toSet(),
|
||||
excludedBackupAlbums: state.excludedBackupAlbums
|
||||
.map((e) => e.copyWith(lastBackup: latestAssetBackup))
|
||||
.toSet(),
|
||||
backupProgress: BackUpProgressEnum.done,
|
||||
progressInPercentage: 0.0,
|
||||
);
|
||||
_updatePersistentAlbumsSelection();
|
||||
}
|
||||
|
||||
_updateServerInfo();
|
||||
|
|
@ -385,7 +489,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||
}
|
||||
}
|
||||
|
||||
void resumeBackup() {
|
||||
Future<void> _resumeBackup() async {
|
||||
// Check if user is login
|
||||
var accessKey = Hive.box(userInfoBox).get(accessTokenKey);
|
||||
|
||||
|
|
@ -404,13 +508,91 @@ class BackupNotifier extends StateNotifier<BackUpState> {
|
|||
return;
|
||||
}
|
||||
|
||||
if (state.backupProgress == BackUpProgressEnum.inBackground) {
|
||||
debugPrint("[resumeBackup] Background backup is running - abort");
|
||||
return;
|
||||
}
|
||||
|
||||
// Run backup
|
||||
debugPrint("[resumeBackup] Start back up");
|
||||
startBackupProcess();
|
||||
await startBackupProcess();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Future<void> resumeBackup() async {
|
||||
if (Platform.isAndroid) {
|
||||
// assumes the background service is currently running
|
||||
// if true, waits until it has stopped to update the app state from HiveDB
|
||||
// before actually resuming backup by calling the internal `_resumeBackup`
|
||||
final BackUpProgressEnum previous = state.backupProgress;
|
||||
state = state.copyWith(backupProgress: BackUpProgressEnum.inBackground);
|
||||
final bool hasLock = await _backgroundService.acquireLock();
|
||||
if (!hasLock) {
|
||||
return;
|
||||
}
|
||||
Box<HiveBackupAlbums> box =
|
||||
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||
HiveBackupAlbums? albums = box.get(backupInfoKey);
|
||||
Set<AvailableAlbum> selectedAlbums = state.selectedBackupAlbums;
|
||||
Set<AvailableAlbum> excludedAlbums = state.excludedBackupAlbums;
|
||||
if (albums != null) {
|
||||
selectedAlbums = _updateAlbumsBackupTime(
|
||||
selectedAlbums,
|
||||
albums.selectedAlbumIds,
|
||||
albums.lastSelectedBackupTime,
|
||||
);
|
||||
excludedAlbums = _updateAlbumsBackupTime(
|
||||
excludedAlbums,
|
||||
albums.excludedAlbumsIds,
|
||||
albums.lastExcludedBackupTime,
|
||||
);
|
||||
}
|
||||
state = state.copyWith(
|
||||
backupProgress: previous,
|
||||
selectedBackupAlbums: selectedAlbums,
|
||||
excludedBackupAlbums: excludedAlbums,
|
||||
);
|
||||
}
|
||||
return _resumeBackup();
|
||||
}
|
||||
|
||||
Set<AvailableAlbum> _updateAlbumsBackupTime(
|
||||
Set<AvailableAlbum> albums,
|
||||
List<String> ids,
|
||||
List<DateTime> times,
|
||||
) {
|
||||
Set<AvailableAlbum> result = {};
|
||||
for (int i = 0; i < ids.length; i++) {
|
||||
try {
|
||||
AvailableAlbum a = albums.firstWhere((e) => e.id == ids[i]);
|
||||
result.add(a.copyWith(lastBackup: times[i]));
|
||||
} on StateError {
|
||||
debugPrint("[_updateAlbumBackupTime] failed to find album in state");
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> _notifyBackgroundServiceCanRun() async {
|
||||
const allowedStates = [
|
||||
AppStateEnum.inactive,
|
||||
AppStateEnum.paused,
|
||||
AppStateEnum.detached,
|
||||
];
|
||||
if (Platform.isAndroid &&
|
||||
allowedStates.contains(ref.read(appStateProvider.notifier).state)) {
|
||||
try {
|
||||
if (Hive.isBoxOpen(hiveBackupInfoBox)) {
|
||||
await Hive.box<HiveBackupAlbums>(hiveBackupInfoBox).close();
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint("[_notifyBackgroundServiceCanRun] failed to close box");
|
||||
}
|
||||
_backgroundService.releaseLock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final backupProvider =
|
||||
|
|
@ -419,6 +601,7 @@ final backupProvider =
|
|||
ref.watch(backupServiceProvider),
|
||||
ref.watch(serverInfoServiceProvider),
|
||||
ref.watch(authenticationProvider),
|
||||
ref.watch(backgroundServiceProvider),
|
||||
ref,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue