Quellcode durchsuchen

fix(mobile): manual asset upload - app state handling + cancel button (#3611)

* feat(mobile): Cancel manual asset upload

* fix(mobile): re-add the missing translation keys

* feat(mobile): show manual upload error in backup page

* refactor: manual upload in-progress count

* fix(mobile): handle app state properly during manual asset upload
shalong-tanwen vor 2 Jahren
Ursprung
Commit
77a5820c3c

+ 11 - 0
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",

+ 4 - 47
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<ImmichApp>
     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;
     }
   }

+ 29 - 22
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;
   }
 }

+ 93 - 57
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<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 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<ManualUploadState> {
               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<ManualUploadState> {
       _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<ManualUploadState> {
           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<bool> _startUpload(Iterable<Asset> allManualUploads) async {
@@ -161,11 +166,11 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
           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<ManualUploadState> {
           ),
           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<ManualUploadState> {
             ref.read(appSettingsServiceProvider).getSetting<bool>(
                       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<ManualUploadState> {
             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<ManualUploadState> {
         }
 
         _backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
+        _handleAppInActivity();
         await _backupProvider.notifyBackgroundServiceCanRun();
         return !hasErrors;
       } else {
@@ -228,20 +249,34 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
     } 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<bool> uploadAssets(
@@ -250,7 +285,8 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
   ) 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(

+ 10 - 2
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() {

+ 10 - 2
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",

+ 81 - 2
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<AppStateEnum>((ref) {
-  return AppStateEnum.active;
+class AppStateNotiifer extends StateNotifier<AppStateEnum> {
+  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<AppStateNotiifer, AppStateEnum>((ref) {
+  return AppStateNotiifer(ref);
 });

+ 80 - 69
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<void> 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<void> _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<void> 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)
+                ? <AndroidNotificationAction>[
+                    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();
+        }
+    }
   }
 }