From 6ffe589ef438f213207c7de2ac6f5d666a227d75 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 27 Jun 2026 18:45:25 +0300 Subject: [PATCH 1/2] Add client media upload and cache pipeline --- app/lib/auth/auth_api_client.dart | 53 +++++ app/lib/auth/auth_repository.dart | 25 +++ app/lib/data/data_store.dart | 4 + app/lib/main.dart | 53 ++++- app/lib/media/media_cache_service.dart | 57 +++++ app/lib/media/media_models.dart | 98 +++++++++ app/lib/media/media_upload_queue.dart | 206 ++++++++++++++++++ app/lib/models/book.dart | 12 + app/lib/pages/book_details_page.dart | 29 ++- app/lib/pages/profile_page.dart | 2 + .../papyrus_powersync_connector.dart | 4 +- app/lib/powersync/papyrus_schema.dart | 2 + app/lib/powersync/powersync_book_mapper.dart | 6 + .../powersync/storage_sync_controller.dart | 11 +- app/lib/providers/auth_provider.dart | 4 + app/lib/providers/sync_settings_provider.dart | 4 +- app/lib/services/book_import_service.dart | 39 ++++ .../services/book_import_service_stub.dart | 15 ++ .../widgets/add_book/import_book_sheet.dart | 49 +++++ .../book_details/book_cover_image.dart | 34 ++- app/lib/widgets/book_details/book_header.dart | 14 +- app/pubspec.lock | 2 +- app/pubspec.yaml | 1 + app/test/auth/auth_api_client_test.dart | 102 +++++++++ app/test/media/media_cache_service_test.dart | 101 +++++++++ app/test/media/media_upload_queue_test.dart | 91 ++++++++ app/test/pages/profile_storage_sync_test.dart | 2 + .../powersync/powersync_book_mapper_test.dart | 8 + .../services/book_import_service_test.dart | 21 ++ app/web/book_worker.js | 16 ++ 30 files changed, 1054 insertions(+), 11 deletions(-) create mode 100644 app/lib/media/media_cache_service.dart create mode 100644 app/lib/media/media_models.dart create mode 100644 app/lib/media/media_upload_queue.dart create mode 100644 app/test/media/media_cache_service_test.dart create mode 100644 app/test/media/media_upload_queue_test.dart diff --git a/app/lib/auth/auth_api_client.dart b/app/lib/auth/auth_api_client.dart index 83b0fa4..a49777f 100644 --- a/app/lib/auth/auth_api_client.dart +++ b/app/lib/auth/auth_api_client.dart @@ -1,8 +1,11 @@ import 'dart:convert'; +import 'dart:typed_data'; import 'package:http/http.dart' as http; +import 'package:http_parser/http_parser.dart'; import 'package:papyrus/auth/auth_models.dart'; import 'package:papyrus/auth/papyrus_api_config.dart'; +import 'package:papyrus/media/media_models.dart'; class AuthApiException implements Exception { final int statusCode; @@ -151,6 +154,46 @@ class AuthApiClient { await _postJson(config.endpoint('/sync/powersync-upload'), accessToken: accessToken, body: {'batch': batch}); } + Future fetchMediaUsage(String accessToken) async { + final json = await _getJson(config.endpoint('/media/usage'), accessToken: accessToken); + return MediaStorageUsage.fromJson(json); + } + + Future uploadMedia(String accessToken, MediaUploadPayload payload) async { + final request = http.MultipartRequest('POST', config.endpoint('/media')) + ..headers.addAll(_authHeaders(accessToken)) + ..fields['book_id'] = payload.bookId + ..fields['kind'] = payload.kind.apiValue + ..files.add( + http.MultipartFile.fromBytes( + 'file', + payload.bytes, + filename: payload.filename, + contentType: _mediaType(payload.contentType), + ), + ); + + final response = await http.Response.fromStream(await _httpClient.send(request)); + return MediaAsset.fromJson(_decodeResponse(response)); + } + + Future downloadMedia(String accessToken, String assetId) async { + final response = await _httpClient.get(config.endpoint('/media/$assetId'), headers: _authHeaders(accessToken)); + if (response.statusCode >= 200 && response.statusCode < 300) { + return response.bodyBytes; + } + _decodeResponse(response); + throw const AuthApiException(statusCode: 0, message: 'Media download failed'); + } + + Future deleteMedia(String accessToken, String assetId) async { + final response = await _httpClient.delete(config.endpoint('/media/$assetId'), headers: _authHeaders(accessToken)); + if (response.statusCode >= 200 && response.statusCode < 300) { + return; + } + _decodeResponse(response); + } + Future> _getJson(Uri uri, {String? accessToken}) async { final response = await _httpClient.get(uri, headers: _headers(accessToken)); @@ -185,6 +228,16 @@ class AuthApiClient { }; } + Map _authHeaders(String accessToken) { + return {'Accept': 'application/json', 'Authorization': 'Bearer $accessToken'}; + } + + MediaType _mediaType(String contentType) { + final parts = contentType.split('/'); + if (parts.length != 2) return MediaType('application', 'octet-stream'); + return MediaType(parts[0], parts[1]); + } + Map _decodeResponse(http.Response response) { final decoded = response.body.isEmpty ? {} : jsonDecode(response.body) as Map; diff --git a/app/lib/auth/auth_repository.dart b/app/lib/auth/auth_repository.dart index aa67a36..ce990a9 100644 --- a/app/lib/auth/auth_repository.dart +++ b/app/lib/auth/auth_repository.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; import 'package:papyrus/auth/auth_api_client.dart'; import 'package:papyrus/auth/auth_models.dart'; +import 'package:papyrus/media/media_models.dart'; import 'package:papyrus/auth/token_store.dart'; import 'package:papyrus/platform/web_redirect.dart'; @@ -194,6 +195,30 @@ class AuthRepository { }); } + Future fetchMediaUsage() { + return _withFreshAccessToken((accessToken) { + return apiClient.fetchMediaUsage(accessToken); + }); + } + + Future uploadMedia(MediaUploadPayload payload) { + return _withFreshAccessToken((accessToken) { + return apiClient.uploadMedia(accessToken, payload); + }); + } + + Future downloadMedia(String assetId) { + return _withFreshAccessToken((accessToken) { + return apiClient.downloadMedia(accessToken, assetId); + }); + } + + Future deleteMedia(String assetId) { + return _withFreshAccessToken((accessToken) { + return apiClient.deleteMedia(accessToken, assetId); + }); + } + Future clearTokens() { return tokenStore.clear(); } diff --git a/app/lib/data/data_store.dart b/app/lib/data/data_store.dart index 4deb034..0a9a276 100644 --- a/app/lib/data/data_store.dart +++ b/app/lib/data/data_store.dart @@ -97,6 +97,8 @@ class DataStore extends ChangeNotifier { if (repository == null) { throw StateError('Book repository is not initialized'); } + _books[book.id] = book; + notifyListeners(); unawaited(repository.upsert(book)); } @@ -105,6 +107,8 @@ class DataStore extends ChangeNotifier { if (repository == null) { throw StateError('Book repository is not initialized'); } + _books[book.id] = book; + notifyListeners(); unawaited(repository.upsert(book)); } diff --git a/app/lib/main.dart b/app/lib/main.dart index 6cad691..0b6f945 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -7,6 +7,9 @@ import 'package:papyrus/auth/auth_repository.dart'; import 'package:papyrus/auth/papyrus_api_config.dart'; import 'package:papyrus/auth/token_store.dart'; import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/media/media_cache_service.dart'; +import 'package:papyrus/media/media_models.dart'; +import 'package:papyrus/media/media_upload_queue.dart'; import 'package:papyrus/powersync/powersync_service.dart'; import 'package:papyrus/powersync/papyrus_powersync_connector.dart'; import 'package:papyrus/powersync/sync_state.dart'; @@ -14,6 +17,8 @@ import 'package:papyrus/providers/auth_provider.dart'; import 'package:papyrus/providers/library_provider.dart'; import 'package:papyrus/providers/preferences_provider.dart'; import 'package:papyrus/providers/sync_settings_provider.dart'; +import 'package:papyrus/services/book_import_service_stub.dart' + if (dart.library.js_interop) 'package:papyrus/services/book_import_service.dart'; import 'package:papyrus/providers/sidebar_provider.dart'; import 'package:papyrus/themes/app_theme.dart'; import 'package:provider/provider.dart'; @@ -42,6 +47,8 @@ class _PapyrusState extends State { late final DataStore _dataStore; late final AuthProvider _authProvider; late final SyncSettingsProvider _syncSettingsProvider; + late final MediaUploadQueue _mediaUploadQueue; + late final BookImportService _bookImportService; late final PapyrusPowerSyncService _powerSyncService; late final PapyrusApiConfig _officialApiConfig; late AuthRepository _authRepository; @@ -59,10 +66,15 @@ class _PapyrusState extends State { _authRepository = _buildAuthRepository(_syncSettingsProvider.activeApiConfig, _activeProfileKey); _dataStore = DataStore(); + _mediaUploadQueue = MediaUploadQueue(widget.prefs); + _bookImportService = BookImportService(); _authProvider = AuthProvider(widget.prefs, repository: _authRepository); _powerSyncService = PapyrusPowerSyncService( - connectorFactory: () => - PapyrusPowerSyncConnector(authRepository: _authRepository, config: _syncSettingsProvider.activeApiConfig), + connectorFactory: () => PapyrusPowerSyncConnector( + authRepository: _authRepository, + config: _syncSettingsProvider.activeApiConfig, + onUploadComplete: _processMediaUploads, + ), ); unawaited(_dataStore.attachBookRepository(_powerSyncService)); _appRouter = AppRouter(authProvider: _authProvider); @@ -76,6 +88,7 @@ class _PapyrusState extends State { _authProvider.removeListener(_syncPowerSyncAuthState); _syncSettingsProvider.removeListener(_handleSyncSettingsChanged); unawaited(_disposeDataServices()); + _bookImportService.dispose(); _authProvider.dispose(); _syncSettingsProvider.dispose(); super.dispose(); @@ -99,6 +112,8 @@ class _PapyrusState extends State { if (user != null && !_authProvider.isOfflineMode) { final userId = user.userId; unawaited(_powerSyncService.activateAuthenticated(userId, profileKey: _activeProfileKey)); + unawaited(_refreshMediaUsage()); + unawaited(_processMediaUploads()); return; } @@ -128,20 +143,52 @@ class _PapyrusState extends State { await _powerSyncService.deactivate(clearAuthenticated: false); _authRepository = _buildAuthRepository(_syncSettingsProvider.activeApiConfig, _activeProfileKey); await _authProvider.replaceRepository(_authRepository, bootstrapNewRepository: !_authProvider.isOfflineMode); + unawaited(_refreshMediaUsage()); } finally { _switchingSyncProfile = false; _syncPowerSyncAuthState(); } } + Future _refreshMediaUsage() async { + if (!_authProvider.isSignedIn || _authProvider.isOfflineMode) return; + try { + await _mediaUploadQueue.refreshUsage(_authRepository.fetchMediaUsage); + } catch (_) { + // Usage is informational; failed refresh must not block data sync. + } + } + + Future _processMediaUploads() async { + if (!_authProvider.isSignedIn || _authProvider.isOfflineMode) return; + await _mediaUploadQueue.processPending( + dataStore: _dataStore, + readBookFile: _bookImportService.getBookFile, + uploadMedia: (payload) async { + try { + return await _authRepository.uploadMedia(payload); + } on AuthApiException catch (error) { + if (error.statusCode == 409) { + throw const MediaUploadException.storageFull(); + } + rethrow; + } + }, + ); + await _refreshMediaUsage(); + } + @override Widget build(BuildContext context) { return MultiProvider( providers: [ // Core data store - single source of truth ChangeNotifierProvider.value(value: _dataStore), + ChangeNotifierProvider.value(value: _mediaUploadQueue), ChangeNotifierProvider.value(value: _syncSettingsProvider), Provider.value(value: _powerSyncService), + Provider.value(value: _bookImportService), + Provider(create: _createMediaCacheService), StreamProvider.value(value: _powerSyncService.syncStates, initialData: _powerSyncService.syncState), // Auth and UI state providers ChangeNotifierProvider.value(value: _authProvider), @@ -165,3 +212,5 @@ class _PapyrusState extends State { ); } } + +MediaCacheService _createMediaCacheService(BuildContext _) => const MediaCacheService(); diff --git a/app/lib/media/media_cache_service.dart b/app/lib/media/media_cache_service.dart new file mode 100644 index 0000000..09640c1 --- /dev/null +++ b/app/lib/media/media_cache_service.dart @@ -0,0 +1,57 @@ +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:papyrus/models/book.dart'; + +typedef LocalBookFileReader = Future Function(String bookId); +typedef LocalBookFileWriter = Future Function(String bookId, String extension, Uint8List bytes); +typedef MediaDownloader = Future Function(String assetId); + +/// Coordinates lazy download and platform-local caching for private media. +class MediaCacheService { + const MediaCacheService(); + + /// Returns a cached book file when present and, if the book has a stored + /// hash, the bytes match the expected hash. + Future getValidCachedBookFile(Book book, {required LocalBookFileReader readLocalBookFile}) async { + final cached = await readLocalBookFile(book.id); + if (cached == null) return null; + return _matchesExpectedHash(cached, book.fileHash) ? cached : null; + } + + /// Returns local book bytes, downloading and caching private server media + /// when needed. + Future ensureBookFileCached( + Book book, { + required LocalBookFileReader readLocalBookFile, + required LocalBookFileWriter writeLocalBookFile, + required MediaDownloader downloadMedia, + }) async { + final cached = await getValidCachedBookFile(book, readLocalBookFile: readLocalBookFile); + if (cached != null) return cached; + + final mediaId = book.fileMediaId; + if (mediaId == null || mediaId.isEmpty) { + throw StateError('Book file is not available on this device or server.'); + } + + final downloaded = await downloadMedia(mediaId); + if (!_matchesExpectedHash(downloaded, book.fileHash)) { + throw StateError('Downloaded book file did not match the expected hash.'); + } + + await writeLocalBookFile(book.id, _extensionFor(book), downloaded); + return downloaded; + } + + String sha256Hex(Uint8List bytes) => sha256.convert(bytes).toString(); + + bool _matchesExpectedHash(Uint8List bytes, String? expectedHash) { + if (expectedHash == null || expectedHash.isEmpty) return true; + return sha256Hex(bytes) == expectedHash; + } + + String _extensionFor(Book book) { + return book.fileFormat?.name ?? 'bin'; + } +} diff --git a/app/lib/media/media_models.dart b/app/lib/media/media_models.dart new file mode 100644 index 0000000..2e249c3 --- /dev/null +++ b/app/lib/media/media_models.dart @@ -0,0 +1,98 @@ +import 'dart:typed_data'; + +enum MediaKind { + bookFile('book_file'), + coverImage('cover_image'); + + const MediaKind(this.apiValue); + + final String apiValue; + + static MediaKind fromApiValue(String value) { + return MediaKind.values.firstWhere((kind) => kind.apiValue == value); + } +} + +class MediaUploadPayload { + const MediaUploadPayload({ + required this.bookId, + required this.kind, + required this.filename, + required this.contentType, + required this.bytes, + }); + + final String bookId; + final MediaKind kind; + final String filename; + final String contentType; + final Uint8List bytes; +} + +class MediaAsset { + const MediaAsset({ + required this.assetId, + required this.ownerUserId, + required this.bookId, + required this.kind, + required this.originalFilename, + required this.contentType, + required this.extension, + required this.sizeBytes, + required this.sha256, + required this.storagePath, + }); + + final String assetId; + final String ownerUserId; + final String bookId; + final MediaKind kind; + final String originalFilename; + final String contentType; + final String extension; + final int sizeBytes; + final String sha256; + final String storagePath; + + factory MediaAsset.fromJson(Map json) { + return MediaAsset( + assetId: json['asset_id'] as String, + ownerUserId: json['owner_user_id'] as String, + bookId: json['book_id'] as String, + kind: MediaKind.fromApiValue(json['kind'] as String), + originalFilename: json['original_filename'] as String, + contentType: json['content_type'] as String, + extension: json['extension'] as String, + sizeBytes: json['size_bytes'] as int, + sha256: json['sha256'] as String, + storagePath: json['storage_path'] as String, + ); + } +} + +class MediaStorageUsage { + const MediaStorageUsage({required this.usedBytes, required this.quotaBytes, required this.availableBytes}); + + final int usedBytes; + final int quotaBytes; + final int availableBytes; + + factory MediaStorageUsage.fromJson(Map json) { + return MediaStorageUsage( + usedBytes: json['used_bytes'] as int, + quotaBytes: json['quota_bytes'] as int, + availableBytes: json['available_bytes'] as int, + ); + } +} + +class MediaUploadException implements Exception { + const MediaUploadException(this.message, {this.storageFull = false}); + const MediaUploadException.storageFull() : this('Storage full', storageFull: true); + + final String message; + final bool storageFull; + + @override + String toString() => message; +} diff --git a/app/lib/media/media_upload_queue.dart b/app/lib/media/media_upload_queue.dart new file mode 100644 index 0000000..87e44f0 --- /dev/null +++ b/app/lib/media/media_upload_queue.dart @@ -0,0 +1,206 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/media/media_models.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +typedef BookFileReader = Future Function(String bookId); +typedef MediaUploader = Future Function(MediaUploadPayload payload); + +enum MediaUploadTaskStatus { pending, failed } + +class MediaUploadTask { + const MediaUploadTask({ + required this.id, + required this.bookId, + required this.kind, + required this.filename, + required this.contentType, + required this.status, + this.coverBase64, + this.errorMessage, + }); + + final String id; + final String bookId; + final MediaKind kind; + final String filename; + final String contentType; + final MediaUploadTaskStatus status; + final String? coverBase64; + final String? errorMessage; + + MediaUploadTask copyWith({MediaUploadTaskStatus? status, String? errorMessage}) { + return MediaUploadTask( + id: id, + bookId: bookId, + kind: kind, + filename: filename, + contentType: contentType, + status: status ?? this.status, + coverBase64: coverBase64, + errorMessage: errorMessage, + ); + } + + Map toJson() { + return { + 'id': id, + 'book_id': bookId, + 'kind': kind.apiValue, + 'filename': filename, + 'content_type': contentType, + 'status': status.name, + 'cover_base64': coverBase64, + 'error_message': errorMessage, + }; + } + + factory MediaUploadTask.fromJson(Map json) { + return MediaUploadTask( + id: json['id'] as String, + bookId: json['book_id'] as String, + kind: MediaKind.fromApiValue(json['kind'] as String), + filename: json['filename'] as String, + contentType: json['content_type'] as String, + status: MediaUploadTaskStatus.values.byName(json['status'] as String? ?? MediaUploadTaskStatus.pending.name), + coverBase64: json['cover_base64'] as String?, + errorMessage: json['error_message'] as String?, + ); + } +} + +class MediaUploadQueue extends ChangeNotifier { + MediaUploadQueue(this._prefs) { + _tasks = _loadTasks(); + } + + static const _storageKey = 'media_upload_queue'; + + final SharedPreferences _prefs; + late List _tasks; + MediaStorageUsage? _storageUsage; + + List get pendingTasks => List.unmodifiable(_tasks); + MediaStorageUsage? get storageUsage => _storageUsage; + + Future refreshUsage(Future Function() fetchUsage) async { + _storageUsage = await fetchUsage(); + notifyListeners(); + } + + Future enqueueBookFile({required Book book, required String filename, required String contentType}) { + return _enqueue( + MediaUploadTask( + id: '${book.id}:book_file', + bookId: book.id, + kind: MediaKind.bookFile, + filename: filename, + contentType: contentType, + status: MediaUploadTaskStatus.pending, + ), + ); + } + + Future enqueueCover({ + required Book book, + required String filename, + required String contentType, + required Uint8List bytes, + }) { + return _enqueue( + MediaUploadTask( + id: '${book.id}:cover_image', + bookId: book.id, + kind: MediaKind.coverImage, + filename: filename, + contentType: contentType, + status: MediaUploadTaskStatus.pending, + coverBase64: base64Encode(bytes), + ), + ); + } + + Future processPending({ + required DataStore dataStore, + required BookFileReader readBookFile, + required MediaUploader uploadMedia, + }) async { + final nextTasks = []; + for (final task in _tasks) { + if (task.status == MediaUploadTaskStatus.failed) { + nextTasks.add(task); + continue; + } + + final bytes = await _bytesForTask(task, readBookFile); + if (bytes == null) { + nextTasks.add(task.copyWith(status: MediaUploadTaskStatus.pending, errorMessage: 'Local file not found')); + continue; + } + + try { + final asset = await uploadMedia( + MediaUploadPayload( + bookId: task.bookId, + kind: task.kind, + filename: task.filename, + contentType: task.contentType, + bytes: bytes, + ), + ); + _applyUploadedAsset(dataStore, asset); + } on MediaUploadException catch (error) { + nextTasks.add( + task.copyWith( + status: error.storageFull ? MediaUploadTaskStatus.failed : MediaUploadTaskStatus.pending, + errorMessage: error.message, + ), + ); + } catch (error) { + nextTasks.add(task.copyWith(status: MediaUploadTaskStatus.pending, errorMessage: error.toString())); + } + } + _tasks = nextTasks; + await _save(); + notifyListeners(); + } + + Future _enqueue(MediaUploadTask task) async { + _tasks = [..._tasks.where((existing) => existing.id != task.id), task]; + await _save(); + notifyListeners(); + } + + Future _bytesForTask(MediaUploadTask task, BookFileReader readBookFile) async { + if (task.kind == MediaKind.coverImage) { + final coverBase64 = task.coverBase64; + return coverBase64 == null ? null : base64Decode(coverBase64); + } + return readBookFile(task.bookId); + } + + void _applyUploadedAsset(DataStore dataStore, MediaAsset asset) { + final book = dataStore.getBook(asset.bookId); + if (book == null) return; + + if (asset.kind == MediaKind.bookFile) { + dataStore.updateBook(book.copyWith(fileMediaId: asset.assetId)); + return; + } + dataStore.updateBook(book.copyWith(coverMediaId: asset.assetId, clearCoverUrl: true)); + } + + List _loadTasks() { + final raw = _prefs.getString(_storageKey); + if (raw == null || raw.isEmpty) return []; + final decoded = jsonDecode(raw) as List; + return decoded.map((item) => MediaUploadTask.fromJson(item as Map)).toList(growable: false); + } + + Future _save() { + return _prefs.setString(_storageKey, jsonEncode(_tasks.map((task) => task.toJson()).toList())); + } +} diff --git a/app/lib/models/book.dart b/app/lib/models/book.dart index 8da3fa8..4211255 100644 --- a/app/lib/models/book.dart +++ b/app/lib/models/book.dart @@ -74,6 +74,8 @@ class Book { final int? pageCount; final String? description; final String? coverUrl; + final String? fileMediaId; + final String? coverMediaId; // Digital book fields final String? filePath; @@ -123,6 +125,8 @@ class Book { this.pageCount, this.description, this.coverUrl, + this.fileMediaId, + this.coverMediaId, this.filePath, this.fileFormat, this.fileSize, @@ -203,6 +207,8 @@ class Book { String? description, String? coverUrl, bool clearCoverUrl = false, + String? fileMediaId, + String? coverMediaId, String? filePath, BookFormat? fileFormat, int? fileSize, @@ -240,6 +246,8 @@ class Book { pageCount: pageCount ?? this.pageCount, description: description ?? this.description, coverUrl: clearCoverUrl ? null : (coverUrl ?? this.coverUrl), + fileMediaId: fileMediaId ?? this.fileMediaId, + coverMediaId: coverMediaId ?? this.coverMediaId, filePath: filePath ?? this.filePath, fileFormat: fileFormat ?? this.fileFormat, fileSize: fileSize ?? this.fileSize, @@ -281,6 +289,8 @@ class Book { 'page_count': pageCount, 'description': description, 'cover_image_url': coverUrl, + 'file_media_id': fileMediaId, + 'cover_media_id': coverMediaId, 'file_path': filePath, 'file_format': fileFormat?.name, 'file_size': fileSize, @@ -322,6 +332,8 @@ class Book { pageCount: json['page_count'] as int?, description: json['description'] as String?, coverUrl: json['cover_image_url'] as String?, + fileMediaId: json['file_media_id'] as String?, + coverMediaId: json['cover_media_id'] as String?, filePath: json['file_path'] as String?, fileFormat: json['file_format'] != null ? BookFormat.values.byName(json['file_format'] as String) : null, fileSize: json['file_size'] as int?, diff --git a/app/lib/pages/book_details_page.dart b/app/lib/pages/book_details_page.dart index 0437135..f6283de 100644 --- a/app/lib/pages/book_details_page.dart +++ b/app/lib/pages/book_details_page.dart @@ -1,10 +1,14 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/media/media_cache_service.dart'; import 'package:papyrus/models/annotation.dart'; import 'package:papyrus/models/bookmark.dart'; import 'package:papyrus/models/note.dart'; +import 'package:papyrus/providers/auth_provider.dart'; import 'package:papyrus/providers/book_details_provider.dart'; +import 'package:papyrus/services/book_import_service_stub.dart' + if (dart.library.js_interop) 'package:papyrus/services/book_import_service.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/book/book_annotations.dart'; import 'package:papyrus/widgets/book/book_bookmarks.dart'; @@ -345,7 +349,30 @@ class _BookDetailsPageState extends State with SingleTickerProv ); } - void _onContinueReading() { + Future _onContinueReading() async { + final book = _provider.book; + if (book == null) return; + + if (book.fileMediaId != null) { + final messenger = ScaffoldMessenger.of(context); + messenger.showSnackBar(const SnackBar(content: Text('Preparing book file...'))); + + try { + final importService = context.read(); + await context.read().ensureBookFileCached( + book, + readLocalBookFile: importService.getBookFile, + writeLocalBookFile: importService.storeBookFile, + downloadMedia: context.read().downloadMedia, + ); + } catch (_) { + if (!mounted) return; + messenger.showSnackBar(const SnackBar(content: Text('Could not download this book file.'))); + return; + } + } + + if (!mounted) return; // TODO: Navigate to reader ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Opening book reader...'))); } diff --git a/app/lib/pages/profile_page.dart b/app/lib/pages/profile_page.dart index df0a6a2..01d4cf1 100644 --- a/app/lib/pages/profile_page.dart +++ b/app/lib/pages/profile_page.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/media/media_upload_queue.dart'; import 'package:papyrus/powersync/powersync_service.dart'; import 'package:papyrus/powersync/storage_sync_controller.dart'; import 'package:papyrus/providers/auth_provider.dart'; @@ -1017,6 +1018,7 @@ class _ProfilePageState extends State { syncSettings: context.watch(), syncState: context.watch(), fileStorageUsedBytes: _fileStorageUsedBytes(context.watch()), + mediaStorageUsage: context.watch().storageUsage, ); } diff --git a/app/lib/powersync/papyrus_powersync_connector.dart b/app/lib/powersync/papyrus_powersync_connector.dart index 43c0325..de0ddc5 100644 --- a/app/lib/powersync/papyrus_powersync_connector.dart +++ b/app/lib/powersync/papyrus_powersync_connector.dart @@ -7,8 +7,9 @@ import 'package:powersync/powersync.dart'; class PapyrusPowerSyncConnector extends PowerSyncBackendConnector { final AuthRepository authRepository; final PapyrusApiConfig config; + final Future Function()? onUploadComplete; - PapyrusPowerSyncConnector({required this.authRepository, required this.config}); + PapyrusPowerSyncConnector({required this.authRepository, required this.config, this.onUploadComplete}); @override Future fetchCredentials() async { @@ -47,6 +48,7 @@ class PapyrusPowerSyncConnector extends PowerSyncBackendConnector { await authRepository.uploadPowerSyncBatch(batch); await transaction.complete(); + await onUploadComplete?.call(); } } } diff --git a/app/lib/powersync/papyrus_schema.dart b/app/lib/powersync/papyrus_schema.dart index a289a00..84a77ee 100644 --- a/app/lib/powersync/papyrus_schema.dart +++ b/app/lib/powersync/papyrus_schema.dart @@ -13,6 +13,8 @@ const _bookColumns = [ Column.integer('page_count'), Column.text('description'), Column.text('cover_image_url'), + Column.text('file_media_id'), + Column.text('cover_media_id'), Column.text('reading_status'), Column.integer('current_page'), Column.real('current_position'), diff --git a/app/lib/powersync/powersync_book_mapper.dart b/app/lib/powersync/powersync_book_mapper.dart index ce079ba..3483506 100644 --- a/app/lib/powersync/powersync_book_mapper.dart +++ b/app/lib/powersync/powersync_book_mapper.dart @@ -14,6 +14,8 @@ const syncedBookColumns = [ 'page_count', 'description', 'cover_image_url', + 'file_media_id', + 'cover_media_id', 'reading_status', 'current_page', 'current_position', @@ -43,6 +45,8 @@ class PowerSyncBookMapper { pageCount: _toInt(row['page_count']), description: row['description'] as String?, coverUrl: row['cover_image_url'] as String?, + fileMediaId: row['file_media_id'] as String?, + coverMediaId: row['cover_media_id'] as String?, fileFormat: _bookFormat(metadata['file_format']), fileSize: _toInt(metadata['file_size']), fileHash: metadata['file_hash'] as String?, @@ -100,6 +104,8 @@ class PowerSyncBookMapper { 'page_count': book.pageCount, 'description': book.description, 'cover_image_url': _remoteCoverUrl(book.coverUrl), + 'file_media_id': book.fileMediaId, + 'cover_media_id': book.coverMediaId, 'reading_status': book.readingStatus.name, 'current_page': book.currentPage, 'current_position': book.currentPosition, diff --git a/app/lib/powersync/storage_sync_controller.dart b/app/lib/powersync/storage_sync_controller.dart index aa35b62..c273852 100644 --- a/app/lib/powersync/storage_sync_controller.dart +++ b/app/lib/powersync/storage_sync_controller.dart @@ -2,6 +2,7 @@ import 'package:papyrus/powersync/powersync_service.dart'; import 'package:papyrus/powersync/sync_state.dart'; import 'package:papyrus/providers/auth_provider.dart'; import 'package:papyrus/providers/sync_settings_provider.dart'; +import 'package:papyrus/media/media_models.dart'; class StorageSyncController { StorageSyncController({ @@ -10,6 +11,7 @@ class StorageSyncController { required this.syncSettings, required this.syncState, required this.fileStorageUsedBytes, + this.mediaStorageUsage, }); final AuthProvider authProvider; @@ -17,6 +19,7 @@ class StorageSyncController { final SyncSettingsProvider syncSettings; final SyncState syncState; final int fileStorageUsedBytes; + final MediaStorageUsage? mediaStorageUsage; LibraryDatabaseMode? get databaseMode => powerSyncService.mode; @@ -54,7 +57,13 @@ class StorageSyncController { bool get shouldShowServerSettings => !isGuest; - String get fileStorageLabel => syncSettings.fileStorageLabel(usedBytes: fileStorageUsedBytes); + String get fileStorageLabel { + final usage = mediaStorageUsage; + if (isAuthenticated && usage != null) { + return syncSettings.fileStorageLabel(usedBytes: usage.usedBytes, quotaBytesOverride: usage.quotaBytes); + } + return syncSettings.fileStorageLabel(usedBytes: fileStorageUsedBytes); + } String get statusLabel { if (isGuest) return 'Guest local'; diff --git a/app/lib/providers/auth_provider.dart b/app/lib/providers/auth_provider.dart index dfe7d47..a692c7d 100644 --- a/app/lib/providers/auth_provider.dart +++ b/app/lib/providers/auth_provider.dart @@ -181,6 +181,10 @@ class AuthProvider extends ChangeNotifier { return _runMessageAction(() => _repository.resendVerification(email)); } + Future downloadMedia(String assetId) { + return _repository.downloadMedia(assetId); + } + void setOfflineMode(bool value) { _isOfflineMode = value; _prefs.setBool(_keyOfflineMode, value); diff --git a/app/lib/providers/sync_settings_provider.dart b/app/lib/providers/sync_settings_provider.dart index c837d26..34a30dd 100644 --- a/app/lib/providers/sync_settings_provider.dart +++ b/app/lib/providers/sync_settings_provider.dart @@ -141,8 +141,8 @@ class SyncSettingsProvider extends ChangeNotifier { return activeCustomServer?.fileStorageQuotaBytes ?? officialFileStorageQuotaBytes; } - String fileStorageLabel({required int usedBytes}) { - final quotaBytes = fileStorageQuotaBytes; + String fileStorageLabel({required int usedBytes, int? quotaBytesOverride}) { + final quotaBytes = quotaBytesOverride ?? fileStorageQuotaBytes; if (quotaBytes == null) return '${_formatBytes(usedBytes)} used'; final availableBytes = quotaBytes > usedBytes ? quotaBytes - usedBytes : 0; diff --git a/app/lib/services/book_import_service.dart b/app/lib/services/book_import_service.dart index 3dd9d87..31d3bf8 100644 --- a/app/lib/services/book_import_service.dart +++ b/app/lib/services/book_import_service.dart @@ -212,6 +212,45 @@ class BookImportService { return (fileDataJs as JSArrayBuffer).toDart.asUint8List(); } + /// Stores raw book bytes in OPFS for [bookId]. + /// + /// Throws [UnsupportedError] when called on non-web platforms. + Future storeBookFile(String bookId, String extension, Uint8List bytes) async { + if (!kIsWeb) { + throw UnsupportedError('BookImportService is only supported on web.'); + } + + final normalizedExtension = extension.toLowerCase().replaceFirst('.', ''); + if (normalizedExtension.isEmpty) { + throw ArgumentError('Book file extension cannot be empty.'); + } + + final completer = Completer(); + final worker = _getWorker(); + + _pending['storeFile:$bookId'] = completer; + + final actualBytes = bytes.offsetInBytes == 0 && bytes.lengthInBytes == bytes.buffer.lengthInBytes + ? bytes + : Uint8List.fromList(bytes); + final jsBuffer = actualBytes.buffer.toJS; + final message = JSObject(); + message['type'] = 'storeFile'.toJS; + message['format'] = normalizedExtension.toJS; + message['bookId'] = bookId.toJS; + message['fileData'] = jsBuffer; + + worker.postMessage(message, [jsBuffer].toJS); + + await completer.future.timeout( + _timeout, + onTimeout: () { + _pending.remove('storeFile:$bookId'); + throw TimeoutException('Store file timed out after ${_timeout.inSeconds}s', _timeout); + }, + ); + } + /// Terminates the Web Worker and releases resources. void dispose() { _worker?.terminate(); diff --git a/app/lib/services/book_import_service_stub.dart b/app/lib/services/book_import_service_stub.dart index 27beb47..06f39da 100644 --- a/app/lib/services/book_import_service_stub.dart +++ b/app/lib/services/book_import_service_stub.dart @@ -86,6 +86,21 @@ class BookImportService { return null; } + /// Stores raw book bytes under the app-local book cache for [bookId]. + /// + /// Used when a signed-in device lazily downloads a book file from the + /// selected server. + Future storeBookFile(String bookId, String extension, Uint8List bytes) async { + final normalizedExtension = extension.toLowerCase().replaceFirst('.', ''); + if (normalizedExtension.isEmpty) { + throw ArgumentError('Book file extension cannot be empty.'); + } + await deleteBookFile(bookId); + final booksDir = await _getBooksDirectory(); + final file = File(p.join(booksDir.path, '$bookId.$normalizedExtension')); + await file.writeAsBytes(bytes); + } + /// No-op on native — no worker to terminate. void dispose() {} diff --git a/app/lib/widgets/add_book/import_book_sheet.dart b/app/lib/widgets/add_book/import_book_sheet.dart index 7693e5b..26f4770 100644 --- a/app/lib/widgets/add_book/import_book_sheet.dart +++ b/app/lib/widgets/add_book/import_book_sheet.dart @@ -4,7 +4,11 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/media/media_upload_queue.dart'; import 'package:papyrus/models/book.dart'; +import 'package:papyrus/powersync/powersync_service.dart'; +import 'package:papyrus/powersync/sync_state.dart'; +import 'package:papyrus/providers/auth_provider.dart'; import 'package:papyrus/services/book_import_service_stub.dart' if (dart.library.js_interop) 'package:papyrus/services/book_import_service.dart'; import 'package:papyrus/themes/design_tokens.dart'; @@ -190,12 +194,57 @@ class _ImportContentState extends State<_ImportContent> { ); dataStore.addBook(book); + unawaited(_enqueueOnlineMediaUploads(book, result)); final messenger = ScaffoldMessenger.of(context); Navigator.of(context).pop(); messenger.showSnackBar(SnackBar(content: Text('Added "${book.title}" to library'))); } + Future _enqueueOnlineMediaUploads(Book book, BookImportResult result) async { + final isOnlineAccount = + context.read().isSignedIn && + context.read().mode == LibraryDatabaseMode.authenticated; + if (!isOnlineAccount) return; + + final queue = context.read(); + await queue.enqueueBookFile( + book: book, + filename: _filename ?? '${book.id}.${result.fileExtension}', + contentType: _contentTypeForExtension(result.fileExtension), + ); + + final coverImage = result.coverImage; + if (coverImage != null) { + await queue.enqueueCover( + book: book, + filename: '${book.id}-cover.${_coverExtension(result.coverMimeType)}', + contentType: result.coverMimeType ?? 'image/jpeg', + bytes: coverImage, + ); + } + } + + String _contentTypeForExtension(String extension) { + return switch (extension) { + 'epub' => 'application/epub+zip', + 'pdf' => 'application/pdf', + 'txt' => 'text/plain', + 'cbz' => 'application/vnd.comicbook+zip', + 'cbr' => 'application/vnd.comicbook-rar', + _ => 'application/octet-stream', + }; + } + + String _coverExtension(String? contentType) { + return switch (contentType) { + 'image/png' => 'png', + 'image/webp' => 'webp', + 'image/gif' => 'gif', + _ => 'jpg', + }; + } + @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; diff --git a/app/lib/widgets/book_details/book_cover_image.dart b/app/lib/widgets/book_details/book_cover_image.dart index 54aa71f..9a77c59 100644 --- a/app/lib/widgets/book_details/book_cover_image.dart +++ b/app/lib/widgets/book_details/book_cover_image.dart @@ -1,6 +1,12 @@ +import 'dart:typed_data'; + import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:papyrus/providers/auth_provider.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:provider/provider.dart'; + +final Map> _privateCoverDownloads = {}; /// Cover image size variants. enum BookCoverSize { @@ -20,10 +26,11 @@ enum BookCoverSize { /// Book cover image widget with size variants and placeholder. class BookCoverImage extends StatelessWidget { final String? imageUrl; + final String? mediaId; final String? bookTitle; final BookCoverSize size; - const BookCoverImage({super.key, this.imageUrl, this.bookTitle, this.size = BookCoverSize.medium}); + const BookCoverImage({super.key, this.imageUrl, this.mediaId, this.bookTitle, this.size = BookCoverSize.medium}); @override Widget build(BuildContext context) { @@ -52,6 +59,24 @@ class BookCoverImage extends StatelessWidget { progressIndicatorBuilder: (context, url, progress) => _buildLoadingIndicator(context, colorScheme, progress), ); } + if (mediaId != null && mediaId!.isNotEmpty) { + final future = _privateCoverDownloads.putIfAbsent( + mediaId!, + () => context.read().downloadMedia(mediaId!), + ); + return FutureBuilder( + future: future, + builder: (context, snapshot) { + if (snapshot.hasData) { + return Image.memory(snapshot.data!, fit: BoxFit.cover); + } + if (snapshot.hasError) { + return _buildPlaceholder(context, colorScheme); + } + return _buildIndeterminateLoadingIndicator(colorScheme); + }, + ); + } return _buildPlaceholder(context, colorScheme); } @@ -96,6 +121,13 @@ class BookCoverImage extends StatelessWidget { ); } + Widget _buildIndeterminateLoadingIndicator(ColorScheme colorScheme) { + return Container( + color: colorScheme.surfaceContainerHighest, + child: const Center(child: CircularProgressIndicator(strokeWidth: 2)), + ); + } + _CoverDimensions _getDimensions() { switch (size) { case BookCoverSize.large: diff --git a/app/lib/widgets/book_details/book_header.dart b/app/lib/widgets/book_details/book_header.dart index e94b8a3..dd68e95 100644 --- a/app/lib/widgets/book_details/book_header.dart +++ b/app/lib/widgets/book_details/book_header.dart @@ -40,7 +40,12 @@ class BookHeader extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ // Cover image - BookCoverImage(imageUrl: book.coverURL, bookTitle: book.title, size: BookCoverSize.large), + BookCoverImage( + imageUrl: book.coverURL, + mediaId: book.coverMediaId, + bookTitle: book.title, + size: BookCoverSize.large, + ), const SizedBox(width: Spacing.xl), // Book info @@ -111,7 +116,12 @@ class BookHeader extends StatelessWidget { const SizedBox(height: Spacing.lg), // Cover image (centered) - BookCoverImage(imageUrl: book.coverURL, bookTitle: book.title, size: BookCoverSize.medium), + BookCoverImage( + imageUrl: book.coverURL, + mediaId: book.coverMediaId, + bookTitle: book.title, + size: BookCoverSize.medium, + ), const SizedBox(height: Spacing.md), // Title (centered) diff --git a/app/pubspec.lock b/app/pubspec.lock index a69813f..eafdf66 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -371,7 +371,7 @@ packages: source: hosted version: "1.6.0" http_parser: - dependency: transitive + dependency: "direct main" description: name: http_parser sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index d691ec2..f811203 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: fl_chart: ^0.69.0 intl: ^0.19.0 http: ^1.2.0 + http_parser: ^4.0.2 file_picker: ^8.0.0+1 cached_network_image: ^3.3.1 google_fonts: ^6.2.1 diff --git a/app/test/auth/auth_api_client_test.dart b/app/test/auth/auth_api_client_test.dart index 5242866..9c44906 100644 --- a/app/test/auth/auth_api_client_test.dart +++ b/app/test/auth/auth_api_client_test.dart @@ -1,10 +1,12 @@ import 'dart:convert'; +import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:papyrus/auth/auth_api_client.dart'; import 'package:papyrus/auth/papyrus_api_config.dart'; +import 'package:papyrus/media/media_models.dart'; const _authResponse = { 'access_token': 'access-token', @@ -143,4 +145,104 @@ void main() { }, ]); }); + + test('fetchMediaUsage maps storage usage response', () async { + final client = AuthApiClient( + config: PapyrusApiConfig(serverBaseUri: Uri.parse('http://server.test')), + httpClient: MockClient((request) async { + expect(request.url.path, '/v1/media/usage'); + expect(request.headers['Authorization'], 'Bearer access-token'); + + return http.Response(jsonEncode({'used_bytes': 10, 'quota_bytes': 100, 'available_bytes': 90}), 200); + }), + ); + + final usage = await client.fetchMediaUsage('access-token'); + + expect(usage.usedBytes, 10); + expect(usage.quotaBytes, 100); + expect(usage.availableBytes, 90); + }); + + test('downloadMedia returns authenticated bytes', () async { + final client = AuthApiClient( + config: PapyrusApiConfig(serverBaseUri: Uri.parse('http://server.test')), + httpClient: MockClient((request) async { + expect(request.url.path, '/v1/media/asset-id'); + expect(request.headers['Authorization'], 'Bearer access-token'); + + return http.Response.bytes([1, 2, 3], 200, headers: {'content-type': 'application/epub+zip'}); + }), + ); + + final bytes = await client.downloadMedia('access-token', 'asset-id'); + + expect(bytes, Uint8List.fromList([1, 2, 3])); + }); + + test('uploadMedia sends authenticated multipart media request', () async { + final client = AuthApiClient( + config: PapyrusApiConfig(serverBaseUri: Uri.parse('http://server.test')), + httpClient: _CapturingMultipartClient((request, body) async { + expect(request.method, 'POST'); + expect(request.url.path, '/v1/media'); + expect(request.headers['Authorization'], 'Bearer access-token'); + expect(request.headers['content-type'], contains('multipart/form-data')); + expect(body, contains('name="book_id"')); + expect(body, contains('11111111-1111-1111-1111-111111111111')); + expect(body, contains('name="kind"')); + expect(body, contains('book_file')); + expect(body, contains('filename="book.epub"')); + expect(body, contains('epub bytes')); + + return http.Response( + jsonEncode({ + 'asset_id': '22222222-2222-2222-2222-222222222222', + 'owner_user_id': '33333333-3333-3333-3333-333333333333', + 'book_id': '11111111-1111-1111-1111-111111111111', + 'kind': 'book_file', + 'original_filename': 'book.epub', + 'content_type': 'application/epub+zip', + 'extension': 'epub', + 'size_bytes': 10, + 'sha256': 'hash', + 'storage_path': 'path', + }), + 201, + ); + }), + ); + + final asset = await client.uploadMedia( + 'access-token', + MediaUploadPayload( + bookId: '11111111-1111-1111-1111-111111111111', + kind: MediaKind.bookFile, + filename: 'book.epub', + contentType: 'application/epub+zip', + bytes: Uint8List.fromList('epub bytes'.codeUnits), + ), + ); + + expect(asset.assetId, '22222222-2222-2222-2222-222222222222'); + expect(asset.kind, MediaKind.bookFile); + }); +} + +class _CapturingMultipartClient extends http.BaseClient { + _CapturingMultipartClient(this.handler); + + final Future Function(http.BaseRequest request, String body) handler; + + @override + Future send(http.BaseRequest request) async { + final body = await request.finalize().bytesToString(); + final response = await handler(request, body); + return http.StreamedResponse( + Stream.value(response.bodyBytes), + response.statusCode, + headers: response.headers, + request: request, + ); + } } diff --git a/app/test/media/media_cache_service_test.dart b/app/test/media/media_cache_service_test.dart new file mode 100644 index 0000000..f54ebc0 --- /dev/null +++ b/app/test/media/media_cache_service_test.dart @@ -0,0 +1,101 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/media/media_cache_service.dart'; +import 'package:papyrus/models/book.dart'; + +void main() { + late MediaCacheService service; + + setUp(() { + service = const MediaCacheService(); + }); + + test('uses cached book file when its hash matches', () async { + var downloads = 0; + var writes = 0; + final bytes = Uint8List.fromList('cached'.codeUnits); + final book = _book(fileHash: service.sha256Hex(bytes), fileMediaId: 'asset-1'); + + final result = await service.ensureBookFileCached( + book, + readLocalBookFile: (_) async => bytes, + writeLocalBookFile: (_, _, _) async => writes++, + downloadMedia: (_) async { + downloads++; + return Uint8List.fromList('remote'.codeUnits); + }, + ); + + expect(result, bytes); + expect(downloads, 0); + expect(writes, 0); + }); + + test('downloads and stores book file when local cache is missing', () async { + Uint8List? written; + String? writtenExtension; + final remote = Uint8List.fromList('remote file'.codeUnits); + final book = _book(fileHash: service.sha256Hex(remote), fileMediaId: 'asset-1', fileFormat: BookFormat.pdf); + + final result = await service.ensureBookFileCached( + book, + readLocalBookFile: (_) async => null, + writeLocalBookFile: (_, extension, bytes) async { + writtenExtension = extension; + written = bytes; + }, + downloadMedia: (assetId) async { + expect(assetId, 'asset-1'); + return remote; + }, + ); + + expect(result, remote); + expect(writtenExtension, 'pdf'); + expect(written, remote); + }); + + test('redownloads when cached hash does not match', () async { + final stale = Uint8List.fromList('stale'.codeUnits); + final remote = Uint8List.fromList('remote file'.codeUnits); + final book = _book(fileHash: service.sha256Hex(remote), fileMediaId: 'asset-1'); + + final result = await service.ensureBookFileCached( + book, + readLocalBookFile: (_) async => stale, + writeLocalBookFile: (_, _, _) async {}, + downloadMedia: (_) async => remote, + ); + + expect(result, remote); + }); + + test('rejects downloaded bytes when expected hash does not match', () async { + final book = _book(fileHash: List.filled(64, '0').join(), fileMediaId: 'asset-1'); + + expect( + () => service.ensureBookFileCached( + book, + readLocalBookFile: (_) async => null, + writeLocalBookFile: (_, _, _) async {}, + downloadMedia: (_) async => Uint8List.fromList('wrong'.codeUnits), + ), + throwsStateError, + ); + }); +} + +Book _book({required String fileHash, String? fileMediaId, BookFormat? fileFormat}) { + return Book( + id: 'book-1', + title: 'Book', + author: 'Author', + filePath: 'book-1', + fileSize: 12, + fileHash: fileHash, + fileFormat: fileFormat ?? BookFormat.epub, + fileMediaId: fileMediaId, + addedAt: DateTime.utc(2026), + ); +} diff --git a/app/test/media/media_upload_queue_test.dart b/app/test/media/media_upload_queue_test.dart new file mode 100644 index 0000000..f894da0 --- /dev/null +++ b/app/test/media/media_upload_queue_test.dart @@ -0,0 +1,91 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/data/repositories/book_repository.dart'; +import 'package:papyrus/media/media_models.dart'; +import 'package:papyrus/media/media_upload_queue.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('processPending uploads book file and stores returned media id on the book', () async { + final prefs = await SharedPreferences.getInstance(); + final repository = InMemoryBookRepository(); + final dataStore = DataStore(bookRepository: repository); + final book = _book(filePath: 'book-1', fileSize: 10, fileHash: 'hash'); + await repository.upsert(book); + await pumpEventQueue(); + final queue = MediaUploadQueue(prefs); + await queue.enqueueBookFile(book: book, filename: 'book.epub', contentType: 'application/epub+zip'); + + await queue.processPending( + dataStore: dataStore, + readBookFile: (bookId) async => Uint8List.fromList('epub bytes'.codeUnits), + uploadMedia: (payload) async { + expect(payload.bookId, book.id); + expect(payload.kind, MediaKind.bookFile); + expect(payload.bytes, Uint8List.fromList('epub bytes'.codeUnits)); + return _asset(assetId: 'file-asset', bookId: book.id, kind: MediaKind.bookFile); + }, + ); + + expect(queue.pendingTasks, isEmpty); + expect(dataStore.getBook(book.id)?.fileMediaId, 'file-asset'); + }); + + test('processPending keeps quota failures visible without dropping local media', () async { + final prefs = await SharedPreferences.getInstance(); + final repository = InMemoryBookRepository(); + final dataStore = DataStore(bookRepository: repository); + final book = _book(filePath: 'book-1', fileSize: 10, fileHash: 'hash'); + await repository.upsert(book); + await pumpEventQueue(); + final queue = MediaUploadQueue(prefs); + await queue.enqueueBookFile(book: book, filename: 'book.epub', contentType: 'application/epub+zip'); + + await queue.processPending( + dataStore: dataStore, + readBookFile: (bookId) async => Uint8List.fromList('epub bytes'.codeUnits), + uploadMedia: (payload) async => throw const MediaUploadException.storageFull(), + ); + + expect(queue.pendingTasks, hasLength(1)); + expect(queue.pendingTasks.single.status, MediaUploadTaskStatus.failed); + expect(queue.pendingTasks.single.errorMessage, 'Storage full'); + expect(dataStore.getBook(book.id)?.fileMediaId, isNull); + expect(dataStore.getBook(book.id)?.filePath, 'book-1'); + }); +} + +Book _book({String? filePath, int? fileSize, String? fileHash}) { + return Book( + id: '11111111-1111-1111-1111-111111111111', + title: 'Book', + author: 'Author', + filePath: filePath, + fileSize: fileSize, + fileHash: fileHash, + fileFormat: BookFormat.epub, + addedAt: DateTime.utc(2026, 6, 27), + ); +} + +MediaAsset _asset({required String assetId, required String bookId, required MediaKind kind}) { + return MediaAsset( + assetId: assetId, + ownerUserId: 'user-id', + bookId: bookId, + kind: kind, + originalFilename: 'book.epub', + contentType: 'application/epub+zip', + extension: 'epub', + sizeBytes: 10, + sha256: 'hash', + storagePath: 'path', + ); +} diff --git a/app/test/pages/profile_storage_sync_test.dart b/app/test/pages/profile_storage_sync_test.dart index 251334e..adbc8f4 100644 --- a/app/test/pages/profile_storage_sync_test.dart +++ b/app/test/pages/profile_storage_sync_test.dart @@ -7,6 +7,7 @@ import 'package:papyrus/auth/papyrus_api_config.dart'; import 'package:papyrus/auth/token_store.dart'; import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/data/repositories/book_repository.dart'; +import 'package:papyrus/media/media_upload_queue.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/pages/profile_page.dart'; import 'package:papyrus/powersync/powersync_service.dart'; @@ -131,6 +132,7 @@ void main() { return MultiProvider( providers: [ ChangeNotifierProvider.value(value: dataStore ?? DataStore()), + ChangeNotifierProvider(create: (_) => MediaUploadQueue(prefs)), ChangeNotifierProvider.value( value: syncSettingsProvider ?? SyncSettingsProvider(prefs, officialConfig: config), ), diff --git a/app/test/powersync/powersync_book_mapper_test.dart b/app/test/powersync/powersync_book_mapper_test.dart index e8c90c1..153f077 100644 --- a/app/test/powersync/powersync_book_mapper_test.dart +++ b/app/test/powersync/powersync_book_mapper_test.dart @@ -13,6 +13,8 @@ void main() { coAuthors: const ['Co Author'], coverUrl: 'data:image/png;base64,abc', filePath: '/local/book.epub', + fileMediaId: '22222222-2222-2222-2222-222222222222', + coverMediaId: '33333333-3333-3333-3333-333333333333', fileFormat: BookFormat.epub, fileSize: 1024, fileHash: 'hash', @@ -28,6 +30,8 @@ void main() { final metadata = jsonDecode(row['custom_metadata']! as String) as Map; expect(row['cover_image_url'], isNull); + expect(row['file_media_id'], '22222222-2222-2222-2222-222222222222'); + expect(row['cover_media_id'], '33333333-3333-3333-3333-333333333333'); expect(row.containsKey('file_path'), isFalse); expect(row['co_authors'], jsonEncode(['Co Author'])); expect(row['reading_status'], 'inProgress'); @@ -48,6 +52,8 @@ void main() { 'reading_status': 'in_progress', 'current_position': 0.5, 'is_favorite': 1, + 'file_media_id': '22222222-2222-2222-2222-222222222222', + 'cover_media_id': '33333333-3333-3333-3333-333333333333', 'custom_metadata': jsonEncode({'file_format': 'epub', 'is_physical': false}), 'added_at': '2026-05-09T12:00:00Z', }); @@ -58,5 +64,7 @@ void main() { expect(book.currentPosition, 0.5); expect(book.isFavorite, isTrue); expect(book.fileFormat, BookFormat.epub); + expect(book.fileMediaId, '22222222-2222-2222-2222-222222222222'); + expect(book.coverMediaId, '33333333-3333-3333-3333-333333333333'); }); } diff --git a/app/test/services/book_import_service_test.dart b/app/test/services/book_import_service_test.dart index 1b85f3e..f2c8839 100644 --- a/app/test/services/book_import_service_test.dart +++ b/app/test/services/book_import_service_test.dart @@ -177,6 +177,27 @@ void main() { }); }); + group('storeBookFile', () { + test('stores downloaded book bytes with the provided extension', () async { + final bytes = Uint8List.fromList('downloaded epub bytes'.codeUnits); + + await service.storeBookFile('downloaded-book', 'epub', bytes); + + final retrieved = await service.getBookFile('downloaded-book'); + final storedFile = File(p.join(tempDir.path, 'books', 'downloaded-book.epub')); + expect(storedFile.existsSync(), isTrue); + expect(retrieved, bytes); + }); + + test('normalizes extension and replaces existing cached file', () async { + await service.storeBookFile('downloaded-book', '.epub', Uint8List.fromList([1, 2, 3])); + await service.storeBookFile('downloaded-book', 'epub', Uint8List.fromList([4, 5])); + + final retrieved = await service.getBookFile('downloaded-book'); + expect(retrieved, Uint8List.fromList([4, 5])); + }); + }); + group('deleteBookFile', () { test('removes stored file', () async { final bytes = loadTestFile('book1.epub'); diff --git a/app/web/book_worker.js b/app/web/book_worker.js index f5f9985..c51fc91 100644 --- a/app/web/book_worker.js +++ b/app/web/book_worker.js @@ -8,11 +8,13 @@ * { type: 'process', format: 'epub', bookId, fileData: ArrayBuffer } * { type: 'delete', bookId } * { type: 'getFile', bookId } + * { type: 'storeFile', format, bookId, fileData: ArrayBuffer } * * Outgoing: * { type: 'success', action: 'process', bookId, metadata, coverData, coverMimeType, fileSize, fileHash } * { type: 'success', action: 'delete', bookId } * { type: 'success', action: 'getFile', bookId, fileData } + * { type: 'success', action: 'storeFile', bookId } * { type: 'error', message } */ @@ -35,6 +37,9 @@ self.onmessage = async (event) => { case 'getFile': await handleGetFile(msg); break; + case 'storeFile': + await handleStoreFile(msg); + break; default: postMessage({ type: 'error', message: `Unknown message type: ${msg.type}` }); } @@ -92,6 +97,17 @@ async function handleGetFile(msg) { ); } +// --------------------------------------------------------------------------- +// StoreFile handler +// --------------------------------------------------------------------------- + +async function handleStoreFile(msg) { + const { bookId, format, fileData } = msg; + await opfsDelete(bookId); + await opfsWrite(bookId, format, new Uint8Array(fileData)); + postMessage({ type: 'success', action: 'storeFile', bookId }); +} + // --------------------------------------------------------------------------- // EPUB processing // --------------------------------------------------------------------------- From 73b422d76ffcf83e9a20ab474430f41abd5884a8 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sun, 5 Jul 2026 01:16:37 +0300 Subject: [PATCH 2/2] feat: add book download and delete functionality with media upload handling - Implemented BookDownloadService for downloading book files. - Added retry mechanism for failed media uploads in MediaUploadQueue. - Enhanced BookDetailsPage to include download option in context menu. - Created deleteBookWithMediaCleanup service to handle book deletion and media upload cleanup. - Updated ProfilePage to display failed media uploads and provide retry option. - Added tests for new download and delete functionalities, ensuring proper behavior. --- app/lib/main.dart | 4 + app/lib/media/media_upload_queue.dart | 20 ++ app/lib/pages/book_details_page.dart | 106 ++++++++- app/lib/pages/profile_page.dart | 25 ++ .../powersync/storage_sync_controller.dart | 8 + .../services/book_delete_cleanup_service.dart | 19 ++ app/lib/services/book_download_service.dart | 40 ++++ .../book_download_service_platform_io.dart | 25 ++ .../book_download_service_platform_web.dart | 45 ++++ app/lib/utils/book_actions.dart | 57 ++++- app/lib/utils/bulk_book_actions.dart | 16 +- .../context_menu/book_context_menu.dart | 29 ++- app/test/media/media_upload_queue_test.dart | 41 ++++ app/test/pages/book_details_delete_test.dart | 217 ++++++++++++++++++ app/test/pages/profile_storage_sync_test.dart | 7 +- .../storage_sync_controller_test.dart | 93 ++++++++ .../book_delete_cleanup_service_test.dart | 39 ++++ .../context_menu/book_context_menu_test.dart | 51 ++++ 18 files changed, 830 insertions(+), 12 deletions(-) create mode 100644 app/lib/services/book_delete_cleanup_service.dart create mode 100644 app/lib/services/book_download_service.dart create mode 100644 app/lib/services/book_download_service_platform_io.dart create mode 100644 app/lib/services/book_download_service_platform_web.dart create mode 100644 app/test/pages/book_details_delete_test.dart create mode 100644 app/test/powersync/storage_sync_controller_test.dart create mode 100644 app/test/services/book_delete_cleanup_service_test.dart create mode 100644 app/test/widgets/context_menu/book_context_menu_test.dart diff --git a/app/lib/main.dart b/app/lib/main.dart index 0b6f945..b551309 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -17,6 +17,7 @@ import 'package:papyrus/providers/auth_provider.dart'; import 'package:papyrus/providers/library_provider.dart'; import 'package:papyrus/providers/preferences_provider.dart'; import 'package:papyrus/providers/sync_settings_provider.dart'; +import 'package:papyrus/services/book_download_service.dart'; import 'package:papyrus/services/book_import_service_stub.dart' if (dart.library.js_interop) 'package:papyrus/services/book_import_service.dart'; import 'package:papyrus/providers/sidebar_provider.dart'; @@ -188,6 +189,7 @@ class _PapyrusState extends State { ChangeNotifierProvider.value(value: _syncSettingsProvider), Provider.value(value: _powerSyncService), Provider.value(value: _bookImportService), + Provider(create: _createBookDownloadService), Provider(create: _createMediaCacheService), StreamProvider.value(value: _powerSyncService.syncStates, initialData: _powerSyncService.syncState), // Auth and UI state providers @@ -213,4 +215,6 @@ class _PapyrusState extends State { } } +BookDownloadService _createBookDownloadService(BuildContext _) => const BookDownloadService(); + MediaCacheService _createMediaCacheService(BuildContext _) => const MediaCacheService(); diff --git a/app/lib/media/media_upload_queue.dart b/app/lib/media/media_upload_queue.dart index 87e44f0..23478ea 100644 --- a/app/lib/media/media_upload_queue.dart +++ b/app/lib/media/media_upload_queue.dart @@ -123,6 +123,26 @@ class MediaUploadQueue extends ChangeNotifier { ); } + Future retryFailed({String? bookId}) async { + _tasks = _tasks + .map((task) { + final matchesBook = bookId == null || task.bookId == bookId; + if (!matchesBook || task.status != MediaUploadTaskStatus.failed) { + return task; + } + return task.copyWith(status: MediaUploadTaskStatus.pending); + }) + .toList(growable: false); + await _save(); + notifyListeners(); + } + + Future removeTasksForBook(String bookId) async { + _tasks = _tasks.where((task) => task.bookId != bookId).toList(growable: false); + await _save(); + notifyListeners(); + } + Future processPending({ required DataStore dataStore, required BookFileReader readBookFile, diff --git a/app/lib/pages/book_details_page.dart b/app/lib/pages/book_details_page.dart index f6283de..e5f9468 100644 --- a/app/lib/pages/book_details_page.dart +++ b/app/lib/pages/book_details_page.dart @@ -1,12 +1,18 @@ +import 'dart:typed_data'; + import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/media/media_cache_service.dart'; +import 'package:papyrus/media/media_upload_queue.dart'; import 'package:papyrus/models/annotation.dart'; +import 'package:papyrus/models/book.dart'; import 'package:papyrus/models/bookmark.dart'; import 'package:papyrus/models/note.dart'; import 'package:papyrus/providers/auth_provider.dart'; import 'package:papyrus/providers/book_details_provider.dart'; +import 'package:papyrus/services/book_delete_cleanup_service.dart'; +import 'package:papyrus/services/book_download_service.dart'; import 'package:papyrus/services/book_import_service_stub.dart' if (dart.library.js_interop) 'package:papyrus/services/book_import_service.dart'; import 'package:papyrus/themes/design_tokens.dart'; @@ -220,7 +226,10 @@ class _BookDetailsPageState extends State with SingleTickerProv PopupMenuButton( icon: const Icon(Icons.more_vert), onSelected: _onMenuAction, - itemBuilder: (context) => [const PopupMenuItem(value: 'delete', child: Text('Delete'))], + itemBuilder: (context) => [ + if (!provider.book!.isPhysical) const PopupMenuItem(value: 'download', child: Text('Download')), + const PopupMenuItem(value: 'delete', child: Text('Delete')), + ], ), ], ), @@ -383,6 +392,52 @@ class _BookDetailsPageState extends State with SingleTickerProv } } + Future _onDownloadBookFile() async { + final book = _provider.book; + if (book == null) return; + + final messenger = ScaffoldMessenger.of(context); + final importService = context.read(); + final mediaCacheService = context.read(); + final downloadService = context.read(); + + messenger.showSnackBar(const SnackBar(content: Text('Preparing download...'))); + + try { + final cached = await mediaCacheService.getValidCachedBookFile(book, readLocalBookFile: importService.getBookFile); + if (!mounted) return; + + final bytes = cached ?? await _downloadAndCacheBookFile(book, importService, mediaCacheService); + final result = await downloadService.saveBookFile(book: book, bytes: bytes); + + if (!mounted) return; + messenger.hideCurrentSnackBar(); + if (result.saved) { + messenger.showSnackBar(SnackBar(content: Text('Downloaded "${book.title}"'))); + } else { + messenger.showSnackBar(const SnackBar(content: Text('Download canceled'))); + } + } catch (_) { + if (!mounted) return; + messenger.hideCurrentSnackBar(); + messenger.showSnackBar(const SnackBar(content: Text('Could not download this book file.'))); + } + } + + Future _downloadAndCacheBookFile( + Book book, + BookImportService importService, + MediaCacheService mediaCacheService, + ) { + final authProvider = context.read(); + return mediaCacheService.ensureBookFileCached( + book, + readLocalBookFile: importService.getBookFile, + writeLocalBookFile: importService.storeBookFile, + downloadMedia: authProvider.downloadMedia, + ); + } + void _onAddNote() async { if (_provider.book == null) return; @@ -528,11 +583,54 @@ class _BookDetailsPageState extends State with SingleTickerProv } } - void _onMenuAction(String action) { + Future _confirmDeleteBook() async { + final book = _provider.book; + if (book == null) return; + + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete book?'), + content: Text('Delete "${book.title}" from your library? This action cannot be undone.'), + actions: [ + TextButton(onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel')), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.error, + foregroundColor: Theme.of(context).colorScheme.onError, + ), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + final dataStore = context.read(); + final mediaUploadQueue = context.read(); + final deleteBookFile = context.read().deleteBookFile; + final messenger = ScaffoldMessenger.of(context); + + await deleteBookWithMediaCleanup( + dataStore: dataStore, + mediaUploadQueue: mediaUploadQueue, + bookId: book.id, + deleteBookFile: deleteBookFile, + ); + + if (!mounted) return; + context.go('/library/books'); + messenger.showSnackBar(SnackBar(content: Text('Deleted "${book.title}"'))); + } + + void _onMenuAction(String action) async { switch (action) { + case 'download': + await _onDownloadBookFile(); case 'delete': - // TODO: Confirm and delete - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Delete functionality coming soon'))); + await _confirmDeleteBook(); } } } diff --git a/app/lib/pages/profile_page.dart b/app/lib/pages/profile_page.dart index 01d4cf1..bc3518f 100644 --- a/app/lib/pages/profile_page.dart +++ b/app/lib/pages/profile_page.dart @@ -240,6 +240,12 @@ class _ProfilePageState extends State { ), SettingsRow(label: 'Current status', value: controller.statusLabel), SettingsRow(label: 'File storage', value: controller.fileStorageLabel), + if (controller.hasFailedMediaUploads) + SettingsRow( + label: 'Media uploads', + value: controller.failedMediaUploadLabel, + onTap: () => _retryFailedMediaUploads(context), + ), SettingsRow(label: 'Manage servers', onTap: () => _showManageSyncServersSheet(context)), if (controller.canReconnect) SettingsRow(label: 'Reconnect', onTap: () => _handleReconnectSync(context)), if (controller.canClearGuestLibrary) @@ -930,6 +936,8 @@ class _ProfilePageState extends State { _buildInfoRow(context, label: 'Active server', value: controller.dataSyncLabel), _buildInfoRow(context, label: 'Status', value: controller.statusLabel), _buildInfoRow(context, label: 'File storage', value: controller.fileStorageLabel), + if (controller.hasFailedMediaUploads) + _buildInfoRow(context, label: 'Media uploads', value: controller.failedMediaUploadLabel), const SizedBox(height: Spacing.sm), Padding( padding: const EdgeInsets.symmetric(horizontal: Spacing.sm, vertical: Spacing.xs), @@ -952,6 +960,12 @@ class _ProfilePageState extends State { icon: const Icon(Icons.dns_outlined, size: IconSizes.small), label: const Text('Manage servers'), ), + if (controller.hasFailedMediaUploads) + OutlinedButton.icon( + onPressed: () => _retryFailedMediaUploads(context), + icon: const Icon(Icons.refresh, size: IconSizes.small), + label: const Text('Retry uploads'), + ), if (controller.canClearAuthenticatedCache) OutlinedButton.icon( onPressed: () => _confirmClearAuthenticatedCache(context), @@ -1019,6 +1033,7 @@ class _ProfilePageState extends State { syncState: context.watch(), fileStorageUsedBytes: _fileStorageUsedBytes(context.watch()), mediaStorageUsage: context.watch().storageUsage, + failedMediaUploadCount: _failedMediaUploadCount(context.watch()), ); } @@ -1026,6 +1041,10 @@ class _ProfilePageState extends State { return dataStore.books.fold(0, (total, book) => total + (book.fileSize ?? 0)); } + int _failedMediaUploadCount(MediaUploadQueue queue) { + return queue.pendingTasks.where((task) => task.status == MediaUploadTaskStatus.failed).length; + } + Widget _buildInfoRow(BuildContext context, {required String label, required String value}) { final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; @@ -1354,6 +1373,12 @@ class _ProfilePageState extends State { } } + Future _retryFailedMediaUploads(BuildContext context) async { + final messenger = ScaffoldMessenger.of(context); + await context.read().retryFailed(); + messenger.showSnackBar(const SnackBar(content: Text('Media uploads will retry on the next sync.'))); + } + void _showOfflineBackupActions(BuildContext context) { showModalBottomSheet( context: context, diff --git a/app/lib/powersync/storage_sync_controller.dart b/app/lib/powersync/storage_sync_controller.dart index c273852..0ed4237 100644 --- a/app/lib/powersync/storage_sync_controller.dart +++ b/app/lib/powersync/storage_sync_controller.dart @@ -11,6 +11,7 @@ class StorageSyncController { required this.syncSettings, required this.syncState, required this.fileStorageUsedBytes, + this.failedMediaUploadCount = 0, this.mediaStorageUsage, }); @@ -19,6 +20,7 @@ class StorageSyncController { final SyncSettingsProvider syncSettings; final SyncState syncState; final int fileStorageUsedBytes; + final int failedMediaUploadCount; final MediaStorageUsage? mediaStorageUsage; LibraryDatabaseMode? get databaseMode => powerSyncService.mode; @@ -89,6 +91,12 @@ class StorageSyncController { bool get canReconnect => isAuthenticated; bool get canClearGuestLibrary => databaseMode == LibraryDatabaseMode.guest; bool get canClearAuthenticatedCache => databaseMode == LibraryDatabaseMode.authenticated; + bool get hasFailedMediaUploads => failedMediaUploadCount > 0; + + String get failedMediaUploadLabel { + if (failedMediaUploadCount == 1) return '1 failed'; + return '$failedMediaUploadCount failed'; + } Future reconnect() => powerSyncService.reconnect(); Future clearGuestLibrary() => powerSyncService.clearGuestLibrary(); diff --git a/app/lib/services/book_delete_cleanup_service.dart b/app/lib/services/book_delete_cleanup_service.dart new file mode 100644 index 0000000..ac8afa1 --- /dev/null +++ b/app/lib/services/book_delete_cleanup_service.dart @@ -0,0 +1,19 @@ +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/media/media_upload_queue.dart'; + +typedef DeleteBookFile = Future Function(String bookId); + +Future deleteBookWithMediaCleanup({ + required DataStore dataStore, + required MediaUploadQueue mediaUploadQueue, + required String bookId, + required DeleteBookFile deleteBookFile, +}) async { + await mediaUploadQueue.removeTasksForBook(bookId); + try { + await deleteBookFile(bookId); + } catch (_) { + // Local cache cleanup is best-effort; the synced book deletion remains authoritative. + } + dataStore.deleteBook(bookId); +} diff --git a/app/lib/services/book_download_service.dart b/app/lib/services/book_download_service.dart new file mode 100644 index 0000000..cf23d58 --- /dev/null +++ b/app/lib/services/book_download_service.dart @@ -0,0 +1,40 @@ +import 'dart:typed_data'; + +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/services/book_download_service_platform_io.dart' + if (dart.library.js_interop) 'package:papyrus/services/book_download_service_platform_web.dart'; + +class BookDownloadService { + const BookDownloadService(); + + Future saveBookFile({required Book book, required Uint8List bytes}) async { + final extension = _extensionFor(book); + final fileName = '${_safeFileName(book.title)}.$extension'; + final path = await saveBookFileToDevice(bytes: bytes, fileName: fileName, extension: extension); + + if (path == null) { + return const BookDownloadResult.cancelled(); + } + return BookDownloadResult.saved(path); + } + + String _extensionFor(Book book) { + return book.fileFormat?.name ?? 'bin'; + } + + String _safeFileName(String value) { + final sanitized = value.trim().replaceAll(RegExp(r'[\\/:*?"<>|]'), '_').replaceAll(RegExp(r'\s+'), ' '); + return sanitized.isEmpty ? 'book' : sanitized; + } +} + +class BookDownloadResult { + const BookDownloadResult._({required this.saved, this.path}); + + const BookDownloadResult.saved(String path) : this._(saved: true, path: path); + + const BookDownloadResult.cancelled() : this._(saved: false); + + final bool saved; + final String? path; +} diff --git a/app/lib/services/book_download_service_platform_io.dart b/app/lib/services/book_download_service_platform_io.dart new file mode 100644 index 0000000..4c1fb18 --- /dev/null +++ b/app/lib/services/book_download_service_platform_io.dart @@ -0,0 +1,25 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:file_picker/file_picker.dart'; + +Future saveBookFileToDevice({ + required Uint8List bytes, + required String fileName, + required String extension, +}) async { + final path = await FilePicker.platform.saveFile( + dialogTitle: 'Download book', + fileName: fileName, + type: FileType.custom, + allowedExtensions: [extension], + bytes: Platform.isAndroid || Platform.isIOS ? bytes : null, + ); + + if (path == null) return null; + + if (!Platform.isAndroid && !Platform.isIOS) { + await File(path).writeAsBytes(bytes); + } + return path; +} diff --git a/app/lib/services/book_download_service_platform_web.dart b/app/lib/services/book_download_service_platform_web.dart new file mode 100644 index 0000000..d05baaf --- /dev/null +++ b/app/lib/services/book_download_service_platform_web.dart @@ -0,0 +1,45 @@ +import 'dart:js_interop'; +import 'dart:typed_data'; + +import 'package:web/web.dart'; + +Future saveBookFileToDevice({ + required Uint8List bytes, + required String fileName, + required String extension, +}) async { + final blob = Blob([bytes.toJS].toJS, BlobPropertyBag(type: _contentTypeForExtension(extension))); + final url = URL.createObjectURL(blob); + final anchor = HTMLAnchorElement() + ..href = url + ..download = fileName + ..style.display = 'none'; + + document.body?.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + + return fileName; +} + +String _contentTypeForExtension(String extension) { + switch (extension.toLowerCase()) { + case 'epub': + return 'application/epub+zip'; + case 'pdf': + return 'application/pdf'; + case 'mobi': + return 'application/x-mobipocket-ebook'; + case 'azw3': + return 'application/vnd.amazon.ebook'; + case 'cbz': + return 'application/vnd.comicbook+zip'; + case 'cbr': + return 'application/vnd.comicbook-rar'; + case 'txt': + return 'text/plain'; + default: + return 'application/octet-stream'; + } +} diff --git a/app/lib/utils/book_actions.dart b/app/lib/utils/book_actions.dart index ac048a0..5f8c0c1 100644 --- a/app/lib/utils/book_actions.dart +++ b/app/lib/utils/book_actions.dart @@ -1,8 +1,17 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/media/media_cache_service.dart'; +import 'package:papyrus/media/media_upload_queue.dart'; import 'package:papyrus/models/book.dart'; +import 'package:papyrus/providers/auth_provider.dart'; import 'package:papyrus/providers/library_provider.dart'; +import 'package:papyrus/services/book_delete_cleanup_service.dart'; +import 'package:papyrus/services/book_download_service.dart'; +import 'package:papyrus/services/book_import_service_stub.dart' + if (dart.library.js_interop) 'package:papyrus/services/book_import_service.dart'; import 'package:papyrus/widgets/context_menu/book_context_menu.dart'; import 'package:papyrus/widgets/shelves/move_to_shelf_sheet.dart'; import 'package:papyrus/widgets/topics/manage_topics_sheet.dart'; @@ -43,12 +52,58 @@ void showBookContextMenu({required BuildContext context, required Book book, Off if (currentBook == null) return; dataStore.updateBook(currentBook.copyWith(readingStatus: status)); }, + onDownload: () { + unawaited(_downloadBookFile(context, book)); + }, onDelete: () { - // TODO: Implement delete + unawaited( + deleteBookWithMediaCleanup( + dataStore: context.read(), + mediaUploadQueue: context.read(), + bookId: book.id, + deleteBookFile: context.read().deleteBookFile, + ), + ); }, ); } +Future _downloadBookFile(BuildContext context, Book book) async { + final messenger = ScaffoldMessenger.of(context); + final importService = context.read(); + final mediaCacheService = context.read(); + final downloadService = context.read(); + + messenger.showSnackBar(const SnackBar(content: Text('Preparing download...'))); + + try { + final cached = await mediaCacheService.getValidCachedBookFile(book, readLocalBookFile: importService.getBookFile); + if (!context.mounted) return; + + final bytes = + cached ?? + await mediaCacheService.ensureBookFileCached( + book, + readLocalBookFile: importService.getBookFile, + writeLocalBookFile: importService.storeBookFile, + downloadMedia: context.read().downloadMedia, + ); + final result = await downloadService.saveBookFile(book: book, bytes: bytes); + + if (!context.mounted) return; + messenger.hideCurrentSnackBar(); + if (result.saved) { + messenger.showSnackBar(SnackBar(content: Text('Downloaded "${book.title}"'))); + } else { + messenger.showSnackBar(const SnackBar(content: Text('Download canceled'))); + } + } catch (_) { + if (!context.mounted) return; + messenger.hideCurrentSnackBar(); + messenger.showSnackBar(const SnackBar(content: Text('Could not download this book file.'))); + } +} + /// Shows the manage topics sheet and handles topic assignments. void _showManageTopicsSheet(BuildContext context, Book book) { final dataStore = context.read(); diff --git a/app/lib/utils/bulk_book_actions.dart b/app/lib/utils/bulk_book_actions.dart index 15cdc3a..c7e3137 100644 --- a/app/lib/utils/bulk_book_actions.dart +++ b/app/lib/utils/bulk_book_actions.dart @@ -1,7 +1,11 @@ import 'package:flutter/material.dart'; import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/media/media_upload_queue.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/providers/library_provider.dart'; +import 'package:papyrus/services/book_delete_cleanup_service.dart'; +import 'package:papyrus/services/book_import_service_stub.dart' + if (dart.library.js_interop) 'package:papyrus/services/book_import_service.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/library/bulk_action_bar.dart'; import 'package:papyrus/widgets/library/bulk_status_sheet.dart'; @@ -141,9 +145,17 @@ void handleBulkDelete(BuildContext context, LibraryProvider libraryProvider) { actions: [ TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), FilledButton( - onPressed: () { + onPressed: () async { + final selectedBookIds = libraryProvider.selectedBookIds.toSet(); Navigator.pop(context); - bulkDelete(dataStore, libraryProvider.selectedBookIds); + for (final bookId in selectedBookIds) { + await deleteBookWithMediaCleanup( + dataStore: dataStore, + mediaUploadQueue: context.read(), + bookId: bookId, + deleteBookFile: context.read().deleteBookFile, + ); + } libraryProvider.exitSelectionMode(); }, style: FilledButton.styleFrom(backgroundColor: Theme.of(context).colorScheme.error), diff --git a/app/lib/widgets/context_menu/book_context_menu.dart b/app/lib/widgets/context_menu/book_context_menu.dart index 7f903f5..5558de5 100644 --- a/app/lib/widgets/context_menu/book_context_menu.dart +++ b/app/lib/widgets/context_menu/book_context_menu.dart @@ -17,6 +17,7 @@ class BookContextMenu { VoidCallback? onMoveToShelf, VoidCallback? onManageTopics, Function(ReadingStatus)? onStatusChange, + VoidCallback? onDownload, VoidCallback? onDelete, }) { final screenWidth = MediaQuery.of(context).size.width; @@ -34,6 +35,7 @@ class BookContextMenu { onMoveToShelf: onMoveToShelf, onManageTopics: onManageTopics, onStatusChange: onStatusChange, + onDownload: onDownload, onDelete: onDelete, ); } else { @@ -47,6 +49,7 @@ class BookContextMenu { onMoveToShelf: onMoveToShelf, onManageTopics: onManageTopics, onStatusChange: onStatusChange, + onDownload: onDownload, onDelete: onDelete, ); } @@ -63,6 +66,7 @@ class BookContextMenu { VoidCallback? onMoveToShelf, VoidCallback? onManageTopics, Function(ReadingStatus)? onStatusChange, + VoidCallback? onDownload, VoidCallback? onDelete, }) { final overlay = Overlay.of(context).context.findRenderObject() as RenderBox; @@ -117,10 +121,16 @@ class BookContextMenu { child: _MenuItemRow(icon: Icons.check_circle_outline, label: 'Mark as finished', isSelected: book.isFinished), ), const PopupMenuDivider(), + if (!book.isPhysical) + const PopupMenuItem( + value: 'download', + height: 40, + child: _MenuItemRow(icon: Icons.file_download_outlined, label: 'Download'), + ), const PopupMenuItem( value: 'delete', height: 40, - child: _MenuItemRow(icon: Icons.delete_outline, label: 'Delete book', isDestructive: true), + child: _MenuItemRow(icon: Icons.delete_outline, label: 'Delete', isDestructive: true), ), ], ).then((value) { @@ -142,6 +152,8 @@ class BookContextMenu { onStatusChange?.call(ReadingStatus.inProgress); case 'finished': onStatusChange?.call(ReadingStatus.completed); + case 'download': + onDownload?.call(); case 'delete': _confirmDelete(context, book, onDelete); } @@ -158,6 +170,7 @@ class BookContextMenu { VoidCallback? onMoveToShelf, VoidCallback? onManageTopics, Function(ReadingStatus)? onStatusChange, + VoidCallback? onDownload, VoidCallback? onDelete, }) { showModalBottomSheet( @@ -175,6 +188,7 @@ class BookContextMenu { onMoveToShelf: onMoveToShelf, onManageTopics: onManageTopics, onStatusChange: onStatusChange, + onDownload: onDownload, onDelete: onDelete, ), ); @@ -250,6 +264,7 @@ class _BookContextBottomSheet extends StatelessWidget { final VoidCallback? onMoveToShelf; final VoidCallback? onManageTopics; final Function(ReadingStatus)? onStatusChange; + final VoidCallback? onDownload; final VoidCallback? onDelete; const _BookContextBottomSheet({ @@ -261,6 +276,7 @@ class _BookContextBottomSheet extends StatelessWidget { this.onMoveToShelf, this.onManageTopics, this.onStatusChange, + this.onDownload, this.onDelete, }); @@ -402,9 +418,18 @@ class _BookContextBottomSheet extends StatelessWidget { const Divider(), + if (!book.isPhysical) + _BottomSheetItem( + icon: Icons.file_download_outlined, + label: 'Download', + onTap: () { + Navigator.pop(context); + onDownload?.call(); + }, + ), _BottomSheetItem( icon: Icons.delete_outline, - label: 'Delete book', + label: 'Delete', isDestructive: true, onTap: () { Navigator.pop(context); diff --git a/app/test/media/media_upload_queue_test.dart b/app/test/media/media_upload_queue_test.dart index f894da0..f448377 100644 --- a/app/test/media/media_upload_queue_test.dart +++ b/app/test/media/media_upload_queue_test.dart @@ -60,6 +60,47 @@ void main() { expect(dataStore.getBook(book.id)?.fileMediaId, isNull); expect(dataStore.getBook(book.id)?.filePath, 'book-1'); }); + + test('retryFailed returns failed upload tasks to pending', () async { + final prefs = await SharedPreferences.getInstance(); + final repository = InMemoryBookRepository(); + final dataStore = DataStore(bookRepository: repository); + final book = _book(filePath: 'book-1', fileSize: 10, fileHash: 'hash'); + await repository.upsert(book); + await pumpEventQueue(); + final queue = MediaUploadQueue(prefs); + await queue.enqueueBookFile(book: book, filename: 'book.epub', contentType: 'application/epub+zip'); + + await queue.processPending( + dataStore: dataStore, + readBookFile: (bookId) async => Uint8List.fromList('epub bytes'.codeUnits), + uploadMedia: (payload) async => throw const MediaUploadException.storageFull(), + ); + + await queue.retryFailed(); + + expect(queue.pendingTasks.single.status, MediaUploadTaskStatus.pending); + expect(queue.pendingTasks.single.errorMessage, isNull); + }); + + test('removeTasksForBook removes queued book file and cover uploads', () async { + final prefs = await SharedPreferences.getInstance(); + final queue = MediaUploadQueue(prefs); + final book = _book(filePath: 'book-1', fileSize: 10, fileHash: 'hash'); + + await queue.enqueueBookFile(book: book, filename: 'book.epub', contentType: 'application/epub+zip'); + await queue.enqueueCover( + book: book, + filename: 'cover.jpg', + contentType: 'image/jpeg', + bytes: Uint8List.fromList('cover'.codeUnits), + ); + + await queue.removeTasksForBook(book.id); + + expect(queue.pendingTasks, isEmpty); + expect(prefs.getString('media_upload_queue'), '[]'); + }); } Book _book({String? filePath, int? fileSize, String? fileHash}) { diff --git a/app/test/pages/book_details_delete_test.dart b/app/test/pages/book_details_delete_test.dart new file mode 100644 index 0000000..1a0ce1d --- /dev/null +++ b/app/test/pages/book_details_delete_test.dart @@ -0,0 +1,217 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/data/repositories/book_repository.dart'; +import 'package:papyrus/media/media_cache_service.dart'; +import 'package:papyrus/media/media_upload_queue.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/pages/book_details_page.dart'; +import 'package:papyrus/services/book_download_service.dart'; +import 'package:papyrus/services/book_import_service_stub.dart' + if (dart.library.js_interop) 'package:papyrus/services/book_import_service.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + testWidgets('details overflow delete removes the book and pending media uploads', (tester) async { + final prefs = await SharedPreferences.getInstance(); + final repository = InMemoryBookRepository(); + final dataStore = DataStore(bookRepository: repository); + final mediaUploadQueue = MediaUploadQueue(prefs); + final importService = _RecordingBookImportService(); + final book = Book(id: 'book-1', title: 'Delete Me', author: 'Author', addedAt: DateTime.utc(2026)); + + await repository.upsert(book); + await tester.pump(); + await mediaUploadQueue.enqueueBookFile(book: book, filename: 'delete-me.epub', contentType: 'application/epub+zip'); + + final router = GoRouter( + initialLocation: '/library/books/${book.id}', + routes: [ + GoRoute( + path: '/library/books', + builder: (context, state) => const Scaffold(body: Text('Library books')), + ), + GoRoute( + path: '/library/books/:bookId', + builder: (context, state) => BookDetailsPage(id: state.pathParameters['bookId']), + ), + ], + ); + addTearDown(router.dispose); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: dataStore), + ChangeNotifierProvider.value(value: mediaUploadQueue), + Provider.value(value: importService), + ], + child: MaterialApp.router(routerConfig: router), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + + expect(find.text('Delete book?'), findsOneWidget); + + await tester.tap(find.widgetWithText(FilledButton, 'Delete')); + await tester.pumpAndSettle(); + + expect(importService.deletedBookIds, [book.id]); + expect(mediaUploadQueue.pendingTasks, isEmpty); + expect(await repository.getById(book.id), isNull); + expect(find.text('Library books'), findsOneWidget); + }); + + testWidgets('details overflow menu shows download before delete', (tester) async { + final prefs = await SharedPreferences.getInstance(); + final repository = InMemoryBookRepository(); + final dataStore = DataStore(bookRepository: repository); + final mediaUploadQueue = MediaUploadQueue(prefs); + final importService = _RecordingBookImportService(); + final book = Book(id: 'book-1', title: 'Download Me', author: 'Author', addedAt: DateTime.utc(2026)); + + await repository.upsert(book); + await tester.pump(); + + final router = GoRouter( + initialLocation: '/library/books/${book.id}', + routes: [ + GoRoute( + path: '/library/books', + builder: (context, state) => const Scaffold(body: Text('Library books')), + ), + GoRoute( + path: '/library/books/:bookId', + builder: (context, state) => BookDetailsPage(id: state.pathParameters['bookId']), + ), + ], + ); + addTearDown(router.dispose); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: dataStore), + ChangeNotifierProvider.value(value: mediaUploadQueue), + Provider.value(value: importService), + ], + child: MaterialApp.router(routerConfig: router), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pumpAndSettle(); + + final downloadTop = tester.getTopLeft(find.text('Download')).dy; + final deleteTop = tester.getTopLeft(find.text('Delete')).dy; + + expect(downloadTop, lessThan(deleteTop)); + }); + + testWidgets('details overflow download saves the cached book file', (tester) async { + final prefs = await SharedPreferences.getInstance(); + final repository = InMemoryBookRepository(); + final dataStore = DataStore(bookRepository: repository); + final mediaUploadQueue = MediaUploadQueue(prefs); + final importService = _RecordingBookImportService(); + final downloadService = _RecordingBookDownloadService(); + final bytes = Uint8List.fromList('cached epub bytes'.codeUnits); + final book = Book( + id: 'book-1', + title: 'Download Me', + author: 'Author', + fileFormat: BookFormat.epub, + fileHash: const MediaCacheService().sha256Hex(bytes), + addedAt: DateTime.utc(2026), + ); + + importService.bookFiles[book.id] = bytes; + await repository.upsert(book); + await tester.pump(); + + final router = GoRouter( + initialLocation: '/library/books/${book.id}', + routes: [ + GoRoute( + path: '/library/books', + builder: (context, state) => const Scaffold(body: Text('Library books')), + ), + GoRoute( + path: '/library/books/:bookId', + builder: (context, state) => BookDetailsPage(id: state.pathParameters['bookId']), + ), + ], + ); + addTearDown(router.dispose); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: dataStore), + ChangeNotifierProvider.value(value: mediaUploadQueue), + Provider.value(value: importService), + Provider.value(value: const MediaCacheService()), + Provider.value(value: downloadService), + ], + child: MaterialApp.router(routerConfig: router), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Download')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(downloadService.savedBooks, [book.id]); + expect(downloadService.savedBytes, bytes); + expect(find.text('Downloaded "Download Me"'), findsOneWidget); + }); +} + +class _RecordingBookImportService extends BookImportService { + final List deletedBookIds = []; + final Map bookFiles = {}; + + @override + Future deleteBookFile(String bookId) async { + deletedBookIds.add(bookId); + } + + @override + Future getBookFile(String bookId) async { + return bookFiles[bookId]; + } + + @override + Future storeBookFile(String bookId, String extension, Uint8List bytes) async { + bookFiles[bookId] = bytes; + } +} + +class _RecordingBookDownloadService extends BookDownloadService { + final List savedBooks = []; + Uint8List? savedBytes; + + @override + Future saveBookFile({required Book book, required Uint8List bytes}) async { + savedBooks.add(book.id); + savedBytes = bytes; + return const BookDownloadResult.saved('/downloads/download-me.epub'); + } +} diff --git a/app/test/pages/profile_storage_sync_test.dart b/app/test/pages/profile_storage_sync_test.dart index adbc8f4..880538d 100644 --- a/app/test/pages/profile_storage_sync_test.dart +++ b/app/test/pages/profile_storage_sync_test.dart @@ -122,6 +122,7 @@ void main() { Size screenSize = const Size(400, 900), SyncSettingsProvider? syncSettingsProvider, DataStore? dataStore, + MediaUploadQueue? mediaUploadQueue, }) async { final prefs = await SharedPreferences.getInstance(); final config = PapyrusApiConfig( @@ -132,7 +133,7 @@ void main() { return MultiProvider( providers: [ ChangeNotifierProvider.value(value: dataStore ?? DataStore()), - ChangeNotifierProvider(create: (_) => MediaUploadQueue(prefs)), + ChangeNotifierProvider.value(value: mediaUploadQueue ?? MediaUploadQueue(prefs)), ChangeNotifierProvider.value( value: syncSettingsProvider ?? SyncSettingsProvider(prefs, officialConfig: config), ), @@ -189,9 +190,9 @@ void main() { await tester.pumpWidget( await buildPage(authProvider: auth, powerSyncService: service, screenSize: const Size(1200, 900)), ); - await tester.pumpAndSettle(); + await tester.pump(); await tester.tap(find.text('Storage & sync').first); - await tester.pumpAndSettle(); + await tester.pump(); expect(find.text('Library storage'), findsOneWidget); expect(find.text('Your library is stored on this device.'), findsOneWidget); diff --git a/app/test/powersync/storage_sync_controller_test.dart b/app/test/powersync/storage_sync_controller_test.dart new file mode 100644 index 0000000..54e9e0d --- /dev/null +++ b/app/test/powersync/storage_sync_controller_test.dart @@ -0,0 +1,93 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/auth/auth_api_client.dart'; +import 'package:papyrus/auth/auth_models.dart'; +import 'package:papyrus/auth/auth_repository.dart'; +import 'package:papyrus/auth/papyrus_api_config.dart'; +import 'package:papyrus/auth/token_store.dart'; +import 'package:papyrus/powersync/powersync_service.dart'; +import 'package:papyrus/powersync/storage_sync_controller.dart'; +import 'package:papyrus/powersync/sync_state.dart'; +import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/providers/sync_settings_provider.dart'; +import 'package:powersync/powersync.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _MemoryRefreshTokenStorage implements RefreshTokenStorage { + @override + Future delete() async {} + + @override + Future read() async => null; + + @override + Future write(String refreshToken) async {} +} + +class _FakeAuthRepository extends AuthRepository { + _FakeAuthRepository() + : super( + apiClient: AuthApiClient(config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.test'))), + tokenStore: TokenStore(_MemoryRefreshTokenStorage()), + ); + + @override + Future bootstrap() async => AuthTokens( + accessToken: 'access-token', + refreshToken: 'refresh-token', + tokenType: 'Bearer', + expiresIn: 3600, + user: PapyrusUser( + userId: '11111111-1111-1111-1111-111111111111', + email: 'reader@example.com', + displayName: 'Reader', + avatarUrl: null, + emailVerified: true, + createdAt: null, + lastLoginAt: null, + ), + ); +} + +class _OfflineConnector extends PowerSyncBackendConnector { + @override + Future fetchCredentials() async => null; + + @override + Future uploadData(PowerSyncDatabase database) async {} +} + +class _FakePowerSyncService extends PapyrusPowerSyncService { + _FakePowerSyncService() : super(connectorFactory: _OfflineConnector.new, connectAuthenticated: false); + + @override + LibraryDatabaseMode? get mode => LibraryDatabaseMode.authenticated; +} + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('failed media upload label is exposed for authenticated storage sync UI', () async { + final prefs = await SharedPreferences.getInstance(); + final authProvider = AuthProvider(prefs, repository: _FakeAuthRepository(), bootstrapOnCreate: false); + await authProvider.bootstrap(); + final controller = StorageSyncController( + authProvider: authProvider, + powerSyncService: _FakePowerSyncService(), + syncSettings: SyncSettingsProvider( + prefs, + officialConfig: PapyrusApiConfig( + serverBaseUri: Uri.parse('https://api.test'), + powerSyncServiceUri: Uri.parse('https://sync.test'), + ), + ), + syncState: const SyncState(connected: true), + fileStorageUsedBytes: 0, + failedMediaUploadCount: 2, + ); + + expect(controller.hasFailedMediaUploads, isTrue); + expect(controller.failedMediaUploadLabel, '2 failed'); + }); +} diff --git a/app/test/services/book_delete_cleanup_service_test.dart b/app/test/services/book_delete_cleanup_service_test.dart new file mode 100644 index 0000000..b989ee9 --- /dev/null +++ b/app/test/services/book_delete_cleanup_service_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/data/repositories/book_repository.dart'; +import 'package:papyrus/media/media_upload_queue.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/services/book_delete_cleanup_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('deleteBookWithMediaCleanup removes queued uploads and cached file before deleting book', () async { + final prefs = await SharedPreferences.getInstance(); + final repository = InMemoryBookRepository(); + final dataStore = DataStore(bookRepository: repository); + final queue = MediaUploadQueue(prefs); + final book = Book(id: 'book-1', title: 'Book', author: 'Author', addedAt: DateTime.utc(2026)); + final deletedLocalFiles = []; + + await repository.upsert(book); + await pumpEventQueue(); + await queue.enqueueBookFile(book: book, filename: 'book.epub', contentType: 'application/epub+zip'); + + await deleteBookWithMediaCleanup( + dataStore: dataStore, + mediaUploadQueue: queue, + bookId: book.id, + deleteBookFile: (bookId) async => deletedLocalFiles.add(bookId), + ); + await pumpEventQueue(); + + expect(queue.pendingTasks, isEmpty); + expect(deletedLocalFiles, [book.id]); + expect(dataStore.getBook(book.id), isNull); + expect(await repository.getById(book.id), isNull); + }); +} diff --git a/app/test/widgets/context_menu/book_context_menu_test.dart b/app/test/widgets/context_menu/book_context_menu_test.dart new file mode 100644 index 0000000..504bc20 --- /dev/null +++ b/app/test/widgets/context_menu/book_context_menu_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/widgets/context_menu/book_context_menu.dart'; + +void main() { + tearDown(() { + TestWidgetsFlutterBinding.instance.platformDispatcher.clearAllTestValues(); + }); + + testWidgets('mobile context menu shows download book above delete book and invokes callback', (tester) async { + await tester.binding.setSurfaceSize(const Size(390, 844)); + var downloaded = false; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: Center( + child: FilledButton( + onPressed: () => BookContextMenu.show( + context: context, + book: _book(), + isFavorite: false, + onDownload: () => downloaded = true, + ), + child: const Text('Open menu'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open menu')); + await tester.pumpAndSettle(); + + final downloadTop = tester.getTopLeft(find.text('Download book')).dy; + final deleteTop = tester.getTopLeft(find.text('Delete book')).dy; + expect(downloadTop, lessThan(deleteTop)); + + await tester.tap(find.text('Download book')); + await tester.pumpAndSettle(); + + expect(downloaded, isTrue); + }); +} + +Book _book() { + return Book(id: 'book-1', title: 'Book', author: 'Author', fileFormat: BookFormat.epub, addedAt: DateTime.utc(2026)); +}