feat(mobile): Manual asset upload (#3445)

* fix: exclude albums filter in backup provider

* refactor: Separate builder methods for Top Control App Bar buttons

* fix: Show download button only for Remote only assets

* fix(mobile): Force Refresh duration is too low to trigger it consistently

* feat(mobile): Make Buttons dynamic in Home Selection DraggableScrollableSheet

* feat(mobile): Manual Asset upload

* refactor(mobile): Replace _showToast with ImmichToast calls

* refactor(mobile): home_page selectionAssetState handling

* chore(mobile): min and initial size of DraggableScrollState increased

This is to prevent the buttons in the bottom sheet getting clipped behind the 3 way navigation buttons
in the default density of Android devices

* feat(mobile): notifications for manual upload progress

* wording

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
shalong-tanwen 2023-08-06 02:40:50 +00:00 committed by GitHub
parent f1b92718d5
commit deaf81e2a4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 887 additions and 163 deletions

View file

@ -18,6 +18,7 @@ import 'package:immich_mobile/modules/backup/services/backup.service.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/utils/backup_progress.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart';
import 'package:path_provider_ios/path_provider_ios.dart';
@ -34,7 +35,6 @@ class BackgroundService {
MethodChannel('immich/foregroundChannel');
static const MethodChannel _backgroundChannel =
MethodChannel('immich/backgroundChannel');
static final NumberFormat numberFormat = NumberFormat("###0.##");
static const notifyInterval = Duration(milliseconds: 400);
bool _isBackgroundInitialized = false;
CancellationToken? _cancellationToken;
@ -48,10 +48,10 @@ class BackgroundService {
int _assetsToUploadCount = 0;
String _lastPrintedDetailContent = "";
String? _lastPrintedDetailTitle;
late final _Throttle _throttledNotifiy =
_Throttle(_updateProgress, notifyInterval);
late final _Throttle _throttledDetailNotify =
_Throttle(_updateDetailProgress, notifyInterval);
late final ThrottleProgressUpdate _throttledNotifiy =
ThrottleProgressUpdate(_updateProgress, notifyInterval);
late final ThrottleProgressUpdate _throttledDetailNotify =
ThrottleProgressUpdate(_updateDetailProgress, notifyInterval);
bool get isBackgroundInitialized {
return _isBackgroundInitialized;
@ -439,7 +439,12 @@ class BackgroundService {
_uploadedAssetsCount = 0;
_updateNotification(
title: "backup_background_service_in_progress_notification".tr(),
content: notifyTotalProgress ? _formatAssetBackupProgress() : null,
content: notifyTotalProgress
? formatAssetBackupProgress(
_uploadedAssetsCount,
_assetsToUploadCount,
)
: null,
progress: 0,
max: notifyTotalProgress ? _assetsToUploadCount : 0,
indeterminate: !notifyTotalProgress,
@ -464,11 +469,6 @@ class BackgroundService {
return ok;
}
String _formatAssetBackupProgress() {
final int percent = (_uploadedAssetsCount * 100) ~/ _assetsToUploadCount;
return "$percent% ($_uploadedAssetsCount/$_assetsToUploadCount)";
}
void _onAssetUploaded(String deviceAssetId, String deviceId, bool isDup) {
_uploadedAssetsCount++;
_throttledNotifiy();
@ -480,7 +480,7 @@ class BackgroundService {
void _updateDetailProgress(String? title, int progress, int total) {
final String msg =
total > 0 ? _humanReadableBytesProgress(progress, total) : "";
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 || _lastPrintedDetailTitle != title) {
_lastPrintedDetailContent = msg;
@ -500,7 +500,10 @@ class BackgroundService {
progress: _uploadedAssetsCount,
max: _assetsToUploadCount,
title: title,
content: _formatAssetBackupProgress(),
content: formatAssetBackupProgress(
_uploadedAssetsCount,
_assetsToUploadCount,
),
);
}
@ -546,26 +549,6 @@ class BackgroundService {
return true;
}
/// prints percentage and absolute progress in useful (kilo/mega/giga)bytes
static String _humanReadableBytesProgress(int bytes, int bytesTotal) {
String unit = "KB"; // Kilobyte
if (bytesTotal >= 0x40000000) {
unit = "GB"; // Gigabyte
bytes >>= 20;
bytesTotal >>= 20;
} else if (bytesTotal >= 0x100000) {
unit = "MB"; // Megabyte
bytes >>= 10;
bytesTotal >>= 10;
} else if (bytesTotal < 0x400) {
return "$bytes / $bytesTotal B";
}
final int percent = (bytes * 100) ~/ bytesTotal;
final String done = numberFormat.format(bytes / 1024.0);
final String total = numberFormat.format(bytesTotal / 1024.0);
return "$percent% ($done/$total$unit)";
}
Future<DateTime?> getIOSBackupLastRun(IosBackgroundTask task) async {
if (!Platform.isIOS) {
return null;
@ -598,43 +581,6 @@ class BackgroundService {
enum IosBackgroundTask { fetch, processing }
class _Throttle {
_Throttle(this._fun, Duration interval) : _interval = interval.inMicroseconds;
final void Function(String?, int, int) _fun;
final int _interval;
int _invokedAt = 0;
Timer? _timer;
String? title;
int progress = 0;
int total = 0;
void call({
final String? title,
final int progress = 0,
final int total = 0,
}) {
final time = Timeline.now;
this.title = title ?? this.title;
this.progress = progress;
this.total = total;
if (time > _invokedAt + _interval) {
_timer?.cancel();
_onTimeElapsed();
} else {
_timer ??= Timer(Duration(microseconds: _interval), _onTimeElapsed);
}
}
void _onTimeElapsed() {
_invokedAt = Timeline.now;
_fun(title, progress, total);
_timer = null;
// clear title to not send/overwrite it next time if unchanged
title = null;
}
}
/// entry point called by Kotlin/Java code; needs to be a top-level function
@pragma('vm:entry-point')
void _nativeEntry() {

View file

@ -6,7 +6,13 @@ import 'package:photo_manager/photo_manager.dart';
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
enum BackUpProgressEnum { idle, inProgress, inBackground, done }
enum BackUpProgressEnum {
idle,
inProgress,
manualInProgress,
inBackground,
done
}
class BackUpState {
// enum

View file

@ -0,0 +1,71 @@
import 'package:cancellation_token_http/http.dart';
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
class ManualUploadState {
final CancellationToken cancelToken;
final double progressInPercentage;
// Current Backup Asset
final CurrentUploadAsset currentUploadAsset;
/// Manual Upload
final int manualUploadsTotal;
final int manualUploadFailures;
final int manualUploadSuccess;
const ManualUploadState({
required this.progressInPercentage,
required this.cancelToken,
required this.currentUploadAsset,
required this.manualUploadsTotal,
required this.manualUploadFailures,
required this.manualUploadSuccess,
});
ManualUploadState copyWith({
double? progressInPercentage,
CancellationToken? cancelToken,
CurrentUploadAsset? currentUploadAsset,
int? manualUploadsTotal,
int? manualUploadFailures,
int? manualUploadSuccess,
}) {
return ManualUploadState(
progressInPercentage: progressInPercentage ?? this.progressInPercentage,
cancelToken: cancelToken ?? this.cancelToken,
currentUploadAsset: currentUploadAsset ?? this.currentUploadAsset,
manualUploadsTotal: manualUploadsTotal ?? this.manualUploadsTotal,
manualUploadFailures: manualUploadFailures ?? this.manualUploadFailures,
manualUploadSuccess: manualUploadSuccess ?? this.manualUploadSuccess,
);
}
@override
String toString() {
return 'ManualUploadState(progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, currentUploadAsset: $currentUploadAsset, manualUploadsTotal: $manualUploadsTotal, manualUploadSuccess: $manualUploadSuccess, manualUploadFailures: $manualUploadFailures)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ManualUploadState &&
other.progressInPercentage == progressInPercentage &&
other.cancelToken == cancelToken &&
other.currentUploadAsset == currentUploadAsset &&
other.manualUploadsTotal == manualUploadsTotal &&
other.manualUploadFailures == manualUploadFailures &&
other.manualUploadSuccess == manualUploadSuccess;
}
@override
int get hashCode {
return progressInPercentage.hashCode ^
cancelToken.hashCode ^
currentUploadAsset.hashCode ^
manualUploadsTotal.hashCode ^
manualUploadFailures.hashCode ^
manualUploadSuccess.hashCode;
}
}

View file

@ -388,7 +388,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
if (state.backupProgress != BackUpProgressEnum.inBackground) {
await _getBackupAlbumsInfo();
await _updateServerInfo();
await updateServerInfo();
await _updateBackupAssetCount();
}
}
@ -465,7 +465,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
_onSetCurrentBackupAsset,
_onBackupError,
);
await _notifyBackgroundServiceCanRun();
await notifyBackgroundServiceCanRun();
} else {
openAppSettings();
}
@ -487,7 +487,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
void cancelBackup() {
if (state.backupProgress != BackUpProgressEnum.inProgress) {
_notifyBackgroundServiceCanRun();
notifyBackgroundServiceCanRun();
}
state.cancelToken.cancel();
state = state.copyWith(
@ -537,7 +537,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
_updatePersistentAlbumsSelection();
}
_updateServerInfo();
updateServerInfo();
}
void _onUploadProgress(int sent, int total) {
@ -546,7 +546,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
);
}
Future<void> _updateServerInfo() async {
Future<void> updateServerInfo() async {
final serverInfo = await _serverInfoService.getServerInfo();
// Update server info
@ -569,9 +569,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
// Check if this device is enable backup by the user
if (state.autoBackup) {
// check if backup is alreayd in process - then return
// check if backup is already in process - then return
if (state.backupProgress == BackUpProgressEnum.inProgress) {
log.info("[_resumeBackup] Backup is already in progress - abort");
log.info("[_resumeBackup] Auto Backup is already in progress - abort");
return;
}
@ -580,6 +580,11 @@ class BackupNotifier extends StateNotifier<BackUpState> {
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();
@ -594,7 +599,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
.findAll();
final List<BackupAlbum> excludedBackupAlbums = await _db.backupAlbums
.filter()
.selectionEqualTo(BackupSelection.select)
.selectionEqualTo(BackupSelection.exclude)
.findAll();
Set<AvailableAlbum> selectedAlbums = state.selectedBackupAlbums;
Set<AvailableAlbum> excludedAlbums = state.excludedBackupAlbums;
@ -646,7 +651,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
return result;
}
Future<void> _notifyBackgroundServiceCanRun() async {
Future<void> notifyBackgroundServiceCanRun() async {
const allowedStates = [
AppStateEnum.inactive,
AppStateEnum.paused,
@ -656,6 +661,11 @@ class BackupNotifier extends StateNotifier<BackUpState> {
_backgroundService.releaseLock();
}
}
BackUpProgressEnum get backupProgress => state.backupProgress;
void updateBackupProgress(BackUpProgressEnum backupProgress) {
state = state.copyWith(backupProgress: backupProgress);
}
}
final backupProvider =

View file

@ -0,0 +1,300 @@
import 'package:cancellation_token_http/http.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/modules/backup/background_service/background.service.dart';
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/manual_upload_state.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/services/local_notification.service.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/utils/backup_progress.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(backgroundServiceProvider),
ref.watch(backupServiceProvider),
ref.watch(backupProvider.notifier),
ref,
);
});
class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
final LocalNotificationService _localNotificationService;
final BackgroundService _backgroundService;
final BackupService _backupService;
final BackupNotifier _backupProvider;
final Ref ref;
ManualUploadNotifier(
this._localNotificationService,
this._backgroundService,
this._backupService,
this._backupProvider,
this.ref,
) : super(
ManualUploadState(
progressInPercentage: 0,
cancelToken: CancellationToken(),
currentUploadAsset: CurrentUploadAsset(
id: '...',
fileCreatedAt: DateTime.parse('2020-10-04'),
fileName: '...',
fileType: '...',
),
manualUploadsTotal: 0,
manualUploadSuccess: 0,
manualUploadFailures: 0,
),
);
int get _uploadedAssetsCount =>
state.manualUploadSuccess + state.manualUploadFailures;
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(
_uploadedAssetsCount,
state.manualUploadsTotal,
),
maxProgress: state.manualUploadsTotal,
progress: _uploadedAssetsCount,
);
}
}
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,
);
}
}
}
void _onManualAssetUploaded(
String deviceAssetId,
String deviceId,
bool isDuplicated,
) {
state = state.copyWith(manualUploadSuccess: state.manualUploadSuccess + 1);
_backupProvider.updateServerInfo();
if (state.manualUploadsTotal > 1) {
_throttledNotifiy();
}
}
void _onManualBackupError(ErrorUploadAsset errorAssetInfo) {
state =
state.copyWith(manualUploadFailures: state.manualUploadFailures + 1);
if (state.manualUploadsTotal > 1) {
_throttledNotifiy();
}
}
void _onProgress(int sent, int total) {
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);
_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 {
try {
_backupProvider.updateBackupProgress(BackUpProgressEnum.manualInProgress);
if (ref.read(galleryPermissionNotifier.notifier).hasPermission) {
await PhotoManager.clearFileCache();
Set<AssetEntity> allUploadAssets = allManualUploads
.where((e) => e.isLocal && e.local != null)
.map((e) => e.local!)
.toSet();
if (allUploadAssets.isEmpty) {
debugPrint("[_startUpload] No Assets to upload - Abort Process");
_backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
return false;
}
// Reset state
state = state.copyWith(
manualUploadsTotal: allManualUploads.length,
manualUploadSuccess: 0,
manualUploadFailures: 0,
currentUploadAsset: CurrentUploadAsset(
id: '...',
fileCreatedAt: DateTime.parse('2020-10-04'),
fileName: '...',
fileType: '...',
),
cancelToken: CancellationToken(),
);
if (state.manualUploadsTotal > 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.manualUploadsTotal == 1;
final bool ok = await _backupService.backupAsset(
allUploadAssets,
state.cancelToken,
_onManualAssetUploaded,
showDetailedNotification ? _onProgress : (sent, total) {},
showDetailedNotification ? _onSetCurrentBackupAsset : (asset) {},
_onManualBackupError,
);
// Close detailed notification
await _localNotificationService.closeNotification(
LocalNotificationService.manualUploadDetailedNotificationID,
);
bool hasErrors = false;
if ((state.manualUploadFailures != 0 &&
state.manualUploadSuccess == 0) ||
(!ok && !state.cancelToken.isCancelled)) {
await _localNotificationService.showOrUpdateManualUploadStatus(
"backup_manual_title".tr(),
"backup_manual_failed".tr(),
presentBanner: true,
);
hasErrors = true;
} else if (state.manualUploadSuccess != 0) {
await _localNotificationService.showOrUpdateManualUploadStatus(
"backup_manual_title".tr(),
"backup_manual_success".tr(),
presentBanner: true,
);
}
_backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
await _backupProvider.notifyBackgroundServiceCanRun();
return !hasErrors;
} else {
openAppSettings();
debugPrint("[_startUpload] Do not have permission to the gallery");
}
} catch (e) {
debugPrint("ERROR _startUpload: ${e.toString()}");
}
await _localNotificationService.closeNotification(
LocalNotificationService.manualUploadDetailedNotificationID,
);
_backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
await _backupProvider.notifyBackgroundServiceCanRun();
return false;
}
void cancelBackup() {
if (_backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) {
_backupProvider.notifyBackgroundServiceCanRun();
}
state.cancelToken.cancel();
_backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
}
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 _backgroundService.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);
}
}

View file

@ -53,7 +53,8 @@ class BackupControllerPage extends HookConsumerWidget {
useEffect(
() {
if (backupState.backupProgress != BackUpProgressEnum.inProgress) {
if (backupState.backupProgress != BackUpProgressEnum.inProgress &&
backupState.backupProgress != BackUpProgressEnum.manualInProgress) {
ref.watch(backupProvider.notifier).getBackupInfo();
}