diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 47e60789ce..a1a76c8439 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -92,6 +92,11 @@ "backup_controller_page_uploading_file_info": "Uploading file info", "backup_err_only_album": "Cannot remove the only album", "backup_info_card_assets": "assets", + "backup_manual_success": "Success", + "backup_manual_failed": "Failed", + "backup_manual_cancelled": "Cancelled", + "backup_manual_title": "Upload status", + "backup_manual_in_progress": "Upload already in progress. Try after sometime", "cache_settings_album_thumbnails": "Library page thumbnails ({} assets)", "cache_settings_clear_cache_button": "Clear cache", "cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.", @@ -138,6 +143,10 @@ "delete_dialog_cancel": "Cancel", "delete_dialog_ok": "Delete", "delete_dialog_title": "Delete Permanently", + "upload_dialog_title": "Upload Asset", + "upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?", + "upload_dialog_ok": "Upload", + "upload_dialog_cancel": "Cancel", "description_input_hint_text": "Add description...", "description_input_submit_error": "Error updating description, check the log for more details", "exif_bottom_sheet_description": "Add Description...", @@ -153,6 +162,7 @@ "home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping", "home_page_add_to_album_success": "Added {added} assets to album {album}.", "home_page_archive_err_local": "Can not archive local assets yet, skipping", + "home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping", "home_page_building_timeline": "Building the timeline", "home_page_favorite_err_local": "Can not favorite local assets yet, skipping", "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", @@ -186,6 +196,7 @@ "login_form_save_login": "Stay logged in", "login_form_server_empty": "Enter a server URL.", "login_form_server_error": "Could not connect to server.", + "login_disabled": "Login has been disabled", "monthly_title_text_date_format": "MMMM y", "motion_photos_page_title": "Motion Photos", "notification_permission_dialog_cancel": "Cancel", diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 482b3d00a1..b447481c69 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -11,13 +11,6 @@ import 'package:immich_mobile/constants/locales.dart'; import 'package:immich_mobile/modules/backup/background_service/background.service.dart'; import 'package:immich_mobile/modules/backup/models/backup_album.model.dart'; import 'package:immich_mobile/modules/backup/models/duplicated_asset.model.dart'; -import 'package:immich_mobile/modules/backup/providers/backup.provider.dart'; -import 'package:immich_mobile/modules/backup/providers/ios_background_settings.provider.dart'; -import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart'; -import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; -import 'package:immich_mobile/modules/memories/providers/memory.provider.dart'; -import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart'; -import 'package:immich_mobile/modules/settings/providers/notification_permission.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/tab_navigation_observer.dart'; import 'package:immich_mobile/shared/models/album.dart'; @@ -30,11 +23,8 @@ import 'package:immich_mobile/shared/models/logger_message.model.dart'; import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/models/user.dart'; import 'package:immich_mobile/shared/providers/app_state.provider.dart'; -import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/shared/providers/db.provider.dart'; import 'package:immich_mobile/shared/providers/release_info.provider.dart'; -import 'package:immich_mobile/shared/providers/server_info.provider.dart'; -import 'package:immich_mobile/shared/providers/websocket.provider.dart'; import 'package:immich_mobile/shared/services/immich_logger.service.dart'; import 'package:immich_mobile/shared/services/local_notification.service.dart'; import 'package:immich_mobile/shared/views/immich_loading_overlay.dart'; @@ -44,7 +34,6 @@ import 'package:immich_mobile/utils/migration.dart'; import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:permission_handler/permission_handler.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -133,54 +122,22 @@ class ImmichAppState extends ConsumerState switch (state) { case AppLifecycleState.resumed: debugPrint("[APP STATE] resumed"); - ref.watch(appStateProvider.notifier).state = AppStateEnum.resumed; - - var isAuthenticated = ref.watch(authenticationProvider).isAuthenticated; - final permission = ref.watch(galleryPermissionNotifier); - - // Needs to be logged in and have gallery permissions - if (isAuthenticated && (permission.isGranted || permission.isLimited)) { - ref.read(backupProvider.notifier).resumeBackup(); - ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); - ref.watch(assetProvider.notifier).getAllAsset(); - ref.watch(serverInfoProvider.notifier).getServerVersion(); - } - - ref.watch(websocketProvider.notifier).connect(); - - ref.watch(releaseInfoProvider.notifier).checkGithubReleaseInfo(); - - ref - .watch(notificationPermissionProvider.notifier) - .getNotificationPermission(); - ref - .watch(galleryPermissionNotifier.notifier) - .getGalleryPermissionStatus(); - - ref.read(iOSBackgroundSettingsProvider.notifier).refresh(); - - ref.invalidate(memoryFutureProvider); - + ref.read(appStateProvider.notifier).handleAppResume(); break; case AppLifecycleState.inactive: debugPrint("[APP STATE] inactive"); - ref.watch(appStateProvider.notifier).state = AppStateEnum.inactive; - ImmichLogger().flush(); - ref.watch(websocketProvider.notifier).disconnect(); - ref.watch(manualUploadProvider.notifier).cancelBackup(); - ref.read(backupProvider.notifier).cancelBackup(); - + ref.read(appStateProvider.notifier).handleAppInactivity(); break; case AppLifecycleState.paused: debugPrint("[APP STATE] paused"); - ref.watch(appStateProvider.notifier).state = AppStateEnum.paused; + ref.read(appStateProvider.notifier).handleAppPause(); break; case AppLifecycleState.detached: debugPrint("[APP STATE] detached"); - ref.watch(appStateProvider.notifier).state = AppStateEnum.detached; + ref.read(appStateProvider.notifier).handleAppDetached(); break; } } diff --git a/mobile/lib/modules/backup/models/manual_upload_state.model.dart b/mobile/lib/modules/backup/models/manual_upload_state.model.dart index f54ab6f2a3..1a83609c2f 100644 --- a/mobile/lib/modules/backup/models/manual_upload_state.model.dart +++ b/mobile/lib/modules/backup/models/manual_upload_state.model.dart @@ -4,46 +4,51 @@ import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.d class ManualUploadState { final CancellationToken cancelToken; - final double progressInPercentage; - // Current Backup Asset final CurrentUploadAsset currentUploadAsset; + final int currentAssetIndex; - /// Manual Upload - final int manualUploadsTotal; - final int manualUploadFailures; - final int manualUploadSuccess; + final bool showDetailedNotification; + + /// Manual Upload Stats + final int totalAssetsToUpload; + final int successfulUploads; + final double progressInPercentage; const ManualUploadState({ required this.progressInPercentage, required this.cancelToken, required this.currentUploadAsset, - required this.manualUploadsTotal, - required this.manualUploadFailures, - required this.manualUploadSuccess, + required this.totalAssetsToUpload, + required this.currentAssetIndex, + required this.successfulUploads, + required this.showDetailedNotification, }); ManualUploadState copyWith({ double? progressInPercentage, CancellationToken? cancelToken, CurrentUploadAsset? currentUploadAsset, - int? manualUploadsTotal, - int? manualUploadFailures, - int? manualUploadSuccess, + int? totalAssetsToUpload, + int? successfulUploads, + int? currentAssetIndex, + bool? showDetailedNotification, }) { 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, + totalAssetsToUpload: totalAssetsToUpload ?? this.totalAssetsToUpload, + currentAssetIndex: currentAssetIndex ?? this.currentAssetIndex, + successfulUploads: successfulUploads ?? this.successfulUploads, + showDetailedNotification: + showDetailedNotification ?? this.showDetailedNotification, ); } @override String toString() { - return 'ManualUploadState(progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, currentUploadAsset: $currentUploadAsset, manualUploadsTotal: $manualUploadsTotal, manualUploadSuccess: $manualUploadSuccess, manualUploadFailures: $manualUploadFailures)'; + return 'ManualUploadState(progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, currentUploadAsset: $currentUploadAsset, totalAssetsToUpload: $totalAssetsToUpload, successfulUploads: $successfulUploads, currentAssetIndex: $currentAssetIndex, showDetailedNotification: $showDetailedNotification)'; } @override @@ -54,9 +59,10 @@ class ManualUploadState { other.progressInPercentage == progressInPercentage && other.cancelToken == cancelToken && other.currentUploadAsset == currentUploadAsset && - other.manualUploadsTotal == manualUploadsTotal && - other.manualUploadFailures == manualUploadFailures && - other.manualUploadSuccess == manualUploadSuccess; + other.totalAssetsToUpload == totalAssetsToUpload && + other.currentAssetIndex == currentAssetIndex && + other.successfulUploads == successfulUploads && + other.showDetailedNotification == showDetailedNotification; } @override @@ -64,8 +70,9 @@ class ManualUploadState { return progressInPercentage.hashCode ^ cancelToken.hashCode ^ currentUploadAsset.hashCode ^ - manualUploadsTotal.hashCode ^ - manualUploadFailures.hashCode ^ - manualUploadSuccess.hashCode; + totalAssetsToUpload.hashCode ^ + currentAssetIndex.hashCode ^ + successfulUploads.hashCode ^ + showDetailedNotification.hashCode; } } diff --git a/mobile/lib/modules/backup/providers/manual_upload.provider.dart b/mobile/lib/modules/backup/providers/manual_upload.provider.dart index 492df2e6c9..3a335a7b1f 100644 --- a/mobile/lib/modules/backup/providers/manual_upload.provider.dart +++ b/mobile/lib/modules/backup/providers/manual_upload.provider.dart @@ -9,14 +9,17 @@ 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/manual_upload_state.model.dart'; import 'package:immich_mobile/modules/backup/providers/backup.provider.dart'; +import 'package:immich_mobile/modules/backup/providers/error_backup_list.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/providers/app_state.provider.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:logging/logging.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:photo_manager/photo_manager.dart'; @@ -24,24 +27,19 @@ final manualUploadProvider = StateNotifierProvider((ref) { return ManualUploadNotifier( ref.watch(localNotificationService), - ref.watch(backgroundServiceProvider), - ref.watch(backupServiceProvider), ref.watch(backupProvider.notifier), ref, ); }); class ManualUploadNotifier extends StateNotifier { + final Logger _log = Logger("ManualUploadNotifier"); 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( @@ -54,15 +52,13 @@ class ManualUploadNotifier extends StateNotifier { fileName: '...', fileType: '...', ), - manualUploadsTotal: 0, - manualUploadSuccess: 0, - manualUploadFailures: 0, + totalAssetsToUpload: 0, + successfulUploads: 0, + currentAssetIndex: 0, + showDetailedNotification: false, ), ); - int get _uploadedAssetsCount => - state.manualUploadSuccess + state.manualUploadFailures; - String _lastPrintedDetailContent = ''; String? _lastPrintedDetailTitle; @@ -78,11 +74,12 @@ class ManualUploadNotifier extends StateNotifier { _localNotificationService.showOrUpdateManualUploadStatus( "backup_background_service_in_progress_notification".tr(), formatAssetBackupProgress( - _uploadedAssetsCount, - state.manualUploadsTotal, + state.currentAssetIndex, + state.totalAssetsToUpload, ), - maxProgress: state.manualUploadsTotal, - progress: _uploadedAssetsCount, + maxProgress: state.totalAssetsToUpload, + progress: state.currentAssetIndex, + showActions: true, ); } } @@ -103,44 +100,52 @@ class ManualUploadNotifier extends StateNotifier { 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 _onManualAssetUploaded( + void _onAssetUploaded( String deviceAssetId, String deviceId, bool isDuplicated, ) { - state = state.copyWith(manualUploadSuccess: state.manualUploadSuccess + 1); + state = state.copyWith(successfulUploads: state.successfulUploads + 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 _onAssetUploadError(ErrorUploadAsset errorAssetInfo) { + ref.watch(errorBackupListProvider.notifier).add(errorAssetInfo); } 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); + state = state.copyWith( + progressInPercentage: (sent.toDouble() / total.toDouble() * 100), + ); + 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); - _throttledDetailNotify.title = - "backup_background_service_current_upload_notification" - .tr(args: [currentUploadAsset.fileName]); - _throttledDetailNotify.progress = 0; - _throttledDetailNotify.total = 0; + 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 _startUpload(Iterable allManualUploads) async { @@ -161,11 +166,11 @@ class ManualUploadNotifier extends StateNotifier { return false; } - // Reset state state = state.copyWith( - manualUploadsTotal: allManualUploads.length, - manualUploadSuccess: 0, - manualUploadFailures: 0, + progressInPercentage: 0, + totalAssetsToUpload: allUploadAssets.length, + successfulUploads: 0, + currentAssetIndex: 0, currentUploadAsset: CurrentUploadAsset( id: '...', fileCreatedAt: DateTime.parse('2020-10-04'), @@ -174,8 +179,10 @@ class ManualUploadNotifier extends StateNotifier { ), cancelToken: CancellationToken(), ); + // Reset Error List + ref.watch(errorBackupListProvider.notifier).empty(); - if (state.manualUploadsTotal > 1) { + if (state.totalAssetsToUpload > 1) { _throttledNotifiy(); } @@ -184,25 +191,38 @@ class ManualUploadNotifier extends StateNotifier { ref.read(appSettingsServiceProvider).getSetting( AppSettingsEnum.backgroundBackupSingleProgress, ) || - state.manualUploadsTotal == 1; + state.totalAssetsToUpload == 1; + state = + state.copyWith(showDetailedNotification: showDetailedNotification); - final bool ok = await _backupService.backupAsset( - allUploadAssets, - state.cancelToken, - _onManualAssetUploaded, - showDetailedNotification ? _onProgress : (sent, total) {}, - showDetailedNotification ? _onSetCurrentBackupAsset : (asset) {}, - _onManualBackupError, - ); + final bool ok = await ref.read(backupServiceProvider).backupAsset( + allUploadAssets, + state.cancelToken, + _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}', + ); bool hasErrors = false; - if ((state.manualUploadFailures != 0 && - state.manualUploadSuccess == 0) || + // 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(), @@ -210,7 +230,7 @@ class ManualUploadNotifier extends StateNotifier { presentBanner: true, ); hasErrors = true; - } else if (state.manualUploadSuccess != 0) { + } else { await _localNotificationService.showOrUpdateManualUploadStatus( "backup_manual_title".tr(), "backup_manual_success".tr(), @@ -219,6 +239,7 @@ class ManualUploadNotifier extends StateNotifier { } _backupProvider.updateBackupProgress(BackUpProgressEnum.idle); + _handleAppInActivity(); await _backupProvider.notifyBackgroundServiceCanRun(); return !hasErrors; } else { @@ -228,20 +249,34 @@ class ManualUploadNotifier extends StateNotifier { } catch (e) { debugPrint("ERROR _startUpload: ${e.toString()}"); } + _backupProvider.updateBackupProgress(BackUpProgressEnum.idle); + _handleAppInActivity(); await _localNotificationService.closeNotification( LocalNotificationService.manualUploadDetailedNotificationID, ); - _backupProvider.updateBackupProgress(BackUpProgressEnum.idle); await _backupProvider.notifyBackgroundServiceCanRun(); return false; } + 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 != AppStateEnum.active || appState != AppStateEnum.resumed) { + ref.read(appStateProvider.notifier).handleAppInactivity(); + } + } + void cancelBackup() { - if (_backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) { + if (_backupProvider.backupProgress != BackUpProgressEnum.inProgress && + _backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) { _backupProvider.notifyBackgroundServiceCanRun(); } state.cancelToken.cancel(); - _backupProvider.updateBackupProgress(BackUpProgressEnum.idle); + if (_backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) { + _backupProvider.updateBackupProgress(BackUpProgressEnum.idle); + } + state = state.copyWith(progressInPercentage: 0); } Future uploadAssets( @@ -250,7 +285,8 @@ class ManualUploadNotifier extends StateNotifier { ) async { // assumes the background service is currently running and // waits until it has stopped to start the backup. - final bool hasLock = await _backgroundService.acquireLock(); + final bool hasLock = + await ref.read(backgroundServiceProvider).acquireLock(); if (!hasLock) { debugPrint("[uploadAssets] could not acquire lock, exiting"); ImmichToast.show( diff --git a/mobile/lib/modules/backup/ui/current_backup_asset_info_box.dart b/mobile/lib/modules/backup/ui/current_backup_asset_info_box.dart index bc1f49e07e..9c20c95bb6 100644 --- a/mobile/lib/modules/backup/ui/current_backup_asset_info_box.dart +++ b/mobile/lib/modules/backup/ui/current_backup_asset_info_box.dart @@ -4,8 +4,10 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/backup/models/backup_state.model.dart'; import 'package:immich_mobile/modules/backup/providers/backup.provider.dart'; import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart'; +import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:photo_manager/photo_manager.dart'; @@ -13,8 +15,14 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget { const CurrentUploadingAssetInfoBox({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - var asset = ref.watch(backupProvider).currentUploadAsset; - var uploadProgress = ref.watch(backupProvider).progressInPercentage; + var isManualUpload = ref.watch(backupProvider).backupProgress == + BackUpProgressEnum.manualInProgress; + var asset = !isManualUpload + ? ref.watch(backupProvider).currentUploadAsset + : ref.watch(manualUploadProvider).currentUploadAsset; + var uploadProgress = !isManualUpload + ? ref.watch(backupProvider).progressInPercentage + : ref.watch(manualUploadProvider).progressInPercentage; final isShowThumbnail = useState(false); String getAssetCreationDate() { diff --git a/mobile/lib/modules/backup/views/backup_controller_page.dart b/mobile/lib/modules/backup/views/backup_controller_page.dart index e6df35ff7a..c689a3141e 100644 --- a/mobile/lib/modules/backup/views/backup_controller_page.dart +++ b/mobile/lib/modules/backup/views/backup_controller_page.dart @@ -9,6 +9,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/backup/background_service/background.service.dart'; import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart'; import 'package:immich_mobile/modules/backup/providers/ios_background_settings.provider.dart'; +import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart'; import 'package:immich_mobile/modules/backup/services/backup_verification.service.dart'; import 'package:immich_mobile/modules/backup/ui/current_backup_asset_info_box.dart'; import 'package:immich_mobile/modules/backup/ui/ios_debug_info_tile.dart'; @@ -657,7 +658,9 @@ class BackupControllerPage extends HookConsumerWidget { top: 24, ), child: Container( - child: backupState.backupProgress == BackUpProgressEnum.inProgress + child: backupState.backupProgress == BackUpProgressEnum.inProgress || + backupState.backupProgress == + BackUpProgressEnum.manualInProgress ? ElevatedButton( style: ElevatedButton.styleFrom( foregroundColor: Colors.grey[50], @@ -665,7 +668,12 @@ class BackupControllerPage extends HookConsumerWidget { // padding: const EdgeInsets.all(14), ), onPressed: () { - ref.read(backupProvider.notifier).cancelBackup(); + if (backupState.backupProgress == + BackUpProgressEnum.manualInProgress) { + ref.read(manualUploadProvider.notifier).cancelBackup(); + } else { + ref.read(backupProvider.notifier).cancelBackup(); + } }, child: const Text( "backup_controller_page_cancel", diff --git a/mobile/lib/shared/providers/app_state.provider.dart b/mobile/lib/shared/providers/app_state.provider.dart index 32854d925b..7dc898be37 100644 --- a/mobile/lib/shared/providers/app_state.provider.dart +++ b/mobile/lib/shared/providers/app_state.provider.dart @@ -1,4 +1,19 @@ 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/providers/backup.provider.dart'; +import 'package:immich_mobile/modules/backup/providers/ios_background_settings.provider.dart'; +import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart'; +import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; +import 'package:immich_mobile/modules/memories/providers/memory.provider.dart'; +import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart'; +import 'package:immich_mobile/modules/settings/providers/notification_permission.provider.dart'; +import 'package:immich_mobile/shared/providers/asset.provider.dart'; +import 'package:immich_mobile/shared/providers/release_info.provider.dart'; +import 'package:immich_mobile/shared/providers/server_info.provider.dart'; +import 'package:immich_mobile/shared/providers/websocket.provider.dart'; +import 'package:immich_mobile/shared/services/immich_logger.service.dart'; +import 'package:permission_handler/permission_handler.dart'; enum AppStateEnum { active, @@ -8,6 +23,70 @@ enum AppStateEnum { detached, } -final appStateProvider = StateProvider((ref) { - return AppStateEnum.active; +class AppStateNotiifer extends StateNotifier { + final Ref ref; + + AppStateNotiifer(this.ref) : super(AppStateEnum.active); + + void updateAppState(AppStateEnum appState) { + state = appState; + } + + AppStateEnum getAppState() { + return state; + } + + void handleAppResume() { + state = AppStateEnum.resumed; + + var isAuthenticated = ref.watch(authenticationProvider).isAuthenticated; + final permission = ref.watch(galleryPermissionNotifier); + + // Needs to be logged in and have gallery permissions + if (isAuthenticated && (permission.isGranted || permission.isLimited)) { + ref.read(backupProvider.notifier).resumeBackup(); + ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); + ref.watch(assetProvider.notifier).getAllAsset(); + ref.watch(serverInfoProvider.notifier).getServerVersion(); + } + + ref.watch(websocketProvider.notifier).connect(); + + ref.watch(releaseInfoProvider.notifier).checkGithubReleaseInfo(); + + ref + .watch(notificationPermissionProvider.notifier) + .getNotificationPermission(); + ref.watch(galleryPermissionNotifier.notifier).getGalleryPermissionStatus(); + + ref.read(iOSBackgroundSettingsProvider.notifier).refresh(); + + ref.invalidate(memoryFutureProvider); + } + + void handleAppInactivity() { + state = AppStateEnum.inactive; + + // Do not handle inactivity if manual upload is in progress + if (ref.watch(backupProvider.notifier).backupProgress != + BackUpProgressEnum.manualInProgress) { + ImmichLogger().flush(); + ref.read(websocketProvider.notifier).disconnect(); + ref.read(backupProvider.notifier).cancelBackup(); + } + } + + void handleAppPause() { + state = AppStateEnum.paused; + } + + void handleAppDetached() { + state = AppStateEnum.detached; + ref.watch(manualUploadProvider.notifier).cancelBackup(); + } +} + +final appStateProvider = + StateNotifierProvider((ref) { + return AppStateNotiifer(ref); }); diff --git a/mobile/lib/shared/services/local_notification.service.dart b/mobile/lib/shared/services/local_notification.service.dart index 36a2400f16..f37da8a4c9 100644 --- a/mobile/lib/shared/services/local_notification.service.dart +++ b/mobile/lib/shared/services/local_notification.service.dart @@ -1,13 +1,24 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart'; +import 'package:immich_mobile/modules/settings/providers/notification_permission.provider.dart'; +import 'package:permission_handler/permission_handler.dart'; -final localNotificationService = Provider((ref) => LocalNotificationService()); +final localNotificationService = Provider( + (ref) => LocalNotificationService( + ref.watch(notificationPermissionProvider), + ref, + ), +); class LocalNotificationService { - static final LocalNotificationService _instance = - LocalNotificationService._internal(); final FlutterLocalNotificationsPlugin _localNotificationPlugin = FlutterLocalNotificationsPlugin(); + final PermissionStatus _permissionStatus; + final Ref ref; + + LocalNotificationService(this._permissionStatus, this.ref); static const manualUploadNotificationID = 4; static const manualUploadDetailedNotificationID = 5; @@ -15,9 +26,7 @@ class LocalNotificationService { static const manualUploadChannelID = 'immich/manualUpload'; static const manualUploadChannelNameDetailed = 'Manual Asset Upload Detailed'; static const manualUploadDetailedChannelID = 'immich/manualUploadDetailed'; - - factory LocalNotificationService() => _instance; - LocalNotificationService._internal(); + static const cancelUploadActionID = 'cancel_upload'; Future setup() async { const androidSetting = AndroidInitializationSettings('notification_icon'); @@ -26,56 +35,28 @@ class LocalNotificationService { const initSettings = InitializationSettings(android: androidSetting, iOS: iosSetting); - await _localNotificationPlugin.initialize(initSettings); + await _localNotificationPlugin.initialize( + initSettings, + onDidReceiveNotificationResponse: + _onDidReceiveForegroundNotificationResponse, + ); } Future _showOrUpdateNotification( int id, - String channelId, - String channelName, String title, - String body, { - bool? ongoing, - bool? playSound, - bool? showProgress, - Priority? priority, - Importance? importance, - bool? onlyAlertOnce, - int? maxProgress, - int? progress, - bool? indeterminate, - bool? presentBadge, - bool? presentBanner, - bool? presentList, - }) async { - var androidNotificationDetails = AndroidNotificationDetails( - channelId, - channelName, - ticker: title, - playSound: playSound ?? false, - showProgress: showProgress ?? false, - maxProgress: maxProgress ?? 0, - progress: progress ?? 0, - onlyAlertOnce: onlyAlertOnce ?? false, - indeterminate: indeterminate ?? false, - priority: priority ?? Priority.defaultPriority, - importance: importance ?? Importance.defaultImportance, - ongoing: ongoing ?? false, - ); - - var iosNotificationDetails = DarwinNotificationDetails( - presentBadge: presentBadge ?? false, - presentBanner: presentBanner ?? false, - presentList: presentList ?? false, - - ); - + String body, + AndroidNotificationDetails androidNotificationDetails, + DarwinNotificationDetails iosNotificationDetails, + ) async { final notificationDetails = NotificationDetails( android: androidNotificationDetails, iOS: iosNotificationDetails, ); - await _localNotificationPlugin.show(id, title, body, notificationDetails); + if (_permissionStatus == PermissionStatus.granted) { + await _localNotificationPlugin.show(id, title, body, notificationDetails); + } } Future closeNotification(int id) { @@ -87,46 +68,76 @@ class LocalNotificationService { String body, { bool? isDetailed, bool? presentBanner, + bool? showActions, int? maxProgress, int? progress, }) { var notificationlId = manualUploadNotificationID; - var channelId = manualUploadChannelID; - var channelName = manualUploadChannelName; + var androidChannelID = manualUploadChannelID; + var androidChannelName = manualUploadChannelName; // Separate Notification for Info/Alerts and Progress if (isDetailed != null && isDetailed) { notificationlId = manualUploadDetailedNotificationID; - channelId = manualUploadDetailedChannelID; - channelName = manualUploadChannelNameDetailed; + androidChannelID = manualUploadDetailedChannelID; + androidChannelName = manualUploadChannelNameDetailed; } - final isProgressNotification = maxProgress != null && progress != null; - return isProgressNotification - ? _showOrUpdateNotification( - notificationlId, - channelId, - channelName, - title, - body, + // Progress notification + final androidNotificationDetails = (maxProgress != null && progress != null) + ? AndroidNotificationDetails( + androidChannelID, + androidChannelName, + ticker: title, showProgress: true, onlyAlertOnce: true, maxProgress: maxProgress, progress: progress, indeterminate: false, - presentList: true, + playSound: false, priority: Priority.low, importance: Importance.low, - presentBadge: true, ongoing: true, + actions: (showActions ?? false) + ? [ + const AndroidNotificationAction( + cancelUploadActionID, + 'Cancel', + showsUserInterface: true, + ) + ] + : null, ) - : _showOrUpdateNotification( - notificationlId, - channelId, - channelName, - title, - body, - presentList: true, - presentBadge: true, - presentBanner: presentBanner, + // Non-progress notification + : AndroidNotificationDetails( + androidChannelID, + androidChannelName, + playSound: false, ); + + final iosNotificationDetails = DarwinNotificationDetails( + presentBadge: true, + presentList: true, + presentBanner: presentBanner, + ); + + return _showOrUpdateNotification( + notificationlId, + title, + body, + androidNotificationDetails, + iosNotificationDetails, + ); + } + + void _onDidReceiveForegroundNotificationResponse( + NotificationResponse notificationResponse, + ) { + // Handle notification actions + switch (notificationResponse.actionId) { + case cancelUploadActionID: + { + debugPrint("User cancelled manual upload operation"); + ref.read(manualUploadProvider.notifier).cancelBackup(); + } + } } }