refactor(mobile): services and providers (#9232)

* refactor(mobile): services and provider

* providers
This commit is contained in:
Alex 2024-05-02 15:59:14 -05:00 committed by GitHub
parent ec4eb7cd19
commit c1253663b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
242 changed files with 497 additions and 503 deletions

View file

@ -0,0 +1,703 @@
import 'dart:io';
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/backup/available_album.model.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/services/backup.service.dart';
import 'package:immich_mobile/models/authentication/authentication_state.model.dart';
import 'package:immich_mobile/providers/authentication.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/models/server_info/server_disk_info.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/services/server_info.service.dart';
import 'package:immich_mobile/utils/backup_progress.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:photo_manager/photo_manager.dart';
class BackupNotifier extends StateNotifier<BackUpState> {
BackupNotifier(
this._backupService,
this._serverInfoService,
this._authState,
this._backgroundService,
this._galleryPermissionNotifier,
this._db,
this.ref,
) : super(
BackUpState(
backupProgress: BackUpProgressEnum.idle,
allAssetsInDatabase: const [],
progressInPercentage: 0,
progressInFileSize: "0 B / 0 B",
progressInFileSpeed: 0,
progressInFileSpeeds: const [],
progressInFileSpeedUpdateTime: DateTime.now(),
progressInFileSpeedUpdateSentBytes: 0,
cancelToken: CancellationToken(),
autoBackup: Store.get(StoreKey.autoBackup, false),
backgroundBackup: Store.get(StoreKey.backgroundBackup, false),
backupRequireWifi: Store.get(StoreKey.backupRequireWifi, true),
backupRequireCharging:
Store.get(StoreKey.backupRequireCharging, false),
backupTriggerDelay: Store.get(StoreKey.backupTriggerDelay, 5000),
serverInfo: const ServerDiskInfo(
diskAvailable: "0",
diskSize: "0",
diskUse: "0",
diskUsagePercentage: 0,
),
availableAlbums: const [],
selectedBackupAlbums: const {},
excludedBackupAlbums: const {},
allUniqueAssets: const {},
selectedAlbumsBackupAssetsIds: const {},
currentUploadAsset: CurrentUploadAsset(
id: '...',
fileCreatedAt: DateTime.parse('2020-10-04'),
fileName: '...',
fileType: '...',
fileSize: 0,
iCloudAsset: false,
),
iCloudDownloadProgress: 0.0,
),
);
final log = Logger('BackupNotifier');
final BackupService _backupService;
final ServerInfoService _serverInfoService;
final AuthenticationState _authState;
final BackgroundService _backgroundService;
final GalleryPermissionNotifier _galleryPermissionNotifier;
final Isar _db;
final Ref ref;
///
/// UI INTERACTION
///
/// Album selection
/// Due to the overlapping assets across multiple albums on the device
/// We have method to include and exclude albums
/// The total unique assets will be used for backing mechanism
///
void addAlbumForBackup(AvailableAlbum album) {
if (state.excludedBackupAlbums.contains(album)) {
removeExcludedAlbumForBackup(album);
}
state = state
.copyWith(selectedBackupAlbums: {...state.selectedBackupAlbums, album});
}
void addExcludedAlbumForBackup(AvailableAlbum album) {
if (state.selectedBackupAlbums.contains(album)) {
removeAlbumForBackup(album);
}
state = state
.copyWith(excludedBackupAlbums: {...state.excludedBackupAlbums, album});
}
void removeAlbumForBackup(AvailableAlbum album) {
Set<AvailableAlbum> currentSelectedAlbums = state.selectedBackupAlbums;
currentSelectedAlbums.removeWhere((a) => a == album);
state = state.copyWith(selectedBackupAlbums: currentSelectedAlbums);
}
void removeExcludedAlbumForBackup(AvailableAlbum album) {
Set<AvailableAlbum> currentExcludedAlbums = state.excludedBackupAlbums;
currentExcludedAlbums.removeWhere((a) => a == album);
state = state.copyWith(excludedBackupAlbums: currentExcludedAlbums);
}
Future<void> backupAlbumSelectionDone() {
if (state.selectedBackupAlbums.isEmpty) {
// disable any backup
cancelBackup();
setAutoBackup(false);
configureBackgroundBackup(
enabled: false,
onError: (msg) {},
onBatteryInfo: () {},
);
}
return _updateBackupAssetCount();
}
void setAutoBackup(bool enabled) {
Store.put(StoreKey.autoBackup, enabled);
state = state.copyWith(autoBackup: enabled);
}
void configureBackgroundBackup({
bool? enabled,
bool? requireWifi,
bool? requireCharging,
int? triggerDelay,
required void Function(String msg) onError,
required void Function() onBatteryInfo,
}) async {
assert(
enabled != null ||
requireWifi != null ||
requireCharging != null ||
triggerDelay != null,
);
final bool wasEnabled = state.backgroundBackup;
final bool wasWifi = state.backupRequireWifi;
final bool wasCharging = state.backupRequireCharging;
final int oldTriggerDelay = state.backupTriggerDelay;
state = state.copyWith(
backgroundBackup: enabled,
backupRequireWifi: requireWifi,
backupRequireCharging: requireCharging,
backupTriggerDelay: triggerDelay,
);
if (state.backgroundBackup) {
bool success = true;
if (!wasEnabled) {
if (!await _backgroundService.isIgnoringBatteryOptimizations()) {
onBatteryInfo();
}
success &= await _backgroundService.enableService(immediate: true);
}
success &= success &&
await _backgroundService.configureService(
requireUnmetered: state.backupRequireWifi,
requireCharging: state.backupRequireCharging,
triggerUpdateDelay: state.backupTriggerDelay,
triggerMaxDelay: state.backupTriggerDelay * 10,
);
if (success) {
await Store.put(StoreKey.backupRequireWifi, state.backupRequireWifi);
await Store.put(
StoreKey.backupRequireCharging,
state.backupRequireCharging,
);
await Store.put(StoreKey.backupTriggerDelay, state.backupTriggerDelay);
await Store.put(StoreKey.backgroundBackup, state.backgroundBackup);
} else {
state = state.copyWith(
backgroundBackup: wasEnabled,
backupRequireWifi: wasWifi,
backupRequireCharging: wasCharging,
backupTriggerDelay: oldTriggerDelay,
);
onError("backup_controller_page_background_configure_error");
}
} else {
final bool success = await _backgroundService.disableService();
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
/// If this is the first time performing backup - set the default selected album to be
/// the one that has all assets (`Recent` on Android, `Recents` on iOS)
///
Future<void> _getBackupAlbumsInfo() async {
Stopwatch stopwatch = Stopwatch()..start();
// Get all albums on the device
List<AvailableAlbum> availableAlbums = [];
List<AssetPathEntity> albums = await PhotoManager.getAssetPathList(
hasAll: true,
type: RequestType.common,
);
// Map of id -> album for quick album lookup later on.
Map<String, AssetPathEntity> albumMap = {};
log.info('Found ${albums.length} local albums');
for (AssetPathEntity album in albums) {
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
availableAlbums.add(availableAlbum);
albumMap[album.id] = album;
}
state = state.copyWith(availableAlbums: availableAlbums);
final List<BackupAlbum> excludedBackupAlbums =
await _backupService.excludedAlbumsQuery().findAll();
final List<BackupAlbum> selectedBackupAlbums =
await _backupService.selectedAlbumsQuery().findAll();
// Generate AssetPathEntity from id to add to local state
final Set<AvailableAlbum> selectedAlbums = {};
for (final BackupAlbum ba in selectedBackupAlbums) {
final albumAsset = albumMap[ba.id];
if (albumAsset != null) {
selectedAlbums.add(
AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup),
);
} else {
log.severe('Selected album not found');
}
}
final Set<AvailableAlbum> excludedAlbums = {};
for (final BackupAlbum ba in excludedBackupAlbums) {
final albumAsset = albumMap[ba.id];
if (albumAsset != null) {
excludedAlbums.add(
AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup),
);
} else {
log.severe('Excluded album not found');
}
}
state = state.copyWith(
selectedBackupAlbums: selectedAlbums,
excludedBackupAlbums: excludedAlbums,
);
log.info(
"_getBackupAlbumsInfo: Found ${availableAlbums.length} available albums",
);
debugPrint("_getBackupAlbumsInfo takes ${stopwatch.elapsedMilliseconds}ms");
}
///
/// From all the selected and albums assets
/// Find the assets that are not overlapping between the two sets
/// Those assets are unique and are used as the total assets
///
Future<void> _updateBackupAssetCount() async {
final duplicatedAssetIds = await _backupService.getDuplicatedAssetIds();
final Set<AssetEntity> assetsFromSelectedAlbums = {};
final Set<AssetEntity> assetsFromExcludedAlbums = {};
for (final album in state.selectedBackupAlbums) {
final assets = await album.albumEntity.getAssetListRange(
start: 0,
end: await album.albumEntity.assetCountAsync,
);
assetsFromSelectedAlbums.addAll(assets);
}
for (final album in state.excludedBackupAlbums) {
final assets = await album.albumEntity.getAssetListRange(
start: 0,
end: await album.albumEntity.assetCountAsync,
);
assetsFromExcludedAlbums.addAll(assets);
}
final Set<AssetEntity> allUniqueAssets =
assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums);
final allAssetsInDatabase = await _backupService.getDeviceBackupAsset();
if (allAssetsInDatabase == null) {
return;
}
// Find asset that were backup from selected albums
final Set<String> selectedAlbumsBackupAssets =
Set.from(allUniqueAssets.map((e) => e.id));
selectedAlbumsBackupAssets
.removeWhere((assetId) => !allAssetsInDatabase.contains(assetId));
// Remove duplicated asset from all unique assets
allUniqueAssets.removeWhere(
(asset) => duplicatedAssetIds.contains(asset.id),
);
if (allUniqueAssets.isEmpty) {
log.info("No assets are selected for back up");
state = state.copyWith(
backupProgress: BackUpProgressEnum.idle,
allAssetsInDatabase: allAssetsInDatabase,
allUniqueAssets: {},
selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets,
);
} else {
state = state.copyWith(
allAssetsInDatabase: allAssetsInDatabase,
allUniqueAssets: allUniqueAssets,
selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets,
);
}
// Save to persistent storage
await _updatePersistentAlbumsSelection();
}
/// Get all necessary information for calculating the available albums,
/// which albums are selected or excluded
/// and then update the UI according to those information
Future<void> getBackupInfo() async {
final isEnabled = await _backgroundService.isBackgroundBackupEnabled();
state = state.copyWith(backgroundBackup: isEnabled);
if (isEnabled != Store.get(StoreKey.backgroundBackup, !isEnabled)) {
Store.put(StoreKey.backgroundBackup, isEnabled);
}
if (state.backupProgress != BackUpProgressEnum.inBackground) {
await _getBackupAlbumsInfo();
await updateServerInfo();
await _updateBackupAssetCount();
} else {
log.warning("cannot get backup info - background backup is in progress!");
}
}
/// Save user selection of selected albums and excluded albums to database
Future<void> _updatePersistentAlbumsSelection() {
final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);
final selected = state.selectedBackupAlbums.map(
(e) => BackupAlbum(e.id, e.lastBackup ?? epoch, BackupSelection.select),
);
final excluded = state.excludedBackupAlbums.map(
(e) => BackupAlbum(e.id, e.lastBackup ?? epoch, BackupSelection.exclude),
);
final backupAlbums = selected.followedBy(excluded).toList();
backupAlbums.sortBy((e) => e.id);
return _db.writeTxn(() async {
final dbAlbums = await _db.backupAlbums.where().sortById().findAll();
final List<int> toDelete = [];
final List<BackupAlbum> toUpsert = [];
// stores the most recent `lastBackup` per album but always keeps the `selection` the user just made
diffSortedListsSync(
dbAlbums,
backupAlbums,
compare: (BackupAlbum a, BackupAlbum b) => a.id.compareTo(b.id),
both: (BackupAlbum a, BackupAlbum b) {
b.lastBackup =
a.lastBackup.isAfter(b.lastBackup) ? a.lastBackup : b.lastBackup;
toUpsert.add(b);
return true;
},
onlyFirst: (BackupAlbum a) => toDelete.add(a.isarId),
onlySecond: (BackupAlbum b) => toUpsert.add(b),
);
await _db.backupAlbums.deleteAll(toDelete);
await _db.backupAlbums.putAll(toUpsert);
});
}
/// Invoke backup process
Future<void> startBackupProcess() async {
debugPrint("Start backup process");
assert(state.backupProgress == BackUpProgressEnum.idle);
state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress);
await getBackupInfo();
final hasPermission = _galleryPermissionNotifier.hasPermission;
if (hasPermission) {
await PhotoManager.clearFileCache();
if (state.allUniqueAssets.isEmpty) {
log.info("No Asset On Device - Abort Backup Process");
state = state.copyWith(backupProgress: BackUpProgressEnum.idle);
return;
}
Set<AssetEntity> assetsWillBeBackup = Set.from(state.allUniqueAssets);
// Remove item that has already been backed up
for (final assetId in state.allAssetsInDatabase) {
assetsWillBeBackup.removeWhere((e) => e.id == assetId);
}
if (assetsWillBeBackup.isEmpty) {
state = state.copyWith(backupProgress: BackUpProgressEnum.idle);
}
// Perform Backup
state = state.copyWith(cancelToken: CancellationToken());
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
pmProgressHandler?.stream.listen((event) {
final double progress = event.progress;
state = state.copyWith(iCloudDownloadProgress: progress);
});
await _backupService.backupAsset(
assetsWillBeBackup,
state.cancelToken,
pmProgressHandler,
_onAssetUploaded,
_onUploadProgress,
_onSetCurrentBackupAsset,
_onBackupError,
);
await notifyBackgroundServiceCanRun();
} else {
openAppSettings();
}
}
void setAvailableAlbums(availableAlbums) {
state = state.copyWith(
availableAlbums: availableAlbums,
);
}
void _onBackupError(ErrorUploadAsset errorAssetInfo) {
ref.watch(errorBackupListProvider.notifier).add(errorAssetInfo);
}
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
state = state.copyWith(currentUploadAsset: currentUploadAsset);
}
void cancelBackup() {
if (state.backupProgress != BackUpProgressEnum.inProgress) {
notifyBackgroundServiceCanRun();
}
state.cancelToken.cancel();
state = state.copyWith(
backupProgress: BackUpProgressEnum.idle,
progressInPercentage: 0.0,
progressInFileSize: "0 B / 0 B",
progressInFileSpeed: 0,
progressInFileSpeedUpdateTime: DateTime.now(),
progressInFileSpeedUpdateSentBytes: 0,
);
}
void _onAssetUploaded(
String deviceAssetId,
String deviceId,
bool isDuplicated,
) {
if (isDuplicated) {
state = state.copyWith(
allUniqueAssets: state.allUniqueAssets
.where((asset) => asset.id != deviceAssetId)
.toSet(),
);
} else {
state = state.copyWith(
selectedAlbumsBackupAssetsIds: {
...state.selectedAlbumsBackupAssetsIds,
deviceAssetId,
},
allAssetsInDatabase: [...state.allAssetsInDatabase, deviceAssetId],
);
}
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,
progressInFileSize: "0 B / 0 B",
progressInFileSpeed: 0,
progressInFileSpeedUpdateTime: DateTime.now(),
progressInFileSpeedUpdateSentBytes: 0,
);
_updatePersistentAlbumsSelection();
}
updateServerInfo();
}
void _onUploadProgress(int sent, int total) {
double lastUploadSpeed = state.progressInFileSpeed;
List<double> lastUploadSpeeds = state.progressInFileSpeeds.toList();
DateTime lastUpdateTime = state.progressInFileSpeedUpdateTime;
int lastSentBytes = state.progressInFileSpeedUpdateSentBytes;
final now = DateTime.now();
final duration = now.difference(lastUpdateTime);
// Keep the upload speed average span limited, to keep it somewhat relevant
if (lastUploadSpeeds.length > 10) {
lastUploadSpeeds.removeAt(0);
}
if (duration.inSeconds > 0) {
lastUploadSpeeds.add(
((sent - lastSentBytes) / duration.inSeconds).abs().roundToDouble(),
);
lastUploadSpeed = lastUploadSpeeds.average.abs().roundToDouble();
lastUpdateTime = now;
lastSentBytes = sent;
}
state = state.copyWith(
progressInPercentage: (sent.toDouble() / total.toDouble() * 100),
progressInFileSize: humanReadableFileBytesProgress(sent, total),
progressInFileSpeed: lastUploadSpeed,
progressInFileSpeeds: lastUploadSpeeds,
progressInFileSpeedUpdateTime: lastUpdateTime,
progressInFileSpeedUpdateSentBytes: lastSentBytes,
);
}
Future<void> updateServerInfo() async {
final serverInfo = await _serverInfoService.getServerInfo();
// Update server info
if (serverInfo != null) {
state = state.copyWith(
serverInfo: serverInfo,
);
}
}
Future<void> _resumeBackup() async {
// Check if user is login
final accessKey = Store.tryGet(StoreKey.accessToken);
// User has been logged out return
if (accessKey == null || !_authState.isAuthenticated) {
log.info("[_resumeBackup] not authenticated - abort");
return;
}
// Check if this device is enable backup by the user
if (state.autoBackup) {
// check if backup is already in process - then return
if (state.backupProgress == BackUpProgressEnum.inProgress) {
log.info("[_resumeBackup] Auto Backup is already in progress - abort");
return;
}
if (state.backupProgress == BackUpProgressEnum.inBackground) {
log.info("[_resumeBackup] Background backup is running - abort");
return;
}
if (state.backupProgress == BackUpProgressEnum.manualInProgress) {
log.info("[_resumeBackup] Manual upload is running - abort");
return;
}
// Run backup
log.info("[_resumeBackup] Start back up");
await startBackupProcess();
}
return;
}
Future<void> resumeBackup() async {
final List<BackupAlbum> selectedBackupAlbums = await _db.backupAlbums
.filter()
.selectionEqualTo(BackupSelection.select)
.findAll();
final List<BackupAlbum> excludedBackupAlbums = await _db.backupAlbums
.filter()
.selectionEqualTo(BackupSelection.exclude)
.findAll();
Set<AvailableAlbum> selectedAlbums = state.selectedBackupAlbums;
Set<AvailableAlbum> excludedAlbums = state.excludedBackupAlbums;
if (selectedAlbums.isNotEmpty) {
selectedAlbums = _updateAlbumsBackupTime(
selectedAlbums,
selectedBackupAlbums,
);
}
if (excludedAlbums.isNotEmpty) {
excludedAlbums = _updateAlbumsBackupTime(
excludedAlbums,
excludedBackupAlbums,
);
}
final BackUpProgressEnum previous = state.backupProgress;
state = state.copyWith(
backupProgress: BackUpProgressEnum.inBackground,
selectedBackupAlbums: selectedAlbums,
excludedBackupAlbums: excludedAlbums,
);
// assumes the background service is currently running
// if true, waits until it has stopped to start the backup
final bool hasLock = await _backgroundService.acquireLock();
if (hasLock) {
state = state.copyWith(backupProgress: previous);
}
return _resumeBackup();
}
Set<AvailableAlbum> _updateAlbumsBackupTime(
Set<AvailableAlbum> albums,
List<BackupAlbum> backupAlbums,
) {
Set<AvailableAlbum> result = {};
for (BackupAlbum ba in backupAlbums) {
try {
AvailableAlbum a = albums.firstWhere((e) => e.id == ba.id);
result.add(a.copyWith(lastBackup: ba.lastBackup));
} on StateError {
log.severe(
"[_updateAlbumBackupTime] failed to find album in state",
"State Error",
StackTrace.current,
);
}
}
return result;
}
Future<void> notifyBackgroundServiceCanRun() async {
const allowedStates = [
AppLifeCycleEnum.inactive,
AppLifeCycleEnum.paused,
AppLifeCycleEnum.detached,
];
if (allowedStates.contains(ref.read(appStateProvider.notifier).state)) {
_backgroundService.releaseLock();
}
}
BackUpProgressEnum get backupProgress => state.backupProgress;
void updateBackupProgress(BackUpProgressEnum backupProgress) {
state = state.copyWith(backupProgress: backupProgress);
}
}
final backupProvider =
StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
return BackupNotifier(
ref.watch(backupServiceProvider),
ref.watch(serverInfoServiceProvider),
ref.watch(authenticationProvider),
ref.watch(backgroundServiceProvider),
ref.watch(galleryPermissionNotifier.notifier),
ref.watch(dbProvider),
ref,
);
});

