manual_upload.provider.dart 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. import 'package:cancellation_token_http/http.dart';
  2. import 'package:easy_localization/easy_localization.dart';
  3. import 'package:flutter/widgets.dart';
  4. import 'package:fluttertoast/fluttertoast.dart';
  5. import 'package:hooks_riverpod/hooks_riverpod.dart';
  6. import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
  7. import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
  8. import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
  9. import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
  10. import 'package:immich_mobile/modules/backup/models/manual_upload_state.model.dart';
  11. import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
  12. import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
  13. import 'package:immich_mobile/modules/backup/services/backup.service.dart';
  14. import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart';
  15. import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
  16. import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
  17. import 'package:immich_mobile/shared/models/asset.dart';
  18. import 'package:immich_mobile/shared/providers/app_state.provider.dart';
  19. import 'package:immich_mobile/shared/services/local_notification.service.dart';
  20. import 'package:immich_mobile/shared/ui/immich_toast.dart';
  21. import 'package:immich_mobile/utils/backup_progress.dart';
  22. import 'package:logging/logging.dart';
  23. import 'package:permission_handler/permission_handler.dart';
  24. import 'package:photo_manager/photo_manager.dart';
  25. final manualUploadProvider =
  26. StateNotifierProvider<ManualUploadNotifier, ManualUploadState>((ref) {
  27. return ManualUploadNotifier(
  28. ref.watch(localNotificationService),
  29. ref.watch(backupProvider.notifier),
  30. ref,
  31. );
  32. });
  33. class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
  34. final Logger _log = Logger("ManualUploadNotifier");
  35. final LocalNotificationService _localNotificationService;
  36. final BackupNotifier _backupProvider;
  37. final Ref ref;
  38. ManualUploadNotifier(
  39. this._localNotificationService,
  40. this._backupProvider,
  41. this.ref,
  42. ) : super(
  43. ManualUploadState(
  44. progressInPercentage: 0,
  45. cancelToken: CancellationToken(),
  46. currentUploadAsset: CurrentUploadAsset(
  47. id: '...',
  48. fileCreatedAt: DateTime.parse('2020-10-04'),
  49. fileName: '...',
  50. fileType: '...',
  51. ),
  52. totalAssetsToUpload: 0,
  53. successfulUploads: 0,
  54. currentAssetIndex: 0,
  55. showDetailedNotification: false,
  56. ),
  57. );
  58. String _lastPrintedDetailContent = '';
  59. String? _lastPrintedDetailTitle;
  60. static const notifyInterval = Duration(milliseconds: 500);
  61. late final ThrottleProgressUpdate _throttledNotifiy =
  62. ThrottleProgressUpdate(_updateProgress, notifyInterval);
  63. late final ThrottleProgressUpdate _throttledDetailNotify =
  64. ThrottleProgressUpdate(_updateDetailProgress, notifyInterval);
  65. void _updateProgress(String? title, int progress, int total) {
  66. // Guard against throttling calling this method after the upload is done
  67. if (_backupProvider.backupProgress == BackUpProgressEnum.manualInProgress) {
  68. _localNotificationService.showOrUpdateManualUploadStatus(
  69. "backup_background_service_in_progress_notification".tr(),
  70. formatAssetBackupProgress(
  71. state.currentAssetIndex,
  72. state.totalAssetsToUpload,
  73. ),
  74. maxProgress: state.totalAssetsToUpload,
  75. progress: state.currentAssetIndex,
  76. showActions: true,
  77. );
  78. }
  79. }
  80. void _updateDetailProgress(String? title, int progress, int total) {
  81. // Guard against throttling calling this method after the upload is done
  82. if (_backupProvider.backupProgress == BackUpProgressEnum.manualInProgress) {
  83. final String msg =
  84. total > 0 ? humanReadableBytesProgress(progress, total) : "";
  85. // only update if message actually differs (to stop many useless notification updates on large assets or slow connections)
  86. if (msg != _lastPrintedDetailContent ||
  87. title != _lastPrintedDetailTitle) {
  88. _lastPrintedDetailContent = msg;
  89. _lastPrintedDetailTitle = title;
  90. _localNotificationService.showOrUpdateManualUploadStatus(
  91. title ?? 'Uploading',
  92. msg,
  93. progress: total > 0 ? (progress * 1000) ~/ total : 0,
  94. maxProgress: 1000,
  95. isDetailed: true,
  96. // Detailed noitifcation is displayed for Single asset uploads. Show actions for such case
  97. showActions: state.totalAssetsToUpload == 1,
  98. );
  99. }
  100. }
  101. }
  102. void _onAssetUploaded(
  103. String deviceAssetId,
  104. String deviceId,
  105. bool isDuplicated,
  106. ) {
  107. state = state.copyWith(successfulUploads: state.successfulUploads + 1);
  108. _backupProvider.updateServerInfo();
  109. }
  110. void _onAssetUploadError(ErrorUploadAsset errorAssetInfo) {
  111. ref.watch(errorBackupListProvider.notifier).add(errorAssetInfo);
  112. }
  113. void _onProgress(int sent, int total) {
  114. state = state.copyWith(
  115. progressInPercentage: (sent.toDouble() / total.toDouble() * 100),
  116. );
  117. if (state.showDetailedNotification) {
  118. final title = "backup_background_service_current_upload_notification"
  119. .tr(args: [state.currentUploadAsset.fileName]);
  120. _throttledDetailNotify(title: title, progress: sent, total: total);
  121. }
  122. }
  123. void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
  124. state = state.copyWith(
  125. currentUploadAsset: currentUploadAsset,
  126. currentAssetIndex: state.currentAssetIndex + 1,
  127. );
  128. if (state.totalAssetsToUpload > 1) {
  129. _throttledNotifiy();
  130. }
  131. if (state.showDetailedNotification) {
  132. _throttledDetailNotify.title =
  133. "backup_background_service_current_upload_notification"
  134. .tr(args: [currentUploadAsset.fileName]);
  135. _throttledDetailNotify.progress = 0;
  136. _throttledDetailNotify.total = 0;
  137. }
  138. }
  139. Future<bool> _startUpload(Iterable<Asset> allManualUploads) async {
  140. bool hasErrors = false;
  141. try {
  142. _backupProvider.updateBackupProgress(BackUpProgressEnum.manualInProgress);
  143. if (ref.read(galleryPermissionNotifier.notifier).hasPermission) {
  144. await PhotoManager.clearFileCache();
  145. // We do not have 1:1 mapping of all AssetEntity fields to Asset. This results in cases
  146. // where platform specific fields such as `subtype` used to detect platform specific assets such as
  147. // LivePhoto in iOS is lost when we directly fetch the local asset from Asset using Asset.local
  148. List<AssetEntity?> allAssetsFromDevice = await Future.wait(
  149. allManualUploads
  150. // Filter local only assets
  151. .where((e) => e.isLocal && !e.isRemote)
  152. .map((e) => e.local!.obtainForNewProperties()),
  153. );
  154. if (allAssetsFromDevice.length != allManualUploads.length) {
  155. _log.warning(
  156. '[_startUpload] Refreshed upload list -> ${allManualUploads.length - allAssetsFromDevice.length} asset will not be uploaded',
  157. );
  158. }
  159. Set<AssetEntity> allUploadAssets = allAssetsFromDevice.nonNulls.toSet();
  160. if (allUploadAssets.isEmpty) {
  161. debugPrint("[_startUpload] No Assets to upload - Abort Process");
  162. _backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
  163. return false;
  164. }
  165. state = state.copyWith(
  166. progressInPercentage: 0,
  167. totalAssetsToUpload: allUploadAssets.length,
  168. successfulUploads: 0,
  169. currentAssetIndex: 0,
  170. currentUploadAsset: CurrentUploadAsset(
  171. id: '...',
  172. fileCreatedAt: DateTime.parse('2020-10-04'),
  173. fileName: '...',
  174. fileType: '...',
  175. ),
  176. cancelToken: CancellationToken(),
  177. );
  178. // Reset Error List
  179. ref.watch(errorBackupListProvider.notifier).empty();
  180. if (state.totalAssetsToUpload > 1) {
  181. _throttledNotifiy();
  182. }
  183. // Show detailed asset if enabled in settings or if a single asset is uploaded
  184. bool showDetailedNotification =
  185. ref.read(appSettingsServiceProvider).getSetting<bool>(
  186. AppSettingsEnum.backgroundBackupSingleProgress,
  187. ) ||
  188. state.totalAssetsToUpload == 1;
  189. state =
  190. state.copyWith(showDetailedNotification: showDetailedNotification);
  191. final bool ok = await ref.read(backupServiceProvider).backupAsset(
  192. allUploadAssets,
  193. state.cancelToken,
  194. _onAssetUploaded,
  195. _onProgress,
  196. _onSetCurrentBackupAsset,
  197. _onAssetUploadError,
  198. );
  199. // Close detailed notification
  200. await _localNotificationService.closeNotification(
  201. LocalNotificationService.manualUploadDetailedNotificationID,
  202. );
  203. _log.info(
  204. '[_startUpload] Manual Upload Completed - success: ${state.successfulUploads},'
  205. ' failed: ${state.totalAssetsToUpload - state.successfulUploads}',
  206. );
  207. // User cancelled upload
  208. if (!ok && state.cancelToken.isCancelled) {
  209. await _localNotificationService.showOrUpdateManualUploadStatus(
  210. "backup_manual_title".tr(),
  211. "backup_manual_cancelled".tr(),
  212. presentBanner: true,
  213. );
  214. hasErrors = true;
  215. } else if (state.successfulUploads == 0 ||
  216. (!ok && !state.cancelToken.isCancelled)) {
  217. await _localNotificationService.showOrUpdateManualUploadStatus(
  218. "backup_manual_title".tr(),
  219. "backup_manual_failed".tr(),
  220. presentBanner: true,
  221. );
  222. hasErrors = true;
  223. } else {
  224. await _localNotificationService.showOrUpdateManualUploadStatus(
  225. "backup_manual_title".tr(),
  226. "backup_manual_success".tr(),
  227. presentBanner: true,
  228. );
  229. }
  230. } else {
  231. openAppSettings();
  232. debugPrint("[_startUpload] Do not have permission to the gallery");
  233. }
  234. } catch (e) {
  235. debugPrint("ERROR _startUpload: ${e.toString()}");
  236. hasErrors = true;
  237. } finally {
  238. _backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
  239. _handleAppInActivity();
  240. await _localNotificationService.closeNotification(
  241. LocalNotificationService.manualUploadDetailedNotificationID,
  242. );
  243. await _backupProvider.notifyBackgroundServiceCanRun();
  244. }
  245. return !hasErrors;
  246. }
  247. void _handleAppInActivity() {
  248. final appState = ref.read(appStateProvider.notifier).getAppState();
  249. // The app is currently in background. Perform the necessary cleanups which
  250. // are on-hold for upload completion
  251. if (appState != AppStateEnum.active && appState != AppStateEnum.resumed) {
  252. ref.read(appStateProvider.notifier).handleAppInactivity();
  253. }
  254. }
  255. void cancelBackup() {
  256. if (_backupProvider.backupProgress != BackUpProgressEnum.inProgress &&
  257. _backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) {
  258. _backupProvider.notifyBackgroundServiceCanRun();
  259. }
  260. state.cancelToken.cancel();
  261. if (_backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) {
  262. _backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
  263. }
  264. state = state.copyWith(progressInPercentage: 0);
  265. }
  266. Future<bool> uploadAssets(
  267. BuildContext context,
  268. Iterable<Asset> allManualUploads,
  269. ) async {
  270. // assumes the background service is currently running and
  271. // waits until it has stopped to start the backup.
  272. final bool hasLock =
  273. await ref.read(backgroundServiceProvider).acquireLock();
  274. if (!hasLock) {
  275. debugPrint("[uploadAssets] could not acquire lock, exiting");
  276. ImmichToast.show(
  277. context: context,
  278. msg: "backup_manual_failed".tr(),
  279. toastType: ToastType.info,
  280. gravity: ToastGravity.BOTTOM,
  281. durationInSecond: 3,
  282. );
  283. return false;
  284. }
  285. bool showInProgress = false;
  286. // check if backup is already in process - then return
  287. if (_backupProvider.backupProgress == BackUpProgressEnum.manualInProgress) {
  288. debugPrint("[uploadAssets] Manual upload is already running - abort");
  289. showInProgress = true;
  290. }
  291. if (_backupProvider.backupProgress == BackUpProgressEnum.inProgress) {
  292. debugPrint("[uploadAssets] Auto Backup is already in progress - abort");
  293. showInProgress = true;
  294. return false;
  295. }
  296. if (_backupProvider.backupProgress == BackUpProgressEnum.inBackground) {
  297. debugPrint("[uploadAssets] Background backup is running - abort");
  298. showInProgress = true;
  299. }
  300. if (showInProgress) {
  301. if (context.mounted) {
  302. ImmichToast.show(
  303. context: context,
  304. msg: "backup_manual_in_progress".tr(),
  305. toastType: ToastType.info,
  306. gravity: ToastGravity.BOTTOM,
  307. durationInSecond: 3,
  308. );
  309. }
  310. return false;
  311. }
  312. return _startUpload(allManualUploads);
  313. }
  314. }