backup.provider.dart 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. import 'package:cancellation_token_http/http.dart';
  2. import 'package:flutter/foundation.dart';
  3. import 'package:hive_flutter/hive_flutter.dart';
  4. import 'package:hooks_riverpod/hooks_riverpod.dart';
  5. import 'package:immich_mobile/constants/hive_box.dart';
  6. import 'package:immich_mobile/modules/backup/models/available_album.model.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/hive_backup_albums.model.dart';
  11. import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
  12. import 'package:immich_mobile/modules/backup/services/backup.service.dart';
  13. import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
  14. import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
  15. import 'package:immich_mobile/shared/services/server_info.service.dart';
  16. import 'package:openapi/api.dart';
  17. import 'package:photo_manager/photo_manager.dart';
  18. class BackupNotifier extends StateNotifier<BackUpState> {
  19. BackupNotifier(
  20. this._backupService,
  21. this._serverInfoService,
  22. this._authState,
  23. this.ref,
  24. ) : super(
  25. BackUpState(
  26. backupProgress: BackUpProgressEnum.idle,
  27. allAssetsInDatabase: const [],
  28. progressInPercentage: 0,
  29. cancelToken: CancellationToken(),
  30. serverInfo: ServerInfoResponseDto(
  31. diskAvailable: "0",
  32. diskAvailableRaw: 0,
  33. diskSize: "0",
  34. diskSizeRaw: 0,
  35. diskUsagePercentage: 0,
  36. diskUse: "0",
  37. diskUseRaw: 0,
  38. ),
  39. availableAlbums: const [],
  40. selectedBackupAlbums: const {},
  41. excludedBackupAlbums: const {},
  42. allUniqueAssets: const {},
  43. selectedAlbumsBackupAssetsIds: const {},
  44. currentUploadAsset: CurrentUploadAsset(
  45. id: '...',
  46. createdAt: DateTime.parse('2020-10-04'),
  47. fileName: '...',
  48. fileType: '...',
  49. ),
  50. ),
  51. ) {
  52. getBackupInfo();
  53. }
  54. final BackupService _backupService;
  55. final ServerInfoService _serverInfoService;
  56. final AuthenticationState _authState;
  57. final Ref ref;
  58. ///
  59. /// UI INTERACTION
  60. ///
  61. /// Album selection
  62. /// Due to the overlapping assets across multiple albums on the device
  63. /// We have method to include and exclude albums
  64. /// The total unique assets will be used for backing mechanism
  65. ///
  66. void addAlbumForBackup(AssetPathEntity album) {
  67. if (state.excludedBackupAlbums.contains(album)) {
  68. removeExcludedAlbumForBackup(album);
  69. }
  70. state = state
  71. .copyWith(selectedBackupAlbums: {...state.selectedBackupAlbums, album});
  72. _updateBackupAssetCount();
  73. }
  74. void addExcludedAlbumForBackup(AssetPathEntity album) {
  75. if (state.selectedBackupAlbums.contains(album)) {
  76. removeAlbumForBackup(album);
  77. }
  78. state = state
  79. .copyWith(excludedBackupAlbums: {...state.excludedBackupAlbums, album});
  80. _updateBackupAssetCount();
  81. }
  82. void removeAlbumForBackup(AssetPathEntity album) {
  83. Set<AssetPathEntity> currentSelectedAlbums = state.selectedBackupAlbums;
  84. currentSelectedAlbums.removeWhere((a) => a == album);
  85. state = state.copyWith(selectedBackupAlbums: currentSelectedAlbums);
  86. _updateBackupAssetCount();
  87. }
  88. void removeExcludedAlbumForBackup(AssetPathEntity album) {
  89. Set<AssetPathEntity> currentExcludedAlbums = state.excludedBackupAlbums;
  90. currentExcludedAlbums.removeWhere((a) => a == album);
  91. state = state.copyWith(excludedBackupAlbums: currentExcludedAlbums);
  92. _updateBackupAssetCount();
  93. }
  94. ///
  95. /// Get all album on the device
  96. /// Get all selected and excluded album from the user's persistent storage
  97. /// If this is the first time performing backup - set the default selected album to be
  98. /// the one that has all assets (Recent on Android, Recents on iOS)
  99. ///
  100. Future<void> _getBackupAlbumsInfo() async {
  101. // Get all albums on the device
  102. List<AvailableAlbum> availableAlbums = [];
  103. List<AssetPathEntity> albums = await PhotoManager.getAssetPathList(
  104. hasAll: true,
  105. type: RequestType.common,
  106. );
  107. for (AssetPathEntity album in albums) {
  108. AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
  109. var assetList =
  110. await album.getAssetListRange(start: 0, end: album.assetCount);
  111. if (assetList.isNotEmpty) {
  112. var thumbnailAsset = assetList.first;
  113. var thumbnailData = await thumbnailAsset
  114. .thumbnailDataWithSize(const ThumbnailSize(512, 512));
  115. availableAlbum = availableAlbum.copyWith(thumbnailData: thumbnailData);
  116. }
  117. availableAlbums.add(availableAlbum);
  118. }
  119. state = state.copyWith(availableAlbums: availableAlbums);
  120. // Put persistent storage info into local state of the app
  121. // Get local storage on selected backup album
  122. Box<HiveBackupAlbums> backupAlbumInfoBox =
  123. Hive.box<HiveBackupAlbums>(hiveBackupInfoBox);
  124. HiveBackupAlbums? backupAlbumInfo = backupAlbumInfoBox.get(
  125. backupInfoKey,
  126. defaultValue: HiveBackupAlbums(
  127. selectedAlbumIds: [],
  128. excludedAlbumsIds: [],
  129. ),
  130. );
  131. if (backupAlbumInfo == null) {
  132. debugPrint("[ERROR] getting Hive backup album infomation");
  133. return;
  134. }
  135. // First time backup - set isAll album is the default one for backup.
  136. if (backupAlbumInfo.selectedAlbumIds.isEmpty) {
  137. debugPrint("First time backup setup recent album as default");
  138. // Get album that contains all assets
  139. var list = await PhotoManager.getAssetPathList(
  140. hasAll: true,
  141. onlyAll: true,
  142. type: RequestType.common,
  143. );
  144. AssetPathEntity albumHasAllAssets = list.first;
  145. backupAlbumInfoBox.put(
  146. backupInfoKey,
  147. HiveBackupAlbums(
  148. selectedAlbumIds: [albumHasAllAssets.id],
  149. excludedAlbumsIds: [],
  150. ),
  151. );
  152. backupAlbumInfo = backupAlbumInfoBox.get(backupInfoKey);
  153. }
  154. // Generate AssetPathEntity from id to add to local state
  155. try {
  156. for (var selectedAlbumId in backupAlbumInfo!.selectedAlbumIds) {
  157. var albumAsset = await AssetPathEntity.fromId(selectedAlbumId);
  158. state = state.copyWith(
  159. selectedBackupAlbums: {...state.selectedBackupAlbums, albumAsset},
  160. );
  161. }
  162. for (var excludedAlbumId in backupAlbumInfo.excludedAlbumsIds) {
  163. var albumAsset = await AssetPathEntity.fromId(excludedAlbumId);
  164. state = state.copyWith(
  165. excludedBackupAlbums: {...state.excludedBackupAlbums, albumAsset},
  166. );
  167. }
  168. } catch (e) {
  169. debugPrint("[ERROR] Failed to generate album from id $e");
  170. }
  171. }
  172. ///
  173. /// From all the selected and albums assets
  174. /// Find the assets that are not overlapping between the two sets
  175. /// Those assets are unique and are used as the total assets
  176. ///
  177. Future<void> _updateBackupAssetCount() async {
  178. Set<AssetEntity> assetsFromSelectedAlbums = {};
  179. Set<AssetEntity> assetsFromExcludedAlbums = {};
  180. for (var album in state.selectedBackupAlbums) {
  181. var assets =
  182. await album.getAssetListRange(start: 0, end: album.assetCount);
  183. assetsFromSelectedAlbums.addAll(assets);
  184. }
  185. for (var album in state.excludedBackupAlbums) {
  186. var assets =
  187. await album.getAssetListRange(start: 0, end: album.assetCount);
  188. assetsFromExcludedAlbums.addAll(assets);
  189. }
  190. Set<AssetEntity> allUniqueAssets =
  191. assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums);
  192. var allAssetsInDatabase = await _backupService.getDeviceBackupAsset();
  193. if (allAssetsInDatabase == null) {
  194. return;
  195. }
  196. // Find asset that were backup from selected albums
  197. Set<String> selectedAlbumsBackupAssets =
  198. Set.from(allUniqueAssets.map((e) => e.id));
  199. selectedAlbumsBackupAssets
  200. .removeWhere((assetId) => !allAssetsInDatabase.contains(assetId));
  201. if (allUniqueAssets.isEmpty) {
  202. debugPrint("No Asset On Device");
  203. state = state.copyWith(
  204. backupProgress: BackUpProgressEnum.idle,
  205. allAssetsInDatabase: allAssetsInDatabase,
  206. allUniqueAssets: {},
  207. selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets,
  208. );
  209. return;
  210. } else {
  211. state = state.copyWith(
  212. allAssetsInDatabase: allAssetsInDatabase,
  213. allUniqueAssets: allUniqueAssets,
  214. selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets,
  215. );
  216. }
  217. // Save to persistent storage
  218. _updatePersistentAlbumsSelection();
  219. return;
  220. }
  221. ///
  222. /// Get all necessary information for calculating the available albums,
  223. /// which albums are selected or excluded
  224. /// and then update the UI according to those information
  225. ///
  226. Future<void> getBackupInfo() async {
  227. await Future.wait([
  228. _getBackupAlbumsInfo(),
  229. _updateServerInfo(),
  230. ]);
  231. await _updateBackupAssetCount();
  232. }
  233. ///
  234. /// Save user selection of selected albums and excluded albums to
  235. /// Hive database
  236. ///
  237. void _updatePersistentAlbumsSelection() {
  238. Box<HiveBackupAlbums> backupAlbumInfoBox =
  239. Hive.box<HiveBackupAlbums>(hiveBackupInfoBox);
  240. backupAlbumInfoBox.put(
  241. backupInfoKey,
  242. HiveBackupAlbums(
  243. selectedAlbumIds: state.selectedBackupAlbums.map((e) => e.id).toList(),
  244. excludedAlbumsIds: state.excludedBackupAlbums.map((e) => e.id).toList(),
  245. ),
  246. );
  247. }
  248. ///
  249. /// Invoke backup process
  250. ///
  251. void startBackupProcess() async {
  252. state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress);
  253. await getBackupInfo();
  254. var authResult = await PhotoManager.requestPermissionExtend();
  255. if (authResult.isAuth) {
  256. await PhotoManager.clearFileCache();
  257. if (state.allUniqueAssets.isEmpty) {
  258. debugPrint("No Asset On Device - Abort Backup Process");
  259. state = state.copyWith(backupProgress: BackUpProgressEnum.idle);
  260. return;
  261. }
  262. Set<AssetEntity> assetsWillBeBackup = Set.from(state.allUniqueAssets);
  263. // Remove item that has already been backed up
  264. for (var assetId in state.allAssetsInDatabase) {
  265. assetsWillBeBackup.removeWhere((e) => e.id == assetId);
  266. }
  267. if (assetsWillBeBackup.isEmpty) {
  268. state = state.copyWith(backupProgress: BackUpProgressEnum.idle);
  269. }
  270. // Perform Backup
  271. state = state.copyWith(cancelToken: CancellationToken());
  272. _backupService.backupAsset(
  273. assetsWillBeBackup,
  274. state.cancelToken,
  275. _onAssetUploaded,
  276. _onUploadProgress,
  277. _onSetCurrentBackupAsset,
  278. _onBackupError,
  279. );
  280. } else {
  281. PhotoManager.openSetting();
  282. }
  283. }
  284. void _onBackupError(ErrorUploadAsset errorAssetInfo) {
  285. ref.watch(errorBackupListProvider.notifier).add(errorAssetInfo);
  286. }
  287. void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
  288. state = state.copyWith(currentUploadAsset: currentUploadAsset);
  289. }
  290. void cancelBackup() {
  291. state.cancelToken.cancel();
  292. state = state.copyWith(
  293. backupProgress: BackUpProgressEnum.idle,
  294. progressInPercentage: 0.0,
  295. );
  296. }
  297. void _onAssetUploaded(String deviceAssetId, String deviceId) {
  298. state = state.copyWith(
  299. selectedAlbumsBackupAssetsIds: {
  300. ...state.selectedAlbumsBackupAssetsIds,
  301. deviceAssetId
  302. },
  303. allAssetsInDatabase: [...state.allAssetsInDatabase, deviceAssetId],
  304. );
  305. if (state.allUniqueAssets.length -
  306. state.selectedAlbumsBackupAssetsIds.length ==
  307. 0) {
  308. state = state.copyWith(
  309. backupProgress: BackUpProgressEnum.done,
  310. progressInPercentage: 0.0,
  311. );
  312. }
  313. _updateServerInfo();
  314. }
  315. void _onUploadProgress(int sent, int total) {
  316. state = state.copyWith(
  317. progressInPercentage: (sent.toDouble() / total.toDouble() * 100),
  318. );
  319. }
  320. Future<void> _updateServerInfo() async {
  321. var serverInfo = await _serverInfoService.getServerInfo();
  322. // Update server info
  323. if (serverInfo != null) {
  324. state = state.copyWith(
  325. serverInfo: serverInfo,
  326. );
  327. }
  328. }
  329. void resumeBackup() {
  330. // Check if user is login
  331. var accessKey = Hive.box(userInfoBox).get(accessTokenKey);
  332. // User has been logged out return
  333. if (accessKey == null || !_authState.isAuthenticated) {
  334. debugPrint("[resumeBackup] not authenticated - abort");
  335. return;
  336. }
  337. // Check if this device is enable backup by the user
  338. if ((_authState.deviceInfo.deviceId == _authState.deviceId) &&
  339. _authState.deviceInfo.isAutoBackup) {
  340. // check if backup is alreayd in process - then return
  341. if (state.backupProgress == BackUpProgressEnum.inProgress) {
  342. debugPrint("[resumeBackup] Backup is already in progress - abort");
  343. return;
  344. }
  345. // Run backup
  346. debugPrint("[resumeBackup] Start back up");
  347. startBackupProcess();
  348. }
  349. return;
  350. }
  351. }
  352. final backupProvider =
  353. StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
  354. return BackupNotifier(
  355. ref.watch(backupServiceProvider),
  356. ref.watch(serverInfoServiceProvider),
  357. ref.watch(authenticationProvider),
  358. ref,
  359. );
  360. });