View file

@ -0,0 +1,109 @@
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/services/backup_verification.service.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
part 'backup_verification.provider.g.dart';
@riverpod
class BackupVerification extends _$BackupVerification {
@override
bool build() => false;
void performBackupCheck(BuildContext context) async {
try {
state = true;
final backupState = ref.read(backupProvider);
if (backupState.allUniqueAssets.length >
backupState.selectedAlbumsBackupAssetsIds.length) {
if (context.mounted) {
ImmichToast.show(
context: context,
msg: "Backup all assets before starting this check!",
toastType: ToastType.error,
);
}
return;
}
final connection = await Connectivity().checkConnectivity();
if (connection != ConnectivityResult.wifi) {
if (context.mounted) {
ImmichToast.show(
context: context,
msg: "Make sure to be connected to unmetered Wi-Fi",
toastType: ToastType.error,
);
}
return;
}
WakelockPlus.enable();
const limit = 100;
final toDelete = await ref
.read(backupVerificationServiceProvider)
.findWronglyBackedUpAssets(limit: limit);
if (toDelete.isEmpty) {
if (context.mounted) {
ImmichToast.show(
context: context,
msg: "Did not find any corrupt asset backups!",
toastType: ToastType.success,
);
}
} else {
if (context.mounted) {
await showDialog(
context: context,
builder: (ctx) => ConfirmDialog(
onOk: () => _performDeletion(context, toDelete),
title: "Corrupt backups!",
ok: "Delete",
content:
"Found ${toDelete.length} (max $limit at once) corrupt asset backups. "
"Run the check again to find more.\n"
"Do you want to delete the corrupt asset backups now?",
),
);
}
}
} finally {
WakelockPlus.disable();
state = false;
}
}
Future<void> _performDeletion(
BuildContext context,
List<Asset> assets,
) async {
try {
state = true;
if (context.mounted) {
ImmichToast.show(
context: context,
msg: "Deleting ${assets.length} assets on the server...",
);
}
await ref.read(assetProvider.notifier).deleteAssets(assets, force: true);
if (context.mounted) {
ImmichToast.show(
context: context,
msg: "Deleted ${assets.length} assets on the server. "
"You can now start a manual backup",
toastType: ToastType.success,
);
}
} finally {
state = false;
}
}
}

