diff --git a/mobile/lib/providers/asset_viewer/download.provider.dart b/mobile/lib/providers/asset_viewer/download.provider.dart index 68b120c38a756..181ea441a8e65 100644 --- a/mobile/lib/providers/asset_viewer/download.provider.dart +++ b/mobile/lib/providers/asset_viewer/download.provider.dart @@ -1,196 +1,221 @@ -import 'package:background_downloader/background_downloader.dart'; -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; -import 'package:fluttertoast/fluttertoast.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/extensions/build_context_extensions.dart'; -import 'package:immich_mobile/models/download/download_state.model.dart'; -import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; -import 'package:immich_mobile/services/album.service.dart'; -import 'package:immich_mobile/services/download.service.dart'; -import 'package:immich_mobile/entities/asset.entity.dart'; -import 'package:immich_mobile/services/share.service.dart'; -import 'package:immich_mobile/widgets/common/immich_toast.dart'; -import 'package:immich_mobile/widgets/common/share_dialog.dart'; - -class DownloadStateNotifier extends StateNotifier { - final DownloadService _downloadService; - final ShareService _shareService; - final AlbumService _albumService; - - DownloadStateNotifier( - this._downloadService, - this._shareService, - this._albumService, - ) : super( - DownloadState( - downloadStatus: TaskStatus.complete, - showProgress: false, - taskProgress: {}, - ), - ) { - _downloadService.onImageDownloadStatus = _downloadImageCallback; - _downloadService.onVideoDownloadStatus = _downloadVideoCallback; - _downloadService.onLivePhotoDownloadStatus = _downloadLivePhotoCallback; - _downloadService.onTaskProgress = _taskProgressCallback; - } - - void _updateDownloadStatus(String taskId, TaskStatus status) { - if (status == TaskStatus.canceled) { - return; - } - - state = state.copyWith( - taskProgress: {} - ..addAll(state.taskProgress) - ..addAll({ - taskId: DownloadInfo( - progress: state.taskProgress[taskId]?.progress ?? 0, - fileName: state.taskProgress[taskId]?.fileName ?? '', - status: status, - ), - }), - ); - } - - // Download live photo callback - void _downloadLivePhotoCallback(TaskStatusUpdate update) { - _updateDownloadStatus(update.task.taskId, update.status); - - switch (update.status) { - case TaskStatus.complete: - if (update.task.metaData.isEmpty) { - return; - } - final livePhotosId = - LivePhotosMetadata.fromJson(update.task.metaData).id; - _downloadService.saveLivePhotos(update.task, livePhotosId); - _onDownloadComplete(update.task.taskId); - break; - - default: - break; - } - } - - // Download image callback - void _downloadImageCallback(TaskStatusUpdate update) { - _updateDownloadStatus(update.task.taskId, update.status); - - switch (update.status) { - case TaskStatus.complete: - _downloadService.saveImageWithPath(update.task); - _onDownloadComplete(update.task.taskId); - break; - - default: - break; - } - } - - // Download video callback - void _downloadVideoCallback(TaskStatusUpdate update) { - _updateDownloadStatus(update.task.taskId, update.status); - - switch (update.status) { - case TaskStatus.complete: - _downloadService.saveVideo(update.task); - _onDownloadComplete(update.task.taskId); - break; - - default: - break; - } - } - - void _taskProgressCallback(TaskProgressUpdate update) { - // Ignore if the task is cancled or completed - if (update.progress == -2 || update.progress == -1) { - return; - } - - state = state.copyWith( - showProgress: true, - taskProgress: {} - ..addAll(state.taskProgress) - ..addAll({ - update.task.taskId: DownloadInfo( - progress: update.progress, - fileName: update.task.filename, - status: TaskStatus.running, - ), - }), - ); - } - - void _onDownloadComplete(String id) { - Future.delayed(const Duration(seconds: 2), () { - state = state.copyWith( - taskProgress: {} - ..addAll(state.taskProgress) - ..remove(id), - ); - - if (state.taskProgress.isEmpty) { - state = state.copyWith( - showProgress: false, - ); - } - _albumService.refreshDeviceAlbums(); - }); - } - - void downloadAsset(Asset asset, BuildContext context) async { - await _downloadService.download(asset); - } - - void cancelDownload(String id) async { - final isCanceled = await _downloadService.cancelDownload(id); - - if (isCanceled) { - state = state.copyWith( - taskProgress: {} - ..addAll(state.taskProgress) - ..remove(id), - ); - } - - if (state.taskProgress.isEmpty) { - state = state.copyWith( - showProgress: false, - ); - } - } - - void shareAsset(Asset asset, BuildContext context) async { - showDialog( - context: context, - builder: (BuildContext buildContext) { - _shareService.shareAsset(asset, context).then( - (bool status) { - if (!status) { - ImmichToast.show( - context: context, - msg: 'image_viewer_page_state_provider_share_error'.tr(), - toastType: ToastType.error, - gravity: ToastGravity.BOTTOM, - ); - } - buildContext.pop(); - }, - ); - return const ShareDialog(); - }, - barrierDismissible: false, - ); - } -} - -final downloadStateProvider = - StateNotifierProvider( - ((ref) => DownloadStateNotifier( - ref.watch(downloadServiceProvider), - ref.watch(shareServiceProvider), - ref.watch(albumServiceProvider), - )), -); +import 'package:background_downloader/background_downloader.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/models/download/download_state.model.dart'; +import 'package:immich_mobile/models/download/livephotos_medatada.model.dart'; +import 'package:immich_mobile/services/album.service.dart'; +import 'package:immich_mobile/services/download.service.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/services/share.service.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; +import 'package:immich_mobile/widgets/common/share_dialog.dart'; + +class DownloadStateNotifier extends StateNotifier { + final DownloadService _downloadService; + final ShareService _shareService; + final AlbumService _albumService; + + final Set _downloadingAssets = {}; + + DownloadStateNotifier( + this._downloadService, + this._shareService, + this._albumService, + ) : super( + DownloadState( + downloadStatus: TaskStatus.complete, + showProgress: false, + taskProgress: {}, + ), + ) { + _downloadService.onImageDownloadStatus = _downloadImageCallback; + _downloadService.onVideoDownloadStatus = _downloadVideoCallback; + _downloadService.onLivePhotoDownloadStatus = _downloadLivePhotoCallback; + _downloadService.onTaskProgress = _taskProgressCallback; + } + + void _updateDownloadStatus(String taskId, TaskStatus status) { + if (status == TaskStatus.canceled) { + return; + } + + state = state.copyWith( + taskProgress: {} + ..addAll(state.taskProgress) + ..addAll({ + taskId: DownloadInfo( + progress: state.taskProgress[taskId]?.progress ?? 0, + fileName: state.taskProgress[taskId]?.fileName ?? '', + status: status, + ), + }), + ); + } + + // Download live photo callback + void _downloadLivePhotoCallback(TaskStatusUpdate update) { + _updateDownloadStatus(update.task.taskId, update.status); + + switch (update.status) { + case TaskStatus.complete: + if (update.task.metaData.isEmpty) { + return; + } + final livePhotosId = + LivePhotosMetadata.fromJson(update.task.metaData).id; + _downloadService.saveLivePhotos(update.task, livePhotosId); + _onDownloadComplete(update.task.taskId); + break; + + default: + break; + } + } + + // Download image callback + void _downloadImageCallback(TaskStatusUpdate update) { + _updateDownloadStatus(update.task.taskId, update.status); + + switch (update.status) { + case TaskStatus.complete: + _downloadService.saveImageWithPath(update.task); + _onDownloadComplete(update.task.taskId); + break; + + default: + break; + } + } + + // Download video callback + void _downloadVideoCallback(TaskStatusUpdate update) { + _updateDownloadStatus(update.task.taskId, update.status); + + switch (update.status) { + case TaskStatus.complete: + _downloadService.saveVideo(update.task); + _onDownloadComplete(update.task.taskId); + break; + + default: + break; + } + } + + void _taskProgressCallback(TaskProgressUpdate update) { + // Ignore if the task is cancled or completed + if (update.progress == -2 || update.progress == -1) { + return; + } + + state = state.copyWith( + showProgress: true, + taskProgress: {} + ..addAll(state.taskProgress) + ..addAll({ + update.task.taskId: DownloadInfo( + progress: update.progress, + fileName: update.task.filename, + status: TaskStatus.running, + ), + }), + ); + } + + void _onDownloadComplete(String id) { + Future.delayed(const Duration(seconds: 2), () { + state = state.copyWith( + taskProgress: {} + ..addAll(state.taskProgress) + ..remove(id), + ); + + if (state.taskProgress.isEmpty) { + state = state.copyWith( + showProgress: false, + ); + } + _albumService.refreshDeviceAlbums(); + }); + } + + void downloadAsset(Asset asset, BuildContext context) async { + if (_downloadingAssets.contains(asset.remoteId)) { + ImmichToast.show( + context: context, + msg: 'Download in progress', + toastType: ToastType.info, + gravity: ToastGravity.BOTTOM, + ); + return; + } + + try { + _downloadingAssets.add(asset.remoteId!); + + await _downloadService.download(asset); + } catch (e) { + ImmichToast.show( + context: context, + msg: 'Download failed', + toastType: ToastType.error, + gravity: ToastGravity.BOTTOM, + ); + } finally { + _downloadingAssets.remove(asset.remoteId); + } + } + + void cancelDownload(String id) async { + final isCanceled = await _downloadService.cancelDownload(id); + + if (isCanceled) { + state = state.copyWith( + taskProgress: {} + ..addAll(state.taskProgress) + ..remove(id), + ); + } + + if (state.taskProgress.isEmpty) { + state = state.copyWith( + showProgress: false, + ); + } + } + + void shareAsset(Asset asset, BuildContext context) async { + showDialog( + context: context, + builder: (BuildContext buildContext) { + _shareService.shareAsset(asset, context).then( + (bool status) { + if (!status) { + ImmichToast.show( + context: context, + msg: 'image_viewer_page_state_provider_share_error'.tr(), + toastType: ToastType.error, + gravity: ToastGravity.BOTTOM, + ); + } + buildContext.pop(); + }, + ); + return const ShareDialog(); + }, + barrierDismissible: false, + ); + } +} + +final downloadStateProvider = + StateNotifierProvider( + ((ref) => DownloadStateNotifier( + ref.watch(downloadServiceProvider), + ref.watch(shareServiceProvider), + ref.watch(albumServiceProvider), + )), +); diff --git a/mobile/lib/services/download.service.dart b/mobile/lib/services/download.service.dart index 7cf6f309e98fe..3d7ba850bb8a9 100644 --- a/mobile/lib/services/download.service.dart +++ b/mobile/lib/services/download.service.dart @@ -13,6 +13,8 @@ import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/utils/download.dart'; import 'package:logging/logging.dart'; +import 'package:crypto/crypto.dart'; +import 'dart:math' show max, min; final downloadServiceProvider = Provider( (ref) => DownloadService( @@ -61,19 +63,57 @@ class DownloadService { final filePath = await task.filePath(); final title = task.filename; final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null; + + _log.info('Attempting to save image: $title'); + _log.info('Source file path: $filePath'); + try { + final isDuplicate = await _isFileDuplicate(filePath, title); + if (isDuplicate) { + _log.info('Duplicate file detected. Skipping save: $title'); + return true; + } + + final sourceFile = File(filePath); + + if (!await sourceFile.exists()) { + _log.severe('Source file does not exist: $filePath'); + return false; + } + + try { + final fileSize = await sourceFile.length(); + _log.info('Source file size: $fileSize bytes'); + } catch (e) { + _log.severe('Cannot read source file size: $e'); + } + final Asset? resultAsset = await _fileMediaRepository.saveImageWithFile( filePath, title: title, relativePath: relativePath, ); - return resultAsset != null; + + if (resultAsset == null) { + _log.severe('Failed to save image through PhotoManager'); + return false; + } + + _log.info('Image saved successfully: $title'); + _log.info('Saved asset ID: ${resultAsset.id}'); + + return true; } catch (error, stack) { - _log.severe("Error saving image", error, stack); + _log.severe("Comprehensive error saving image", error, stack); return false; } finally { - if (await File(filePath).exists()) { - await File(filePath).delete(); + try { + if (await File(filePath).exists()) { + await File(filePath).delete(); + _log.info('Temporary download file deleted: $filePath'); + } + } catch (e) { + _log.severe('Error deleting temporary file: $e'); } } } @@ -82,24 +122,83 @@ class DownloadService { final filePath = await task.filePath(); final title = task.filename; final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null; - final file = File(filePath); + + _log.info('Attempting to save video: $title'); + _log.info('Source file path: $filePath'); + try { + final isDuplicate = await _isFileDuplicate(filePath, title); + if (isDuplicate) { + _log.info('Duplicate file detected. Skipping save: $title'); + return true; + } + + final sourceFile = File(filePath); + + if (!await sourceFile.exists()) { + _log.severe('Source file does not exist: $filePath'); + return false; + } + + try { + final fileSize = await sourceFile.length(); + _log.info('Source file size: $fileSize bytes'); + } catch (e) { + _log.severe('Cannot read source file size: $e'); + } + final Asset? resultAsset = await _fileMediaRepository.saveVideo( - file, + sourceFile, title: title, relativePath: relativePath, ); - return resultAsset != null; + + if (resultAsset == null) { + _log.severe('Failed to save video through PhotoManager'); + return false; + } + + _log.info('Video saved successfully: $title'); + _log.info('Saved asset ID: ${resultAsset.id}'); + + return true; } catch (error, stack) { - _log.severe("Error saving video", error, stack); + _log.severe("Comprehensive error saving video", error, stack); return false; } finally { - if (await file.exists()) { - await file.delete(); + try { + if (await File(filePath).exists()) { + await File(filePath).delete(); + _log.info('Temporary download file deleted: $filePath'); + } + } catch (e) { + _log.severe('Error deleting temporary file: $e'); } } } + // Future _getUniqueDestinationPath(String filename, String? relativePath) async { + // final baseDirectory = Platform.isAndroid + // ? '/storage/emulated/0/DCIM/Immich' + // : '${Platform.environment['HOME']}/Pictures/Immich'; + + // await Directory(baseDirectory).create(recursive: true); + + // final filenameWithoutExtension = filename.split('.').first; + // final extension = filename.split('.').last; + + // String destinationPath = '$baseDirectory/$filename'; + + // int counter = 1; + // while (await File(destinationPath).exists()) { + + // destinationPath = '$baseDirectory/${filenameWithoutExtension} ($counter).$extension'; + // counter++; + // } + + // return destinationPath; + // } + Future saveLivePhotos( Task task, String livePhotosId, @@ -159,6 +258,14 @@ class DownloadService { } Future download(Asset asset) async { + final assetHash = await _calculateAssetHash(asset); + + final existingLocalFile = await _findLocalFileWithHash(assetHash); + if (existingLocalFile != null) { + _log.info('File with hash $assetHash already exists. Skipping download.'); + return; + } + if (asset.isImage && asset.livePhotoVideoId != null && Platform.isIOS) { await _downloadRepository.download( _buildDownloadTask( @@ -196,6 +303,49 @@ class DownloadService { } } + Future _calculateAssetHash(Asset asset) async { + try { + return asset.remoteId; + } catch (e) { + _log.severe('Error calculating asset hash', e); + return null; + } + } + + Future _findLocalFileWithHash(String? hash) async { + if (hash == null) return null; + + final deviceAlbumPath = Platform.isAndroid + ? '/storage/emulated/0/DCIM/Immich' + : '${Platform.environment['HOME']}/Pictures/Immich'; + + final directory = Directory(deviceAlbumPath); + if (!await directory.exists()) return null; + + final files = await directory.list(recursive: true).toList(); + for (var fileSystemEntity in files) { + if (fileSystemEntity is File) { + final bytes = await fileSystemEntity.readAsBytes(); + final fileHash = sha256.convert(bytes).toString(); + if (fileHash == hash) { + return fileSystemEntity; + } + } + } + return null; + } + + // Future _fileExistsWithHash(String filePath, String expectedHash) async { + // final file = File(filePath); + // if (!await file.exists()) { + // return false; + // } + + // final bytes = await file.readAsBytes(); + // final fileHash = sha256.convert(bytes).toString(); + // return fileHash == expectedHash; + // } + DownloadTask _buildDownloadTask( String id, String filename, { @@ -218,6 +368,126 @@ class DownloadService { } } +Future _isFileDuplicate(String filePath, String filename) async { + final sourceFile = File(filePath); + + if (!await sourceFile.exists()) { + return false; + } + + String? sourceHash; + try { + final sourceBytes = await sourceFile.readAsBytes(); + sourceHash = sha256.convert(sourceBytes).toString(); + } catch (e) { + return false; + } + + final searchDirectory = Platform.isAndroid + ? '/storage/emulated/0/DCIM/Immich' + : '${Platform.environment['HOME']}/Pictures/Immich'; + + final directory = Directory(searchDirectory); + if (!await directory.exists()) { + return false; + } + + final files = await directory.list(recursive: true).toList(); + + for (var fileEntity in files) { + if (fileEntity is File) { + final existingFilePath = fileEntity.path; + final existingFileName = existingFilePath.split('/').last; + + if (_areFilenamesSimilar(existingFileName, filename)) { + try { + final existingBytes = await fileEntity.readAsBytes(); + final existingHash = sha256.convert(existingBytes).toString(); + + if (existingHash == sourceHash) { + return true; + } else { + return false; + } + } catch (e) { + return false; + } + } + + // Separate hash check + try { + final existingBytes = await fileEntity.readAsBytes(); + final existingHash = sha256.convert(existingBytes).toString(); + + if (existingHash == sourceHash) { + return true; + } + } catch (e) { + return false; + } + } + } + + return false; +} + +bool _areFilenamesSimilar(String existingFileName, String newFileName) { + final existingName = existingFileName.split('.').first.toLowerCase(); + final newName = newFileName.split('.').first.toLowerCase(); + final existingExt = existingFileName.split('.').last.toLowerCase(); + final newExt = newFileName.split('.').last.toLowerCase(); + + final exactMatch = existingName == newName && existingExt == newExt; + + final numberedMatch = RegExp(r'^' + RegExp.escape(newName) + r'\s*\(\d+\)$') + .hasMatch(existingName); + + final similarityThreshold = 0.8; + final similarityScore = _calculateStringSimilarity(existingName, newName); + final extensionMatch = existingExt == newExt; + + return exactMatch || + numberedMatch || + (similarityScore >= similarityThreshold && extensionMatch); +} + +double _calculateStringSimilarity(String s1, String s2) { + if (s1 == s2) return 1.0; + if (s1.isEmpty || s2.isEmpty) return 0.0; + + final len1 = s1.length; + final len2 = s2.length; + final maxLen = max(len1, len2); + + final distance = _levenshteinDistance(s1, s2); + return 1.0 - (distance / maxLen); +} + +int _levenshteinDistance(String s1, String s2) { + final m = s1.length; + final n = s2.length; + final dp = List.generate(m + 1, (_) => List.filled(n + 1, 0)); + + for (var i = 0; i <= m; i++) { + dp[i][0] = i; + } + for (var j = 0; j <= n; j++) { + dp[0][j] = j; + } + + for (var i = 1; i <= m; i++) { + for (var j = 1; j <= n; j++) { + final cost = s1[i - 1] == s2[j - 1] ? 0 : 1; + dp[i][j] = min( + min(dp[i - 1][j] + 1, dp[i][j - 1] + 1), + dp[i - 1][j - 1] + cost, + ); + } + } + + return dp[m][n]; +} + TaskRecord _findTaskRecord( List records, String livePhotosId, diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 34eb217828102..74234e7839851 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -295,7 +295,7 @@ packages: source: hosted version: "0.3.4+2" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index beabbd89b6cd7..79bb4994e063f 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -66,6 +66,7 @@ dependencies: git: url: https://github.com/immich-app/native_video_player ref: ac78487 + crypto: ^3.0.3 #image editing packages crop_image: ^1.0.13