This commit is contained in:
Alex 2025-06-09 14:35:37 -05:00
parent f87ae08cd1
commit 3ccde454b1
No known key found for this signature in database
GPG Key ID: 53CD082B3A5E1082
11 changed files with 374 additions and 95 deletions

View File

@ -7,8 +7,10 @@ abstract interface class IBackupRepository implements IDatabaseRepository {
Future<List<String>> getAssetIds(String albumId); Future<List<String>> getAssetIds(String albumId);
/// Returns the total number of assets that are selected for backup. Future<int> getTotalCount();
Future<int> getTotalCount(BackupSelection selection); Future<int> getRemainderCount();
Future<int> getBackupCount(); Future<int> getBackupCount();
Future<List<LocalAlbum>> getBackupAlbums(BackupSelection selectionType);
Future<List<LocalAsset>> getCandidates();
} }

View File

@ -1,7 +1,10 @@
import 'dart:io'; import 'dart:io';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
// ignore: import_rule_photo_manager
import 'package:photo_manager/photo_manager.dart';
abstract interface class IStorageRepository { abstract interface class IStorageRepository {
Future<File?> getFileForAsset(LocalAsset asset); Future<File?> getFileForAsset(LocalAsset asset);
Future<AssetEntity?> getAssetEntityForAsset(LocalAsset asset);
} }

View File

@ -51,7 +51,9 @@ class DriftBackupRepository extends DriftDatabaseRepository
} }
@override @override
Future<int> getTotalCount(BackupSelection selection) { Future<int> getTotalCount() async {
final excludedAssetIds = await _getExcludedAssetIds();
final query = _db.localAlbumAssetEntity.selectOnly(distinct: true) final query = _db.localAlbumAssetEntity.selectOnly(distinct: true)
..addColumns([_db.localAlbumAssetEntity.assetId]) ..addColumns([_db.localAlbumAssetEntity.assetId])
..join([ ..join([
@ -61,29 +63,147 @@ class DriftBackupRepository extends DriftDatabaseRepository
), ),
]) ])
..where( ..where(
_db.localAlbumEntity.backupSelection.equals(selection.index), _db.localAlbumEntity.backupSelection
.equals(BackupSelection.selected.index) &
(excludedAssetIds.isEmpty
? const Constant(true)
: _db.localAlbumAssetEntity.assetId.isNotIn(excludedAssetIds)),
); );
return query.get().then((rows) => rows.length); return query.get().then((rows) => rows.length);
} }
@override @override
Future<int> getBackupCount() { Future<int> getRemainderCount() async {
final query = _db.localAlbumEntity.select().join( final excludedAssetIds = await _getExcludedAssetIds();
[
final query = _db.localAlbumAssetEntity.selectOnly(distinct: true)
..addColumns(
[_db.localAlbumAssetEntity.assetId],
)
..join([
innerJoin( innerJoin(
_db.localAlbumAssetEntity, _db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id), _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
), ),
], innerJoin(
)..where( _db.localAssetEntity,
_db.localAlbumEntity.backupSelection.equals( _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
BackupSelection.selected.index,
), ),
leftOuterJoin(
_db.remoteAssetEntity,
_db.localAssetEntity.checksum
.equalsExp(_db.remoteAssetEntity.checksum),
),
])
..where(
_db.localAlbumEntity.backupSelection
.equals(BackupSelection.selected.index) &
_db.remoteAssetEntity.checksum.isNull() &
(excludedAssetIds.isEmpty
? const Constant(true)
: _db.localAlbumAssetEntity.assetId.isNotIn(excludedAssetIds)),
); );
return query.get().then((rows) => rows.length); return query.get().then((rows) => rows.length);
} }
@override
Future<int> getBackupCount() async {
final excludedAssetIds = await _getExcludedAssetIds();
final query = _db.localAlbumAssetEntity.selectOnly(distinct: true)
..addColumns(
[_db.localAlbumAssetEntity.assetId],
)
..join([
innerJoin(
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
),
innerJoin(
_db.localAssetEntity,
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
),
innerJoin(
_db.remoteAssetEntity,
_db.localAssetEntity.checksum
.equalsExp(_db.remoteAssetEntity.checksum),
),
])
..where(
_db.localAlbumEntity.backupSelection
.equals(BackupSelection.selected.index) &
_db.remoteAssetEntity.checksum.isNotNull() &
(excludedAssetIds.isEmpty
? const Constant(true)
: _db.localAlbumAssetEntity.assetId.isNotIn(excludedAssetIds)),
);
return query.get().then((rows) => rows.length);
}
Future<List<String>> _getExcludedAssetIds() async {
final query = _db.localAlbumAssetEntity.selectOnly()
..addColumns([_db.localAlbumAssetEntity.assetId])
..join([
innerJoin(
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
),
])
..where(
_db.localAlbumEntity.backupSelection
.equals(BackupSelection.excluded.index),
);
return query
.map((row) => row.read(_db.localAlbumAssetEntity.assetId)!)
.get();
}
@override
Future<List<LocalAlbum>> getBackupAlbums(BackupSelection selectionType) {
final query = _db.localAlbumEntity.select()
..where(
(tbl) => tbl.backupSelection.equals(selectionType.index),
);
return query.map((localAlbum) => localAlbum.toDto(assetCount: 0)).get();
}
@override
Future<List<LocalAsset>> getCandidates() async {
final excludedAssetIds = await _getExcludedAssetIds();
final query = _db.localAlbumAssetEntity.select(distinct: true).join(
[
innerJoin(
_db.localAlbumEntity,
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
),
innerJoin(
_db.localAssetEntity,
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
),
leftOuterJoin(
_db.remoteAssetEntity,
_db.localAssetEntity.checksum
.equalsExp(_db.remoteAssetEntity.checksum),
),
],
)..where(
_db.localAlbumEntity.backupSelection
.equals(BackupSelection.selected.index) &
_db.remoteAssetEntity.checksum.isNull() &
(excludedAssetIds.isEmpty
? const Constant(true)
: _db.localAlbumAssetEntity.assetId.isNotIn(excludedAssetIds)),
);
return query
.map((row) => row.readTable(_db.localAssetEntity).toDto())
.get();
}
} }
extension on LocalAlbumEntityData { extension on LocalAlbumEntityData {

View File

@ -28,4 +28,24 @@ class StorageRepository implements IStorageRepository {
} }
return file; return file;
} }
@override
Future<AssetEntity?> getAssetEntityForAsset(LocalAsset asset) async {
AssetEntity? entity;
try {
entity = await AssetEntity.fromId(asset.id);
if (entity == null) {
_log.warning(
"Cannot get AssetEntity for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
);
}
} catch (error, stackTrace) {
_log.warning(
"Error getting AssetEntity for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}",
error,
stackTrace,
);
}
return entity;
}
} }