View file

@ -0,0 +1,27 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'backup_verification.provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$backupVerificationHash() =>
r'b691e0cc27856eef189258d3c102cc73ce4812a4';
/// See also [BackupVerification].
@ProviderFor(BackupVerification)
final backupVerificationProvider =
AutoDisposeNotifierProvider<BackupVerification, bool>.internal(
BackupVerification.new,
name: r'backupVerificationProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$backupVerificationHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$BackupVerification = AutoDisposeNotifier<bool>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member

View file

@ -0,0 +1,23 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
class ErrorBackupListNotifier extends StateNotifier<Set<ErrorUploadAsset>> {
ErrorBackupListNotifier() : super({});
add(ErrorUploadAsset errorAsset) {
state = state.union({errorAsset});
}
remove(ErrorUploadAsset errorAsset) {
state = state.difference({errorAsset});
}
empty() {
state = {};
}
}
final errorBackupListProvider =
StateNotifierProvider<ErrorBackupListNotifier, Set<ErrorUploadAsset>>(
(ref) => ErrorBackupListNotifier(),
);

View file

@ -0,0 +1,59 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/services/background.service.dart';
class IOSBackgroundSettings {
final bool appRefreshEnabled;
final int numberOfBackgroundTasksQueued;
final DateTime? timeOfLastFetch;
final DateTime? timeOfLastProcessing;
IOSBackgroundSettings({
required this.appRefreshEnabled,
required this.numberOfBackgroundTasksQueued,
this.timeOfLastFetch,
this.timeOfLastProcessing,
});
}
class IOSBackgroundSettingsNotifier
extends StateNotifier<IOSBackgroundSettings?> {
final BackgroundService _service;
IOSBackgroundSettingsNotifier(this._service) : super(null);
IOSBackgroundSettings? get settings => state;
Future<IOSBackgroundSettings> refresh() async {
final lastFetchTime =
await _service.getIOSBackupLastRun(IosBackgroundTask.fetch);
final lastProcessingTime =
await _service.getIOSBackupLastRun(IosBackgroundTask.processing);
int numberOfProcesses = await _service.getIOSBackupNumberOfProcesses();
final appRefreshEnabled =
await _service.getIOSBackgroundAppRefreshEnabled();
// If this is enabled and there are no background processes,
// the user just enabled app refresh in Settings.
// But we don't have any background services running, since it was disabled
// before.
if (await _service.isBackgroundBackupEnabled() && numberOfProcesses == 0) {
// We need to restart the background service
await _service.enableService();
numberOfProcesses = await _service.getIOSBackupNumberOfProcesses();
}
final settings = IOSBackgroundSettings(
appRefreshEnabled: appRefreshEnabled,
numberOfBackgroundTasksQueued: numberOfProcesses,
timeOfLastFetch: lastFetchTime,
timeOfLastProcessing: lastProcessingTime,
);
state = settings;
return settings;
}
}
final iOSBackgroundSettingsProvider = StateNotifierProvider<
IOSBackgroundSettingsNotifier, IOSBackgroundSettings?>(
(ref) => IOSBackgroundSettingsNotifier(ref.watch(backgroundServiceProvider)),
);

View file

@ -0,0 +1,395 @@
import 'dart:io';
import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/widgets.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/manual_upload_state.model.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
import 'package:immich_mobile/services/backup.service.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/app_life_cycle.provider.dart';
import 'package:immich_mobile/services/local_notification.service.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/utils/backup_progress.dart';
import 'package:logging/logging.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:photo_manager/photo_manager.dart';
final manualUploadProvider =
StateNotifierProvider<ManualUploadNotifier, ManualUploadState>((ref) {
return ManualUploadNotifier(
ref.watch(localNotificationService),
ref.watch(backupProvider.notifier),
ref,
);
});
class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
final Logger _log = Logger("ManualUploadNotifier");
final LocalNotificationService _localNotificationService;
final BackupNotifier _backupProvider;
final Ref ref;
ManualUploadNotifier(
this._localNotificationService,
this._backupProvider,
this.ref,
) : super(
ManualUploadState(
progressInPercentage: 0,
progressInFileSize: "0 B / 0 B",
progressInFileSpeed: 0,
progressInFileSpeeds: const [],
progressInFileSpeedUpdateTime: DateTime.now(),
progressInFileSpeedUpdateSentBytes: 0,
cancelToken: CancellationToken(),
currentUploadAsset: CurrentUploadAsset(
id: '...',
fileCreatedAt: DateTime.parse('2020-10-04'),
fileName: '...',
fileType: '...',
),
totalAssetsToUpload: 0,
successfulUploads: 0,
currentAssetIndex: 0,
showDetailedNotification: false,
),
);
String _lastPrintedDetailContent = '';
String? _lastPrintedDetailTitle;
static const notifyInterval = Duration(milliseconds: 500);
late final ThrottleProgressUpdate _throttledNotifiy =
ThrottleProgressUpdate(_updateProgress, notifyInterval);
late final ThrottleProgressUpdate _throttledDetailNotify =
ThrottleProgressUpdate(_updateDetailProgress, notifyInterval);
void _updateProgress(String? title, int progress, int total) {
// Guard against throttling calling this method after the upload is done
if (_backupProvider.backupProgress == BackUpProgressEnum.manualInProgress) {
_localNotificationService.showOrUpdateManualUploadStatus(
"backup_background_service_in_progress_notification".tr(),
formatAssetBackupProgress(
state.currentAssetIndex,
state.totalAssetsToUpload,
),
maxProgress: state.totalAssetsToUpload,
progress: state.currentAssetIndex,
showActions: true,
);
}
}
void _updateDetailProgress(String? title, int progress, int total) {
// Guard against throttling calling this method after the upload is done
if (_backupProvider.backupProgress == BackUpProgressEnum.manualInProgress) {
final String msg =
total > 0 ? humanReadableBytesProgress(progress, total) : "";
// only update if message actually differs (to stop many useless notification updates on large assets or slow connections)
if (msg != _lastPrintedDetailContent ||
title != _lastPrintedDetailTitle) {
_lastPrintedDetailContent = msg;
_lastPrintedDetailTitle = title;
_localNotificationService.showOrUpdateManualUploadStatus(
title ?? 'Uploading',
msg,
progress: total > 0 ? (progress * 1000) ~/ total : 0,
maxProgress: 1000,
isDetailed: true,
// Detailed noitifcation is displayed for Single asset uploads. Show actions for such case
showActions: state.totalAssetsToUpload == 1,
);
}
}
}
void _onAssetUploaded(
String deviceAssetId,
String deviceId,
bool isDuplicated,
) {
state = state.copyWith(successfulUploads: state.successfulUploads + 1);
_backupProvider.updateServerInfo();
}
void _onAssetUploadError(ErrorUploadAsset errorAssetInfo) {
ref.watch(errorBackupListProvider.notifier).add(errorAssetInfo);
}
void _onProgress(int sent, int total) {
double lastUploadSpeed = state.progressInFileSpeed;
List<double> lastUploadSpeeds = state.progressInFileSpeeds.toList();
DateTime lastUpdateTime = state.progressInFileSpeedUpdateTime;
int lastSentBytes = state.progressInFileSpeedUpdateSentBytes;
final now = DateTime.now();
final duration = now.difference(lastUpdateTime);
// Keep the upload speed average span limited, to keep it somewhat relevant
if (lastUploadSpeeds.length > 10) {
lastUploadSpeeds.removeAt(0);
}
if (duration.inSeconds > 0) {
lastUploadSpeeds.add(
((sent - lastSentBytes) / duration.inSeconds).abs().roundToDouble(),
);
lastUploadSpeed = lastUploadSpeeds.average.abs().roundToDouble();
lastUpdateTime = now;
lastSentBytes = sent;
}
state = state.copyWith(
progressInPercentage: (sent.toDouble() / total.toDouble() * 100),
progressInFileSize: humanReadableFileBytesProgress(sent, total),
progressInFileSpeed: lastUploadSpeed,
progressInFileSpeeds: lastUploadSpeeds,
progressInFileSpeedUpdateTime: lastUpdateTime,
progressInFileSpeedUpdateSentBytes: lastSentBytes,
);
if (state.showDetailedNotification) {
final title = "backup_background_service_current_upload_notification"
.tr(args: [state.currentUploadAsset.fileName]);
_throttledDetailNotify(title: title, progress: sent, total: total);
}
}
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
state = state.copyWith(
currentUploadAsset: currentUploadAsset,
currentAssetIndex: state.currentAssetIndex + 1,
);
if (state.totalAssetsToUpload > 1) {
_throttledNotifiy();
}
if (state.showDetailedNotification) {
_throttledDetailNotify.title =
"backup_background_service_current_upload_notification"
.tr(args: [currentUploadAsset.fileName]);
_throttledDetailNotify.progress = 0;
_throttledDetailNotify.total = 0;
}
}
Future<bool> _startUpload(Iterable<Asset> allManualUploads) async {
bool hasErrors = false;
try {
_backupProvider.updateBackupProgress(BackUpProgressEnum.manualInProgress);
if (ref.read(galleryPermissionNotifier.notifier).hasPermission) {
await PhotoManager.clearFileCache();
// We do not have 1:1 mapping of all AssetEntity fields to Asset. This results in cases
// where platform specific fields such as `subtype` used to detect platform specific assets such as
// LivePhoto in iOS is lost when we directly fetch the local asset from Asset using Asset.local
List<AssetEntity?> allAssetsFromDevice = await Future.wait(
allManualUploads
// Filter local only assets
.where((e) => e.isLocal && !e.isRemote)
.map((e) => e.local!.obtainForNewProperties()),
);
if (allAssetsFromDevice.length != allManualUploads.length) {
_log.warning(
'[_startUpload] Refreshed upload list -> ${allManualUploads.length - allAssetsFromDevice.length} asset will not be uploaded',
);
}
Set<AssetEntity> allUploadAssets = allAssetsFromDevice.nonNulls.toSet();
if (allUploadAssets.isEmpty) {
debugPrint("[_startUpload] No Assets to upload - Abort Process");
_backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
return false;
}
state = state.copyWith(
progressInPercentage: 0,
progressInFileSize: "0 B / 0 B",
progressInFileSpeed: 0,
totalAssetsToUpload: allUploadAssets.length,
successfulUploads: 0,
currentAssetIndex: 0,
currentUploadAsset: CurrentUploadAsset(
id: '...',
fileCreatedAt: DateTime.parse('2020-10-04'),
fileName: '...',
fileType: '...',
),
cancelToken: CancellationToken(),
);
// Reset Error List
ref.watch(errorBackupListProvider.notifier).empty();
if (state.totalAssetsToUpload > 1) {
_throttledNotifiy();
}
// Show detailed asset if enabled in settings or if a single asset is uploaded
bool showDetailedNotification =
ref.read(appSettingsServiceProvider).getSetting<bool>(
AppSettingsEnum.backgroundBackupSingleProgress,
) ||
state.totalAssetsToUpload == 1;
state =
state.copyWith(showDetailedNotification: showDetailedNotification);
final pmProgressHandler = Platform.isIOS ? PMProgressHandler() : null;
final bool ok = await ref.read(backupServiceProvider).backupAsset(
allUploadAssets,
state.cancelToken,
pmProgressHandler,
_onAssetUploaded,
_onProgress,
_onSetCurrentBackupAsset,
_onAssetUploadError,
);
// Close detailed notification
await _localNotificationService.closeNotification(
LocalNotificationService.manualUploadDetailedNotificationID,
);
_log.info(
'[_startUpload] Manual Upload Completed - success: ${state.successfulUploads},'
' failed: ${state.totalAssetsToUpload - state.successfulUploads}',
);
// User cancelled upload
if (!ok && state.cancelToken.isCancelled) {
await _localNotificationService.showOrUpdateManualUploadStatus(
"backup_manual_title".tr(),
"backup_manual_cancelled".tr(),
presentBanner: true,
);
hasErrors = true;
} else if (state.successfulUploads == 0 ||
(!ok && !state.cancelToken.isCancelled)) {
await _localNotificationService.showOrUpdateManualUploadStatus(
"backup_manual_title".tr(),
"backup_manual_failed".tr(),
presentBanner: true,
);
hasErrors = true;
} else {
await _localNotificationService.showOrUpdateManualUploadStatus(
"backup_manual_title".tr(),
"backup_manual_success".tr(),
presentBanner: true,
);
}
} else {
openAppSettings();
debugPrint("[_startUpload] Do not have permission to the gallery");
}
} catch (e) {
debugPrint("ERROR _startUpload: ${e.toString()}");
hasErrors = true;
} finally {
_backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
_handleAppInActivity();
await _localNotificationService.closeNotification(
LocalNotificationService.manualUploadDetailedNotificationID,
);
await _backupProvider.notifyBackgroundServiceCanRun();
}
return !hasErrors;
}
void _handleAppInActivity() {
final appState = ref.read(appStateProvider.notifier).getAppState();
// The app is currently in background. Perform the necessary cleanups which
// are on-hold for upload completion
if (appState != AppLifeCycleEnum.active &&
appState != AppLifeCycleEnum.resumed) {
ref.read(backupProvider.notifier).cancelBackup();
}
}
void cancelBackup() {
if (_backupProvider.backupProgress != BackUpProgressEnum.inProgress &&
_backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) {
_backupProvider.notifyBackgroundServiceCanRun();
}
state.cancelToken.cancel();
if (_backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) {
_backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
}
state = state.copyWith(
progressInPercentage: 0,
progressInFileSize: "0 B / 0 B",
progressInFileSpeed: 0,
progressInFileSpeedUpdateTime: DateTime.now(),
progressInFileSpeedUpdateSentBytes: 0,
);
}
Future<bool> uploadAssets(
BuildContext context,
Iterable<Asset> allManualUploads,
) async {
// assumes the background service is currently running and
// waits until it has stopped to start the backup.
final bool hasLock =
await ref.read(backgroundServiceProvider).acquireLock();
if (!hasLock) {
debugPrint("[uploadAssets] could not acquire lock, exiting");
ImmichToast.show(
context: context,
msg: "backup_manual_failed".tr(),
toastType: ToastType.info,
gravity: ToastGravity.BOTTOM,
durationInSecond: 3,
);
return false;
}
bool showInProgress = false;
// check if backup is already in process - then return
if (_backupProvider.backupProgress == BackUpProgressEnum.manualInProgress) {
debugPrint("[uploadAssets] Manual upload is already running - abort");
showInProgress = true;
}
if (_backupProvider.backupProgress == BackUpProgressEnum.inProgress) {
debugPrint("[uploadAssets] Auto Backup is already in progress - abort");
showInProgress = true;
return false;
}
if (_backupProvider.backupProgress == BackUpProgressEnum.inBackground) {
debugPrint("[uploadAssets] Background backup is running - abort");
showInProgress = true;
}
if (showInProgress) {
if (context.mounted) {
ImmichToast.show(
context: context,
msg: "backup_manual_in_progress".tr(),
toastType: ToastType.info,
gravity: ToastGravity.BOTTOM,
durationInSecond: 3,
);
}
return false;
}
return _startUpload(allManualUploads);
}
}