diff --git a/mobile/apps/locker/lib/l10n/app_en.arb b/mobile/apps/locker/lib/l10n/app_en.arb index b758d27deb2..757719e8999 100644 --- a/mobile/apps/locker/lib/l10n/app_en.arb +++ b/mobile/apps/locker/lib/l10n/app_en.arb @@ -29,6 +29,53 @@ } } }, + "saveOffline": "Save offline", + "unsave": "Unsave", + "savingOffline": "Saving offline...", + "filesAvailableOffline": "{count, plural, =1{1 file saved offline} other{{count} files saved offline}}", + "@filesAvailableOffline": { + "description": "Success message when files are saved offline", + "placeholders": { + "count": { + "type": "int", + "example": "3" + } + } + }, + "filesAvailableOfflinePartial": "{successCount, plural, =1{Saved 1 file offline} other{Saved {successCount} files offline}}, {failureCount, plural, =1{1 failed} other{{failureCount} failed}}", + "@filesAvailableOfflinePartial": { + "description": "Partial success message for offline save", + "placeholders": { + "successCount": { + "type": "int", + "example": "2" + }, + "failureCount": { + "type": "int", + "example": "1" + } + } + }, + "failedToSaveFilesOffline": "{count, plural, =1{Failed to save 1 file offline} other{Failed to save {count} files offline}}", + "@failedToSaveFilesOffline": { + "description": "Failure message when files could not be saved offline", + "placeholders": { + "count": { + "type": "int", + "example": "2" + } + } + }, + "filesRemovedFromOffline": "{count, plural, =1{1 file removed from offline} other{{count} files removed from offline}}", + "@filesRemovedFromOffline": { + "description": "Success message when offline copies are removed", + "placeholders": { + "count": { + "type": "int", + "example": "2" + } + } + }, "downloadFailed": "Download failed", "failedToDownloadOrDecrypt": "Failed to download item", "errorOpeningFile": "Error opening item", diff --git a/mobile/apps/locker/lib/l10n/app_localizations.dart b/mobile/apps/locker/lib/l10n/app_localizations.dart index c64783df781..d5aa87532ea 100644 --- a/mobile/apps/locker/lib/l10n/app_localizations.dart +++ b/mobile/apps/locker/lib/l10n/app_localizations.dart @@ -270,6 +270,48 @@ abstract class AppLocalizations { /// **'Downloading... {percentage}%'** String downloadingProgress(int percentage); + /// No description provided for @saveOffline. + /// + /// In en, this message translates to: + /// **'Save offline'** + String get saveOffline; + + /// No description provided for @unsave. + /// + /// In en, this message translates to: + /// **'Unsave'** + String get unsave; + + /// No description provided for @savingOffline. + /// + /// In en, this message translates to: + /// **'Saving offline...'** + String get savingOffline; + + /// Success message when files are saved offline + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 file saved offline} other{{count} files saved offline}}'** + String filesAvailableOffline(int count); + + /// Partial success message for offline save + /// + /// In en, this message translates to: + /// **'{successCount, plural, =1{Saved 1 file offline} other{Saved {successCount} files offline}}, {failureCount, plural, =1{1 failed} other{{failureCount} failed}}'** + String filesAvailableOfflinePartial(int successCount, int failureCount); + + /// Failure message when files could not be saved offline + /// + /// In en, this message translates to: + /// **'{count, plural, =1{Failed to save 1 file offline} other{Failed to save {count} files offline}}'** + String failedToSaveFilesOffline(int count); + + /// Success message when offline copies are removed + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 file removed from offline} other{{count} files removed from offline}}'** + String filesRemovedFromOffline(int count); + /// No description provided for @downloadFailed. /// /// In en, this message translates to: diff --git a/mobile/apps/locker/lib/l10n/app_localizations_en.dart b/mobile/apps/locker/lib/l10n/app_localizations_en.dart index 521096504df..16c554d4081 100644 --- a/mobile/apps/locker/lib/l10n/app_localizations_en.dart +++ b/mobile/apps/locker/lib/l10n/app_localizations_en.dart @@ -75,6 +75,65 @@ class AppLocalizationsEn extends AppLocalizations { return 'Downloading... $percentage%'; } + @override + String get saveOffline => 'Save offline'; + + @override + String get unsave => 'Unsave'; + + @override + String get savingOffline => 'Saving offline...'; + + @override + String filesAvailableOffline(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count files saved offline', + one: '1 file saved offline', + ); + return '$_temp0'; + } + + @override + String filesAvailableOfflinePartial(int successCount, int failureCount) { + String _temp0 = intl.Intl.pluralLogic( + successCount, + locale: localeName, + other: 'Saved $successCount files offline', + one: 'Saved 1 file offline', + ); + String _temp1 = intl.Intl.pluralLogic( + failureCount, + locale: localeName, + other: '$failureCount failed', + one: '1 failed', + ); + return '$_temp0, $_temp1'; + } + + @override + String failedToSaveFilesOffline(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Failed to save $count files offline', + one: 'Failed to save 1 file offline', + ); + return '$_temp0'; + } + + @override + String filesRemovedFromOffline(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count files removed from offline', + one: '1 file removed from offline', + ); + return '$_temp0'; + } + @override String get downloadFailed => 'Download failed'; diff --git a/mobile/apps/locker/lib/main.dart b/mobile/apps/locker/lib/main.dart index dadfc03abcc..b89596572e4 100644 --- a/mobile/apps/locker/lib/main.dart +++ b/mobile/apps/locker/lib/main.dart @@ -25,13 +25,14 @@ import 'package:locker/l10n/app_localizations.dart'; import 'package:locker/services/collections/collections_api_client.dart'; import 'package:locker/services/collections/collections_service.dart'; import 'package:locker/services/configuration.dart'; -import "package:locker/services/db/locker_db.dart"; +import 'package:locker/services/db/locker_db.dart'; import 'package:locker/services/favorites_service.dart'; import 'package:locker/services/files/download/service_locator.dart'; -import "package:locker/services/files/links/links_client.dart"; -import "package:locker/services/files/links/links_service.dart"; +import 'package:locker/services/files/links/links_client.dart'; +import 'package:locker/services/files/links/links_service.dart'; +import 'package:locker/services/files/offline/offline_files_service.dart'; import 'package:locker/services/trash/trash_service.dart'; -import "package:locker/services/update_service.dart"; +import 'package:locker/services/update_service.dart'; import 'package:locker/ui/pages/home_page.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:path_provider/path_provider.dart'; @@ -179,6 +180,7 @@ Future _init(bool bool, {String? via}) async { await CollectionApiClient.instance.init(); await CollectionService.instance.init(preferences); await FavoritesService.instance.init(); + await OfflineFilesService.instance.init(); await LinksClient.instance.init(); await LinksService.instance.init(); await ServiceLocator.instance.init( diff --git a/mobile/apps/locker/lib/services/collections/collections_service.dart b/mobile/apps/locker/lib/services/collections/collections_service.dart index 23894237a7c..55ca3c1634f 100644 --- a/mobile/apps/locker/lib/services/collections/collections_service.dart +++ b/mobile/apps/locker/lib/services/collections/collections_service.dart @@ -21,6 +21,7 @@ import "package:locker/services/collections/models/files_split.dart"; import "package:locker/services/collections/models/public_url.dart"; import 'package:locker/services/configuration.dart'; import "package:locker/services/db/locker_db.dart"; +import 'package:locker/services/files/offline/offline_files_service.dart'; import 'package:locker/services/files/sync/models/file.dart'; import 'package:locker/services/trash/models/trash_item_request.dart'; import "package:locker/services/trash/trash_service.dart"; @@ -133,6 +134,7 @@ class CollectionService { ); } await Future.wait(fileFutures); + await OfflineFilesService.instance.cleanupInactiveOfflineFiles(); if (updatedCollections.isNotEmpty) { Bus.instance.fire(CollectionsUpdatedEvent('sync')); } @@ -436,11 +438,6 @@ class CollectionService { try { await _apiClient.removeFromCollection(collectionId, files); - final collection = await getCollectionByID(collectionId); - if (collection != null) { - await _db.deleteFilesFromCollection(collection, files); - } - Bus.instance.fire(CollectionsUpdatedEvent('files_removed')); await sync(); @@ -469,17 +466,15 @@ class CollectionService { // Call API to move files on server await _apiClient.move(files, from, to); - // Update local database for all files - // Remove from source collection - await _db.deleteFilesFromCollection(from, files); - // Update collectionID for all files for (final file in files) { file.collectionID = to.id; } - // Add to target collection + // Write the destination row first so local key material and offline state + // stay attached to the moved file before the source mapping is removed. await _db.addFilesToCollection(to, files); + await _db.deleteFilesFromCollection(from, files); // Let sync update the local state to ensure consistency if (runSync) { @@ -729,9 +724,10 @@ class CollectionService { } final Collection? targetCollection = await getCollectionByID(toCollectionID); - // ignore non-cached collections, uncategorized and favorite - // collections and collections ignored by others + // ignore non-cached, deleted, uncategorized and favorite collections, + // and collections ignored by others if (targetCollection == null || + targetCollection.isDeleted || (CollectionType.uncategorized == targetCollection.type || targetCollection.type == CollectionType.favorites) || targetCollection.owner.id != userID) { diff --git a/mobile/apps/locker/lib/services/collections/models/collection_view_type.dart b/mobile/apps/locker/lib/services/collections/models/collection_view_type.dart index 786cb5d234a..cd3b71b3eb6 100644 --- a/mobile/apps/locker/lib/services/collections/models/collection_view_type.dart +++ b/mobile/apps/locker/lib/services/collections/models/collection_view_type.dart @@ -28,6 +28,8 @@ extension CollectionViewTypeActions on CollectionViewType { bool get showAddToCollectionOption => !isIncomingShare; + bool get showOfflineOption => !isIncomingShare; + bool get showMarkImportantOption => !isIncomingShare; } diff --git a/mobile/apps/locker/lib/services/configuration.dart b/mobile/apps/locker/lib/services/configuration.dart index 2ecca1c5ea8..85c3f3f9a1b 100644 --- a/mobile/apps/locker/lib/services/configuration.dart +++ b/mobile/apps/locker/lib/services/configuration.dart @@ -1,14 +1,11 @@ -import 'dart:io'; - import 'package:ente_configuration/base_configuration.dart'; import 'package:locker/services/collections/collections_service.dart'; import 'package:locker/services/favorites_service.dart'; -import 'package:logging/logging.dart'; +import 'package:locker/services/files/offline/offline_file_storage.dart'; class Configuration extends BaseConfiguration { Configuration._privateConstructor(); static final Configuration instance = Configuration._privateConstructor(); - static final _logger = Logger("Configuration"); @override // Provide all secure storage keys that should be wiped on logout. @@ -24,18 +21,6 @@ class Configuration extends BaseConfiguration { FavoritesService.instance.clearCache(); await super.logout(autoLogout: autoLogout); - await _clearCachedFiles(); - } - - Future _clearCachedFiles() async { - try { - final cacheDir = Directory(getCacheDirectory()); - if (!await cacheDir.exists()) return; - await for (final entity in cacheDir.list(followLinks: false)) { - await entity.delete(recursive: true); - } - } catch (e, s) { - _logger.warning("Failed to clear cached files on logout", e, s); - } + await clearAllOfflineFileCopies(); } } diff --git a/mobile/apps/locker/lib/services/db/locker_db.dart b/mobile/apps/locker/lib/services/db/locker_db.dart index 89c230a2492..92773570739 100644 --- a/mobile/apps/locker/lib/services/db/locker_db.dart +++ b/mobile/apps/locker/lib/services/db/locker_db.dart @@ -22,20 +22,27 @@ class LockerDB extends EnteBaseDatabase { Database? _database; int _collectionSyncTime = 0; final Map _collectionSyncTimesCache = {}; + final Set _offlineMarkedFileIDsCache = {}; static const String databaseName = 'locker.db'; static const String _collectionsTable = 'collections'; static const String _filesTable = 'files'; static const String trashTable = 'trash_files'; static const String _collectionFilesTable = 'collection_files'; + static const String _offlineFilesTable = 'offline_marks'; static const String _syncTimesTable = 'sync_times'; static const int _collectionPayloadVersion = 1; static const int _filePayloadVersion = 1; static const int _trashPayloadVersion = 1; + static const int _databaseVersion = 2; + + static final List Function(DatabaseExecutor)> _migrationScripts = + [ + _createOfflineFilesTable, + ]; Future init() async { _database = await _initDatabase(); - await _createTables(_db, 1); await _loadCaches(); } @@ -57,6 +64,19 @@ class LockerDB extends EnteBaseDatabase { final globalSyncTime = await getSyncTimeAsync(); _collectionSyncTime = globalSyncTime; + await _populateOfflineMarkedFileIDsCache(); + } + + Future _populateOfflineMarkedFileIDsCache() async { + final offlineMarkedRows = await _db.query( + _offlineFilesTable, + columns: ['uploaded_file_id'], + ); + _offlineMarkedFileIDsCache + ..clear() + ..addAll( + offlineMarkedRows.map((row) => row['uploaded_file_id'] as int), + ); } Future _initDatabase() async { @@ -69,12 +89,25 @@ class LockerDB extends EnteBaseDatabase { return await openDatabase( path, - version: 1, - onCreate: _createTables, + version: _databaseVersion, + onCreate: (db, _) => _createTables(db), + onUpgrade: _onUpgrade, ); } - Future _createTables(Database db, int version) async { + Future _onUpgrade(Database db, int oldVersion, int newVersion) async { + if (oldVersion >= newVersion) { + return; + } + + await db.transaction((txn) async { + for (var index = oldVersion - 1; index < newVersion - 1; index++) { + await _migrationScripts[index](txn); + } + }); + } + + Future _createTables(Database db) async { await db.execute(''' CREATE TABLE IF NOT EXISTS $_collectionsTable ( id INTEGER PRIMARY KEY, @@ -92,6 +125,7 @@ class LockerDB extends EnteBaseDatabase { await _createFilesTable(db); await _createTrashTable(db); + await _createOfflineFilesTable(db); await db.execute(''' CREATE TABLE IF NOT EXISTS $_collectionFilesTable ( @@ -145,6 +179,14 @@ class LockerDB extends EnteBaseDatabase { '''); } + static Future _createOfflineFilesTable(DatabaseExecutor db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS $_offlineFilesTable ( + uploaded_file_id INTEGER PRIMARY KEY + ) + '''); + } + Future _createTrashTable(Database db) async { await db.execute(''' CREATE TABLE IF NOT EXISTS ${LockerDB.trashTable} ( @@ -538,6 +580,83 @@ class LockerDB extends EnteBaseDatabase { await batch.commit(); } + bool isFileMarkedOfflineById(int? uploadedFileID) { + return uploadedFileID != null && + _offlineMarkedFileIDsCache.contains(uploadedFileID); + } + + bool isFileMarkedOffline(EnteFile file) { + return isFileMarkedOfflineById(file.uploadedFileID); + } + + Future setFilesMarkedOffline( + Iterable uploadedFileIDs, + bool isMarkedOffline, + ) async { + final ids = uploadedFileIDs.toSet(); + if (ids.isEmpty) { + return; + } + + final batch = _db.batch(); + for (final uploadedFileID in ids) { + if (isMarkedOffline) { + batch.insert( + _offlineFilesTable, + {'uploaded_file_id': uploadedFileID}, + conflictAlgorithm: ConflictAlgorithm.ignore, + ); + } else { + batch.delete( + _offlineFilesTable, + where: 'uploaded_file_id = ?', + whereArgs: [uploadedFileID], + ); + } + } + await batch.commit(); + if (isMarkedOffline) { + _offlineMarkedFileIDsCache.addAll(ids); + } else { + _offlineMarkedFileIDsCache.removeAll(ids); + } + } + + Future hasActiveFile(int uploadedFileID) async { + final rows = await _db.rawQuery( + ''' + SELECT 1 + FROM $_collectionFilesTable cf + INNER JOIN $_collectionsTable c + ON c.id = cf.collection_id + WHERE c.is_deleted = 0 + AND cf.uploaded_file_id = ? + LIMIT 1 + ''', + [uploadedFileID], + ); + return rows.isNotEmpty; + } + + Future> getStaleOfflineMarkedFileIDs() async { + final rows = await _db.rawQuery( + ''' + SELECT om.uploaded_file_id + FROM $_offlineFilesTable om + WHERE NOT EXISTS ( + SELECT 1 + FROM $_collectionFilesTable cf + INNER JOIN $_collectionsTable c + ON c.id = cf.collection_id + WHERE cf.uploaded_file_id = om.uploaded_file_id + AND c.is_deleted = 0 + ) + ''', + ); + + return rows.map((row) => row['uploaded_file_id'] as int).toList(); + } + Map _collectionToMap(Collection collection) { final collectionKey = CryptoHelper.instance.getCollectionKey(collection); final encryptedPayload = CryptoUtil.encryptSync( @@ -852,8 +971,10 @@ class LockerDB extends EnteBaseDatabase { await _database?.delete(_filesTable); await _database?.delete(LockerDB.trashTable); await _database?.delete(_collectionFilesTable); + await _database?.delete(_offlineFilesTable); await _database?.delete(_syncTimesTable); _collectionSyncTimesCache.clear(); + _offlineMarkedFileIDsCache.clear(); _collectionSyncTime = 0; } } diff --git a/mobile/apps/locker/lib/services/files/download/file_downloader.dart b/mobile/apps/locker/lib/services/files/download/file_downloader.dart index 849992730ba..f2fc51229eb 100644 --- a/mobile/apps/locker/lib/services/files/download/file_downloader.dart +++ b/mobile/apps/locker/lib/services/files/download/file_downloader.dart @@ -6,30 +6,198 @@ import 'package:ente_crypto_api/ente_crypto_api.dart'; import 'package:ente_network/network.dart'; import 'package:ente_pure_utils/ente_pure_utils.dart'; import 'package:locker/services/configuration.dart'; +import 'package:locker/services/db/locker_db.dart'; import 'package:locker/services/files/download/models/task.dart'; import 'package:locker/services/files/download/service_locator.dart'; +import 'package:locker/services/files/offline/offline_file_storage.dart'; import 'package:locker/services/files/sync/models/file.dart'; import 'package:logging/logging.dart'; import 'package:path/path.dart' as p; final _logger = Logger("FileDownloader"); -String getCachedEncryptedFilePath(EnteFile file) { - final String cacheDir = Configuration.instance.getCacheDirectory(); - return "$cacheDir${file.uploadedFileID}.encrypted"; -} +/// Returns the encrypted offline blob for this device, downloading it only when +/// a usable local copy does not already exist. +Future ensureEncryptedOfflineCopy( + EnteFile file, { + ProgressCallback? progressCallback, +}) async { + final existingFile = await getCurrentOfflineEncryptedCopy(file); + if (existingFile != null) { + final existingSize = await existingFile.length(); + progressCallback?.call(existingSize, existingSize); + return existingFile; + } + + final String logPrefix = 'File-${file.uploadedFileID}:'; + final String tempDir = Configuration.instance.getTempDirectory(); + final String tempEncryptedFilePath = + "$tempDir${file.uploadedFileID}.encrypted"; + final String finalEncryptedFilePath = await getOfflineEncryptedFilePath(file); + final File finalEncryptedFile = File(finalEncryptedFilePath); -String getCachedDecryptedFilePath(EnteFile file) { - final String cacheDir = Configuration.instance.getCacheDirectory(); - final String extension = _safeExtension(file.displayName); - return "$cacheDir${file.uploadedFileID}.decrypted$extension"; + String encryptedFilePath = tempEncryptedFilePath; + File encryptedFile = File(encryptedFilePath); + + try { + if (downloadManager.enableResumableDownload(file.fileSize)) { + final DownloadResult result = await downloadManager.download( + file.uploadedFileID!, + file.displayName, + file.fileSize!, + ); + if (!result.success || result.task.filePath == null) { + throw Exception( + '$logPrefix download failed ${result.task.error} ${result.task.status}', + ); + } + encryptedFilePath = result.task.filePath!; + encryptedFile = File(encryptedFilePath); + final encryptedSize = await encryptedFile.length(); + progressCallback?.call(encryptedSize, encryptedSize); + } else { + late final Response response; + try { + response = await Network.instance.getDio().download( + file.downloadUrl, + tempEncryptedFilePath, + options: Options( + headers: {"X-Auth-Token": Configuration.instance.getToken()}, + ), + onReceiveProgress: progressCallback, + ); + } catch (e) { + try { + if (await encryptedFile.exists()) { + await encryptedFile.delete(); + } + } catch (_) {} + rethrow; + } + + if (response.statusCode != 200 || !await encryptedFile.exists()) { + throw Exception('$logPrefix download failed ${response.toString()}'); + } + } + + if (await finalEncryptedFile.exists()) { + await finalEncryptedFile.delete(); + } + + if (encryptedFilePath == finalEncryptedFilePath) { + return finalEncryptedFile; + } + + await encryptedFile.copy(finalEncryptedFilePath); + await encryptedFile.delete(); + _logger.info('$logPrefix persisted encrypted offline copy'); + return finalEncryptedFile; + } catch (e, s) { + _logger.severe( + '$logPrefix failed to ensure encrypted offline copy', + e, + s, + ); + try { + if (await encryptedFile.exists() && + encryptedFile.path != finalEncryptedFilePath) { + await encryptedFile.delete(); + } + } catch (_) {} + try { + if (await finalEncryptedFile.exists()) { + await finalEncryptedFile.delete(); + } + } catch (_) {} + rethrow; + } } -String _safeExtension(String fileName) { - final ext = p.extension(p.basename(fileName)); - if (ext.isEmpty) return ''; - final sanitized = ext.replaceAll(RegExp(r'[\\/:*?"<>|]'), ''); - return sanitized == '.' ? '' : sanitized; +Future openFile( + EnteFile file, + Uint8List fileKey, { + ProgressCallback? progressCallback, +}) async { + if (!LockerDB.instance.isFileMarkedOffline(file)) { + return downloadAndDecrypt( + file, + fileKey, + progressCallback: progressCallback, + shouldUseCache: true, + ); + } + + try { + final offlineEncryptedFile = await ensureEncryptedOfflineCopy( + file, + progressCallback: progressCallback, + ); + final String logPrefix = 'File-${file.uploadedFileID}:'; + final int startTime = DateTime.now().millisecondsSinceEpoch; + final String decryptedFilePath = getCachedDecryptedFilePath(file); + final File decryptedFile = File(decryptedFilePath); + final int sizeInBytes = file.fileSize ?? await offlineEncryptedFile.length(); + + try { + if (await decryptedFile.exists()) { + final decryptedSize = await decryptedFile.length(); + if (decryptedSize > 0) { + progressCallback?.call(decryptedSize, decryptedSize); + return decryptedFile; + } + await decryptedFile.delete(); + } + + await CryptoUtil.decryptFile( + offlineEncryptedFile.path, + decryptedFilePath, + CryptoUtil.base642bin(file.fileDecryptionHeader!), + fileKey, + ); + + final double elapsedSeconds = + (DateTime.now().millisecondsSinceEpoch - startTime) / 1000; + final double speedInKBps = + elapsedSeconds <= 0 ? 0 : sizeInBytes / 1024.0 / elapsedSeconds; + _logger.info( + '$logPrefix local decrypt completed: ${formatBytes(sizeInBytes)}, avg speed: ${speedInKBps.toStringAsFixed(2)} KB/s', + ); + return decryptedFile; + } catch (e, s) { + _logger.severe("Critical: $logPrefix failed to decrypt", e, s); + try { + if (await decryptedFile.exists()) { + await decryptedFile.delete(); + } + } catch (_) {} + } + } catch (e, s) { + _logger.warning( + 'Failed to use offline encrypted copy for ${file.uploadedFileID}, falling back to direct download', + e, + s, + ); + } + + if (file.uploadedFileID != null) { + try { + await removeOfflineFileCopiesFromDisk( + [file.uploadedFileID!], + removeWorkingCopies: false, + ); + await LockerDB.instance.setFilesMarkedOffline( + [file.uploadedFileID!], + false, + ); + } catch (_) {} + } + + return downloadAndDecrypt( + file, + fileKey, + progressCallback: progressCallback, + shouldUseCache: true, + ); } Future downloadAndDecrypt( @@ -108,7 +276,6 @@ Future downloadAndDecrypt( return null; } } else { - // If the file is small, download it directly to the final location final response = await Network.instance.getDio().download( file.downloadUrl, tempEncryptedFilePath, @@ -138,18 +305,15 @@ Future downloadAndDecrypt( '$logPrefix download completed: ${formatBytes(sizeInBytes)}, avg speed: ${speedInKBps.toStringAsFixed(2)} KB/s', ); - // As decryption can take time, emit fake progress for large files during - // decryption final FakePeriodicProgress? fakeProgress = shouldFakeProgress ? FakePeriodicProgress( - callback: (count) { + callback: (_) { progressCallback?.call(sizeInBytes, sizeInBytes); }, duration: const Duration(milliseconds: 5000), ) : null; try { - // Start the periodic callback after initial 5 seconds fakeProgress?.start(); if (await decryptedFile.exists()) { await decryptedFile.delete(); @@ -171,14 +335,13 @@ Future downloadAndDecrypt( await encryptedFile.delete(); } catch (_) {} } else if (usingCachedEncryptedFile) { - // Cached encrypted file is likely corrupted; remove it so next attempt - // fetches a fresh copy. try { await encryptedFile.delete(); } catch (_) {} } return null; } + if (shouldUseCache && downloadedFreshEncryptedFile) { try { if (encryptedFilePath != cachedEncryptedFilePath) { diff --git a/mobile/apps/locker/lib/services/files/offline/offline_file_storage.dart b/mobile/apps/locker/lib/services/files/offline/offline_file_storage.dart new file mode 100644 index 00000000000..331438d1d1e --- /dev/null +++ b/mobile/apps/locker/lib/services/files/offline/offline_file_storage.dart @@ -0,0 +1,194 @@ +import 'dart:io'; + +import 'package:locker/services/configuration.dart'; +import 'package:locker/services/files/sync/models/file.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +const _offlineEncryptedDirName = 'offline_documents'; +final _logger = Logger('OfflineFileStorage'); + +String _safeExtension(String fileName) { + final ext = p.extension(p.basename(fileName)); + if (ext.isEmpty) return ''; + final sanitized = ext.replaceAll(RegExp(r'[\\/:*?"<>|]'), ''); + return sanitized == '.' ? '' : sanitized; +} + +String getCachedEncryptedFilePath(EnteFile file) { + final String cacheDir = Configuration.instance.getCacheDirectory(); + return "$cacheDir${file.uploadedFileID}.encrypted"; +} + +String getCachedDecryptedFilePath(EnteFile file) { + final String cacheDir = Configuration.instance.getCacheDirectory(); + final String extension = _safeExtension(file.displayName); + return "$cacheDir${file.uploadedFileID}.decrypted$extension"; +} + +Future _getOfflineEncryptedDirectory() async { + final supportDirectory = await getApplicationSupportDirectory(); + final directory = Directory( + p.join(supportDirectory.path, _offlineEncryptedDirName), + ); + if (!await directory.exists()) { + await directory.create(recursive: true); + } + return directory; +} + +Future getOfflineEncryptedFilePath(EnteFile file) async { + final directory = await _getOfflineEncryptedDirectory(); + return p.join(directory.path, '${file.uploadedFileID}.encrypted'); +} + +Future getCurrentOfflineEncryptedCopy(EnteFile file) async { + final finalPath = await getOfflineEncryptedFilePath(file); + final encryptedFile = File(finalPath); + if (await encryptedFile.exists()) { + final encryptedSize = await encryptedFile.length(); + if (encryptedSize > 0) { + return encryptedFile; + } + _logger.warning( + 'Deleting empty offline encrypted copy for ${file.uploadedFileID}', + ); + await encryptedFile.delete(); + } + + return null; +} + +Future removeOfflineFileCopiesFromDisk( + Iterable fileIds, { + bool removeWorkingCopies = true, +}) async { + final ids = fileIds.toSet(); + if (ids.isEmpty) { + return; + } + + _logger.fine( + 'Removing offline file copies for ${ids.length} files' + ' (removeWorkingCopies=$removeWorkingCopies)', + ); + + await _deleteFilesFromDirectory(await _getOfflineEncryptedDirectory(), ( + baseName, + ) { + final fileId = _parseEncryptedFileId(baseName); + return fileId != null && ids.contains(fileId); + }); + + if (!removeWorkingCopies) { + return; + } + + await _deleteCachedFileCopies(ids); +} + +Future clearAllOfflineFileCopies() async { + _logger.info('Clearing all offline file copies'); + await _deleteFilesFromDirectory( + await _getOfflineEncryptedDirectory(), + (_) => true, + ); + + final cacheDirectory = Directory(Configuration.instance.getCacheDirectory()); + await _deleteFilesFromDirectory(cacheDirectory, _isOfflineWorkingCopyBaseName); +} + +Future cleanupOfflineWorkingFiles({ + Duration olderThan = Duration.zero, +}) async { + _logger.fine('Cleaning offline working files older than $olderThan'); + final directory = Directory(Configuration.instance.getCacheDirectory()); + if (!await directory.exists()) { + return; + } + + final now = DateTime.now(); + await for (final entity in directory.list(followLinks: false)) { + if (entity is! File) { + continue; + } + + final baseName = p.basename(entity.path); + if (!_isOfflineWorkingCopyBaseName(baseName)) { + continue; + } + + try { + if (olderThan > Duration.zero) { + final stat = await entity.stat(); + if (now.difference(stat.modified) < olderThan) { + continue; + } + } + await entity.delete(); + } catch (e, s) { + _logger.warning('Failed to delete cached offline working file', e, s); + } + } +} + +bool _isOfflineWorkingCopyBaseName(String baseName) { + return _parseEncryptedFileId(baseName) != null || + _parseCachedDecryptedFileId(baseName) != null; +} + +int? _parseEncryptedFileId(String baseName) { + final match = RegExp(r'^(\d+)\.encrypted$').firstMatch(baseName); + if (match == null) { + return null; + } + return int.tryParse(match.group(1)!); +} + +int? _parseCachedDecryptedFileId(String baseName) { + final match = RegExp(r'^(\d+)\.decrypted').firstMatch(baseName); + if (match == null) { + return null; + } + return int.tryParse(match.group(1)!); +} + +Future _deleteCachedFileCopies(Set ids) async { + final cacheDirectory = Directory(Configuration.instance.getCacheDirectory()); + await _deleteFilesFromDirectory(cacheDirectory, (baseName) { + final encryptedId = _parseEncryptedFileId(baseName); + if (encryptedId != null && ids.contains(encryptedId)) { + return true; + } + + final decryptedId = _parseCachedDecryptedFileId(baseName); + return decryptedId != null && ids.contains(decryptedId); + }); +} + +Future _deleteFilesFromDirectory( + Directory directory, + bool Function(String baseName) shouldDelete, +) async { + if (!await directory.exists()) { + return; + } + + await for (final entity in directory.list(followLinks: false)) { + if (entity is! File) { + continue; + } + + final baseName = p.basename(entity.path); + if (!shouldDelete(baseName)) { + continue; + } + + try { + await entity.delete(); + } catch (e, s) { + _logger.warning('Failed to delete offline file copy ${entity.path}', e, s); + } + } +} diff --git a/mobile/apps/locker/lib/services/files/offline/offline_files_service.dart b/mobile/apps/locker/lib/services/files/offline/offline_files_service.dart new file mode 100644 index 00000000000..088c7d3edb9 --- /dev/null +++ b/mobile/apps/locker/lib/services/files/offline/offline_files_service.dart @@ -0,0 +1,266 @@ +import 'package:ente_events/event_bus.dart'; +import 'package:ente_ui/utils/dialog_util.dart'; +import 'package:ente_ui/utils/toast_util.dart'; +import 'package:flutter/material.dart'; +import 'package:locker/events/collections_updated_event.dart'; +import 'package:locker/l10n/l10n.dart'; +import 'package:locker/services/configuration.dart'; +import 'package:locker/services/db/locker_db.dart'; +import 'package:locker/services/files/download/file_downloader.dart'; +import 'package:locker/services/files/offline/offline_file_storage.dart'; +import 'package:locker/services/files/sync/models/file.dart'; +import 'package:locker/services/info_file_service.dart'; +import 'package:logging/logging.dart'; + +/// Handles Locker's explicit per-file offline save flow. +/// +/// A file is marked offline only after the encrypted blob has been saved on +/// this device. +class OfflineFilesService { + OfflineFilesService._privateConstructor(); + + static const _cachedFileCleanupAge = Duration(hours: 12); + + static final OfflineFilesService instance = + OfflineFilesService._privateConstructor(); + + final Logger _logger = Logger('OfflineFilesService'); + + Future init() async { + _logger.fine('Cleaning up stale offline working files'); + await cleanupOfflineWorkingFiles( + olderThan: _cachedFileCleanupAge, + ); + } + + /// Shared files and info records are intentionally excluded from offline save. + bool _canMarkOffline(EnteFile file) { + final currentUserID = Configuration.instance.getUserID(); + return file.uploadedFileID != null && + file.fileDecryptionHeader != null && + file.ownerID == currentUserID && + !InfoFileService.instance.isInfoFile(file); + } + + List getEligibleFiles(Iterable files) { + final eligibleFilesById = {}; + + for (final file in files) { + final fileId = file.uploadedFileID; + if (fileId == null || !_canMarkOffline(file)) { + continue; + } + eligibleFilesById[fileId] = file; + } + + return eligibleFilesById.values.toList(); + } + + /// Downloads the encrypted blob and marks the file offline on success. + Future markFilesOffline( + BuildContext context, + Iterable files, + ) async { + final eligibleFiles = getEligibleFiles(files); + _logger.info( + 'Mark offline requested for ${eligibleFiles.length} eligible files', + ); + + if (eligibleFiles.isEmpty || !context.mounted) { + _logger.fine('No eligible files to mark offline'); + return false; + } + + final total = eligibleFiles.length; + final dialog = createProgressDialog( + context, + total == 1 ? context.l10n.savingOffline : '${context.l10n.savingOffline} 0/$total', + isDismissible: false, + ); + + var successCount = 0; + var failureCount = 0; + + await dialog.show(); + + try { + for (var index = 0; index < eligibleFiles.length; index++) { + final file = eligibleFiles[index]; + final fileID = file.uploadedFileID!; + final currentStep = index + 1; + + dialog.update( + message: total == 1 + ? context.l10n.savingOffline + : '${context.l10n.savingOffline} $currentStep/$total', + ); + + final alreadyHasOfflineCopy = + LockerDB.instance.isFileMarkedOffline(file) && + await getCurrentOfflineEncryptedCopy(file) != null; + if (alreadyHasOfflineCopy) { + _logger.fine('File $fileID already available offline'); + successCount += 1; + continue; + } + + try { + final didMarkFile = await _ensureOfflineCopyAndMark(file); + if (didMarkFile) { + _logger.info('Marked file $fileID available offline'); + successCount += 1; + continue; + } + + failureCount += 1; + } catch (e, s) { + failureCount += 1; + _logger.warning( + 'Failed to mark file $fileID available offline', + e, + s, + ); + await _clearOfflineState( + [fileID], + removeWorkingCopies: false, + ); + } + } + } finally { + try { + await dialog.hide(); + } catch (_) {} + } + + _logger.info( + 'Mark offline completed: success=$successCount failure=$failureCount', + ); + + if (successCount > 0) { + Bus.instance + .fire(CollectionsUpdatedEvent('offline_availability_changed')); + } + + if (!context.mounted) { + return successCount > 0; + } + + if (failureCount == 0) { + showToast( + context, + context.l10n.filesAvailableOffline(successCount), + ); + } else if (successCount > 0) { + showToast( + context, + context.l10n.filesAvailableOfflinePartial(successCount, failureCount), + ); + } else { + showToast( + context, + context.l10n.failedToSaveFilesOffline(failureCount), + ); + } + + return successCount > 0; + } + + /// Clears offline state for the selected files on this device. + Future unmarkFilesOffline( + BuildContext context, + Iterable files, + ) async { + final eligibleFiles = getEligibleFiles(files); + _logger.info( + 'Remove offline requested for ${eligibleFiles.length} eligible files', + ); + + if (eligibleFiles.isEmpty) { + _logger.fine('No eligible files to remove from offline'); + return false; + } + + final fileIDsToUnmark = {}; + for (final file in eligibleFiles) { + final fileID = file.uploadedFileID!; + final hasOfflineCopy = await getCurrentOfflineEncryptedCopy(file) != null; + if (!LockerDB.instance.isFileMarkedOffline(file) && !hasOfflineCopy) { + continue; + } + fileIDsToUnmark.add(fileID); + } + + final changedCount = fileIDsToUnmark.length; + _logger.fine('Removing offline state for $changedCount files'); + await _clearOfflineState(fileIDsToUnmark); + + if (changedCount > 0) { + Bus.instance + .fire(CollectionsUpdatedEvent('offline_availability_changed')); + } + + if (changedCount == 0 || !context.mounted) { + return changedCount > 0; + } + + showToast( + context, + context.l10n.filesRemovedFromOffline(changedCount), + ); + return true; + } + + Future _clearOfflineState( + Iterable fileIDs, { + bool removeWorkingCopies = true, + }) async { + final ids = fileIDs.toSet(); + if (ids.isEmpty) { + return; + } + + _logger.fine( + 'Clearing offline state for ${ids.length} files' + ' (removeWorkingCopies=$removeWorkingCopies)', + ); + + await LockerDB.instance.setFilesMarkedOffline(ids, false); + await removeOfflineFileCopiesFromDisk( + ids, + removeWorkingCopies: removeWorkingCopies, + ); + } + + Future cleanupInactiveOfflineFiles() async { + final staleFileIDs = await LockerDB.instance.getStaleOfflineMarkedFileIDs(); + if (staleFileIDs.isEmpty) { + return; + } + + _logger.info( + 'Clearing offline state for ${staleFileIDs.length} stale files after sync', + ); + await _clearOfflineState(staleFileIDs); + } + + /// Downloads first, then writes the local offline mark if the file is still + /// active in the current library view. + Future _ensureOfflineCopyAndMark(EnteFile file) async { + final fileID = file.uploadedFileID!; + await ensureEncryptedOfflineCopy(file); + + if (!await LockerDB.instance.hasActiveFile(fileID)) { + _logger.warning( + 'Skipping offline mark for file $fileID because it is no longer active', + ); + await _clearOfflineState( + [fileID], + removeWorkingCopies: false, + ); + return false; + } + + await LockerDB.instance.setFilesMarkedOffline([fileID], true); + return true; + } +} diff --git a/mobile/apps/locker/lib/ui/components/file_list_widget.dart b/mobile/apps/locker/lib/ui/components/file_list_widget.dart index e351b6ff59f..1dfa6486bd8 100644 --- a/mobile/apps/locker/lib/ui/components/file_list_widget.dart +++ b/mobile/apps/locker/lib/ui/components/file_list_widget.dart @@ -6,8 +6,10 @@ import "package:hugeicons/hugeicons.dart"; import "package:locker/models/selected_files.dart"; import "package:locker/services/collections/collections_service.dart"; import "package:locker/services/configuration.dart"; +import "package:locker/services/db/locker_db.dart"; import "package:locker/services/files/sync/models/file.dart"; import "package:locker/services/info_file_service.dart"; +import "package:locker/services/trash/models/trash_file.dart"; import "package:locker/utils/file_icon_utils.dart"; import "package:locker/utils/file_util.dart"; import "package:locker/utils/info_item_utils.dart"; @@ -45,7 +47,10 @@ class FileListWidget extends StatelessWidget { final bool hasSharees = sharees.isNotEmpty; final bool isOutgoing = isOwner && hasSharees; final bool isIncoming = collection != null && !isOwner; - final bool showSharingIndicator = isOutgoing || isIncoming; + final bool isTrashFile = file is TrashFile; + final bool showSharingIndicator = + !isTrashFile && (isOutgoing || isIncoming); + final isMarkedOffline = LockerDB.instance.isFileMarkedOffline(file); final fileRowWidget = Flexible( flex: 6, @@ -139,7 +144,7 @@ class FileListWidget extends StatelessWidget { Padding( padding: const EdgeInsets.only(right: 12.0), child: SizedBox( - width: 44, + width: 24, height: 24, child: AnimatedSwitcher( duration: const Duration(milliseconds: 300), @@ -154,18 +159,13 @@ class FileListWidget extends StatelessWidget { ], ); }, - child: isSelected - ? Icon( - key: const ValueKey("selected"), - Icons.check_circle_rounded, - color: colorScheme.primary700, - size: 24, - ) - : isIncoming - ? _buildOwnerAvatar(collection.owner) - : const SizedBox( - key: ValueKey("unselected"), - ), + child: _buildTrailingIndicator( + primaryColor: colorScheme.primary700, + isSelected: isSelected, + isIncoming: isIncoming, + isMarkedOffline: !isTrashFile && isMarkedOffline, + owner: collection?.owner, + ), ), ), ), @@ -206,4 +206,37 @@ class FileListWidget extends StatelessWidget { ), ); } + + Widget _buildTrailingIndicator({ + required Color primaryColor, + required bool isSelected, + required bool isIncoming, + required bool isMarkedOffline, + required User? owner, + }) { + if (isSelected) { + return Icon( + key: const ValueKey("selected"), + Icons.check_circle_rounded, + color: primaryColor, + size: 24, + ); + } + + if (isMarkedOffline) { + return HugeIcon( + key: const ValueKey("offline"), + icon: HugeIcons.strokeRoundedBookmark02, + color: primaryColor, + size: 20.0, + strokeWidth: 2.0, + ); + } + + if (isIncoming && owner != null) { + return _buildOwnerAvatar(owner); + } + + return const SizedBox(key: ValueKey("unselected")); + } } diff --git a/mobile/apps/locker/lib/ui/components/selection_action_button_widget.dart b/mobile/apps/locker/lib/ui/components/selection_action_button_widget.dart index 6e96551b094..72b7305c351 100644 --- a/mobile/apps/locker/lib/ui/components/selection_action_button_widget.dart +++ b/mobile/apps/locker/lib/ui/components/selection_action_button_widget.dart @@ -25,6 +25,13 @@ class SelectionActionButton extends StatelessWidget { final colorScheme = getEnteColorScheme(context); final textTheme = getEnteTextTheme(context); final color = isDestructive ? colorScheme.warning500 : colorScheme.textBase; + final iconWidget = + hugeIcon ?? + Icon( + icon!, + color: color, + size: 24, + ); return GestureDetector( onTap: onTap, @@ -38,7 +45,11 @@ class SelectionActionButton extends StatelessWidget { mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ - hugeIcon ?? Icon(icon!, color: color, size: 24), + SizedBox( + width: 28, + height: 28, + child: Center(child: iconWidget), + ), const SizedBox(height: 8), Text( label, diff --git a/mobile/apps/locker/lib/ui/viewer/actions/file_selection_overlay_bar.dart b/mobile/apps/locker/lib/ui/viewer/actions/file_selection_overlay_bar.dart index 9c43ee265ee..9da75ec5485 100644 --- a/mobile/apps/locker/lib/ui/viewer/actions/file_selection_overlay_bar.dart +++ b/mobile/apps/locker/lib/ui/viewer/actions/file_selection_overlay_bar.dart @@ -14,7 +14,9 @@ import "package:locker/services/collections/collections_service.dart"; import "package:locker/services/collections/models/collection.dart"; import "package:locker/services/collections/models/collection_view_type.dart"; import "package:locker/services/configuration.dart"; +import "package:locker/services/db/locker_db.dart"; import "package:locker/services/favorites_service.dart"; +import "package:locker/services/files/offline/offline_files_service.dart"; import "package:locker/services/files/sync/models/file.dart"; import "package:locker/services/trash/trash_service.dart"; import "package:locker/ui/components/add_to_collection_sheet.dart"; @@ -444,17 +446,17 @@ class _FileSelectionOverlayBarState extends State { borderRadius: BorderRadius.circular(24), ), child: Row( - children: _buildActionRow(actions), + children: _buildActionRow(actions, spacing: 0), ), ); } - List _buildActionRow(List actions) { + List _buildActionRow(List actions, {double spacing = 12}) { final children = []; for (var i = 0; i < actions.length; i++) { children.add(Expanded(child: actions[i])); - if (i != actions.length - 1) { - children.add(const SizedBox(width: 12)); + if (i != actions.length - 1 && spacing > 0) { + children.add(SizedBox(width: spacing)); } } return children; @@ -465,11 +467,15 @@ class _FileSelectionOverlayBarState extends State { final file = isSingleSelection ? selectedFiles.first : null; final files = selectedFiles.toList(); final viewType = widget.collectionViewType; + final colorScheme = getEnteColorScheme(context); final actions = []; + final eligibleOfflineFiles = + OfflineFilesService.instance.getEligibleFiles(files); final showEdit = viewType?.showEditOption ?? true; final showShare = viewType?.showShareOption ?? true; final showAddTo = viewType?.showAddToCollectionOption ?? true; + final showOffline = viewType?.showOfflineOption ?? true; if (isSingleSelection && showEdit) { actions.add( @@ -507,9 +513,56 @@ class _FileSelectionOverlayBarState extends State { ); } + if (showOffline) { + if (eligibleOfflineFiles.isEmpty) { + return actions; + } + + final shouldRemoveOffline = isSingleSelection && + eligibleOfflineFiles.length == 1 && + LockerDB.instance.isFileMarkedOffline(eligibleOfflineFiles.first); + + actions.add( + SelectionActionButton( + hugeIcon: HugeIcon( + icon: HugeIcons.strokeRoundedBookmark02, + color: colorScheme.textBase, + ), + label: shouldRemoveOffline + ? context.l10n.unsave + : context.l10n.saveOffline, + onTap: () => _toggleOfflineAvailability( + context, + files, + shouldRemoveOffline: shouldRemoveOffline, + ), + ), + ); + } + return actions; } + Future _toggleOfflineAvailability( + BuildContext context, + List files, { + required bool shouldRemoveOffline, + }) async { + final success = shouldRemoveOffline + ? await OfflineFilesService.instance.unmarkFilesOffline( + context, + files, + ) + : await OfflineFilesService.instance.markFilesOffline( + context, + files, + ); + + if (success) { + widget.selectedFiles.clearAll(); + } + } + Future _downloadFile(BuildContext context, EnteFile file) async { try { final success = await FileUtil.downloadFile(context, file); diff --git a/mobile/apps/locker/lib/utils/file_actions.dart b/mobile/apps/locker/lib/utils/file_actions.dart index b628ea8bd29..c82a5c952d8 100644 --- a/mobile/apps/locker/lib/utils/file_actions.dart +++ b/mobile/apps/locker/lib/utils/file_actions.dart @@ -174,12 +174,15 @@ class FileActions { } } - showToast(context, context.l10n.fileUpdatedSuccessfully); - await CollectionService.instance.sync(); - await dialog.hide(); + if (!context.mounted) { + return; + } + + showToast(context, context.l10n.fileUpdatedSuccessfully); + onSuccess?.call(); } catch (e) { await dialog.hide(); diff --git a/mobile/apps/locker/lib/utils/file_util.dart b/mobile/apps/locker/lib/utils/file_util.dart index b9a4a0916d8..dc4a33c9254 100644 --- a/mobile/apps/locker/lib/utils/file_util.dart +++ b/mobile/apps/locker/lib/utils/file_util.dart @@ -11,7 +11,9 @@ import "package:flutter/material.dart"; import "package:locker/l10n/l10n.dart"; import "package:locker/models/info/info_item.dart"; import "package:locker/services/collections/collections_service.dart"; -import "package:locker/services/files/download/file_downloader.dart"; +import "package:locker/services/files/download/file_downloader.dart" + as file_downloader; +import "package:locker/services/files/offline/offline_file_storage.dart"; import "package:locker/services/files/sync/models/file.dart"; import "package:locker/services/info_file_service.dart"; import "package:locker/ui/components/gradient_button.dart"; @@ -35,14 +37,14 @@ class FileUtil { if (file.localPath != null) { final localFile = File(file.localPath!); if (await localFile.exists()) { - await _launchFile(context, localFile, file.displayName); + await _launchFile(context, localFile); return; } } final cachedDecryptedFile = File(getCachedDecryptedFilePath(file)); if (await cachedDecryptedFile.exists()) { - await _launchFile(context, cachedDecryptedFile, file.displayName); + await _launchFile(context, cachedDecryptedFile); return; } @@ -55,27 +57,27 @@ class FileUtil { try { await dialog.show(); final fileKey = await CollectionService.instance.getFileKey(file); - final decryptedFile = await downloadAndDecrypt( + void progressCallback(int downloaded, int total) { + if (total > 0 && downloaded >= 0) { + final percentage = ((downloaded / total) * 100).clamp(0, 100).round(); + dialog.update( + message: context.l10n.downloadingProgress(percentage), + ); + } else { + dialog.update(message: context.l10n.downloading); + } + } + + final decryptedFile = await file_downloader.openFile( file, fileKey, - progressCallback: (downloaded, total) { - if (total > 0 && downloaded >= 0) { - final percentage = - ((downloaded / total) * 100).clamp(0, 100).round(); - dialog.update( - message: context.l10n.downloadingProgress(percentage), - ); - } else { - dialog.update(message: context.l10n.downloading); - } - }, - shouldUseCache: true, + progressCallback: progressCallback, ); await dialog.hide(); if (decryptedFile != null) { - await _launchFile(context, decryptedFile, file.displayName); + await _launchFile(context, decryptedFile); } else { await showAlertBottomSheet( context, @@ -227,7 +229,7 @@ class FileUtil { }) async { final fileKey = await CollectionService.instance.getFileKey(file); - final decryptedFile = await downloadAndDecrypt( + final decryptedFile = await file_downloader.openFile( file, fileKey, progressCallback: (downloaded, total) { @@ -239,7 +241,6 @@ class FileUtil { ); } }, - shouldUseCache: false, ); if (decryptedFile == null) { @@ -398,10 +399,12 @@ class FileUtil { static Future _launchFile( BuildContext context, File file, - String fileName, ) async { try { - await OpenFile.open(file.path); + final result = await OpenFile.open(file.path); + if (result.type != ResultType.done) { + throw Exception(result.message); + } } catch (e) { await showGenericErrorBottomSheet( context: context,