View File

@ -6,7 +6,7 @@ abstract interface class IUploadRepository {
void enqueueAll(List<UploadTask> tasks); void enqueueAll(List<UploadTask> tasks);
Future<bool> cancel(String id); Future<bool> cancel(String id);
void cancelAll(); Future<bool> cancelAll();
Future<void> pauseAll(); Future<void> pauseAll();
Future<void> deleteAllTrackingRecords(); Future<void> deleteAllTrackingRecords();
Future<void> deleteRecordsWithIds(List<String> id); Future<void> deleteRecordsWithIds(List<String> id);

View File

@ -95,55 +95,34 @@ class ExpBackupPage extends HookConsumerWidget {
[backupState.backupProgress], [backupState.backupProgress],
); );
void startBackup() { Widget buildControlButtons() {
ref.watch(errorBackupListProvider.notifier).empty();
if (ref.watch(backupProvider).backupProgress !=
BackUpProgressEnum.inBackground) {
ref.watch(backupProvider.notifier).startBackupProcess();
}
}
Widget buildBackupButton() {
return Padding( return Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
top: 24, top: 24,
), ),
child: Container( child: Column(
child: backupState.backupProgress == BackUpProgressEnum.inProgress || children: [
backupState.backupProgress == ElevatedButton(
BackUpProgressEnum.manualInProgress onPressed: () => ref.read(expBackupProvider.notifier).backup(),
? ElevatedButton( child: const Text(
style: ElevatedButton.styleFrom( "backup_controller_page_start_backup",
foregroundColor: Colors.grey[50], style: TextStyle(
backgroundColor: Colors.red[300], fontSize: 16,
// padding: const EdgeInsets.all(14), fontWeight: FontWeight.bold,
),
onPressed: () {
if (backupState.backupProgress ==
BackUpProgressEnum.manualInProgress) {
ref.read(manualUploadProvider.notifier).cancelBackup();
} else {
ref.read(backupProvider.notifier).cancelBackup();
}
},
child: const Text(
"cancel",
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
).tr(),
)
: ElevatedButton(
onPressed: shouldBackup ? startBackup : null,
child: const Text(
"backup_controller_page_start_backup",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
).tr(),
), ),
).tr(),
),
OutlinedButton(
onPressed: () => ref.read(expBackupProvider.notifier).cancel(),
child: const Text(
"cancel",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
).tr(),
),
],
), ),
); );
} }
@ -214,11 +193,11 @@ class ExpBackupPage extends HookConsumerWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
const BackupAlbumSelectionCard(), const BackupAlbumSelectionCard(),
const TotalCard(), const TotalCard(),
const BackupCard(),
const RemainderCard(), const RemainderCard(),
const Divider(), const Divider(),
buildControlButtons(),
const CurrentUploadingAssetInfoBox(), const CurrentUploadingAssetInfoBox(),
if (!hasExclusiveAccess) buildBackgroundBackupInfo(),
buildBackupButton(),
] ]
: [ : [
const BackupAlbumSelectionCard(), const BackupAlbumSelectionCard(),
@ -369,18 +348,33 @@ class TotalCard extends ConsumerWidget {
} }
} }
class BackupCard extends ConsumerWidget {
const BackupCard({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final backupCount =
ref.watch(expBackupProvider.select((p) => p.backupCount));
return BackupInfoCard(
title: "backup_controller_page_backup".tr(),
subtitle: "backup_controller_page_backup_sub".tr(),
info: backupCount.toString(),
);
}
}
class RemainderCard extends ConsumerWidget { class RemainderCard extends ConsumerWidget {
const RemainderCard({super.key}); const RemainderCard({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final backupState = ref.watch(backupProvider); final remainderCount =
ref.watch(expBackupProvider.select((p) => p.remainderCount));
return BackupInfoCard( return BackupInfoCard(
title: "backup_controller_page_remainder".tr(), title: "backup_controller_page_remainder".tr(),
subtitle: "backup_controller_page_remainder_sub".tr(), subtitle: "backup_controller_page_remainder_sub".tr(),
info: backupState.availableAlbums.isEmpty info: remainderCount.toString(),
? "..."
: "${max(0, backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length)}",
); );
} }
} }

View File

@ -1,33 +1,52 @@
// ignore_for_file: public_member_api_docs, sort_constructors_first // ignore_for_file: public_member_api_docs, sort_constructors_first
import 'dart:convert'; import 'dart:convert';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/cupertino.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/utils/background_sync.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/services/exp_backup.service.dart'; import 'package:immich_mobile/services/exp_backup.service.dart';
import 'package:immich_mobile/services/upload.service.dart';
class ExpBackupState { class ExpBackupState {
final int totalCount; final int totalCount;
final int backupCount;
final int remainderCount;
ExpBackupState({ ExpBackupState({
required this.totalCount, required this.totalCount,
required this.backupCount,
required this.remainderCount,
}); });
ExpBackupState copyWith({ ExpBackupState copyWith({
int? totalCount, int? totalCount,
int? backupCount,
int? remainderCount,
}) { }) {
return ExpBackupState( return ExpBackupState(
totalCount: totalCount ?? this.totalCount, totalCount: totalCount ?? this.totalCount,
backupCount: backupCount ?? this.backupCount,
remainderCount: remainderCount ?? this.remainderCount,
); );
} }
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return <String, dynamic>{ return <String, dynamic>{
'totalCount': totalCount, 'totalCount': totalCount,
'backupCount': backupCount,
'remainderCount': remainderCount,
}; };
} }
factory ExpBackupState.fromMap(Map<String, dynamic> map) { factory ExpBackupState.fromMap(Map<String, dynamic> map) {
return ExpBackupState( return ExpBackupState(
totalCount: map['totalCount'] as int, totalCount: map['totalCount'] as int,
backupCount: map['backupCount'] as int,
remainderCount: map['remainderCount'] as int,
); );
} }
@ -37,37 +56,103 @@ class ExpBackupState {
ExpBackupState.fromMap(json.decode(source) as Map<String, dynamic>); ExpBackupState.fromMap(json.decode(source) as Map<String, dynamic>);
@override @override
String toString() => 'ExpBackupState(totalCount: $totalCount)'; String toString() =>
'ExpBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount)';
@override @override
bool operator ==(covariant ExpBackupState other) { bool operator ==(covariant ExpBackupState other) {
if (identical(this, other)) return true; if (identical(this, other)) return true;
return other.totalCount == totalCount; return other.totalCount == totalCount &&
other.backupCount == backupCount &&
other.remainderCount == remainderCount;
} }
@override @override
int get hashCode => totalCount.hashCode; int get hashCode =>
totalCount.hashCode ^ backupCount.hashCode ^ remainderCount.hashCode;
} }
final expBackupProvider = final expBackupProvider =
StateNotifierProvider<ExpBackupNotifier, ExpBackupState>((ref) { StateNotifierProvider<ExpBackupNotifier, ExpBackupState>((ref) {
return ExpBackupNotifier(ref.watch(expBackupServiceProvider)); return ExpBackupNotifier(
ref.watch(expBackupServiceProvider),
ref.watch(uploadServiceProvider),
ref.watch(backgroundSyncProvider),
);
}); });
class ExpBackupNotifier extends StateNotifier<ExpBackupState> { class ExpBackupNotifier extends StateNotifier<ExpBackupState> {
ExpBackupNotifier(this._backupService) ExpBackupNotifier(
: super( this._backupService,
this._uploadService,
this._backgroundSyncManager,
) : super(
ExpBackupState( ExpBackupState(
totalCount: 0, totalCount: 0,
backupCount: 0,
remainderCount: 0,
), ),
); ) {
{
_uploadService.onUploadStatus = _uploadStatusCallback;
_uploadService.onTaskProgress = _taskProgressCallback;
}
}
final ExpBackupService _backupService; final ExpBackupService _backupService;
final UploadService _uploadService;
final BackgroundSyncManager _backgroundSyncManager;
void _updateUploadStatus(TaskStatusUpdate task, TaskStatus status) async {
if (status == TaskStatus.canceled) {
return;
}
}
void _uploadStatusCallback(TaskStatusUpdate update) {
_updateUploadStatus(update, update.status);
switch (update.status) {
case TaskStatus.complete:
state = state.copyWith(
backupCount: state.backupCount + 1,
remainderCount: state.remainderCount - 1,
);
// TODO: find a better place to call this.
_backgroundSyncManager.syncRemote();
break;
default:
break;
}
}
void _taskProgressCallback(TaskProgressUpdate update) {
debugPrint("[_taskProgressCallback] $update");
}
Future<void> getBackupStatus() async { Future<void> getBackupStatus() async {
final totalCount = await _backupService.getTotalCount(); final [totalCount, backupCount, remainderCount] = await Future.wait([
_backupService.getTotalCount(),
_backupService.getBackupCount(),
_backupService.getRemainderCount(),
]);
state = state.copyWith(totalCount: totalCount); state = state.copyWith(
totalCount: totalCount,
backupCount: backupCount,
remainderCount: remainderCount,
);
}
Future<void> backup() async {
await _backupService.backup();
}
Future<void> cancel() async {
await _uploadService.cancel();
debugPrint("Cancel uploads");
} }
} }

View File

@ -33,7 +33,7 @@ class UploadRepository implements IUploadRepository {
@override @override
Future<void> deleteAllTrackingRecords() { Future<void> deleteAllTrackingRecords() {
return FileDownloader().database.deleteAllRecords(); return FileDownloader().database.deleteAllRecords(group: kUploadGroup);
} }
@override @override
@ -42,8 +42,9 @@ class UploadRepository implements IUploadRepository {
} }
@override @override
void cancelAll() { Future<bool> cancelAll() {
return taskQueue.removeAll(); taskQueue.removeTasksWithGroup(kUploadGroup);
return FileDownloader().cancelAll(group: kUploadGroup);
} }
@override @override

View File

@ -298,7 +298,7 @@ class BackupService {
print("Uploading ${uploadTasks.length} assets"); print("Uploading ${uploadTasks.length} assets");
_uploadService.upload(uploadTasks); _uploadService.enqueueTasks(uploadTasks);
} }
Future<bool> backupAsset( Future<bool> backupAsset(

View File

@ -1,31 +1,87 @@
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/interfaces/backup.interface.dart'; import 'package:immich_mobile/domain/interfaces/backup.interface.dart';
import 'package:immich_mobile/domain/interfaces/local_album.interface.dart'; import 'package:immich_mobile/domain/interfaces/storage.interface.dart';
import 'package:immich_mobile/domain/models/local_album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/services/upload.service.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
final expBackupServiceProvider = Provider<ExpBackupService>( final expBackupServiceProvider = Provider<ExpBackupService>(
(ref) => ExpBackupService( (ref) => ExpBackupService(
ref.watch(backupRepositoryProvider), ref.watch(backupRepositoryProvider),
ref.watch(storageRepositoryProvider),
ref.watch(uploadServiceProvider),
), ),
); );
class ExpBackupService { class ExpBackupService {
ExpBackupService(this._backupRepository); ExpBackupService(
this._backupRepository,
this._storageRepository,
this._uploadService,
);
final IBackupRepository _backupRepository; final IBackupRepository _backupRepository;
final IStorageRepository _storageRepository;
final UploadService _uploadService;
Future<int> getTotalCount() async { Future<int> getTotalCount() {
final [selectedCount, excludedCount] = await Future.wait([ return _backupRepository.getTotalCount();
_backupRepository.getTotalCount(BackupSelection.selected), }
_backupRepository.getTotalCount(BackupSelection.excluded),
]);
return selectedCount - excludedCount; Future<int> getRemainderCount() {
return _backupRepository.getRemainderCount();
} }
Future<int> getBackupCount() { Future<int> getBackupCount() {
return _backupRepository.getBackupCount(); return _backupRepository.getBackupCount();
} }
Future<void> backup() async {
final candidates = await _backupRepository.getCandidates();
if (candidates.isEmpty) {
return;
}
const batchSize = 5;
for (int i = 0; i < candidates.length; i += batchSize) {
final batch = candidates.skip(i).take(batchSize).toList();
List<UploadTask> tasks = [];
for (final asset in batch) {
final task = await _getUploadTask(asset);
if (task != null) {
tasks.add(task);
}
}
if (tasks.isNotEmpty) {
_uploadService.enqueueTasks(tasks);
}
}
}
Future<UploadTask?> _getUploadTask(LocalAsset asset) async {
final entity = await _storageRepository.getAssetEntityForAsset(asset);
if (entity == null) {
return null;
}
final file = await _storageRepository.getFileForAsset(asset);
if (file == null) {
return null;
}
return _uploadService.buildUploadTask(
file,
originalFileName: asset.name,
deviceAssetId: asset.id,
);
}
Future<void> cancel() async {
await _uploadService.cancel();
}
} }

View File

@ -42,18 +42,15 @@ class UploadService {
return FileDownloader().cancelTaskWithId(id); return FileDownloader().cancelTaskWithId(id);
} }
void cancelAllUpload() { Future<void> cancel() async {
return _uploadRepository.cancelAll(); await _uploadRepository.cancelAll();
await _uploadRepository.deleteAllTrackingRecords();
} }
Future<void> pauseAllUploads() { Future<void> pause() {
return _uploadRepository.pauseAll(); return _uploadRepository.pauseAll();
} }
Future<void> deleteAllUploadTasks() {
return _uploadRepository.deleteAllTrackingRecords();
}
Future<List<TaskRecord>> getRecords() async { Future<List<TaskRecord>> getRecords() async {
final all = await _uploadRepository.getRecords(); final all = await _uploadRepository.getRecords();
print('all record: all: ${all.length} records found'); print('all record: all: ${all.length} records found');
@ -64,7 +61,7 @@ class UploadService {
return all; return all;
} }
void upload(List<UploadTask> tasks) { void enqueueTasks(List<UploadTask> tasks) {
_uploadRepository.enqueueAll(tasks); _uploadRepository.enqueueAll(tasks);
} }
@ -111,6 +108,7 @@ class UploadService {
return UploadTask( return UploadTask(
taskId: id, taskId: id,
displayName: filename,
httpRequestMethod: 'POST', httpRequestMethod: 'POST',
url: url, url: url,
headers: headers, headers: headers,