From 6ffe589ef438f213207c7de2ac6f5d666a227d75 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 27 Jun 2026 18:45:25 +0300 Subject: [PATCH 01/48] 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 02/48] 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)); +} From 8517b6a4ddf91771f79efb3cf5f2c7e82fe82aab Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 16:50:53 +0300 Subject: [PATCH 03/48] docs: design media storage hardening --- ...26-07-11-media-storage-hardening-design.md | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-11-media-storage-hardening-design.md diff --git a/docs/superpowers/specs/2026-07-11-media-storage-hardening-design.md b/docs/superpowers/specs/2026-07-11-media-storage-hardening-design.md new file mode 100644 index 0000000..7cea016 --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-media-storage-hardening-design.md @@ -0,0 +1,133 @@ +# Media Storage Hardening Design + +## Context + +The media-storage pipeline spans the Papyrus Flutter client and FastAPI server. Book metadata is synchronized through PowerSync while book files and cover images remain private media assets stored outside the synchronized database. + +Guest and account libraries are intentionally separate. Signing in does not migrate or merge guest books, files, covers, or pending work into an account library. This design preserves that boundary and hardens only authenticated media behavior. + +## Goals + +- Persist authenticated cover images in platform-local files after their first successful display. +- Render private covers consistently across every book-cover surface. +- Isolate pending uploads and cached covers by selected server and authenticated user. +- Ensure only one client upload-queue processor runs at a time. +- Start upload processing after new tasks have been durably enqueued. +- Prevent concurrent server uploads from creating duplicate assets or exceeding quota. +- Restore a passing client CI suite with the intended `Download` and `Delete` labels. + +## Non-goals + +- Migrating or merging guest libraries into account libraries. +- Proactively downloading all covers during metadata synchronization. +- Storing cover bytes in SQLite, PowerSync tables, PostgreSQL, or SharedPreferences. +- Adding cross-server media transfer. +- Redesigning the existing media API. + +## Profile identity and isolation + +Authenticated local media state is scoped by two values: + +1. The selected sync-server profile key already produced by `SyncSettingsProvider.activeProfileKey`. +2. The authenticated server user ID. + +Together they form an immutable media scope for one server/account pair. Upload tasks and cover-cache paths include both values. Signed-out and guest modes expose no authenticated media scope and therefore no authenticated pending tasks. + +Switching server or account changes the active scope without deleting other scopes. Returning to the same server/account restores its pending uploads and cached covers. Clearing an authenticated cache explicitly removes the active scope's cover files; signing out alone retains them for offline reuse after a later sign-in to the same profile. + +## Filesystem cover cache + +### Storage layout + +Cover bytes are stored as opaque files; image decoding uses the bytes rather than a filename extension. + +- Native: the application-support directory contains `media-covers///.bin`. +- Web: OPFS contains the equivalent directory hierarchy. + +Every write uses a temporary file or temporary OPFS entry followed by replacement so an interrupted write cannot leave a valid-looking partial cache entry. Cache path components are normalized before use, and callers cannot supply arbitrary paths. + +### Cache behavior + +A reusable cover loader follows this order: + +1. If the book has a non-empty public/data `coverUrl`, render it through the existing network/data-image path. +2. If the book has `coverMediaId`, read the scoped cover file. +3. If a cached file exists, render it immediately. +4. Otherwise download the authenticated media asset, persist it in the active scope, and render it. +5. If local read or download fails, render the existing placeholder without deleting book metadata. + +Downloads are coalesced by `(scope, mediaId)` so multiple widgets requesting the same cover share one operation. Completed futures are removed from the in-flight map; the persisted file, rather than a process-global future, is the long-lived cache. + +### UI integration + +A single reusable private-cover content widget owns the public-URL/private-media/placeholder decision. The existing sized `BookCoverImage` composes it, and library cards, list rows, dashboard cards, shelf/topic sheets, context menus, and other book-cover surfaces use the same content widget. This prevents successful upload from clearing `coverUrl` and making covers disappear outside the details page. + +Book deletion removes the cached cover for that book on a best-effort basis after pending upload tasks are removed. Clearing the active authenticated cache removes the whole scoped cover directory. Guest book-file and cover storage remains untouched. + +## Scoped upload queue + +`MediaUploadQueue` manages tasks per media scope instead of using one global `media_upload_queue` preference key. Each persisted key includes a normalized server profile and user ID. Task payloads do not need to duplicate the scope because they are stored under the scoped key. + +Changing the active scope reloads that scope's tasks and usage state, then notifies the UI. Guest or signed-out state activates no scope and presents an empty authenticated queue. Cover payloads remain in the scoped queue until upload succeeds; they never appear in another account's pending or failed list. + +Queue processing is single-flight. If processing is already active, additional calls return the same future instead of iterating the task list again. The operation captures its media scope, `AuthRepository`, and local file reader at start, so a later profile switch cannot redirect in-flight work to another server. Results are saved back to the captured scope. + +Enqueue and retry operations invoke a work-available callback only after the updated task list has been persisted. The application wires this callback to the existing media processor. This makes a newly imported book trigger its upload after enqueue, independently of PowerSync callback timing. PowerSync and authentication callbacks remain useful retry triggers and are safe because processing is single-flight. + +## Server concurrency and quota integrity + +The server serializes media uploads per user inside the database transaction: + +1. Lock the authenticated user's row with `SELECT ... FOR UPDATE`. +2. Lock and validate the target book row. +3. Recalculate the user's used bytes while holding the user lock. +4. Stream the upload into a temporary file while enforcing the remaining quota. +5. Replace the prior `(book_id, kind)` asset and update the book reference. +6. Commit before deleting the previous physical file. + +All uploads for one user therefore observe committed quota usage in order, including uploads for different books. Users do not block one another. + +The `media_assets` table also has a unique constraint on `(book_id, kind)` as a defensive invariant. The SQLAlchemy model and the existing unmerged Alembic revision are amended together. Because the revision has not shipped or merged, updating it avoids creating a corrective migration for a schema no deployment should have consumed. + +Rollback continues to remove temporary/new files and retain the previously committed asset. Physical-file deletion remains post-commit and best-effort. + +## CI label correction + +The mobile context-menu test asserts the product labels `Download` and `Delete`. No `book` suffix is added to the UI. + +## Error handling + +- Cover-cache failures degrade to a placeholder and remain retryable on a future build. +- A failed cache write does not hide successfully downloaded bytes during the current render, but it does not count as persisted. +- Queue network failures retain the task under its original scope. +- Storage-quota failures remain visible as failed tasks and require explicit retry. +- Profile switches never mutate or discard another scope's queue. +- Server constraint or transaction failures roll back database changes and remove the new temporary/final file. + +## Testing strategy + +All behavior changes follow red-green-refactor cycles. + +Client tests cover: + +- native/local cover-cache persistence, cache hits, atomic replacement, deletion, and scope isolation; +- web cover-store message handling and OPFS worker actions; +- lazy download followed by a persisted cache hit; +- shared in-flight cover downloads; +- private-cover rendering in details and representative library/dashboard surfaces; +- upload task isolation across server/user scopes; +- restoration of tasks when returning to a scope; +- single-flight processing under overlapping triggers; +- enqueue-triggered processing after persistence; +- profile switching while processing without backend redirection; +- corrected `Download` and `Delete` context-menu expectations. + +Server tests cover: + +- the `(book_id, kind)` uniqueness metadata and migration definition; +- concurrent same-kind uploads producing one stored asset; +- concurrent uploads for different books respecting aggregate user quota; +- replacement rollback retaining the old physical file; +- the existing upload, download, delete, ownership, and sync-reference behavior. + +Final verification runs Flutter analysis and the full Flutter test suite, then Ruff, the full server pytest suite, Alembic single-head inspection, and migration upgrade/downgrade/upgrade against the test database when the repository runbook environment is available. From b0722db4cba9284252019f3e46d9d9dd341743fe Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 17:06:05 +0300 Subject: [PATCH 04/48] docs: plan media storage hardening --- .../2026-07-11-media-storage-hardening.md | 773 ++++++++++++++++++ 1 file changed, 773 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-11-media-storage-hardening.md diff --git a/docs/superpowers/plans/2026-07-11-media-storage-hardening.md b/docs/superpowers/plans/2026-07-11-media-storage-hardening.md new file mode 100644 index 0000000..4402683 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-media-storage-hardening.md @@ -0,0 +1,773 @@ +# Media Storage Hardening Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make authenticated covers persist in scoped local files, serialize and scope client uploads, close the enqueue race, harden server upload concurrency, and restore client CI while preserving separate guest libraries. + +**Architecture:** Introduce a shared `MediaStorageScope` for server/account isolation, extend the existing native-filesystem/web-OPFS book storage service with scoped cover-file operations, and route every private cover through one lazy persistent loader. Make `MediaUploadQueue` scope-aware and single-flight, then serialize server uploads with a PostgreSQL user-row lock plus a unique `(book_id, kind)` invariant. + +**Tech Stack:** Flutter/Dart, SharedPreferences, native filesystem, web OPFS/Web Worker, PowerSync, FastAPI, async SQLAlchemy, PostgreSQL, Alembic, pytest. + +--- + +## File structure + +Client files to create: + +- `app/lib/media/media_storage_scope.dart`: immutable server/user scope and safe persistence key. +- `app/lib/widgets/book/private_book_cover.dart`: reusable public/private/local cover renderer. +- `app/test/media/media_storage_scope_test.dart`: scope identity and normalization tests. +- `app/test/widgets/book/private_book_cover_test.dart`: lazy cache and placeholder widget tests. + +Client files to modify: + +- `app/lib/services/book_import_service_stub.dart`: native scoped cover-file storage. +- `app/lib/services/book_import_service.dart`: web worker cover-file request methods. +- `app/web/book_worker.js`: scoped OPFS cover read/write/delete/clear actions. +- `app/lib/media/media_cache_service.dart`: lazy cover cache and in-flight coalescing. +- `app/lib/media/media_upload_queue.dart`: scoped keys, scope activation, single-flight processing, and persisted-work callback. +- `app/lib/main.dart`: scope lifecycle, stable repository capture, and queue callback wiring. +- `app/lib/services/book_delete_cleanup_service.dart`: best-effort cached-cover deletion. +- `app/lib/widgets/book_details/book_cover_image.dart`: compose the shared cover renderer. +- Cover surfaces under `app/lib/widgets/library/`, `app/lib/widgets/dashboard/`, `app/lib/widgets/context_menu/`, `app/lib/widgets/shelves/`, and `app/lib/widgets/topics/`: pass `coverMediaId` to the shared renderer. +- Existing media, deletion, widget, profile-switch, and context-menu tests. + +Server files to modify: + +- `papyrus/models/media.py`: unique `(book_id, kind)` model invariant. +- `alembic/versions/c3f8b2a9d1e4_add_media_assets.py`: matching unique constraint in the unmerged revision. +- `papyrus/services/media.py`: per-user transaction lock and locked book lookup. +- `tests/api/routes/test_media.py`: concurrent replacement and quota tests. +- `tests/test_models.py`: metadata constraint assertion. + +### Task 1: Restore the intended context-menu contract + +**Files:** +- Modify: `app/test/widgets/context_menu/book_context_menu_test.dart:11-45` +- Verify: `app/lib/widgets/context_menu/book_context_menu.dart:421-442` + +- [ ] **Step 1: Change the failing test to assert the approved labels** + +```dart +final downloadTop = tester.getTopLeft(find.text('Download')).dy; +final deleteTop = tester.getTopLeft(find.text('Delete')).dy; +expect(downloadTop, lessThan(deleteTop)); + +await tester.tap(find.text('Download')); +``` + +- [ ] **Step 2: Run the focused test and verify it passes without production changes** + +Run: `cd app && flutter test test/widgets/context_menu/book_context_menu_test.dart` + +Expected: one passing test. This is an assertion correction for an already-approved UI contract, so no red production cycle is required. + +- [ ] **Step 3: Commit the CI correction** + +```bash +git add app/test/widgets/context_menu/book_context_menu_test.dart +git commit -m "test: align book menu labels" +``` + +### Task 2: Define authenticated media scope + +**Files:** +- Create: `app/lib/media/media_storage_scope.dart` +- Create: `app/test/media/media_storage_scope_test.dart` + +- [ ] **Step 1: Write failing scope tests** + +```dart +test('scope key isolates server and user', () { + final first = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + final second = MediaStorageScope(profileKey: 'custom-deadbeef', userId: 'user-1'); + + expect(first.persistenceKey, isNot(second.persistenceKey)); + expect(first.persistenceKey, 'official--user-1'); +}); + +test('scope rejects path separators', () { + expect( + () => MediaStorageScope(profileKey: '../official', userId: 'user-1'), + throwsArgumentError, + ); +}); +``` + +- [ ] **Step 2: Run the test and verify RED** + +Run: `cd app && flutter test test/media/media_storage_scope_test.dart` + +Expected: compilation failure because `MediaStorageScope` does not exist. + +- [ ] **Step 3: Add the immutable scope type** + +```dart +class MediaStorageScope { + MediaStorageScope({required this.profileKey, required this.userId}) { + if (!_safePart.hasMatch(profileKey) || !_safePart.hasMatch(userId)) { + throw ArgumentError('Media storage scope contains unsafe characters'); + } + } + + static final RegExp _safePart = RegExp(r'^[a-zA-Z0-9_.-]+$'); + + final String profileKey; + final String userId; + + String get persistenceKey => '$profileKey--$userId'; + + @override + bool operator ==(Object other) => + other is MediaStorageScope && other.profileKey == profileKey && other.userId == userId; + + @override + int get hashCode => Object.hash(profileKey, userId); +} +``` + +- [ ] **Step 4: Run the focused test and verify GREEN** + +Run: `cd app && flutter test test/media/media_storage_scope_test.dart` + +Expected: all tests pass. + +- [ ] **Step 5: Commit the scope type** + +```bash +git add app/lib/media/media_storage_scope.dart app/test/media/media_storage_scope_test.dart +git commit -m "feat: define scoped media identity" +``` + +### Task 3: Add filesystem and OPFS cover-file operations + +**Files:** +- Modify: `app/lib/services/book_import_service_stub.dart` +- Modify: `app/lib/services/book_import_service.dart` +- Modify: `app/web/book_worker.js` +- Create: `app/test/services/book_cover_storage_test.dart` +- Modify: `app/test/services/book_import_service_test.dart` + +- [ ] **Step 1: Write failing native cover-storage tests with an injected root directory** + +```dart +test('native cover storage is isolated by scope', () async { + final root = await Directory.systemTemp.createTemp('papyrus-covers-'); + addTearDown(() => root.delete(recursive: true)); + final service = BookImportService(storageRootOverride: root); + final first = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + final second = MediaStorageScope(profileKey: 'official', userId: 'user-2'); + final bytes = Uint8List.fromList([1, 2, 3]); + + await service.storeCoverFile(first, 'asset-1', bytes); + + expect(await service.getCoverFile(first, 'asset-1'), bytes); + expect(await service.getCoverFile(second, 'asset-1'), isNull); +}); + +test('clearCoverFiles removes only the selected scope', () async { + final root = await Directory.systemTemp.createTemp('papyrus-covers-'); + addTearDown(() => root.delete(recursive: true)); + final service = BookImportService(storageRootOverride: root); + final first = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + final second = MediaStorageScope(profileKey: 'official', userId: 'user-2'); + + await service.storeCoverFile(first, 'asset-1', Uint8List.fromList([1])); + await service.storeCoverFile(second, 'asset-2', Uint8List.fromList([2])); + await service.clearCoverFiles(first); + + expect(await service.getCoverFile(first, 'asset-1'), isNull); + expect(await service.getCoverFile(second, 'asset-2'), Uint8List.fromList([2])); +}); +``` + +- [ ] **Step 2: Run the native tests and verify RED** + +Run: `cd app && flutter test test/services/book_cover_storage_test.dart` + +Expected: missing constructor parameter and cover methods. + +- [ ] **Step 3: Implement native scoped file operations** + +Add methods with these signatures: + +```dart +Future getCoverFile(MediaStorageScope scope, String mediaId); +Future storeCoverFile(MediaStorageScope scope, String mediaId, Uint8List bytes); +Future deleteCoverFile(MediaStorageScope scope, String mediaId); +Future clearCoverFiles(MediaStorageScope scope); +``` + +Use `/media-covers//.bin`. Validate `mediaId` with the same safe-component rule. Write `.tmp`, flush it, then rename it over the destination. Add `Directory? storageRootOverride` for tests; production falls back to `getApplicationSupportDirectory()`. + +- [ ] **Step 4: Run native tests and verify GREEN** + +Run: `cd app && flutter test test/services/book_cover_storage_test.dart test/services/book_import_service_test.dart` + +Expected: all tests pass. + +- [ ] **Step 5: Write a failing worker-contract test** + +```dart +test('book worker declares scoped cover cache actions', () { + final source = File('web/book_worker.js').readAsStringSync(); + for (final action in ['getCover', 'storeCover', 'deleteCover', 'clearCovers']) { + expect(source, contains("case '$action':")); + } +}); +``` + +- [ ] **Step 6: Run the worker-contract test and verify RED** + +Run: `cd app && flutter test test/services/book_cover_storage_test.dart` + +Expected: missing `getCover` action. + +- [ ] **Step 7: Extend the OPFS worker and web service** + +Add `getCover`, `storeCover`, `deleteCover`, and `clearCovers` messages containing `scopeKey`, `mediaId`, and a stable `requestId`. Store files under the OPFS `media-covers//` directory. Update the Dart pending map to key cover requests by `requestId`, transfer downloaded/stored `ArrayBuffer` values, and expose the same four Dart method signatures as the native implementation. + +- [ ] **Step 8: Verify worker syntax and focused Flutter tests** + +Run: `node --check app/web/book_worker.js` + +Run: `cd app && flutter test test/services/book_cover_storage_test.dart test/services/book_import_service_test.dart` + +Expected: JavaScript syntax succeeds and all focused tests pass. + +- [ ] **Step 9: Commit platform cover storage** + +```bash +git add app/lib/services/book_import_service.dart app/lib/services/book_import_service_stub.dart app/web/book_worker.js app/test/services/book_cover_storage_test.dart app/test/services/book_import_service_test.dart +git commit -m "feat: persist scoped covers in local files" +``` + +### Task 4: Add lazy persistent cover loading and shared rendering + +**Files:** +- Modify: `app/lib/media/media_cache_service.dart` +- Modify: `app/test/media/media_cache_service_test.dart` +- Create: `app/lib/widgets/book/private_book_cover.dart` +- Create: `app/test/widgets/book/private_book_cover_test.dart` +- Modify: `app/lib/widgets/book_details/book_cover_image.dart` +- Modify representative and remaining cover surfaces found by `rg -n "coverURL|coverUrl" app/lib/widgets app/lib/pages` + +- [ ] **Step 1: Write failing lazy-cache and coalescing tests** + +```dart +test('cover download persists and the next load reads local bytes', () async { + final service = MediaCacheService(); + final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + Uint8List? stored; + var downloads = 0; + + final first = await service.ensureCoverCached( + scope: scope, + mediaId: 'asset-1', + readLocalCover: (_, __) async => stored, + writeLocalCover: (_, __, bytes) async => stored = bytes, + downloadMedia: (_) async { + downloads++; + return Uint8List.fromList([1, 2, 3]); + }, + ); + final second = await service.ensureCoverCached( + scope: scope, + mediaId: 'asset-1', + readLocalCover: (_, __) async => stored, + writeLocalCover: (_, __, bytes) async => stored = bytes, + downloadMedia: (_) async { + downloads++; + return Uint8List.fromList([1, 2, 3]); + }, + ); + + expect(first, second); + expect(downloads, 1); +}); + +test('overlapping cover requests share one download', () async { + final service = MediaCacheService(); + final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + final gate = Completer(); + var downloads = 0; + + Future load() => service.ensureCoverCached( + scope: scope, + mediaId: 'asset-1', + readLocalCover: (_, __) async => null, + writeLocalCover: (_, __, ___) async {}, + downloadMedia: (_) { + downloads++; + return gate.future; + }, + ); + + final first = load(); + final second = load(); + gate.complete(Uint8List.fromList([1, 2, 3])); + + expect(await first, await second); + expect(downloads, 1); +}); +``` + +- [ ] **Step 2: Run cache tests and verify RED** + +Run: `cd app && flutter test test/media/media_cache_service_test.dart` + +Expected: `ensureCoverCached` is missing. + +- [ ] **Step 3: Implement cover caching in `MediaCacheService`** + +Use a `Map>` keyed by `:`. Read local bytes first; otherwise coalesce download/write work. Remove the map entry in `whenComplete` so the filesystem remains the durable cache. + +- [ ] **Step 4: Run cache tests and verify GREEN** + +Run: `cd app && flutter test test/media/media_cache_service_test.dart` + +Expected: all book-file and cover-cache tests pass. + +- [ ] **Step 5: Write failing widget tests for a private cover** + +```dart +testWidgets('private cover loads from scoped local cache', (tester) async { + final bytes = Uint8List.fromList([137, 80, 78, 71]); + var loads = 0; + await tester.pumpWidget( + MaterialApp( + home: PrivateBookCover( + mediaId: 'asset-1', + loadPrivateCover: (_) async { + loads++; + return bytes; + }, + placeholder: const Icon(Icons.menu_book), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(Image), findsOneWidget); + expect(loads, 1); +}); + +testWidgets('private cover falls back to placeholder when signed out', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: PrivateBookCover( + mediaId: 'asset-1', + placeholder: Icon(Icons.menu_book, key: Key('placeholder')), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('placeholder')), findsOneWidget); +}); +``` + +- [ ] **Step 6: Run widget tests and verify RED** + +Run: `cd app && flutter test test/widgets/book/private_book_cover_test.dart` + +Expected: `PrivateBookCover` does not exist. + +- [ ] **Step 7: Implement `PrivateBookCover` and integrate `BookCoverImage`** + +The stateful widget accepts `imageUrl`, `mediaId`, `fit`, and a placeholder builder. It caches its future until `mediaId` or scope changes, uses the active `MediaStorageScope`, reads/writes through `BookImportService`, downloads through `AuthProvider`, and delegates lazy caching to `MediaCacheService`. + +- [ ] **Step 8: Replace private-cover-blind surfaces** + +Use `PrivateBookCover` in library cards/list rows, recently-added and continue-reading dashboard cards, move-to-shelf and manage-topics sheets, and `BookContextMenu`. Pass both `book.coverURL` and `book.coverMediaId`; preserve each surface's existing dimensions, fit, and placeholder styling. + +- [ ] **Step 9: Run focused cover widget tests and analyzer** + +Run: `cd app && flutter test test/widgets/book/private_book_cover_test.dart test/widgets/library/book_card_test.dart test/widgets/library/book_list_item_test.dart` + +Run: `cd app && flutter analyze` + +Expected: focused tests and analysis pass. + +- [ ] **Step 10: Commit lazy cover rendering** + +```bash +git add app/lib/media/media_cache_service.dart app/lib/widgets app/test/media/media_cache_service_test.dart app/test/widgets +git commit -m "feat: render private covers from persistent cache" +``` + +### Task 5: Scope upload tasks and make processing single-flight + +**Files:** +- Modify: `app/lib/media/media_upload_queue.dart` +- Modify: `app/test/media/media_upload_queue_test.dart` + +- [ ] **Step 1: Write failing scope-isolation tests** + +```dart +test('pending uploads are isolated and restored per media scope', () async { + final queue = MediaUploadQueue(prefs); + final first = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + final second = MediaStorageScope(profileKey: 'custom-a', userId: 'user-2'); + + await queue.activateScope(first); + await queue.enqueueBookFile(book: book, filename: 'book.epub', contentType: 'application/epub+zip'); + await queue.activateScope(second); + expect(queue.pendingTasks, isEmpty); + await queue.activateScope(first); + expect(queue.pendingTasks, hasLength(1)); +}); +``` + +- [ ] **Step 2: Run the isolation test and verify RED** + +Run: `cd app && flutter test test/media/media_upload_queue_test.dart --plain-name "pending uploads are isolated"` + +Expected: `activateScope` is missing. + +- [ ] **Step 3: Implement scoped persistence** + +Replace the global key with `media_upload_queue:`. `activateScope(null)` exposes an empty queue. Loading malformed JSON should preserve app startup by returning an empty task list and reporting through `FlutterError.reportError`. + +- [ ] **Step 4: Run queue tests and verify scope GREEN** + +Run: `cd app && flutter test test/media/media_upload_queue_test.dart` + +Expected: scope tests pass; update existing assertions to inspect the scoped key. + +- [ ] **Step 5: Write a failing overlapping-processing test** + +```dart +test('overlapping processPending calls share one upload operation', () async { + final gate = Completer(); + var uploads = 0; + Future process() => queue.processPending( + dataStore: dataStore, + readBookFile: (_) async => Uint8List.fromList([1, 2, 3]), + uploadMedia: (_) { + uploads++; + return gate.future; + }, + ); + + final first = process(); + final second = process(); + expect(identical(first, second), isTrue); + gate.complete(asset); + await Future.wait([first, second]); + expect(uploads, 1); +}); +``` + +- [ ] **Step 6: Run the overlap test and verify RED** + +Run: `cd app && flutter test test/media/media_upload_queue_test.dart --plain-name "overlapping processPending"` + +Expected: two uploads or non-identical futures. + +- [ ] **Step 7: Implement single-flight processing** + +Track `Future? _processing`. Return the current future when non-null, capture the active scope at operation start, and clear the field only when the identical operation completes. Expose `Future waitUntilIdle()` for profile changes. + +- [ ] **Step 8: Run all queue tests and verify GREEN** + +Run: `cd app && flutter test test/media/media_upload_queue_test.dart` + +Expected: all tests pass. + +- [ ] **Step 9: Commit queue isolation and serialization** + +```bash +git add app/lib/media/media_upload_queue.dart app/test/media/media_upload_queue_test.dart +git commit -m "fix: scope and serialize media uploads" +``` + +### Task 6: Trigger uploads after durable enqueue and coordinate profile changes + +**Files:** +- Modify: `app/lib/media/media_upload_queue.dart` +- Modify: `app/lib/main.dart` +- Modify: `app/lib/widgets/add_book/import_book_sheet.dart` +- Modify: `app/test/media/media_upload_queue_test.dart` +- Modify: `app/test/powersync/storage_sync_controller_test.dart` +- Modify or create: `app/test/main_media_lifecycle_test.dart` + +- [ ] **Step 1: Write a failing persisted-work callback test** + +```dart +test('enqueue invokes work callback after scoped tasks are persisted', () async { + String? storedAtCallback; + late SharedPreferences prefs; + final queue = MediaUploadQueue( + prefs, + onWorkAvailable: () async { + storedAtCallback = prefs.getString('media_upload_queue:${scope.persistenceKey}'); + }, + ); + + await queue.activateScope(scope); + await queue.enqueueBookFile( + book: book, + filename: 'book.epub', + contentType: 'application/epub+zip', + ); + expect(storedAtCallback, isNotNull); + expect(jsonDecode(storedAtCallback!), hasLength(1)); +}); +``` + +- [ ] **Step 2: Run the callback test and verify RED** + +Run: `cd app && flutter test test/media/media_upload_queue_test.dart --plain-name "enqueue invokes work callback"` + +Expected: constructor does not accept `onWorkAvailable`. + +- [ ] **Step 3: Implement the post-persistence callback** + +Add `Future Function()? onWorkAvailable`. Invoke it after `_save()` in `_enqueue` and after `retryFailed` makes at least one task pending. Do not invoke it from internal task-status saves. + +- [ ] **Step 4: Run queue tests and verify GREEN** + +Run: `cd app && flutter test test/media/media_upload_queue_test.dart` + +Expected: all tests pass. + +- [ ] **Step 5: Write failing lifecycle tests** + +Cover three concrete cases in `main_media_lifecycle_test.dart`: a signed-in import persists a task before its fake uploader is invoked; a server switch remains incomplete while a fake upload `Completer` is pending and activates the new scope after completion; and guest activation leaves `MediaUploadQueue.activeScope` null with no visible authenticated tasks. Build the harness with injected fake `AuthRepository`, `PapyrusPowerSyncService`, and uploader closures, then assert invocation order through a shared event list. + +- [ ] **Step 6: Run lifecycle tests and verify RED** + +Run: `cd app && flutter test test/main_media_lifecycle_test.dart` + +Expected: missing scope activation/callback wiring. + +- [ ] **Step 7: Wire scope and processing lifecycle in `main.dart`** + +Construct `MediaStorageScope(profileKey: _activeProfileKey, userId: user.userId)` only for signed-in account mode. Activate it before processing. Capture `final repository = _authRepository` inside each processor operation. Before replacing the repository during a server switch, await `_mediaUploadQueue.waitUntilIdle()`. Wire `onWorkAvailable` to `_processMediaUploads`; single-flight makes authentication and PowerSync triggers safe. + +Remove any direct process call from the import widget if the queue callback makes it redundant. Keep the import sequence awaiting both book-file and cover enqueue operations so the callback sees durable work. + +- [ ] **Step 8: Run lifecycle and import tests** + +Run: `cd app && flutter test test/main_media_lifecycle_test.dart test/services/book_import_service_test.dart test/media/media_upload_queue_test.dart` + +Expected: all tests pass. + +- [ ] **Step 9: Commit enqueue/lifecycle coordination** + +```bash +git add app/lib/main.dart app/lib/media/media_upload_queue.dart app/lib/widgets/add_book/import_book_sheet.dart app/test +git commit -m "fix: process media immediately after enqueue" +``` + +### Task 7: Clean cached covers with book/account cache deletion + +**Files:** +- Modify: `app/lib/services/book_delete_cleanup_service.dart` +- Modify callers in `app/lib/pages/book_details_page.dart`, `app/lib/utils/book_actions.dart`, and `app/lib/utils/bulk_book_actions.dart` +- Modify: `app/test/services/book_delete_cleanup_service_test.dart` +- Modify authenticated-cache clearing path in `app/lib/main.dart` or `app/lib/powersync/storage_sync_controller.dart` + +- [ ] **Step 1: Write a failing deletion test** + +```dart +test('deleteBookWithMediaCleanup removes the scoped cached cover', () async { + final deletedCovers = []; + await deleteBookWithMediaCleanup( + dataStore: dataStore, + mediaUploadQueue: queue, + bookId: book.id, + coverMediaId: 'cover-1', + deleteBookFile: (_) async {}, + deleteCoverFile: (mediaId) async => deletedCovers.add(mediaId), + ); + expect(deletedCovers, ['cover-1']); +}); +``` + +- [ ] **Step 2: Run the deletion test and verify RED** + +Run: `cd app && flutter test test/services/book_delete_cleanup_service_test.dart` + +Expected: missing cover parameters. + +- [ ] **Step 3: Implement best-effort cover cleanup and update callers** + +Remove scoped upload tasks first, then independently attempt local book-file and cover-file deletion, finally delete metadata. Pass the current book's `coverMediaId` and a closure bound to the active `MediaStorageScope`. + +- [ ] **Step 4: Add and pass an authenticated-cache-clear test** + +Assert clearing the active authenticated cache invokes `clearCoverFiles(activeScope)` but guest clearing does not. + +- [ ] **Step 5: Run deletion/profile tests and commit** + +Run: `cd app && flutter test test/services/book_delete_cleanup_service_test.dart test/pages/book_details_delete_test.dart test/powersync/storage_sync_controller_test.dart` + +Expected: all tests pass. + +```bash +git add app/lib app/test +git commit -m "fix: clean scoped cover files" +``` + +### Task 8: Enforce one server asset per book kind and serialize quota checks + +**Files:** +- Modify: `../server/papyrus/models/media.py` +- Modify: `../server/alembic/versions/c3f8b2a9d1e4_add_media_assets.py` +- Modify: `../server/papyrus/services/media.py` +- Modify: `../server/tests/test_models.py` +- Modify: `../server/tests/api/routes/test_media.py` + +- [ ] **Step 1: Write a failing metadata constraint test** + +```python +def test_media_asset_kind_is_unique_per_book() -> None: + table = Base.metadata.tables['media_assets'] + unique_columns = { + tuple(constraint.columns.keys()) + for constraint in table.constraints + if isinstance(constraint, UniqueConstraint) + } + assert ('book_id', 'kind') in unique_columns +``` + +- [ ] **Step 2: Run the model test and verify RED** + +Run: `cd ../server && .venv/bin/pytest tests/test_models.py -q` + +Expected: the unique column pair is absent. + +- [ ] **Step 3: Add the SQLAlchemy and Alembic constraint** + +```python +__table_args__ = ( + UniqueConstraint('book_id', 'kind', name='uq_media_assets_book_kind'), +) +``` + +Add the equivalent `sa.UniqueConstraint` to `c3f8b2a9d1e4_add_media_assets.py`. Do not create a new revision because this revision is unmerged and undeployed. + +- [ ] **Step 4: Run the model test and verify GREEN** + +Run: `cd ../server && .venv/bin/pytest tests/test_models.py -q` + +Expected: all model tests pass. + +- [ ] **Step 5: Write failing concurrent upload tests** + +Using `test_session_maker`, create two independent `AsyncSession` instances and start two `media_service.upload_media` coroutines with `asyncio.gather`. In `test_concurrent_same_kind_uploads_leave_one_asset`, target one user/book/kind and assert exactly one `MediaAsset` row, the book references that row, and exactly one physical file remains. In `test_concurrent_uploads_enforce_aggregate_user_quota`, target two books owned by one user with two files whose combined size exceeds the configured quota; assert one coroutine succeeds, one returns `ConflictError`, and both the SQL sum and physical-file sum remain at or below quota. + +- [ ] **Step 6: Run concurrent tests and verify RED** + +Run: `cd ../server && .venv/bin/pytest tests/api/routes/test_media.py -q -k concurrent` + +Expected: duplicate assets or quota overflow. + +- [ ] **Step 7: Lock the user and book rows before quota/replacement reads** + +Add transaction helpers equivalent to: + +```python +await session.execute( + select(User.user_id).where(User.user_id == user_id).with_for_update() +) +book = ( + await session.execute( + select(SyncBook) + .where(SyncBook.book_id == book_id) + .with_for_update() + ) +).scalar_one_or_none() +``` + +Acquire the user lock first for every upload, then validate the locked book, calculate usage, stream/write, replace, and commit. Keep the existing rollback cleanup and post-commit old-file deletion. + +- [ ] **Step 8: Run media and sync tests and verify GREEN** + +Run: `cd ../server && .venv/bin/pytest tests/api/routes/test_media.py tests/api/routes/test_sync.py -q` + +Expected: all focused server tests pass. + +- [ ] **Step 9: Verify migration shape** + +Run: `cd ../server && .venv/bin/alembic heads` + +Expected: one head, `c3f8b2a9d1e4`. + +Run, when the runbook database is available: + +```bash +cd ../server +.venv/bin/alembic downgrade a1d7c2f4e8b9 +.venv/bin/alembic upgrade head +.venv/bin/alembic downgrade a1d7c2f4e8b9 +.venv/bin/alembic upgrade head +``` + +Expected: every command succeeds and the final revision is `c3f8b2a9d1e4`. + +- [ ] **Step 10: Commit server hardening** + +```bash +cd ../server +git add papyrus/models/media.py papyrus/services/media.py alembic/versions/c3f8b2a9d1e4_add_media_assets.py tests/test_models.py tests/api/routes/test_media.py +git commit -m "fix: serialize media storage updates" +``` + +### Task 9: Full verification and PR readiness + +**Files:** +- Review all files changed by Tasks 1-8. + +- [ ] **Step 1: Run client formatting and diff checks** + +Run: `cd app && dart format --output=none --set-exit-if-changed lib test` + +Run: `git diff --check origin/master...HEAD` + +Expected: both pass. + +- [ ] **Step 2: Run full client verification** + +Run: `cd app && flutter analyze` + +Run: `cd app && flutter test` + +Run: `node --check app/web/book_worker.js` + +Expected: no analyzer issues and all tests pass. + +- [ ] **Step 3: Run server formatting/lint and full tests** + +Run: `cd ../server && .venv/bin/ruff format --check papyrus tests alembic` + +Run: `cd ../server && .venv/bin/ruff check papyrus tests alembic` + +Run: `cd ../server && .venv/bin/pytest -q` + +Expected: formatting and lint pass; all non-provider tests pass with only the established opt-in skips. + +- [ ] **Step 4: Confirm clean scoped behavior manually from code paths** + +Check that: + +- guest activation uses no `MediaStorageScope`; +- sign-out does not delete another account's queue/cache; +- server switch waits for queue idle and captures the old repository; +- every book-cover surface found by `rg -n "coverURL|coverUrl" app/lib/widgets app/lib/pages` either uses `PrivateBookCover` or intentionally renders a public-only decorative URL; +- no cover bytes are stored in SQL or SharedPreferences. + +- [ ] **Step 5: Inspect GitHub checks without changing remote state** + +Run: + +```bash +gh pr checks 16 --repo PapyrusReader/client +gh pr checks 3 --repo PapyrusReader/server +``` + +Expected: existing remote checks may still reflect prior commits until pushed; report this distinction explicitly. + +- [ ] **Step 6: Report final commits, test counts, migration caveats, and any residual risk** + +Do not claim either PR passes until the fresh commands above prove it locally. Do not push or submit GitHub reviews unless separately requested. From 1df18f2950b3d9465e4786f7cf398adcf36f95c0 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 17:07:03 +0300 Subject: [PATCH 05/48] test: align book menu labels --- app/test/widgets/context_menu/book_context_menu_test.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/test/widgets/context_menu/book_context_menu_test.dart b/app/test/widgets/context_menu/book_context_menu_test.dart index 504bc20..9feb872 100644 --- a/app/test/widgets/context_menu/book_context_menu_test.dart +++ b/app/test/widgets/context_menu/book_context_menu_test.dart @@ -35,11 +35,11 @@ void main() { 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; + final downloadTop = tester.getTopLeft(find.text('Download')).dy; + final deleteTop = tester.getTopLeft(find.text('Delete')).dy; expect(downloadTop, lessThan(deleteTop)); - await tester.tap(find.text('Download book')); + await tester.tap(find.text('Download')); await tester.pumpAndSettle(); expect(downloaded, isTrue); From 6cdc91ccdcef393c23f02e7af0c323a8d08a6f19 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 17:07:53 +0300 Subject: [PATCH 06/48] feat: define scoped media identity --- app/lib/media/media_storage_scope.dart | 23 ++++++++++++++ app/test/media/media_storage_scope_test.dart | 33 ++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 app/lib/media/media_storage_scope.dart create mode 100644 app/test/media/media_storage_scope_test.dart diff --git a/app/lib/media/media_storage_scope.dart b/app/lib/media/media_storage_scope.dart new file mode 100644 index 0000000..0276c2a --- /dev/null +++ b/app/lib/media/media_storage_scope.dart @@ -0,0 +1,23 @@ +/// Identifies local authenticated media belonging to one server and user. +class MediaStorageScope { + MediaStorageScope({required this.profileKey, required this.userId}) { + if (!_safePart.hasMatch(profileKey) || !_safePart.hasMatch(userId)) { + throw ArgumentError('Media storage scope contains unsafe characters'); + } + } + + static final RegExp _safePart = RegExp(r'^[a-zA-Z0-9_.-]+$'); + + final String profileKey; + final String userId; + + String get persistenceKey => '$profileKey--$userId'; + + @override + bool operator ==(Object other) { + return other is MediaStorageScope && other.profileKey == profileKey && other.userId == userId; + } + + @override + int get hashCode => Object.hash(profileKey, userId); +} diff --git a/app/test/media/media_storage_scope_test.dart b/app/test/media/media_storage_scope_test.dart new file mode 100644 index 0000000..cca6751 --- /dev/null +++ b/app/test/media/media_storage_scope_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/media/media_storage_scope.dart'; + +void main() { + test('scope key isolates server and user', () { + final first = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + final second = MediaStorageScope(profileKey: 'custom-deadbeef', userId: 'user-1'); + + expect(first.persistenceKey, isNot(second.persistenceKey)); + expect(first.persistenceKey, 'official--user-1'); + }); + + test('scope equality uses server and user', () { + final first = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + final same = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + final otherUser = MediaStorageScope(profileKey: 'official', userId: 'user-2'); + + expect(first, same); + expect(first.hashCode, same.hashCode); + expect(first, isNot(otherUser)); + }); + + test('scope rejects path separators', () { + expect( + () => MediaStorageScope(profileKey: '../official', userId: 'user-1'), + throwsArgumentError, + ); + expect( + () => MediaStorageScope(profileKey: 'official', userId: r'user\1'), + throwsArgumentError, + ); + }); +} From 7ba5163db1ffb58d81e91276cab8f65b7301f6b4 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 17:11:01 +0300 Subject: [PATCH 07/48] feat: persist scoped covers in local files --- app/lib/services/book_import_service.dart | 74 +++++++++++- .../services/book_import_service_stub.dart | 58 +++++++++ .../services/book_cover_storage_test.dart | 94 +++++++++++++++ app/web/book_worker.js | 113 ++++++++++++++++++ 4 files changed, 336 insertions(+), 3 deletions(-) create mode 100644 app/test/services/book_cover_storage_test.dart diff --git a/app/lib/services/book_import_service.dart b/app/lib/services/book_import_service.dart index 31d3bf8..411767f 100644 --- a/app/lib/services/book_import_service.dart +++ b/app/lib/services/book_import_service.dart @@ -3,6 +3,7 @@ import 'dart:js_interop'; import 'dart:js_interop_unsafe'; import 'package:flutter/foundation.dart'; +import 'package:papyrus/media/media_storage_scope.dart'; import 'package:papyrus/services/book_import_result.dart'; import 'package:uuid/uuid.dart'; import 'package:web/web.dart' as web; @@ -65,7 +66,15 @@ class BookImportService { final message = _jsToNullableString(obj['message']) ?? 'Unknown error'; final error = Exception(message); final action = _jsToNullableString(obj['action']); + final requestId = _jsToNullableString(obj['requestId']); final bookId = _jsToNullableString(obj['bookId']); + if (requestId != null) { + final c = _pending.remove(requestId); + if (c != null && !c.isCompleted) { + c.completeError(error); + return; + } + } if (action != null && bookId != null) { final key = '$action:$bookId'; final c = _pending.remove(key); @@ -83,16 +92,17 @@ class BookImportService { if (type == 'success') { final action = _jsToNullableString(obj['action']); + final requestId = _jsToNullableString(obj['requestId']); final bookId = _jsToNullableString(obj['bookId']); - if (action == null || bookId == null) { + final key = requestId ?? (action != null && bookId != null ? '$action:$bookId' : null); + if (key == null) { debugPrint( 'BookImportService: success message with null ' - 'action=$action bookId=$bookId — ignoring', + 'action=$action bookId=$bookId requestId=$requestId — ignoring', ); return; } - final key = '$action:$bookId'; final c = _pending.remove(key); if (c != null && !c.isCompleted) { c.complete(obj); @@ -251,6 +261,27 @@ class BookImportService { ); } + Future getCoverFile(MediaStorageScope scope, String mediaId) async { + final obj = await _sendCoverRequest(type: 'getCover', scope: scope, mediaId: mediaId); + final fileDataJs = obj['fileData']; + if (fileDataJs == null || fileDataJs.isNull || fileDataJs.isUndefined) { + return null; + } + return (fileDataJs as JSArrayBuffer).toDart.asUint8List(); + } + + Future storeCoverFile(MediaStorageScope scope, String mediaId, Uint8List bytes) async { + await _sendCoverRequest(type: 'storeCover', scope: scope, mediaId: mediaId, bytes: bytes); + } + + Future deleteCoverFile(MediaStorageScope scope, String mediaId) async { + await _sendCoverRequest(type: 'deleteCover', scope: scope, mediaId: mediaId); + } + + Future clearCoverFiles(MediaStorageScope scope) async { + await _sendCoverRequest(type: 'clearCovers', scope: scope); + } + /// Terminates the Web Worker and releases resources. void dispose() { _worker?.terminate(); @@ -266,6 +297,43 @@ class BookImportService { // Private helpers // --------------------------------------------------------------------------- + Future _sendCoverRequest({ + required String type, + required MediaStorageScope scope, + String? mediaId, + Uint8List? bytes, + }) async { + final requestId = const Uuid().v4(); + final completer = Completer(); + final worker = _getWorker(); + _pending[requestId] = completer; + + final message = JSObject(); + message['type'] = type.toJS; + message['requestId'] = requestId.toJS; + message['scopeKey'] = scope.persistenceKey.toJS; + if (mediaId != null) message['mediaId'] = mediaId.toJS; + + if (bytes == null) { + worker.postMessage(message); + } else { + final actualBytes = bytes.offsetInBytes == 0 && bytes.lengthInBytes == bytes.buffer.lengthInBytes + ? bytes + : Uint8List.fromList(bytes); + final jsBuffer = actualBytes.buffer.toJS; + message['fileData'] = jsBuffer; + worker.postMessage(message, [jsBuffer].toJS); + } + + return completer.future.timeout( + _timeout, + onTimeout: () { + _pending.remove(requestId); + throw TimeoutException('$type timed out after ${_timeout.inSeconds}s', _timeout); + }, + ); + } + BookImportResult _parseImportResult(JSObject data, String bookId, String fileExtension) { final metadataRaw = data['metadata']; if (metadataRaw == null || metadataRaw.isNull || metadataRaw.isUndefined) { diff --git a/app/lib/services/book_import_service_stub.dart b/app/lib/services/book_import_service_stub.dart index 06f39da..29a70f4 100644 --- a/app/lib/services/book_import_service_stub.dart +++ b/app/lib/services/book_import_service_stub.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:crypto/crypto.dart'; +import 'package:papyrus/media/media_storage_scope.dart'; import 'package:papyrus/services/book_import_result.dart'; import 'package:papyrus/services/file_metadata_service.dart'; import 'package:path/path.dart' as p; @@ -15,6 +16,8 @@ export 'package:papyrus/services/book_import_result.dart'; /// Uses [FileMetadataService] for metadata extraction and stores files /// in the application documents directory. class BookImportService { + static final RegExp _safeFilePart = RegExp(r'^[a-zA-Z0-9_.-]+$'); + final _metadataService = FileMetadataService(); /// Imports a book file: extracts metadata and stores the file locally. @@ -101,6 +104,44 @@ class BookImportService { await file.writeAsBytes(bytes); } + /// Returns a persistently cached private cover for [scope], when present. + Future getCoverFile(MediaStorageScope scope, String mediaId) async { + final file = await _coverFile(scope, mediaId); + if (!await file.exists()) return null; + return file.readAsBytes(); + } + + /// Atomically stores private cover bytes in the selected account scope. + Future storeCoverFile(MediaStorageScope scope, String mediaId, Uint8List bytes) async { + final file = await _coverFile(scope, mediaId); + final tempFile = File('${file.path}.tmp'); + await tempFile.writeAsBytes(bytes, flush: true); + try { + await tempFile.rename(file.path); + } on FileSystemException { + if (await file.exists()) { + await file.delete(); + } + await tempFile.rename(file.path); + } + } + + /// Deletes one cached private cover without affecting other scopes. + Future deleteCoverFile(MediaStorageScope scope, String mediaId) async { + final file = await _coverFile(scope, mediaId); + if (await file.exists()) { + await file.delete(); + } + } + + /// Deletes all cached private covers for one server/account scope. + Future clearCoverFiles(MediaStorageScope scope) async { + final directory = await _coverDirectory(scope, create: false); + if (directory != null && await directory.exists()) { + await directory.delete(recursive: true); + } + } + /// No-op on native — no worker to terminate. void dispose() {} @@ -113,4 +154,21 @@ class BookImportService { } return booksDir; } + + Future _coverFile(MediaStorageScope scope, String mediaId) async { + if (!_safeFilePart.hasMatch(mediaId)) { + throw ArgumentError.value(mediaId, 'mediaId', 'Media id contains unsafe characters'); + } + final directory = await _coverDirectory(scope, create: true); + return File(p.join(directory!.path, '$mediaId.bin')); + } + + Future _coverDirectory(MediaStorageScope scope, {required bool create}) async { + final appDir = await getApplicationSupportDirectory(); + final directory = Directory(p.join(appDir.path, 'media-covers', scope.persistenceKey)); + if (create && !await directory.exists()) { + await directory.create(recursive: true); + } + return directory; + } } diff --git a/app/test/services/book_cover_storage_test.dart b/app/test/services/book_cover_storage_test.dart new file mode 100644 index 0000000..8571b66 --- /dev/null +++ b/app/test/services/book_cover_storage_test.dart @@ -0,0 +1,94 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/media/media_storage_scope.dart'; +import 'package:papyrus/services/book_import_service_stub.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +class _FakePathProvider extends Fake with MockPlatformInterfaceMixin implements PathProviderPlatform { + _FakePathProvider(this.root); + + final Directory root; + + @override + Future getApplicationDocumentsPath() async => root.path; + + @override + Future getApplicationSupportPath() async => root.path; +} + +void main() { + late Directory root; + late BookImportService service; + + setUp(() async { + root = await Directory.systemTemp.createTemp('papyrus-cover-cache-'); + PathProviderPlatform.instance = _FakePathProvider(root); + service = BookImportService(); + }); + + tearDown(() async { + if (root.existsSync()) { + await root.delete(recursive: true); + } + }); + + test('cover storage is isolated by server and user scope', () async { + final first = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + final second = MediaStorageScope(profileKey: 'official', userId: 'user-2'); + final bytes = Uint8List.fromList([1, 2, 3]); + + await service.storeCoverFile(first, 'asset-1', bytes); + + expect(await service.getCoverFile(first, 'asset-1'), bytes); + expect(await service.getCoverFile(second, 'asset-1'), isNull); + }); + + test('storing a cover atomically replaces existing bytes', () async { + final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + + await service.storeCoverFile(scope, 'asset-1', Uint8List.fromList([1, 2, 3])); + await service.storeCoverFile(scope, 'asset-1', Uint8List.fromList([4, 5])); + + expect(await service.getCoverFile(scope, 'asset-1'), Uint8List.fromList([4, 5])); + expect(root.listSync(recursive: true).whereType().where((file) => file.path.endsWith('.tmp')), isEmpty); + }); + + test('deleting a cover is idempotent', () async { + final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + await service.storeCoverFile(scope, 'asset-1', Uint8List.fromList([1])); + + await service.deleteCoverFile(scope, 'asset-1'); + await service.deleteCoverFile(scope, 'asset-1'); + + expect(await service.getCoverFile(scope, 'asset-1'), isNull); + }); + + test('clearing covers removes only the selected scope', () async { + final first = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + final second = MediaStorageScope(profileKey: 'official', userId: 'user-2'); + + await service.storeCoverFile(first, 'asset-1', Uint8List.fromList([1])); + await service.storeCoverFile(second, 'asset-2', Uint8List.fromList([2])); + await service.clearCoverFiles(first); + + expect(await service.getCoverFile(first, 'asset-1'), isNull); + expect(await service.getCoverFile(second, 'asset-2'), Uint8List.fromList([2])); + }); + + test('cover storage rejects unsafe media ids', () async { + final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + + expect(() => service.storeCoverFile(scope, '../asset', Uint8List.fromList([1])), throwsArgumentError); + }); + + test('book worker declares scoped cover cache actions', () { + final source = File('web/book_worker.js').readAsStringSync(); + + for (final action in ['getCover', 'storeCover', 'deleteCover', 'clearCovers']) { + expect(source, contains("case '$action':")); + } + }); +} diff --git a/app/web/book_worker.js b/app/web/book_worker.js index c51fc91..12f9ec7 100644 --- a/app/web/book_worker.js +++ b/app/web/book_worker.js @@ -9,6 +9,10 @@ * { type: 'delete', bookId } * { type: 'getFile', bookId } * { type: 'storeFile', format, bookId, fileData: ArrayBuffer } + * { type: 'getCover', requestId, scopeKey, mediaId } + * { type: 'storeCover', requestId, scopeKey, mediaId, fileData: ArrayBuffer } + * { type: 'deleteCover', requestId, scopeKey, mediaId } + * { type: 'clearCovers', requestId, scopeKey } * * Outgoing: * { type: 'success', action: 'process', bookId, metadata, coverData, coverMimeType, fileSize, fileHash } @@ -40,6 +44,18 @@ self.onmessage = async (event) => { case 'storeFile': await handleStoreFile(msg); break; + case 'getCover': + await handleGetCover(msg); + break; + case 'storeCover': + await handleStoreCover(msg); + break; + case 'deleteCover': + await handleDeleteCover(msg); + break; + case 'clearCovers': + await handleClearCovers(msg); + break; default: postMessage({ type: 'error', message: `Unknown message type: ${msg.type}` }); } @@ -48,6 +64,7 @@ self.onmessage = async (event) => { type: 'error', action: msg.type, bookId: msg.bookId, + requestId: msg.requestId, message: err.message || String(err), }); } @@ -108,6 +125,34 @@ async function handleStoreFile(msg) { postMessage({ type: 'success', action: 'storeFile', bookId }); } +async function handleGetCover(msg) { + const { requestId, scopeKey, mediaId } = msg; + const bytes = await opfsReadCover(scopeKey, mediaId); + const fileData = bytes ? bytes.buffer : null; + postMessage( + { type: 'success', action: 'getCover', requestId, fileData }, + fileData ? [fileData] : [], + ); +} + +async function handleStoreCover(msg) { + const { requestId, scopeKey, mediaId, fileData } = msg; + await opfsWriteCover(scopeKey, mediaId, new Uint8Array(fileData)); + postMessage({ type: 'success', action: 'storeCover', requestId }); +} + +async function handleDeleteCover(msg) { + const { requestId, scopeKey, mediaId } = msg; + await opfsDeleteCover(scopeKey, mediaId); + postMessage({ type: 'success', action: 'deleteCover', requestId }); +} + +async function handleClearCovers(msg) { + const { requestId, scopeKey } = msg; + await opfsClearCovers(scopeKey); + postMessage({ type: 'success', action: 'clearCovers', requestId }); +} + // --------------------------------------------------------------------------- // EPUB processing // --------------------------------------------------------------------------- @@ -575,6 +620,74 @@ async function opfsDelete(bookId) { } } +function validateFilePart(value, fieldName) { + if (typeof value !== 'string' || !/^[a-zA-Z0-9_.-]+$/.test(value)) { + throw new Error(`${fieldName} contains unsafe characters`); + } +} + +async function opfsCoverDirectory(scopeKey, create) { + validateFilePart(scopeKey, 'scopeKey'); + const root = await navigator.storage.getDirectory(); + let coversRoot; + try { + coversRoot = await root.getDirectoryHandle('media-covers', { create }); + return await coversRoot.getDirectoryHandle(scopeKey, { create }); + } catch (_) { + if (!create) return null; + throw _; + } +} + +async function opfsWriteCover(scopeKey, mediaId, uint8Array) { + validateFilePart(mediaId, 'mediaId'); + const directory = await opfsCoverDirectory(scopeKey, true); + const fileHandle = await directory.getFileHandle(`${mediaId}.bin`, { create: true }); + const writable = await fileHandle.createWritable(); + await writable.write(uint8Array); + await writable.close(); +} + +async function opfsReadCover(scopeKey, mediaId) { + validateFilePart(mediaId, 'mediaId'); + const directory = await opfsCoverDirectory(scopeKey, false); + if (!directory) return null; + try { + const fileHandle = await directory.getFileHandle(`${mediaId}.bin`, { create: false }); + const file = await fileHandle.getFile(); + return new Uint8Array(await file.arrayBuffer()); + } catch (_) { + return null; + } +} + +async function opfsDeleteCover(scopeKey, mediaId) { + validateFilePart(mediaId, 'mediaId'); + const directory = await opfsCoverDirectory(scopeKey, false); + if (!directory) return; + try { + await directory.removeEntry(`${mediaId}.bin`); + } catch (_) { + // Deletion is idempotent. + } +} + +async function opfsClearCovers(scopeKey) { + validateFilePart(scopeKey, 'scopeKey'); + const root = await navigator.storage.getDirectory(); + let coversRoot; + try { + coversRoot = await root.getDirectoryHandle('media-covers', { create: false }); + } catch (_) { + return; + } + try { + await coversRoot.removeEntry(scopeKey, { recursive: true }); + } catch (_) { + // Clearing an absent scope is successful. + } +} + // --------------------------------------------------------------------------- // Utility helpers // --------------------------------------------------------------------------- From 2a93ee1e444322d2b1baacb0636e67c7c35c48a0 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 17:19:05 +0300 Subject: [PATCH 08/48] feat: render private covers from persistent cache --- app/lib/data/data_store.dart | 5 +- app/lib/main.dart | 2 +- app/lib/media/media_cache_service.dart | 53 ++++++- app/lib/models/shelf.dart | 3 +- app/lib/pages/annotations_page.dart | 1 + app/lib/pages/bookmarks_page.dart | 1 + app/lib/pages/notes_page.dart | 1 + app/lib/providers/annotations_provider.dart | 4 + app/lib/providers/bookmarks_provider.dart | 4 + app/lib/providers/notes_provider.dart | 4 + app/lib/widgets/book/private_book_cover.dart | 131 ++++++++++++++++++ .../book_details/book_cover_image.dart | 50 +------ .../context_menu/book_context_menu.dart | 16 +-- .../dashboard/continue_reading_card.dart | 13 +- .../dashboard/recently_added_section.dart | 13 +- app/lib/widgets/library/book_card.dart | 22 +-- app/lib/widgets/library/book_list_item.dart | 17 +-- app/lib/widgets/shared/book_group_header.dart | 26 ++-- .../widgets/shelves/move_to_shelf_sheet.dart | 17 +-- app/lib/widgets/shelves/shelf_card.dart | 22 +-- .../widgets/topics/manage_topics_sheet.dart | 17 +-- app/test/media/media_cache_service_test.dart | 84 ++++++++++- app/test/pages/book_details_delete_test.dart | 4 +- .../widgets/book/private_book_cover_test.dart | 90 ++++++++++++ 24 files changed, 436 insertions(+), 164 deletions(-) create mode 100644 app/lib/widgets/book/private_book_cover.dart create mode 100644 app/test/widgets/book/private_book_cover_test.dart diff --git a/app/lib/data/data_store.dart b/app/lib/data/data_store.dart index 0a9a276..ec9724d 100644 --- a/app/lib/data/data_store.dart +++ b/app/lib/data/data_store.dart @@ -189,7 +189,10 @@ class DataStore extends ChangeNotifier { /// Get cover previews for a shelf (up to 4 books). List getCoverPreviewsForShelf(String shelfId, {int limit = 4}) { final books = getBooksInShelf(shelfId); - return books.take(limit).map((b) => CoverPreview(url: b.coverUrl, title: b.title)).toList(); + return books + .take(limit) + .map((b) => CoverPreview(url: b.coverUrl, mediaId: b.coverMediaId, title: b.title)) + .toList(); } // ============================================================ diff --git a/app/lib/main.dart b/app/lib/main.dart index b551309..56185b1 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -217,4 +217,4 @@ class _PapyrusState extends State { BookDownloadService _createBookDownloadService(BuildContext _) => const BookDownloadService(); -MediaCacheService _createMediaCacheService(BuildContext _) => const MediaCacheService(); +MediaCacheService _createMediaCacheService(BuildContext _) => MediaCacheService(); diff --git a/app/lib/media/media_cache_service.dart b/app/lib/media/media_cache_service.dart index 09640c1..45d1774 100644 --- a/app/lib/media/media_cache_service.dart +++ b/app/lib/media/media_cache_service.dart @@ -1,15 +1,18 @@ import 'dart:typed_data'; import 'package:crypto/crypto.dart'; +import 'package:papyrus/media/media_storage_scope.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); +typedef LocalCoverReader = Future Function(MediaStorageScope scope, String mediaId); +typedef LocalCoverWriter = Future Function(MediaStorageScope scope, String mediaId, Uint8List bytes); /// Coordinates lazy download and platform-local caching for private media. class MediaCacheService { - const MediaCacheService(); + final Map> _coverDownloads = {}; /// Returns a cached book file when present and, if the book has a stored /// hash, the bytes match the expected hash. @@ -44,6 +47,54 @@ class MediaCacheService { return downloaded; } + /// Returns a scoped local cover, downloading and persisting it when absent. + Future ensureCoverCached({ + required MediaStorageScope scope, + required String mediaId, + required LocalCoverReader readLocalCover, + required LocalCoverWriter writeLocalCover, + required MediaDownloader downloadMedia, + }) { + final key = '${scope.persistenceKey}:$mediaId'; + final existing = _coverDownloads[key]; + if (existing != null) return existing; + + final operation = _loadAndPersistCover( + scope: scope, + mediaId: mediaId, + readLocalCover: readLocalCover, + writeLocalCover: writeLocalCover, + downloadMedia: downloadMedia, + ); + _coverDownloads[key] = operation; + operation.then( + (_) => _removeCoverOperation(key, operation), + onError: (Object error, StackTrace stackTrace) => _removeCoverOperation(key, operation), + ); + return operation; + } + + Future _loadAndPersistCover({ + required MediaStorageScope scope, + required String mediaId, + required LocalCoverReader readLocalCover, + required LocalCoverWriter writeLocalCover, + required MediaDownloader downloadMedia, + }) async { + final cached = await readLocalCover(scope, mediaId); + if (cached != null) return cached; + + final downloaded = await downloadMedia(mediaId); + await writeLocalCover(scope, mediaId, downloaded); + return downloaded; + } + + void _removeCoverOperation(String key, Future operation) { + if (identical(_coverDownloads[key], operation)) { + _coverDownloads.remove(key); + } + } + String sha256Hex(Uint8List bytes) => sha256.convert(bytes).toString(); bool _matchesExpectedHash(Uint8List bytes, String? expectedHash) { diff --git a/app/lib/models/shelf.dart b/app/lib/models/shelf.dart index c706fb7..1a87bff 100644 --- a/app/lib/models/shelf.dart +++ b/app/lib/models/shelf.dart @@ -6,9 +6,10 @@ typedef ShelfData = Shelf; /// Lightweight cover preview data for shelf mosaic. class CoverPreview { final String? url; + final String? mediaId; final String title; - const CoverPreview({this.url, required this.title}); + const CoverPreview({this.url, this.mediaId, required this.title}); } /// Data model for a book shelf (collection). diff --git a/app/lib/pages/annotations_page.dart b/app/lib/pages/annotations_page.dart index f145cd4..17f37e1 100644 --- a/app/lib/pages/annotations_page.dart +++ b/app/lib/pages/annotations_page.dart @@ -278,6 +278,7 @@ class _AnnotationsPageState extends State { BookGroupHeader( bookTitle: provider.getBookTitle(entry.key), coverUrl: provider.getBookCoverUrl(entry.key), + coverMediaId: provider.getBookCoverMediaId(entry.key), count: entry.value.length, itemLabel: 'annotation', isCollapsed: isCollapsed, diff --git a/app/lib/pages/bookmarks_page.dart b/app/lib/pages/bookmarks_page.dart index 04f790d..7527c38 100644 --- a/app/lib/pages/bookmarks_page.dart +++ b/app/lib/pages/bookmarks_page.dart @@ -290,6 +290,7 @@ class _BookmarksPageState extends State { BookGroupHeader( bookTitle: provider.getBookTitle(entry.key), coverUrl: provider.getBookCoverUrl(entry.key), + coverMediaId: provider.getBookCoverMediaId(entry.key), count: entry.value.length, itemLabel: 'bookmark', isCollapsed: isCollapsed, diff --git a/app/lib/pages/notes_page.dart b/app/lib/pages/notes_page.dart index 095bbe6..088b8d5 100644 --- a/app/lib/pages/notes_page.dart +++ b/app/lib/pages/notes_page.dart @@ -269,6 +269,7 @@ class _NotesPageState extends State { BookGroupHeader( bookTitle: provider.getBookTitle(entry.key), coverUrl: provider.getBookCoverUrl(entry.key), + coverMediaId: provider.getBookCoverMediaId(entry.key), count: entry.value.length, itemLabel: 'note', isCollapsed: isCollapsed, diff --git a/app/lib/providers/annotations_provider.dart b/app/lib/providers/annotations_provider.dart index e90c06c..b9a4236 100644 --- a/app/lib/providers/annotations_provider.dart +++ b/app/lib/providers/annotations_provider.dart @@ -86,6 +86,10 @@ class AnnotationsProvider extends ChangeNotifier { return _dataStore?.getBook(bookId)?.coverUrl; } + String? getBookCoverMediaId(String bookId) { + return _dataStore?.getBook(bookId)?.coverMediaId; + } + // ============================================================================ // SORTING & FILTERING // ============================================================================ diff --git a/app/lib/providers/bookmarks_provider.dart b/app/lib/providers/bookmarks_provider.dart index c02d7ae..405b735 100644 --- a/app/lib/providers/bookmarks_provider.dart +++ b/app/lib/providers/bookmarks_provider.dart @@ -86,6 +86,10 @@ class BookmarksProvider extends ChangeNotifier { return _dataStore?.getBook(bookId)?.coverUrl; } + String? getBookCoverMediaId(String bookId) { + return _dataStore?.getBook(bookId)?.coverMediaId; + } + // ============================================================================ // SORTING & FILTERING // ============================================================================ diff --git a/app/lib/providers/notes_provider.dart b/app/lib/providers/notes_provider.dart index d2e7d09..6d76c96 100644 --- a/app/lib/providers/notes_provider.dart +++ b/app/lib/providers/notes_provider.dart @@ -96,6 +96,10 @@ class NotesProvider extends ChangeNotifier { return _dataStore?.getBook(bookId)?.coverUrl; } + String? getBookCoverMediaId(String bookId) { + return _dataStore?.getBook(bookId)?.coverMediaId; + } + // ============================================================================ // SORTING & FILTERING // ============================================================================ diff --git a/app/lib/widgets/book/private_book_cover.dart b/app/lib/widgets/book/private_book_cover.dart new file mode 100644 index 0000000..e21fa0a --- /dev/null +++ b/app/lib/widgets/book/private_book_cover.dart @@ -0,0 +1,131 @@ +import 'dart:typed_data'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:papyrus/media/media_cache_service.dart'; +import 'package:papyrus/media/media_storage_scope.dart'; +import 'package:papyrus/providers/auth_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:provider/provider.dart'; + +typedef PrivateCoverLoader = Future Function(String mediaId); + +/// Renders a public cover URL or a lazily persisted authenticated cover. +class PrivateBookCover extends StatefulWidget { + const PrivateBookCover({ + super.key, + this.imageUrl, + this.mediaId, + this.fit = BoxFit.cover, + required this.placeholder, + this.loadPrivateCover, + }); + + final String? imageUrl; + final String? mediaId; + final BoxFit fit; + final Widget placeholder; + final PrivateCoverLoader? loadPrivateCover; + + @override + State createState() => _PrivateBookCoverState(); +} + +class _PrivateBookCoverState extends State { + Future? _coverFuture; + String? _loadKey; + + @override + void initState() { + super.initState(); + _configureInjectedLoader(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (widget.loadPrivateCover == null) { + _configureProviderLoader(); + } + } + + @override + void didUpdateWidget(covariant PrivateBookCover oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.imageUrl != widget.imageUrl || + oldWidget.mediaId != widget.mediaId || + !identical(oldWidget.loadPrivateCover, widget.loadPrivateCover)) { + _coverFuture = null; + _loadKey = null; + _configureInjectedLoader(); + if (widget.loadPrivateCover == null) { + _configureProviderLoader(); + } + } + } + + void _configureInjectedLoader() { + final mediaId = widget.mediaId; + final loader = widget.loadPrivateCover; + if (_hasPublicUrl || mediaId == null || mediaId.isEmpty || loader == null) return; + final key = 'injected:$mediaId'; + if (_loadKey == key) return; + _loadKey = key; + _coverFuture = loader(mediaId); + } + + void _configureProviderLoader() { + final mediaId = widget.mediaId; + if (_hasPublicUrl || mediaId == null || mediaId.isEmpty) return; + + final authProvider = context.read(); + final user = authProvider.user; + if (!authProvider.isSignedIn || authProvider.isOfflineMode || user == null) return; + + final syncSettings = context.read(); + final scope = MediaStorageScope(profileKey: syncSettings.activeProfileKey, userId: user.userId); + final key = '${scope.persistenceKey}:$mediaId'; + if (_loadKey == key) return; + + final importService = context.read(); + final cacheService = context.read(); + _loadKey = key; + _coverFuture = cacheService.ensureCoverCached( + scope: scope, + mediaId: mediaId, + readLocalCover: importService.getCoverFile, + writeLocalCover: importService.storeCoverFile, + downloadMedia: authProvider.downloadMedia, + ); + } + + bool get _hasPublicUrl => widget.imageUrl != null && widget.imageUrl!.isNotEmpty; + + @override + Widget build(BuildContext context) { + if (_hasPublicUrl) { + return CachedNetworkImage( + imageUrl: widget.imageUrl!, + fit: widget.fit, + errorWidget: (_, _, _) => widget.placeholder, + ); + } + + final coverFuture = _coverFuture; + if (coverFuture == null) return widget.placeholder; + + return FutureBuilder( + future: coverFuture, + builder: (context, snapshot) { + final bytes = snapshot.data; + if (bytes != null) { + return Image.memory(bytes, fit: widget.fit, errorBuilder: (_, _, _) => widget.placeholder); + } + if (snapshot.hasError) return widget.placeholder; + return widget.placeholder; + }, + ); + } +} diff --git a/app/lib/widgets/book_details/book_cover_image.dart b/app/lib/widgets/book_details/book_cover_image.dart index 9a77c59..de31372 100644 --- a/app/lib/widgets/book_details/book_cover_image.dart +++ b/app/lib/widgets/book_details/book_cover_image.dart @@ -1,12 +1,6 @@ -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 = {}; +import 'package:papyrus/widgets/book/private_book_cover.dart'; /// Cover image size variants. enum BookCoverSize { @@ -51,33 +45,7 @@ class BookCoverImage extends StatelessWidget { } Widget _buildImage(BuildContext context, ColorScheme colorScheme) { - if (imageUrl != null && imageUrl!.isNotEmpty) { - return CachedNetworkImage( - imageUrl: imageUrl!, - fit: BoxFit.cover, - errorWidget: (context, url, error) => _buildPlaceholder(context, colorScheme), - 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); + return PrivateBookCover(imageUrl: imageUrl, mediaId: mediaId, placeholder: _buildPlaceholder(context, colorScheme)); } Widget _buildPlaceholder(BuildContext context, ColorScheme colorScheme) { @@ -114,20 +82,6 @@ class BookCoverImage extends StatelessWidget { ); } - Widget _buildLoadingIndicator(BuildContext context, ColorScheme colorScheme, DownloadProgress progress) { - return Container( - color: colorScheme.surfaceContainerHighest, - child: Center(child: CircularProgressIndicator(value: progress.progress, strokeWidth: 2)), - ); - } - - 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/context_menu/book_context_menu.dart b/app/lib/widgets/context_menu/book_context_menu.dart index 5558de5..1d65d3c 100644 --- a/app/lib/widgets/context_menu/book_context_menu.dart +++ b/app/lib/widgets/context_menu/book_context_menu.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/book/private_book_cover.dart'; /// Context menu for book actions. /// Shows a bottom sheet on mobile and a popup menu on desktop. @@ -450,22 +451,11 @@ class _BookContextBottomSheet extends StatelessWidget { Widget _buildCover(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - - if (book.coverURL != null && book.coverURL!.isNotEmpty) { - return Image.network( - book.coverURL!, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => Container( - color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.menu_book, color: colorScheme.onSurfaceVariant), - ), - ); - } - - return Container( + final placeholder = Container( color: colorScheme.surfaceContainerHighest, child: Icon(Icons.menu_book, color: colorScheme.onSurfaceVariant), ); + return PrivateBookCover(imageUrl: book.coverURL, mediaId: book.coverMediaId, placeholder: placeholder); } } diff --git a/app/lib/widgets/dashboard/continue_reading_card.dart b/app/lib/widgets/dashboard/continue_reading_card.dart index 83005b1..b80d9d7 100644 --- a/app/lib/widgets/dashboard/continue_reading_card.dart +++ b/app/lib/widgets/dashboard/continue_reading_card.dart @@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/book_details/book_progress_bar.dart'; +import 'package:papyrus/widgets/book/private_book_cover.dart'; /// Card displaying the currently reading book with progress and continue button. class ContinueReadingCard extends StatelessWidget { @@ -180,13 +181,11 @@ class ContinueReadingCard extends StatelessWidget { borderRadius: BorderRadius.circular(AppRadius.md), ), clipBehavior: Clip.antiAlias, - child: book?.coverURL != null && book!.coverURL!.isNotEmpty - ? Image.network( - book!.coverURL!, - fit: BoxFit.cover, - errorBuilder: (_, _, _) => _buildCoverPlaceholder(context), - ) - : _buildCoverPlaceholder(context), + child: PrivateBookCover( + imageUrl: book?.coverURL, + mediaId: book?.coverMediaId, + placeholder: _buildCoverPlaceholder(context), + ), ); } diff --git a/app/lib/widgets/dashboard/recently_added_section.dart b/app/lib/widgets/dashboard/recently_added_section.dart index 4dc4e58..d7c7ff0 100644 --- a/app/lib/widgets/dashboard/recently_added_section.dart +++ b/app/lib/widgets/dashboard/recently_added_section.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/book/private_book_cover.dart'; /// Section displaying recently added books in a horizontal scroll. class RecentlyAddedSection extends StatelessWidget { @@ -86,13 +87,11 @@ class RecentlyAddedSection extends StatelessWidget { ], ), clipBehavior: Clip.antiAlias, - child: book.coverURL != null && book.coverURL!.isNotEmpty - ? Image.network( - book.coverURL!, - fit: BoxFit.cover, - errorBuilder: (_, _, _) => _buildCoverPlaceholder(context, book), - ) - : _buildCoverPlaceholder(context, book), + child: PrivateBookCover( + imageUrl: book.coverURL, + mediaId: book.coverMediaId, + placeholder: _buildCoverPlaceholder(context, book), + ), ), ), ); diff --git a/app/lib/widgets/library/book_card.dart b/app/lib/widgets/library/book_card.dart index 1d7166e..22bbd64 100644 --- a/app/lib/widgets/library/book_card.dart +++ b/app/lib/widgets/library/book_card.dart @@ -1,8 +1,8 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/utils/book_actions.dart'; +import 'package:papyrus/widgets/book/private_book_cover.dart'; /// Responsive book card for grid display. /// - Mobile: 171×256 with 8px gap @@ -178,21 +178,11 @@ class _BookCardState extends State { } Widget _buildCover(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - - if (widget.book.coverURL != null && widget.book.coverURL!.isNotEmpty) { - return CachedNetworkImage( - imageUrl: widget.book.coverURL!, - fit: BoxFit.cover, - errorWidget: (context, url, error) => _buildPlaceholder(context), - placeholder: (context, url) => Container( - color: colorScheme.surfaceContainerHighest, - child: const Center(child: CircularProgressIndicator(strokeWidth: 2)), - ), - ); - } - - return _buildPlaceholder(context); + return PrivateBookCover( + imageUrl: widget.book.coverURL, + mediaId: widget.book.coverMediaId, + placeholder: _buildPlaceholder(context), + ); } Widget _buildPlaceholder(BuildContext context) { diff --git a/app/lib/widgets/library/book_list_item.dart b/app/lib/widgets/library/book_list_item.dart index 024811d..a2b674c 100644 --- a/app/lib/widgets/library/book_list_item.dart +++ b/app/lib/widgets/library/book_list_item.dart @@ -1,8 +1,8 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/utils/book_actions.dart'; +import 'package:papyrus/widgets/book/private_book_cover.dart'; /// List row for displaying a book with cover thumbnail, title, author, /// progress, format badge, and favorite indicator. @@ -173,16 +173,11 @@ class _BookListItemState extends State { } Widget _buildCover(BuildContext context) { - if (widget.book.coverURL != null && widget.book.coverURL!.isNotEmpty) { - return CachedNetworkImage( - imageUrl: widget.book.coverURL!, - fit: BoxFit.cover, - errorWidget: (context, url, error) => _buildPlaceholder(context), - placeholder: (context, url) => _buildPlaceholder(context), - ); - } - - return _buildPlaceholder(context); + return PrivateBookCover( + imageUrl: widget.book.coverURL, + mediaId: widget.book.coverMediaId, + placeholder: _buildPlaceholder(context), + ); } Widget _buildPlaceholder(BuildContext context) { diff --git a/app/lib/widgets/shared/book_group_header.dart b/app/lib/widgets/shared/book_group_header.dart index 27da6e2..d379187 100644 --- a/app/lib/widgets/shared/book_group_header.dart +++ b/app/lib/widgets/shared/book_group_header.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/book/private_book_cover.dart'; /// A collapsible header showing a book's cover thumbnail, title, item count, /// and expand/collapse chevron. @@ -12,6 +13,9 @@ class BookGroupHeader extends StatelessWidget { /// Optional cover image URL for the thumbnail. final String? coverUrl; + /// Optional private cover media ID for authenticated books. + final String? coverMediaId; + /// Number of items in this group (e.g. 3 notes). final int count; @@ -28,6 +32,7 @@ class BookGroupHeader extends StatelessWidget { super.key, required this.bookTitle, this.coverUrl, + this.coverMediaId, required this.count, required this.itemLabel, required this.isCollapsed, @@ -54,19 +59,14 @@ class BookGroupHeader extends StatelessWidget { child: SizedBox( width: 32, height: 48, - child: coverUrl != null && coverUrl!.isNotEmpty - ? Image.network( - coverUrl!, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => Container( - color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.menu_book, size: 16, color: colorScheme.onSurfaceVariant), - ), - ) - : Container( - color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.menu_book, size: 16, color: colorScheme.onSurfaceVariant), - ), + child: PrivateBookCover( + imageUrl: coverUrl, + mediaId: coverMediaId, + placeholder: Container( + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.menu_book, size: 16, color: colorScheme.onSurfaceVariant), + ), + ), ), ), const SizedBox(width: Spacing.sm), diff --git a/app/lib/widgets/shelves/move_to_shelf_sheet.dart b/app/lib/widgets/shelves/move_to_shelf_sheet.dart index a22a708..8642b40 100644 --- a/app/lib/widgets/shelves/move_to_shelf_sheet.dart +++ b/app/lib/widgets/shelves/move_to_shelf_sheet.dart @@ -5,6 +5,7 @@ import 'package:papyrus/models/shelf.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/utils/text_utils.dart'; import 'package:papyrus/widgets/input/search_field.dart'; +import 'package:papyrus/widgets/book/private_book_cover.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; import 'package:papyrus/widgets/shelves/add_shelf_sheet.dart'; import 'package:provider/provider.dart'; @@ -200,22 +201,12 @@ class _MoveToShelfSheetState extends State { Widget _buildCover(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final book = widget.book; - - if (book != null && book.coverURL != null && book.coverURL!.isNotEmpty) { - return Image.network( - book.coverURL!, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => Container( - color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.menu_book, color: colorScheme.onSurfaceVariant, size: 20), - ), - ); - } - - return Container( + final placeholder = Container( color: colorScheme.surfaceContainerHighest, child: Icon(Icons.menu_book, color: colorScheme.onSurfaceVariant, size: 20), ); + if (book == null) return placeholder; + return PrivateBookCover(imageUrl: book.coverURL, mediaId: book.coverMediaId, placeholder: placeholder); } Widget _buildShelfTile(BuildContext context, Shelf shelf) { diff --git a/app/lib/widgets/shelves/shelf_card.dart b/app/lib/widgets/shelves/shelf_card.dart index 3c495ab..eeea3f1 100644 --- a/app/lib/widgets/shelves/shelf_card.dart +++ b/app/lib/widgets/shelves/shelf_card.dart @@ -1,7 +1,7 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:papyrus/models/shelf.dart' show CoverPreview, ShelfData; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/book/private_book_cover.dart'; /// Card widget for displaying a shelf in grid or list view. /// @@ -216,21 +216,11 @@ class _ShelfCardState extends State { } Widget _buildCoverImage(CoverPreview cover, ColorScheme colorScheme) { - if (cover.url != null && cover.url!.isNotEmpty) { - return CachedNetworkImage( - imageUrl: cover.url!, - fit: BoxFit.cover, - imageBuilder: (context, imageProvider) => Container( - decoration: BoxDecoration( - image: DecorationImage(image: imageProvider, fit: BoxFit.cover), - ), - ), - errorWidget: (context, url, error) => _buildCoverPlaceholder(colorScheme, cover.title), - placeholder: (context, url) => Container(color: colorScheme.surfaceContainerHighest), - ); - } - - return _buildCoverPlaceholder(colorScheme, cover.title); + return PrivateBookCover( + imageUrl: cover.url, + mediaId: cover.mediaId, + placeholder: _buildCoverPlaceholder(colorScheme, cover.title), + ); } Widget _buildCoverPlaceholder(ColorScheme colorScheme, String title) { diff --git a/app/lib/widgets/topics/manage_topics_sheet.dart b/app/lib/widgets/topics/manage_topics_sheet.dart index 765125d..8fadd9c 100644 --- a/app/lib/widgets/topics/manage_topics_sheet.dart +++ b/app/lib/widgets/topics/manage_topics_sheet.dart @@ -5,6 +5,7 @@ import 'package:papyrus/models/tag.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/utils/text_utils.dart'; import 'package:papyrus/widgets/input/search_field.dart'; +import 'package:papyrus/widgets/book/private_book_cover.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; import 'package:papyrus/widgets/topics/add_topic_sheet.dart'; import 'package:provider/provider.dart'; @@ -196,22 +197,12 @@ class _ManageTopicsSheetState extends State { Widget _buildCover(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final book = widget.book; - - if (book != null && book.coverURL != null && book.coverURL!.isNotEmpty) { - return Image.network( - book.coverURL!, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => Container( - color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.menu_book, color: colorScheme.onSurfaceVariant, size: 20), - ), - ); - } - - return Container( + final placeholder = Container( color: colorScheme.surfaceContainerHighest, child: Icon(Icons.menu_book, color: colorScheme.onSurfaceVariant, size: 20), ); + if (book == null) return placeholder; + return PrivateBookCover(imageUrl: book.coverURL, mediaId: book.coverMediaId, placeholder: placeholder); } Widget _buildEmptyState(BuildContext context) { diff --git a/app/test/media/media_cache_service_test.dart b/app/test/media/media_cache_service_test.dart index f54ebc0..9d23c9e 100644 --- a/app/test/media/media_cache_service_test.dart +++ b/app/test/media/media_cache_service_test.dart @@ -1,14 +1,16 @@ +import 'dart:async'; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/media/media_cache_service.dart'; +import 'package:papyrus/media/media_storage_scope.dart'; import 'package:papyrus/models/book.dart'; void main() { late MediaCacheService service; setUp(() { - service = const MediaCacheService(); + service = MediaCacheService(); }); test('uses cached book file when its hash matches', () async { @@ -84,6 +86,86 @@ void main() { throwsStateError, ); }); + + test('cover download persists and the next load reads local bytes', () async { + final coverService = MediaCacheService(); + final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + Uint8List? stored; + var downloads = 0; + + Future load() { + return coverService.ensureCoverCached( + scope: scope, + mediaId: 'asset-1', + readLocalCover: (_, _) async => stored, + writeLocalCover: (_, _, bytes) async => stored = bytes, + downloadMedia: (_) async { + downloads++; + return Uint8List.fromList([1, 2, 3]); + }, + ); + } + + final first = await load(); + final second = await load(); + + expect(first, Uint8List.fromList([1, 2, 3])); + expect(second, first); + expect(downloads, 1); + }); + + test('overlapping cover requests share one download', () async { + final coverService = MediaCacheService(); + final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + final gate = Completer(); + var downloads = 0; + + Future load() { + return coverService.ensureCoverCached( + scope: scope, + mediaId: 'asset-1', + readLocalCover: (_, _) async => null, + writeLocalCover: (_, _, _) async {}, + downloadMedia: (_) { + downloads++; + return gate.future; + }, + ); + } + + final first = load(); + final second = load(); + await Future.delayed(Duration.zero); + expect(downloads, 1); + gate.complete(Uint8List.fromList([1, 2, 3])); + + expect(await first, await second); + expect(downloads, 1); + }); + + test('cover download failures are retryable', () async { + final coverService = MediaCacheService(); + final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + var downloads = 0; + + Future load() { + return coverService.ensureCoverCached( + scope: scope, + mediaId: 'asset-1', + readLocalCover: (_, _) async => null, + writeLocalCover: (_, _, _) async {}, + downloadMedia: (_) async { + downloads++; + if (downloads == 1) throw StateError('offline'); + return Uint8List.fromList([1]); + }, + ); + } + + await expectLater(load(), throwsStateError); + expect(await load(), Uint8List.fromList([1])); + expect(downloads, 2); + }); } Book _book({required String fileHash, String? fileMediaId, BookFormat? fileFormat}) { diff --git a/app/test/pages/book_details_delete_test.dart b/app/test/pages/book_details_delete_test.dart index 1a0ce1d..9a370f6 100644 --- a/app/test/pages/book_details_delete_test.dart +++ b/app/test/pages/book_details_delete_test.dart @@ -135,7 +135,7 @@ void main() { title: 'Download Me', author: 'Author', fileFormat: BookFormat.epub, - fileHash: const MediaCacheService().sha256Hex(bytes), + fileHash: MediaCacheService().sha256Hex(bytes), addedAt: DateTime.utc(2026), ); @@ -164,7 +164,7 @@ void main() { ChangeNotifierProvider.value(value: dataStore), ChangeNotifierProvider.value(value: mediaUploadQueue), Provider.value(value: importService), - Provider.value(value: const MediaCacheService()), + Provider.value(value: MediaCacheService()), Provider.value(value: downloadService), ], child: MaterialApp.router(routerConfig: router), diff --git a/app/test/widgets/book/private_book_cover_test.dart b/app/test/widgets/book/private_book_cover_test.dart new file mode 100644 index 0000000..781f106 --- /dev/null +++ b/app/test/widgets/book/private_book_cover_test.dart @@ -0,0 +1,90 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/widgets/book/private_book_cover.dart'; + +void main() { + final pngBytes = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + ); + + testWidgets('private cover renders lazily loaded bytes', (tester) async { + var loads = 0; + await tester.pumpWidget( + MaterialApp( + home: PrivateBookCover( + mediaId: 'asset-1', + loadPrivateCover: (_) async { + loads++; + return Uint8List.fromList(pngBytes); + }, + placeholder: const SizedBox(key: Key('placeholder')), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(Image), findsOneWidget); + expect(find.byKey(const Key('placeholder')), findsNothing); + expect(loads, 1); + }); + + testWidgets('private cover keeps one load future across rebuilds', (tester) async { + var loads = 0; + Future loader(String _) async { + loads++; + return Uint8List.fromList(pngBytes); + } + + Widget build() { + return MaterialApp( + home: PrivateBookCover( + mediaId: 'asset-1', + loadPrivateCover: loader, + placeholder: const SizedBox(key: Key('placeholder')), + ), + ); + } + + await tester.pumpWidget(build()); + await tester.pumpAndSettle(); + await tester.pumpWidget(build()); + await tester.pumpAndSettle(); + + expect(loads, 1); + }); + + testWidgets('private cover falls back to placeholder when the loader has no bytes', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: PrivateBookCover( + mediaId: 'asset-1', + loadPrivateCover: (_) async => null, + placeholder: const SizedBox(key: Key('placeholder')), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('placeholder')), findsOneWidget); + expect(find.byType(Image), findsNothing); + }); + + testWidgets('private cover falls back to placeholder after a load error', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: PrivateBookCover( + mediaId: 'asset-1', + loadPrivateCover: (_) async => throw StateError('offline'), + placeholder: const SizedBox(key: Key('placeholder')), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('placeholder')), findsOneWidget); + expect(find.byType(Image), findsNothing); + }); +} From 98aae0ec8678058aa790f9dddb74c52d1d1ecef5 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 17:21:29 +0300 Subject: [PATCH 09/48] fix: scope and serialize media uploads --- app/lib/media/media_upload_queue.dart | 100 +++++++++++++++++--- app/test/media/media_upload_queue_test.dart | 84 +++++++++++++++- 2 files changed, 164 insertions(+), 20 deletions(-) diff --git a/app/lib/media/media_upload_queue.dart b/app/lib/media/media_upload_queue.dart index 23478ea..dfce9b1 100644 --- a/app/lib/media/media_upload_queue.dart +++ b/app/lib/media/media_upload_queue.dart @@ -3,6 +3,7 @@ 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/media/media_storage_scope.dart'; import 'package:papyrus/models/book.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -73,19 +74,31 @@ class MediaUploadTask { } class MediaUploadQueue extends ChangeNotifier { - MediaUploadQueue(this._prefs) { - _tasks = _loadTasks(); - } + MediaUploadQueue(this._prefs); - static const _storageKey = 'media_upload_queue'; + static const _storageKeyPrefix = 'media_upload_queue:'; final SharedPreferences _prefs; - late List _tasks; + List _tasks = []; + MediaStorageScope? _activeScope; MediaStorageUsage? _storageUsage; + Future? _processing; List get pendingTasks => List.unmodifiable(_tasks); + MediaStorageScope? get activeScope => _activeScope; MediaStorageUsage? get storageUsage => _storageUsage; + Future activateScope(MediaStorageScope? scope) async { + if (_activeScope == scope) return; + await waitUntilIdle(); + _activeScope = scope; + _tasks = scope == null ? [] : _loadTasks(scope); + _storageUsage = null; + notifyListeners(); + } + + Future waitUntilIdle() => _processing ?? Future.value(); + Future refreshUsage(Future Function() fetchUsage) async { _storageUsage = await fetchUsage(); notifyListeners(); @@ -124,6 +137,7 @@ class MediaUploadQueue extends ChangeNotifier { } Future retryFailed({String? bookId}) async { + final scope = _requireActiveScope(); _tasks = _tasks .map((task) { final matchesBook = bookId == null || task.bookId == bookId; @@ -133,13 +147,15 @@ class MediaUploadQueue extends ChangeNotifier { return task.copyWith(status: MediaUploadTaskStatus.pending); }) .toList(growable: false); - await _save(); + await _save(scope); notifyListeners(); } Future removeTasksForBook(String bookId) async { + final scope = _activeScope; + if (scope == null) return; _tasks = _tasks.where((task) => task.bookId != bookId).toList(growable: false); - await _save(); + await _save(scope); notifyListeners(); } @@ -147,6 +163,31 @@ class MediaUploadQueue extends ChangeNotifier { required DataStore dataStore, required BookFileReader readBookFile, required MediaUploader uploadMedia, + }) { + final inFlight = _processing; + if (inFlight != null) return inFlight; + final scope = _activeScope; + if (scope == null) return Future.value(); + + final operation = _processPending( + scope: scope, + dataStore: dataStore, + readBookFile: readBookFile, + uploadMedia: uploadMedia, + ); + _processing = operation; + operation.then( + (_) => _clearProcessing(operation), + onError: (Object error, StackTrace stackTrace) => _clearProcessing(operation), + ); + return operation; + } + + Future _processPending({ + required MediaStorageScope scope, + required DataStore dataStore, + required BookFileReader readBookFile, + required MediaUploader uploadMedia, }) async { final nextTasks = []; for (final task in _tasks) { @@ -184,13 +225,14 @@ class MediaUploadQueue extends ChangeNotifier { } } _tasks = nextTasks; - await _save(); + await _save(scope); notifyListeners(); } Future _enqueue(MediaUploadTask task) async { + final scope = _requireActiveScope(); _tasks = [..._tasks.where((existing) => existing.id != task.id), task]; - await _save(); + await _save(scope); notifyListeners(); } @@ -213,14 +255,42 @@ class MediaUploadQueue extends ChangeNotifier { dataStore.updateBook(book.copyWith(coverMediaId: asset.assetId, clearCoverUrl: true)); } - List _loadTasks() { - final raw = _prefs.getString(_storageKey); + List _loadTasks(MediaStorageScope scope) { + final raw = _prefs.getString(_storageKey(scope)); if (raw == null || raw.isEmpty) return []; - final decoded = jsonDecode(raw) as List; - return decoded.map((item) => MediaUploadTask.fromJson(item as Map)).toList(growable: false); + try { + final decoded = jsonDecode(raw) as List; + return decoded.map((item) => MediaUploadTask.fromJson(item as Map)).toList(growable: false); + } catch (error, stackTrace) { + FlutterError.reportError( + FlutterErrorDetails( + exception: error, + stack: stackTrace, + library: 'papyrus media upload queue', + context: ErrorDescription('while loading persisted media uploads'), + ), + ); + return []; + } + } + + Future _save(MediaStorageScope scope) { + return _prefs.setString(_storageKey(scope), jsonEncode(_tasks.map((task) => task.toJson()).toList())); } - Future _save() { - return _prefs.setString(_storageKey, jsonEncode(_tasks.map((task) => task.toJson()).toList())); + String _storageKey(MediaStorageScope scope) => '$_storageKeyPrefix${scope.persistenceKey}'; + + MediaStorageScope _requireActiveScope() { + final scope = _activeScope; + if (scope == null) { + throw StateError('Authenticated media upload scope is not active'); + } + return scope; + } + + void _clearProcessing(Future operation) { + if (identical(_processing, operation)) { + _processing = null; + } } } diff --git a/app/test/media/media_upload_queue_test.dart b/app/test/media/media_upload_queue_test.dart index f448377..5c55956 100644 --- a/app/test/media/media_upload_queue_test.dart +++ b/app/test/media/media_upload_queue_test.dart @@ -1,9 +1,11 @@ +import 'dart:async'; 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_storage_scope.dart'; import 'package:papyrus/media/media_upload_queue.dart'; import 'package:papyrus/models/book.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -20,7 +22,7 @@ void main() { final book = _book(filePath: 'book-1', fileSize: 10, fileHash: 'hash'); await repository.upsert(book); await pumpEventQueue(); - final queue = MediaUploadQueue(prefs); + final queue = await _activeQueue(prefs); await queue.enqueueBookFile(book: book, filename: 'book.epub', contentType: 'application/epub+zip'); await queue.processPending( @@ -45,7 +47,7 @@ void main() { final book = _book(filePath: 'book-1', fileSize: 10, fileHash: 'hash'); await repository.upsert(book); await pumpEventQueue(); - final queue = MediaUploadQueue(prefs); + final queue = await _activeQueue(prefs); await queue.enqueueBookFile(book: book, filename: 'book.epub', contentType: 'application/epub+zip'); await queue.processPending( @@ -68,7 +70,7 @@ void main() { final book = _book(filePath: 'book-1', fileSize: 10, fileHash: 'hash'); await repository.upsert(book); await pumpEventQueue(); - final queue = MediaUploadQueue(prefs); + final queue = await _activeQueue(prefs); await queue.enqueueBookFile(book: book, filename: 'book.epub', contentType: 'application/epub+zip'); await queue.processPending( @@ -85,7 +87,7 @@ void main() { test('removeTasksForBook removes queued book file and cover uploads', () async { final prefs = await SharedPreferences.getInstance(); - final queue = MediaUploadQueue(prefs); + final queue = await _activeQueue(prefs); final book = _book(filePath: 'book-1', fileSize: 10, fileHash: 'hash'); await queue.enqueueBookFile(book: book, filename: 'book.epub', contentType: 'application/epub+zip'); @@ -99,7 +101,73 @@ void main() { await queue.removeTasksForBook(book.id); expect(queue.pendingTasks, isEmpty); - expect(prefs.getString('media_upload_queue'), '[]'); + expect(prefs.getString('media_upload_queue:official--user-1'), '[]'); + }); + + test('pending uploads are isolated and restored per media scope', () async { + final prefs = await SharedPreferences.getInstance(); + final queue = MediaUploadQueue(prefs); + final first = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + final second = MediaStorageScope(profileKey: 'custom-a', userId: 'user-2'); + final book = _book(filePath: 'book-1', fileSize: 10, fileHash: 'hash'); + + await queue.activateScope(first); + await queue.enqueueBookFile(book: book, filename: 'book.epub', contentType: 'application/epub+zip'); + await queue.activateScope(second); + expect(queue.pendingTasks, isEmpty); + + await queue.activateScope(first); + expect(queue.pendingTasks, hasLength(1)); + expect(queue.pendingTasks.single.bookId, book.id); + }); + + test('signed out queue exposes no authenticated tasks', () async { + final prefs = await SharedPreferences.getInstance(); + final queue = MediaUploadQueue(prefs); + final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + final book = _book(filePath: 'book-1', fileSize: 10, fileHash: 'hash'); + await queue.activateScope(scope); + await queue.enqueueBookFile(book: book, filename: 'book.epub', contentType: 'application/epub+zip'); + + await queue.activateScope(null); + + expect(queue.activeScope, isNull); + expect(queue.pendingTasks, isEmpty); + }); + + test('overlapping processPending calls share one upload operation', () 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.activateScope(MediaStorageScope(profileKey: 'official', userId: 'user-1')); + await queue.enqueueBookFile(book: book, filename: 'book.epub', contentType: 'application/epub+zip'); + final gate = Completer(); + var uploads = 0; + + Future process() { + return queue.processPending( + dataStore: dataStore, + readBookFile: (_) async => Uint8List.fromList([1, 2, 3]), + uploadMedia: (_) { + uploads++; + return gate.future; + }, + ); + } + + final first = process(); + final second = process(); + expect(identical(first, second), isTrue); + await Future.delayed(Duration.zero); + expect(uploads, 1); + + gate.complete(_asset(assetId: 'file-asset', bookId: book.id, kind: MediaKind.bookFile)); + await Future.wait([first, second]); + expect(uploads, 1); }); } @@ -130,3 +198,9 @@ MediaAsset _asset({required String assetId, required String bookId, required Med storagePath: 'path', ); } + +Future _activeQueue(SharedPreferences prefs) async { + final queue = MediaUploadQueue(prefs); + await queue.activateScope(MediaStorageScope(profileKey: 'official', userId: 'user-1')); + return queue; +} From ee0c80e56ae78a3d044ad5bcee01f4bc7de9690f Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 17:24:44 +0300 Subject: [PATCH 10/48] fix: process media immediately after enqueue --- app/lib/main.dart | 71 ++++++++++++++++++--- app/lib/media/media_upload_queue.dart | 10 ++- app/test/media/media_upload_queue_test.dart | 47 ++++++++++++++ 3 files changed, 118 insertions(+), 10 deletions(-) diff --git a/app/lib/main.dart b/app/lib/main.dart index 56185b1..6bbe3eb 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -9,6 +9,7 @@ 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_storage_scope.dart'; import 'package:papyrus/media/media_upload_queue.dart'; import 'package:papyrus/powersync/powersync_service.dart'; import 'package:papyrus/powersync/papyrus_powersync_connector.dart'; @@ -56,6 +57,8 @@ class _PapyrusState extends State { late String _activeProfileKey; late final AppRouter _appRouter; bool _switchingSyncProfile = false; + Future? _authStateOperation; + bool _authStateUpdateQueued = false; @override void initState() { @@ -67,7 +70,7 @@ class _PapyrusState extends State { _authRepository = _buildAuthRepository(_syncSettingsProvider.activeApiConfig, _activeProfileKey); _dataStore = DataStore(); - _mediaUploadQueue = MediaUploadQueue(widget.prefs); + _mediaUploadQueue = MediaUploadQueue(widget.prefs, onWorkAvailable: _processMediaUploads); _bookImportService = BookImportService(); _authProvider = AuthProvider(widget.prefs, repository: _authRepository); _powerSyncService = PapyrusPowerSyncService( @@ -109,22 +112,60 @@ class _PapyrusState extends State { } void _syncPowerSyncAuthState() { + if (_authStateOperation != null) { + _authStateUpdateQueued = true; + return; + } + + final operation = _drainAuthStateUpdates(); + _authStateOperation = operation; + operation.then( + (_) => _clearAuthStateOperation(operation), + onError: (Object error, StackTrace stackTrace) { + _clearAuthStateOperation(operation); + FlutterError.reportError( + FlutterErrorDetails(exception: error, stack: stackTrace, library: 'papyrus media/auth lifecycle'), + ); + }, + ); + } + + Future _drainAuthStateUpdates() async { + do { + _authStateUpdateQueued = false; + await _applyPowerSyncAuthState(); + } while (_authStateUpdateQueued); + } + + Future _applyPowerSyncAuthState() async { final user = _authProvider.user; if (user != null && !_authProvider.isOfflineMode) { final userId = user.userId; - unawaited(_powerSyncService.activateAuthenticated(userId, profileKey: _activeProfileKey)); - unawaited(_refreshMediaUsage()); - unawaited(_processMediaUploads()); + await _mediaUploadQueue.activateScope(MediaStorageScope(profileKey: _activeProfileKey, userId: userId)); + await _powerSyncService.activateAuthenticated(userId, profileKey: _activeProfileKey); + await _refreshMediaUsage(); + await _processMediaUploads(); return; } if (_authProvider.isOfflineMode) { - unawaited(_powerSyncService.activateGuest()); + await _mediaUploadQueue.activateScope(null); + await _powerSyncService.activateGuest(); return; } + await _mediaUploadQueue.activateScope(null); if (!_authProvider.isBootstrapping && _powerSyncService.mode != null) { - unawaited(_powerSyncService.deactivate(clearAuthenticated: !_switchingSyncProfile)); + await _powerSyncService.deactivate(clearAuthenticated: !_switchingSyncProfile); + } + } + + void _clearAuthStateOperation(Future operation) { + if (identical(_authStateOperation, operation)) { + _authStateOperation = null; + } + if (_authStateUpdateQueued) { + _syncPowerSyncAuthState(); } } @@ -141,6 +182,8 @@ class _PapyrusState extends State { Future _switchActiveSyncProfile() async { _switchingSyncProfile = true; try { + await _mediaUploadQueue.waitUntilIdle(); + await _mediaUploadQueue.activateScope(null); await _powerSyncService.deactivate(clearAuthenticated: false); _authRepository = _buildAuthRepository(_syncSettingsProvider.activeApiConfig, _activeProfileKey); await _authProvider.replaceRepository(_authRepository, bootstrapNewRepository: !_authProvider.isOfflineMode); @@ -153,8 +196,9 @@ class _PapyrusState extends State { Future _refreshMediaUsage() async { if (!_authProvider.isSignedIn || _authProvider.isOfflineMode) return; + final repository = _authRepository; try { - await _mediaUploadQueue.refreshUsage(_authRepository.fetchMediaUsage); + await _mediaUploadQueue.refreshUsage(repository.fetchMediaUsage); } catch (_) { // Usage is informational; failed refresh must not block data sync. } @@ -162,12 +206,19 @@ class _PapyrusState extends State { Future _processMediaUploads() async { if (!_authProvider.isSignedIn || _authProvider.isOfflineMode) return; + final user = _authProvider.user; + if (user == null) return; + final scope = MediaStorageScope(profileKey: _activeProfileKey, userId: user.userId); + if (_mediaUploadQueue.activeScope != scope) { + await _mediaUploadQueue.activateScope(scope); + } + final repository = _authRepository; await _mediaUploadQueue.processPending( dataStore: _dataStore, readBookFile: _bookImportService.getBookFile, uploadMedia: (payload) async { try { - return await _authRepository.uploadMedia(payload); + return await repository.uploadMedia(payload); } on AuthApiException catch (error) { if (error.statusCode == 409) { throw const MediaUploadException.storageFull(); @@ -176,7 +227,9 @@ class _PapyrusState extends State { } }, ); - await _refreshMediaUsage(); + if (identical(repository, _authRepository) && _mediaUploadQueue.activeScope == scope) { + await _refreshMediaUsage(); + } } @override diff --git a/app/lib/media/media_upload_queue.dart b/app/lib/media/media_upload_queue.dart index dfce9b1..3cd150f 100644 --- a/app/lib/media/media_upload_queue.dart +++ b/app/lib/media/media_upload_queue.dart @@ -9,6 +9,7 @@ import 'package:shared_preferences/shared_preferences.dart'; typedef BookFileReader = Future Function(String bookId); typedef MediaUploader = Future Function(MediaUploadPayload payload); +typedef MediaWorkAvailableCallback = Future Function(); enum MediaUploadTaskStatus { pending, failed } @@ -74,11 +75,12 @@ class MediaUploadTask { } class MediaUploadQueue extends ChangeNotifier { - MediaUploadQueue(this._prefs); + MediaUploadQueue(this._prefs, {this.onWorkAvailable}); static const _storageKeyPrefix = 'media_upload_queue:'; final SharedPreferences _prefs; + final MediaWorkAvailableCallback? onWorkAvailable; List _tasks = []; MediaStorageScope? _activeScope; MediaStorageUsage? _storageUsage; @@ -138,17 +140,22 @@ class MediaUploadQueue extends ChangeNotifier { Future retryFailed({String? bookId}) async { final scope = _requireActiveScope(); + var retriedAny = false; _tasks = _tasks .map((task) { final matchesBook = bookId == null || task.bookId == bookId; if (!matchesBook || task.status != MediaUploadTaskStatus.failed) { return task; } + retriedAny = true; return task.copyWith(status: MediaUploadTaskStatus.pending); }) .toList(growable: false); await _save(scope); notifyListeners(); + if (retriedAny) { + await onWorkAvailable?.call(); + } } Future removeTasksForBook(String bookId) async { @@ -234,6 +241,7 @@ class MediaUploadQueue extends ChangeNotifier { _tasks = [..._tasks.where((existing) => existing.id != task.id), task]; await _save(scope); notifyListeners(); + await onWorkAvailable?.call(); } Future _bytesForTask(MediaUploadTask task, BookFileReader readBookFile) async { diff --git a/app/test/media/media_upload_queue_test.dart b/app/test/media/media_upload_queue_test.dart index 5c55956..86c7379 100644 --- a/app/test/media/media_upload_queue_test.dart +++ b/app/test/media/media_upload_queue_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; @@ -169,6 +170,52 @@ void main() { await Future.wait([first, second]); expect(uploads, 1); }); + + test('enqueue invokes work callback after scoped tasks are persisted', () async { + final prefs = await SharedPreferences.getInstance(); + final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + String? storedAtCallback; + final queue = MediaUploadQueue( + prefs, + onWorkAvailable: () async { + storedAtCallback = prefs.getString('media_upload_queue:${scope.persistenceKey}'); + }, + ); + await queue.activateScope(scope); + + await queue.enqueueBookFile( + book: _book(filePath: 'book-1', fileSize: 10, fileHash: 'hash'), + filename: 'book.epub', + contentType: 'application/epub+zip', + ); + + expect(storedAtCallback, isNotNull); + expect(jsonDecode(storedAtCallback!) as List, hasLength(1)); + }); + + test('retry invokes work callback only when failed tasks become pending', () async { + final prefs = await SharedPreferences.getInstance(); + var callbacks = 0; + final queue = MediaUploadQueue(prefs, onWorkAvailable: () async => callbacks++); + await queue.activateScope(MediaStorageScope(profileKey: 'official', userId: 'user-1')); + final repository = InMemoryBookRepository(); + final dataStore = DataStore(bookRepository: repository); + final book = _book(filePath: 'book-1', fileSize: 10, fileHash: 'hash'); + await repository.upsert(book); + await pumpEventQueue(); + await queue.enqueueBookFile(book: book, filename: 'book.epub', contentType: 'application/epub+zip'); + callbacks = 0; + await queue.processPending( + dataStore: dataStore, + readBookFile: (_) async => Uint8List.fromList([1]), + uploadMedia: (_) async => throw const MediaUploadException.storageFull(), + ); + + await queue.retryFailed(); + await queue.retryFailed(); + + expect(callbacks, 1); + }); } Book _book({String? filePath, int? fileSize, String? fileHash}) { From 422efc70755f8ef089a05c98b170c18b2634fb31 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 17:28:07 +0300 Subject: [PATCH 11/48] fix: clean scoped cover files --- app/lib/pages/book_details_page.dart | 7 +++++-- app/lib/pages/profile_page.dart | 10 +++++++++- app/lib/services/book_delete_cleanup_service.dart | 10 ++++++++++ app/lib/utils/book_actions.dart | 9 +++++++-- app/lib/utils/bulk_book_actions.dart | 12 ++++++++++-- app/test/pages/book_details_delete_test.dart | 2 ++ .../services/book_delete_cleanup_service_test.dart | 14 +++++++++++++- 7 files changed, 56 insertions(+), 8 deletions(-) diff --git a/app/lib/pages/book_details_page.dart b/app/lib/pages/book_details_page.dart index e5f9468..bfc1580 100644 --- a/app/lib/pages/book_details_page.dart +++ b/app/lib/pages/book_details_page.dart @@ -610,14 +610,17 @@ class _BookDetailsPageState extends State with SingleTickerProv final dataStore = context.read(); final mediaUploadQueue = context.read(); - final deleteBookFile = context.read().deleteBookFile; + final importService = context.read(); + final mediaScope = mediaUploadQueue.activeScope; final messenger = ScaffoldMessenger.of(context); await deleteBookWithMediaCleanup( dataStore: dataStore, mediaUploadQueue: mediaUploadQueue, bookId: book.id, - deleteBookFile: deleteBookFile, + coverMediaId: book.coverMediaId, + deleteBookFile: importService.deleteBookFile, + deleteCoverFile: mediaScope == null ? null : (mediaId) => importService.deleteCoverFile(mediaScope, mediaId), ); if (!mounted) return; diff --git a/app/lib/pages/profile_page.dart b/app/lib/pages/profile_page.dart index bc3518f..a8ca4f8 100644 --- a/app/lib/pages/profile_page.dart +++ b/app/lib/pages/profile_page.dart @@ -11,6 +11,8 @@ import 'package:papyrus/providers/auth_provider.dart'; import 'package:papyrus/providers/preferences_provider.dart'; import 'package:papyrus/providers/sync_settings_provider.dart'; import 'package:papyrus/powersync/sync_state.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/settings/settings_row.dart'; import 'package:papyrus/widgets/settings/settings_section.dart'; @@ -1445,7 +1447,13 @@ class _ProfilePageState extends State { final messenger = ScaffoldMessenger.of(context); try { - await context.read().clearAuthenticatedCache(); + final scope = context.read().activeScope; + final powerSyncService = context.read(); + final importService = context.read(); + await powerSyncService.clearAuthenticatedCache(); + if (scope != null) { + await importService.clearCoverFiles(scope); + } messenger.showSnackBar(const SnackBar(content: Text('Local copy cleared.'))); } catch (error) { messenger.showSnackBar(SnackBar(content: Text('Could not clear local copy: $error'))); diff --git a/app/lib/services/book_delete_cleanup_service.dart b/app/lib/services/book_delete_cleanup_service.dart index ac8afa1..ac5294a 100644 --- a/app/lib/services/book_delete_cleanup_service.dart +++ b/app/lib/services/book_delete_cleanup_service.dart @@ -2,12 +2,15 @@ import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/media/media_upload_queue.dart'; typedef DeleteBookFile = Future Function(String bookId); +typedef DeleteCoverFile = Future Function(String mediaId); Future deleteBookWithMediaCleanup({ required DataStore dataStore, required MediaUploadQueue mediaUploadQueue, required String bookId, required DeleteBookFile deleteBookFile, + String? coverMediaId, + DeleteCoverFile? deleteCoverFile, }) async { await mediaUploadQueue.removeTasksForBook(bookId); try { @@ -15,5 +18,12 @@ Future deleteBookWithMediaCleanup({ } catch (_) { // Local cache cleanup is best-effort; the synced book deletion remains authoritative. } + if (coverMediaId != null && deleteCoverFile != null) { + try { + await deleteCoverFile(coverMediaId); + } catch (_) { + // A missing or inaccessible local cover must not block book deletion. + } + } dataStore.deleteBook(bookId); } diff --git a/app/lib/utils/book_actions.dart b/app/lib/utils/book_actions.dart index 5f8c0c1..8d7e4ac 100644 --- a/app/lib/utils/book_actions.dart +++ b/app/lib/utils/book_actions.dart @@ -56,12 +56,17 @@ void showBookContextMenu({required BuildContext context, required Book book, Off unawaited(_downloadBookFile(context, book)); }, onDelete: () { + final mediaUploadQueue = context.read(); + final importService = context.read(); + final mediaScope = mediaUploadQueue.activeScope; unawaited( deleteBookWithMediaCleanup( dataStore: context.read(), - mediaUploadQueue: context.read(), + mediaUploadQueue: mediaUploadQueue, bookId: book.id, - deleteBookFile: context.read().deleteBookFile, + coverMediaId: book.coverMediaId, + deleteBookFile: importService.deleteBookFile, + deleteCoverFile: mediaScope == null ? null : (mediaId) => importService.deleteCoverFile(mediaScope, mediaId), ), ); }, diff --git a/app/lib/utils/bulk_book_actions.dart b/app/lib/utils/bulk_book_actions.dart index c7e3137..4a6d5d4 100644 --- a/app/lib/utils/bulk_book_actions.dart +++ b/app/lib/utils/bulk_book_actions.dart @@ -147,13 +147,21 @@ void handleBulkDelete(BuildContext context, LibraryProvider libraryProvider) { FilledButton( onPressed: () async { final selectedBookIds = libraryProvider.selectedBookIds.toSet(); + final mediaUploadQueue = context.read(); + final importService = context.read(); + final mediaScope = mediaUploadQueue.activeScope; Navigator.pop(context); for (final bookId in selectedBookIds) { + final book = dataStore.getBook(bookId); await deleteBookWithMediaCleanup( dataStore: dataStore, - mediaUploadQueue: context.read(), + mediaUploadQueue: mediaUploadQueue, bookId: bookId, - deleteBookFile: context.read().deleteBookFile, + coverMediaId: book?.coverMediaId, + deleteBookFile: importService.deleteBookFile, + deleteCoverFile: mediaScope == null + ? null + : (mediaId) => importService.deleteCoverFile(mediaScope, mediaId), ); } libraryProvider.exitSelectionMode(); diff --git a/app/test/pages/book_details_delete_test.dart b/app/test/pages/book_details_delete_test.dart index 9a370f6..1560782 100644 --- a/app/test/pages/book_details_delete_test.dart +++ b/app/test/pages/book_details_delete_test.dart @@ -7,6 +7,7 @@ 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/media/media_storage_scope.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/pages/book_details_page.dart'; import 'package:papyrus/services/book_download_service.dart'; @@ -25,6 +26,7 @@ void main() { final repository = InMemoryBookRepository(); final dataStore = DataStore(bookRepository: repository); final mediaUploadQueue = MediaUploadQueue(prefs); + await mediaUploadQueue.activateScope(MediaStorageScope(profileKey: 'official', userId: 'user-1')); final importService = _RecordingBookImportService(); final book = Book(id: 'book-1', title: 'Delete Me', author: 'Author', addedAt: DateTime.utc(2026)); diff --git a/app/test/services/book_delete_cleanup_service_test.dart b/app/test/services/book_delete_cleanup_service_test.dart index b989ee9..9ebdb39 100644 --- a/app/test/services/book_delete_cleanup_service_test.dart +++ b/app/test/services/book_delete_cleanup_service_test.dart @@ -2,6 +2,7 @@ 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/media/media_storage_scope.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/services/book_delete_cleanup_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -16,8 +17,16 @@ void main() { 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)); + await queue.activateScope(MediaStorageScope(profileKey: 'official', userId: 'user-1')); + final book = Book( + id: 'book-1', + title: 'Book', + author: 'Author', + coverMediaId: 'cover-1', + addedAt: DateTime.utc(2026), + ); final deletedLocalFiles = []; + final deletedCovers = []; await repository.upsert(book); await pumpEventQueue(); @@ -27,12 +36,15 @@ void main() { dataStore: dataStore, mediaUploadQueue: queue, bookId: book.id, + coverMediaId: book.coverMediaId, deleteBookFile: (bookId) async => deletedLocalFiles.add(bookId), + deleteCoverFile: (mediaId) async => deletedCovers.add(mediaId), ); await pumpEventQueue(); expect(queue.pendingTasks, isEmpty); expect(deletedLocalFiles, [book.id]); + expect(deletedCovers, ['cover-1']); expect(dataStore.getBook(book.id), isNull); expect(await repository.getById(book.id), isNull); }); From 7689ad75e2700547c0c81f57b93acc9cca8b0535 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 17:38:48 +0300 Subject: [PATCH 12/48] style: format media scope test --- app/test/media/media_storage_scope_test.dart | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/app/test/media/media_storage_scope_test.dart b/app/test/media/media_storage_scope_test.dart index cca6751..b4baa89 100644 --- a/app/test/media/media_storage_scope_test.dart +++ b/app/test/media/media_storage_scope_test.dart @@ -21,13 +21,7 @@ void main() { }); test('scope rejects path separators', () { - expect( - () => MediaStorageScope(profileKey: '../official', userId: 'user-1'), - throwsArgumentError, - ); - expect( - () => MediaStorageScope(profileKey: 'official', userId: r'user\1'), - throwsArgumentError, - ); + expect(() => MediaStorageScope(profileKey: '../official', userId: 'user-1'), throwsArgumentError); + expect(() => MediaStorageScope(profileKey: 'official', userId: r'user\1'), throwsArgumentError); }); } From f0b35861d324ed7496d84483bb8f256788dcf36a Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 17:51:31 +0300 Subject: [PATCH 13/48] fix: close media lifecycle races --- app/lib/main.dart | 33 ++- app/lib/media/media_upload_queue.dart | 224 +++++++++++++----- .../powersync/sync_profile_switch_queue.dart | 27 +++ app/lib/services/book_import_service.dart | 6 +- .../media_profile_switch_contract_test.dart | 27 +++ app/test/media/media_upload_queue_test.dart | 84 ++++++- .../sync_profile_switch_queue_test.dart | 45 ++++ .../services/book_cover_storage_test.dart | 11 + 8 files changed, 377 insertions(+), 80 deletions(-) create mode 100644 app/lib/powersync/sync_profile_switch_queue.dart create mode 100644 app/test/media/media_profile_switch_contract_test.dart create mode 100644 app/test/powersync/sync_profile_switch_queue_test.dart diff --git a/app/lib/main.dart b/app/lib/main.dart index 6bbe3eb..761ed73 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -13,6 +13,7 @@ import 'package:papyrus/media/media_storage_scope.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_profile_switch_queue.dart'; import 'package:papyrus/powersync/sync_state.dart'; import 'package:papyrus/providers/auth_provider.dart'; import 'package:papyrus/providers/library_provider.dart'; @@ -55,6 +56,7 @@ class _PapyrusState extends State { late final PapyrusApiConfig _officialApiConfig; late AuthRepository _authRepository; late String _activeProfileKey; + late final SyncProfileSwitchQueue _profileSwitchQueue; late final AppRouter _appRouter; bool _switchingSyncProfile = false; Future? _authStateOperation; @@ -68,6 +70,14 @@ class _PapyrusState extends State { _syncSettingsProvider = SyncSettingsProvider(widget.prefs, officialConfig: _officialApiConfig); _activeProfileKey = _syncSettingsProvider.activeProfileKey; _authRepository = _buildAuthRepository(_syncSettingsProvider.activeApiConfig, _activeProfileKey); + _profileSwitchQueue = SyncProfileSwitchQueue( + initialProfileKey: _activeProfileKey, + onError: (error, stackTrace) { + FlutterError.reportError( + FlutterErrorDetails(exception: error, stack: stackTrace, library: 'papyrus sync profile lifecycle'), + ); + }, + ); _dataStore = DataStore(); _mediaUploadQueue = MediaUploadQueue(widget.prefs, onWorkAvailable: _processMediaUploads); @@ -171,21 +181,18 @@ class _PapyrusState extends State { void _handleSyncSettingsChanged() { final nextProfileKey = _syncSettingsProvider.activeProfileKey; - if (nextProfileKey == _activeProfileKey) { - return; - } - - _activeProfileKey = nextProfileKey; - unawaited(_switchActiveSyncProfile()); + final nextConfig = _syncSettingsProvider.activeApiConfig; + _profileSwitchQueue.request(nextProfileKey, () => _switchActiveSyncProfile(nextProfileKey, nextConfig)); } - Future _switchActiveSyncProfile() async { + Future _switchActiveSyncProfile(String nextProfileKey, PapyrusApiConfig nextConfig) async { _switchingSyncProfile = true; try { await _mediaUploadQueue.waitUntilIdle(); await _mediaUploadQueue.activateScope(null); await _powerSyncService.deactivate(clearAuthenticated: false); - _authRepository = _buildAuthRepository(_syncSettingsProvider.activeApiConfig, _activeProfileKey); + _authRepository = _buildAuthRepository(nextConfig, nextProfileKey); + _activeProfileKey = nextProfileKey; await _authProvider.replaceRepository(_authRepository, bootstrapNewRepository: !_authProvider.isOfflineMode); unawaited(_refreshMediaUsage()); } finally { @@ -205,14 +212,18 @@ class _PapyrusState extends State { } Future _processMediaUploads() async { - if (!_authProvider.isSignedIn || _authProvider.isOfflineMode) return; + if (_switchingSyncProfile || !_authProvider.isSignedIn || _authProvider.isOfflineMode) return; final user = _authProvider.user; if (user == null) return; - final scope = MediaStorageScope(profileKey: _activeProfileKey, userId: user.userId); + final profileKey = _activeProfileKey; + final repository = _authRepository; + final scope = MediaStorageScope(profileKey: profileKey, userId: user.userId); if (_mediaUploadQueue.activeScope != scope) { await _mediaUploadQueue.activateScope(scope); } - final repository = _authRepository; + if (_switchingSyncProfile || profileKey != _activeProfileKey || !identical(repository, _authRepository)) { + return; + } await _mediaUploadQueue.processPending( dataStore: _dataStore, readBookFile: _bookImportService.getBookFile, diff --git a/app/lib/media/media_upload_queue.dart b/app/lib/media/media_upload_queue.dart index 3cd150f..a0272b8 100644 --- a/app/lib/media/media_upload_queue.dart +++ b/app/lib/media/media_upload_queue.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; @@ -85,6 +86,8 @@ class MediaUploadQueue extends ChangeNotifier { MediaStorageScope? _activeScope; MediaStorageUsage? _storageUsage; Future? _processing; + Future _mutationTail = Future.value(); + Map _taskVersions = {}; List get pendingTasks => List.unmodifiable(_tasks); MediaStorageScope? get activeScope => _activeScope; @@ -93,8 +96,10 @@ class MediaUploadQueue extends ChangeNotifier { Future activateScope(MediaStorageScope? scope) async { if (_activeScope == scope) return; await waitUntilIdle(); + await _waitForMutations(); _activeScope = scope; _tasks = scope == null ? [] : _loadTasks(scope); + _taskVersions = {for (final task in _tasks) task.id: 0}; _storageUsage = null; notifyListeners(); } @@ -141,18 +146,21 @@ class MediaUploadQueue extends ChangeNotifier { Future retryFailed({String? bookId}) async { final scope = _requireActiveScope(); var retriedAny = false; - _tasks = _tasks - .map((task) { - final matchesBook = bookId == null || task.bookId == bookId; - if (!matchesBook || task.status != MediaUploadTaskStatus.failed) { - return task; - } - retriedAny = true; - return task.copyWith(status: MediaUploadTaskStatus.pending); - }) - .toList(growable: false); - await _save(scope); - notifyListeners(); + await _withMutation(() async { + _tasks = _tasks + .map((task) { + final matchesBook = bookId == null || task.bookId == bookId; + if (!matchesBook || task.status != MediaUploadTaskStatus.failed) { + return task; + } + retriedAny = true; + _advanceTaskVersion(task.id); + return task.copyWith(status: MediaUploadTaskStatus.pending); + }) + .toList(growable: false); + await _save(scope); + notifyListeners(); + }); if (retriedAny) { await onWorkAvailable?.call(); } @@ -161,9 +169,14 @@ class MediaUploadQueue extends ChangeNotifier { Future removeTasksForBook(String bookId) async { final scope = _activeScope; if (scope == null) return; - _tasks = _tasks.where((task) => task.bookId != bookId).toList(growable: false); - await _save(scope); - notifyListeners(); + await _withMutation(() async { + for (final task in _tasks.where((task) => task.bookId == bookId)) { + _advanceTaskVersion(task.id); + } + _tasks = _tasks.where((task) => task.bookId != bookId).toList(growable: false); + await _save(scope); + notifyListeners(); + }); } Future processPending({ @@ -176,17 +189,19 @@ class MediaUploadQueue extends ChangeNotifier { final scope = _activeScope; if (scope == null) return Future.value(); - final operation = _processPending( - scope: scope, - dataStore: dataStore, - readBookFile: readBookFile, - uploadMedia: uploadMedia, - ); + final completer = Completer(); + final operation = completer.future; _processing = operation; - operation.then( - (_) => _clearProcessing(operation), - onError: (Object error, StackTrace stackTrace) => _clearProcessing(operation), - ); + unawaited(() async { + try { + await _processPending(scope: scope, dataStore: dataStore, readBookFile: readBookFile, uploadMedia: uploadMedia); + _clearProcessing(operation); + completer.complete(); + } catch (error, stackTrace) { + _clearProcessing(operation); + completer.completeError(error, stackTrace); + } + }()); return operation; } @@ -196,54 +211,135 @@ class MediaUploadQueue extends ChangeNotifier { 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())); + final processedVersions = {}; + while (_activeScope == scope) { + await _waitForMutations(); + final tasks = _tasks + .where((task) { + if (task.status == MediaUploadTaskStatus.failed) return false; + return !processedVersions.contains(_taskVersionKey(task.id)); + }) + .toList(growable: false); + if (tasks.isEmpty) return; + + for (final task in tasks) { + final version = _taskVersions[task.id] ?? 0; + processedVersions.add('${task.id}:$version'); + + final bytes = await _bytesForTask(task, readBookFile); + if (bytes == null) { + await _replaceTaskIfCurrent( + scope, + task, + version, + 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); + await _removeTaskIfCurrent(scope, task, version); + } on MediaUploadException catch (error) { + await _replaceTaskIfCurrent( + scope, + task, + version, + task.copyWith( + status: error.storageFull ? MediaUploadTaskStatus.failed : MediaUploadTaskStatus.pending, + errorMessage: error.message, + ), + ); + } catch (error) { + await _replaceTaskIfCurrent( + scope, + task, + version, + task.copyWith(status: MediaUploadTaskStatus.pending, errorMessage: error.toString()), + ); + } } } - _tasks = nextTasks; - await _save(scope); - notifyListeners(); } Future _enqueue(MediaUploadTask task) async { final scope = _requireActiveScope(); - _tasks = [..._tasks.where((existing) => existing.id != task.id), task]; - await _save(scope); - notifyListeners(); + await _withMutation(() async { + _advanceTaskVersion(task.id); + _tasks = [..._tasks.where((existing) => existing.id != task.id), task]; + await _save(scope); + notifyListeners(); + }); await onWorkAvailable?.call(); } + Future _removeTaskIfCurrent(MediaStorageScope scope, MediaUploadTask task, int version) { + return _withMutation(() async { + final index = _currentTaskIndex(task, version); + if (index < 0) return; + _tasks = [..._tasks]..removeAt(index); + await _save(scope); + notifyListeners(); + }); + } + + Future _replaceTaskIfCurrent( + MediaStorageScope scope, + MediaUploadTask task, + int version, + MediaUploadTask replacement, + ) { + return _withMutation(() async { + final index = _currentTaskIndex(task, version); + if (index < 0) return; + _tasks = [..._tasks]..[index] = replacement; + await _save(scope); + notifyListeners(); + }); + } + + int _currentTaskIndex(MediaUploadTask task, int version) { + if ((_taskVersions[task.id] ?? 0) != version) return -1; + return _tasks.indexWhere((candidate) => identical(candidate, task)); + } + + String _taskVersionKey(String taskId) => '$taskId:${_taskVersions[taskId] ?? 0}'; + + void _advanceTaskVersion(String taskId) { + _taskVersions[taskId] = (_taskVersions[taskId] ?? 0) + 1; + } + + Future _withMutation(FutureOr Function() mutation) { + final previous = _mutationTail; + final released = Completer(); + _mutationTail = released.future; + return (() async { + await previous; + try { + return await mutation(); + } finally { + released.complete(); + } + })(); + } + + Future _waitForMutations() async { + while (true) { + final pending = _mutationTail; + await pending; + if (identical(pending, _mutationTail)) return; + } + } + Future _bytesForTask(MediaUploadTask task, BookFileReader readBookFile) async { if (task.kind == MediaKind.coverImage) { final coverBase64 = task.coverBase64; diff --git a/app/lib/powersync/sync_profile_switch_queue.dart b/app/lib/powersync/sync_profile_switch_queue.dart new file mode 100644 index 0000000..503bfa5 --- /dev/null +++ b/app/lib/powersync/sync_profile_switch_queue.dart @@ -0,0 +1,27 @@ +import 'dart:async'; + +typedef SyncProfileSwitchErrorHandler = void Function(Object error, StackTrace stackTrace); + +/// Serializes server-profile switches while retaining the latest user request. +class SyncProfileSwitchQueue { + SyncProfileSwitchQueue({required String initialProfileKey, required SyncProfileSwitchErrorHandler onError}) + : _requestedProfileKey = initialProfileKey, + _onError = onError; + + final SyncProfileSwitchErrorHandler _onError; + String _requestedProfileKey; + Future _tail = Future.value(); + + String get requestedProfileKey => _requestedProfileKey; + + bool request(String profileKey, Future Function() switchProfile) { + if (profileKey == _requestedProfileKey) return false; + _requestedProfileKey = profileKey; + + final operation = _tail.then((_) => switchProfile()); + _tail = operation.then((_) {}, onError: _onError); + return true; + } + + Future waitUntilIdle() => _tail; +} diff --git a/app/lib/services/book_import_service.dart b/app/lib/services/book_import_service.dart index 411767f..c61ad69 100644 --- a/app/lib/services/book_import_service.dart +++ b/app/lib/services/book_import_service.dart @@ -317,9 +317,9 @@ class BookImportService { if (bytes == null) { worker.postMessage(message); } else { - final actualBytes = bytes.offsetInBytes == 0 && bytes.lengthInBytes == bytes.buffer.lengthInBytes - ? bytes - : Uint8List.fromList(bytes); + // Cover bytes are also returned to Image.memory on a cache miss. Transfer + // a copy so postMessage does not detach the caller's backing buffer. + final actualBytes = Uint8List.fromList(bytes); final jsBuffer = actualBytes.buffer.toJS; message['fileData'] = jsBuffer; worker.postMessage(message, [jsBuffer].toJS); diff --git a/app/test/media/media_profile_switch_contract_test.dart b/app/test/media/media_profile_switch_contract_test.dart new file mode 100644 index 0000000..4e91f4c --- /dev/null +++ b/app/test/media/media_profile_switch_contract_test.dart @@ -0,0 +1,27 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('profile switch publishes its key with the replacement repository', () { + final source = File('lib/main.dart').readAsStringSync(); + final handler = source.substring( + source.indexOf('void _handleSyncSettingsChanged()'), + source.indexOf('Future _refreshMediaUsage()'), + ); + + expect(handler, contains('_switchActiveSyncProfile(nextProfileKey, nextConfig)')); + expect(handler.indexOf('_activeProfileKey = nextProfileKey'), greaterThan(handler.indexOf('_buildAuthRepository'))); + }); + + test('upload processing rechecks profile transition after scope activation', () { + final source = File('lib/main.dart').readAsStringSync(); + final processor = source.substring( + source.indexOf('Future _processMediaUploads()'), + source.indexOf('@override\n Widget build'), + ); + + expect(RegExp(r'_switchingSyncProfile').allMatches(processor), hasLength(greaterThanOrEqualTo(2))); + expect(processor, contains('!identical(repository, _authRepository)')); + }); +} diff --git a/app/test/media/media_upload_queue_test.dart b/app/test/media/media_upload_queue_test.dart index 86c7379..f3df1fb 100644 --- a/app/test/media/media_upload_queue_test.dart +++ b/app/test/media/media_upload_queue_test.dart @@ -171,6 +171,86 @@ void main() { expect(uploads, 1); }); + test('enqueue during an upload is persisted and drained before processing completes', () async { + final prefs = await SharedPreferences.getInstance(); + final repository = InMemoryBookRepository(); + final dataStore = DataStore(bookRepository: repository); + final firstBook = _book(id: '11111111-1111-1111-1111-111111111111', filePath: 'book-1'); + final secondBook = _book(id: '22222222-2222-2222-2222-222222222222', filePath: 'book-2'); + await repository.upsert(firstBook); + await repository.upsert(secondBook); + await pumpEventQueue(); + final firstUploadStarted = Completer(); + final releaseFirstUpload = Completer(); + final uploadedBookIds = []; + late MediaUploadQueue queue; + + Future process() => queue.processPending( + dataStore: dataStore, + readBookFile: (_) async => Uint8List.fromList([1]), + uploadMedia: (payload) async { + uploadedBookIds.add(payload.bookId); + if (payload.bookId == firstBook.id) { + firstUploadStarted.complete(); + await releaseFirstUpload.future; + } + return _asset(assetId: 'asset-${payload.bookId}', bookId: payload.bookId, kind: payload.kind); + }, + ); + + queue = MediaUploadQueue(prefs, onWorkAvailable: process); + await queue.activateScope(MediaStorageScope(profileKey: 'official', userId: 'user-1')); + final firstEnqueue = queue.enqueueBookFile( + book: firstBook, + filename: 'first.epub', + contentType: 'application/epub+zip', + ); + await firstUploadStarted.future; + final secondEnqueue = queue.enqueueBookFile( + book: secondBook, + filename: 'second.epub', + contentType: 'application/epub+zip', + ); + await pumpEventQueue(); + releaseFirstUpload.complete(); + + await Future.wait([firstEnqueue, secondEnqueue]); + + expect(uploadedBookIds, [firstBook.id, secondBook.id]); + expect(queue.pendingTasks, isEmpty); + expect(prefs.getString('media_upload_queue:official--user-1'), '[]'); + }); + + test('removing a book during a failed upload does not resurrect its task', () async { + final prefs = await SharedPreferences.getInstance(); + final repository = InMemoryBookRepository(); + final dataStore = DataStore(bookRepository: repository); + final book = _book(filePath: 'book-1'); + await repository.upsert(book); + await pumpEventQueue(); + final uploadStarted = Completer(); + final releaseUpload = Completer(); + final queue = await _activeQueue(prefs); + await queue.enqueueBookFile(book: book, filename: 'book.epub', contentType: 'application/epub+zip'); + + final processing = queue.processPending( + dataStore: dataStore, + readBookFile: (_) async => Uint8List.fromList([1]), + uploadMedia: (_) async { + uploadStarted.complete(); + await releaseUpload.future; + throw const MediaUploadException('network failure'); + }, + ); + await uploadStarted.future; + await queue.removeTasksForBook(book.id); + releaseUpload.complete(); + await processing; + + expect(queue.pendingTasks, isEmpty); + expect(prefs.getString('media_upload_queue:official--user-1'), '[]'); + }); + test('enqueue invokes work callback after scoped tasks are persisted', () async { final prefs = await SharedPreferences.getInstance(); final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); @@ -218,9 +298,9 @@ void main() { }); } -Book _book({String? filePath, int? fileSize, String? fileHash}) { +Book _book({String id = '11111111-1111-1111-1111-111111111111', String? filePath, int? fileSize, String? fileHash}) { return Book( - id: '11111111-1111-1111-1111-111111111111', + id: id, title: 'Book', author: 'Author', filePath: filePath, diff --git a/app/test/powersync/sync_profile_switch_queue_test.dart b/app/test/powersync/sync_profile_switch_queue_test.dart new file mode 100644 index 0000000..c69185e --- /dev/null +++ b/app/test/powersync/sync_profile_switch_queue_test.dart @@ -0,0 +1,45 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/powersync/sync_profile_switch_queue.dart'; + +void main() { + test('rapid switch away and back preserves the final requested profile', () async { + final errors = []; + final queue = SyncProfileSwitchQueue(initialProfileKey: 'profile-a', onError: (error, _) => errors.add(error)); + final firstStarted = Completer(); + final releaseFirst = Completer(); + final applied = []; + + expect( + queue.request('profile-b', () async { + firstStarted.complete(); + await releaseFirst.future; + applied.add('profile-b'); + }), + isTrue, + ); + await firstStarted.future; + expect(queue.requestedProfileKey, 'profile-b'); + + expect(queue.request('profile-a', () async => applied.add('profile-a')), isTrue); + expect(queue.requestedProfileKey, 'profile-a'); + releaseFirst.complete(); + await queue.waitUntilIdle(); + + expect(applied, ['profile-b', 'profile-a']); + expect(errors, isEmpty); + }); + + test('failed switch does not prevent the next request', () async { + final errors = []; + final queue = SyncProfileSwitchQueue(initialProfileKey: 'profile-a', onError: (error, _) => errors.add(error)); + + queue.request('profile-b', () async => throw StateError('unavailable')); + queue.request('profile-c', () async {}); + await queue.waitUntilIdle(); + + expect(queue.requestedProfileKey, 'profile-c'); + expect(errors, hasLength(1)); + }); +} diff --git a/app/test/services/book_cover_storage_test.dart b/app/test/services/book_cover_storage_test.dart index 8571b66..2ca5eae 100644 --- a/app/test/services/book_cover_storage_test.dart +++ b/app/test/services/book_cover_storage_test.dart @@ -91,4 +91,15 @@ void main() { expect(source, contains("case '$action':")); } }); + + test('web cover writes transfer a copy so caller bytes remain renderable', () { + final source = File('lib/services/book_import_service.dart').readAsStringSync(); + final helper = source.substring( + source.indexOf('Future _sendCoverRequest'), + source.indexOf('BookImportResult _parseImportResult'), + ); + + expect(helper, contains('final actualBytes = Uint8List.fromList(bytes);')); + expect(helper, isNot(contains('bytes.offsetInBytes == 0 && bytes.lengthInBytes == bytes.buffer.lengthInBytes'))); + }); } From 7a5d306cc96e4ce8b4b04abf30ce213b7e3a4b04 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 18:22:22 +0300 Subject: [PATCH 14/48] docs: define local-first cover lifecycle --- ...26-07-11-media-storage-hardening-design.md | 52 +++++++++++++++---- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/docs/superpowers/specs/2026-07-11-media-storage-hardening-design.md b/docs/superpowers/specs/2026-07-11-media-storage-hardening-design.md index 7cea016..e328c0e 100644 --- a/docs/superpowers/specs/2026-07-11-media-storage-hardening-design.md +++ b/docs/superpowers/specs/2026-07-11-media-storage-hardening-design.md @@ -4,11 +4,12 @@ The media-storage pipeline spans the Papyrus Flutter client and FastAPI server. Book metadata is synchronized through PowerSync while book files and cover images remain private media assets stored outside the synchronized database. -Guest and account libraries are intentionally separate. Signing in does not migrate or merge guest books, files, covers, or pending work into an account library. This design preserves that boundary and hardens only authenticated media behavior. +Guest and account libraries are intentionally separate. Signing in does not migrate or merge guest books, files, covers, or pending work into an account library. Guest covers remain permanent local media, while account covers use a local-first upload and cross-device cache lifecycle. ## Goals - Persist authenticated cover images in platform-local files after their first successful display. +- Persist newly imported guest and account covers in platform-local files before book metadata is saved. - Render private covers consistently across every book-cover surface. - Isolate pending uploads and cached covers by selected server and authenticated user. - Ensure only one client upload-queue processor runs at a time. @@ -31,7 +32,7 @@ Authenticated local media state is scoped by two values: 1. The selected sync-server profile key already produced by `SyncSettingsProvider.activeProfileKey`. 2. The authenticated server user ID. -Together they form an immutable media scope for one server/account pair. Upload tasks and cover-cache paths include both values. Signed-out and guest modes expose no authenticated media scope and therefore no authenticated pending tasks. +Together they form an immutable media scope for one server/account pair. Upload tasks, pending-cover paths, and cover-cache paths include both values. Signed-out and guest modes expose no authenticated upload scope and therefore no authenticated pending tasks. The single guest library uses a distinct local-only namespace for its permanent cover files. Switching server or account changes the active scope without deleting other scopes. Returning to the same server/account restores its pending uploads and cached covers. Clearing an authenticated cache explicitly removes the active scope's cover files; signing out alone retains them for offline reuse after a later sign-in to the same profile. @@ -41,8 +42,9 @@ Switching server or account changes the active scope without deleting other scop Cover bytes are stored as opaque files; image decoding uses the bytes rather than a filename extension. -- Native: the application-support directory contains `media-covers///.bin`. +- Native: the application-support directory contains `media-covers///cached/.bin` and `media-covers///pending/.bin`. - Web: OPFS contains the equivalent directory hierarchy. +- Guest covers use the equivalent `media-covers/local/guest/books/.bin` layout and never enter an authenticated scope. Every write uses a temporary file or temporary OPFS entry followed by replacement so an interrupted write cannot leave a valid-looking partial cache entry. Cache path components are normalized before use, and callers cannot supply arbitrary paths. @@ -50,11 +52,12 @@ Every write uses a temporary file or temporary OPFS entry followed by replacemen A reusable cover loader follows this order: -1. If the book has a non-empty public/data `coverUrl`, render it through the existing network/data-image path. -2. If the book has `coverMediaId`, read the scoped cover file. -3. If a cached file exists, render it immediately. +1. If the book has a non-empty public HTTP(S) `coverUrl`, render it through the existing network-image path. Legacy `data:` values remain readable for backward compatibility, but new imports and edits never create them. +2. If the book has `coverMediaId`, read the authenticated scoped cache file. +3. If the cached media file exists, render it immediately. 4. Otherwise download the authenticated media asset, persist it in the active scope, and render it. -5. If local read or download fails, render the existing placeholder without deleting book metadata. +5. If the book has no `coverMediaId`, look for a pending account cover or permanent guest cover derived from the book ID and active library mode. +6. If local read or download fails, render the existing placeholder without deleting book metadata. Downloads are coalesced by `(scope, mediaId)` so multiple widgets requesting the same cover share one operation. Completed futures are removed from the in-flight map; the persisted file, rather than a process-global future, is the long-lived cache. @@ -62,15 +65,35 @@ Downloads are coalesced by `(scope, mediaId)` so multiple widgets requesting the A single reusable private-cover content widget owns the public-URL/private-media/placeholder decision. The existing sized `BookCoverImage` composes it, and library cards, list rows, dashboard cards, shelf/topic sheets, context menus, and other book-cover surfaces use the same content widget. This prevents successful upload from clearing `coverUrl` and making covers disappear outside the details page. -Book deletion removes the cached cover for that book on a best-effort basis after pending upload tasks are removed. Clearing the active authenticated cache removes the whole scoped cover directory. Guest book-file and cover storage remains untouched. +Book deletion removes pending, guest, and cached cover files for that book on a best-effort basis after pending upload tasks are removed. Clearing the active authenticated cache removes the whole scoped cover directory. Clearing authenticated data never removes guest book-file or cover storage. + +## Two-mode cover lifecycle + +### Fully offline guest library + +Import writes the extracted cover to the guest filesystem/OPFS namespace before saving book metadata. The book row contains no cover bytes or device-local path. Cover widgets derive the local cover key from the book ID and guest library mode. The cover remains local permanently unless the guest book is deleted. Signing in does not migrate it. + +### Account library and cross-device synchronization + +Account imports are local-first and remain usable without network connectivity: + +1. Write the extracted cover to the active server/user scope under `pending/.bin`. +2. Save and synchronize book metadata without cover bytes, a data URI, or a device-local path. +3. Persist a media upload task that references the pending cover key rather than embedding cover bytes. +4. After PowerSync has created the owned server book, upload the pending cover. +5. The server stores the private asset, updates `cover_media_id`, and exposes that portable ID through PowerSync. +6. Promote the source device's pending bytes into the scoped `.bin` cache and remove the pending file. +7. Other devices receive `cover_media_id`, lazily download the authenticated asset when first displayed, and persist their own scoped cache file. + +An account device that remains offline keeps its pending file and durable queue task until connectivity returns. Another device may show a placeholder before `cover_media_id` is available; it never receives a local path or raw cover bytes through synchronization. Concurrent replacements retain the server's existing last-committed replacement behavior. ## Scoped upload queue -`MediaUploadQueue` manages tasks per media scope instead of using one global `media_upload_queue` preference key. Each persisted key includes a normalized server profile and user ID. Task payloads do not need to duplicate the scope because they are stored under the scoped key. +`MediaUploadQueue` manages tasks per media scope instead of using one global `media_upload_queue` preference key. Each persisted key includes a normalized server profile and user ID. Task payloads do not duplicate the scope because they are stored under the scoped key. Cover tasks store only the book ID, pending-cover key, filename, content type, status, and error state; cover bytes remain in filesystem/OPFS and never enter SharedPreferences. -Changing the active scope reloads that scope's tasks and usage state, then notifies the UI. Guest or signed-out state activates no scope and presents an empty authenticated queue. Cover payloads remain in the scoped queue until upload succeeds; they never appear in another account's pending or failed list. +Changing the active scope reloads that scope's tasks and usage state, then notifies the UI. Guest or signed-out state activates no scope and presents an empty authenticated queue. Cover task references remain in the scoped queue until upload succeeds; they never appear in another account's pending or failed list. -Queue processing is single-flight. If processing is already active, additional calls return the same future instead of iterating the task list again. The operation captures its media scope, `AuthRepository`, and local file reader at start, so a later profile switch cannot redirect in-flight work to another server. Results are saved back to the captured scope. +Queue processing is single-flight. If processing is already active, additional calls return the same future and request one additional drain pass so a PowerSync completion signal cannot be lost behind an earlier `Book was not found` response. The operation captures its media scope, `AuthRepository`, and local file readers at start, so a later profile switch cannot redirect in-flight work to another server. Results are saved back to the captured scope. Enqueue and retry operations invoke a work-available callback only after the updated task list has been persisted. The application wires this callback to the existing media processor. This makes a newly imported book trigger its upload after enqueue, independently of PowerSync callback timing. PowerSync and authentication callbacks remain useful retry triggers and are safe because processing is single-flight. @@ -100,6 +123,8 @@ The mobile context-menu test asserts the product labels `Download` and `Delete`. - Cover-cache failures degrade to a placeholder and remain retryable on a future build. - A failed cache write does not hide successfully downloaded bytes during the current render, but it does not count as persisted. - Queue network failures retain the task under its original scope. +- A missing server book leaves account media pending until a later PowerSync completion trigger retries it. +- A failed pending-cover read retains both the file reference and queue task. - Storage-quota failures remain visible as failed tasks and require explicit retry. - Profile switches never mutate or discard another scope's queue. - Server constraint or transaction failures roll back database changes and remove the new temporary/final file. @@ -112,12 +137,17 @@ Client tests cover: - native/local cover-cache persistence, cache hits, atomic replacement, deletion, and scope isolation; - web cover-store message handling and OPFS worker actions; +- guest cover persistence derived from book ID without SQL metadata bytes; +- account pending-cover persistence and restoration without SharedPreferences payload bytes; +- pending-cover promotion to `coverMediaId` cache after upload; +- cross-device behavior where a device with only `coverMediaId` lazily downloads its own cache; - lazy download followed by a persisted cache hit; - shared in-flight cover downloads; - private-cover rendering in details and representative library/dashboard surfaces; - upload task isolation across server/user scopes; - restoration of tasks when returning to a scope; - single-flight processing under overlapping triggers; +- a PowerSync retry trigger arriving during a failed media request causing an additional drain pass; - enqueue-triggered processing after persistence; - profile switching while processing without backend redirection; - corrected `Download` and `Delete` context-menu expectations. From c0628ad92d546fb280ce0da9eafae029573a2a1c Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 18:28:59 +0300 Subject: [PATCH 15/48] docs: plan local-first cover lifecycle --- .../2026-07-11-local-first-cover-lifecycle.md | 616 ++++++++++++++++++ 1 file changed, 616 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-11-local-first-cover-lifecycle.md diff --git a/docs/superpowers/plans/2026-07-11-local-first-cover-lifecycle.md b/docs/superpowers/plans/2026-07-11-local-first-cover-lifecycle.md new file mode 100644 index 0000000..71dafc1 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-local-first-cover-lifecycle.md @@ -0,0 +1,616 @@ +# Local-First Cover Lifecycle Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep guest and account cover bytes in filesystem/OPFS, synchronize only `cover_media_id`, and make account covers survive offline import and appear on other devices through lazy authenticated downloads. + +**Architecture:** Extend the existing scoped cover store with explicit cached, pending, and guest-book buckets. Account imports write `pending/.bin`, synchronize metadata without an inline cover, and enqueue a metadata-only cover task; successful upload promotes those bytes to `cached/.bin`. Guest imports write `local/guest/books/.bin`, while other account devices receive `cover_media_id` and reuse the existing lazy cache downloader. + +**Tech Stack:** Flutter/Dart, `SharedPreferences`, native filesystem through `path_provider`, web OPFS through `book_worker.js`, PowerSync, Flutter widget/unit tests. + +--- + +## File structure + +- Create `app/lib/media/cover_storage_bucket.dart`: safe bucket names shared by native and web cover storage. +- Create `app/lib/services/book_import_commit_service.dart`: one testable import-commit boundary that persists a cover before saving metadata and enqueueing account media. +- Create `app/test/services/book_import_commit_service_test.dart`: verifies guest/account behavior without widget timing. +- Modify `app/lib/media/media_storage_scope.dart`: add the single local guest cover namespace. +- Modify `app/lib/services/book_import_service_stub.dart`: native cached, pending, and guest cover operations. +- Modify `app/lib/services/book_import_service.dart`: matching web worker requests and pending-to-cache promotion. +- Modify `app/web/book_worker.js`: validate a cover bucket and store files beneath that bucket. +- Modify `app/lib/media/media_upload_queue.dart`: persist cover task metadata only and read pending bytes at processing time. +- Modify `app/lib/widgets/add_book/import_book_sheet.dart`: delegate committing to the new service; stop generating new data URIs. +- Modify `app/lib/widgets/book/private_book_cover.dart`: load a book-ID fallback when no public URL or media ID exists. +- Modify cover call sites and `CoverPreview`: propagate book IDs to the shared cover renderer. +- Modify `app/lib/main.dart`: promote uploaded pending cover bytes to the media-ID cache. +- Modify book deletion cleanup: delete pending/guest files as well as media-ID cache files. + +### Task 1: Add explicit filesystem cover buckets + +**Files:** +- Create: `app/lib/media/cover_storage_bucket.dart` +- Modify: `app/lib/media/media_storage_scope.dart` +- Modify: `app/lib/services/book_import_service_stub.dart` +- Modify: `app/lib/services/book_import_service.dart` +- Modify: `app/web/book_worker.js` +- Test: `app/test/services/book_cover_storage_test.dart` +- Test: `app/test/media/media_storage_scope_test.dart` + +- [ ] **Step 1: Write failing native storage and guest-scope tests** + +Add tests that exercise the wished-for API: + +```dart +test('pending and cached covers use separate files', () async { + final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + await service.storePendingCoverFile(scope, 'book-1', Uint8List.fromList([1])); + await service.storeCoverFile(scope, 'asset-1', Uint8List.fromList([2])); + + expect(await service.getPendingCoverFile(scope, 'book-1'), [1]); + expect(await service.getCoverFile(scope, 'asset-1'), [2]); +}); + +test('guest cover files use the local guest namespace', () async { + await service.storeGuestCoverFile('book-1', Uint8List.fromList([3])); + expect(await service.getGuestCoverFile('book-1'), [3]); + expect(MediaStorageScope.localGuest.persistenceKey, 'local--guest'); +}); + +test('promotion stores media cache bytes and removes pending file', () async { + final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + await service.storePendingCoverFile(scope, 'book-1', Uint8List.fromList([4])); + await service.promotePendingCoverFile(scope, bookId: 'book-1', mediaId: 'asset-1'); + + expect(await service.getPendingCoverFile(scope, 'book-1'), isNull); + expect(await service.getCoverFile(scope, 'asset-1'), [4]); +}); +``` + +Extend the worker source assertion to require `bucket` validation and the buckets `cached`, `pending`, and `books`. + +- [ ] **Step 2: Run tests and verify RED** + +Run: + +```bash +cd app +flutter test test/services/book_cover_storage_test.dart test/media/media_storage_scope_test.dart --reporter expanded +``` + +Expected: FAIL because `localGuest`, pending/guest methods, promotion, and worker bucket handling do not exist. + +- [ ] **Step 3: Implement bucketed native and web storage** + +Create: + +```dart +enum CoverStorageBucket { + cached('cached'), + pending('pending'), + guestBooks('books'); + + const CoverStorageBucket(this.pathComponent); + final String pathComponent; +} +``` + +Add: + +```dart +static const localGuest = MediaStorageScope(profileKey: 'local', userId: 'guest'); +``` + +Route the existing `getCoverFile`, `storeCoverFile`, and `deleteCoverFile` through `CoverStorageBucket.cached`. Add pending and guest wrappers. Native `_coverFile` must build: + +```dart +File(p.join(directory.path, bucket.pathComponent, '$id.bin')) +``` + +and create the bucket directory recursively. Promotion must read pending bytes, atomically store the cached file, and delete pending only after the cached write succeeds. + +On web, add `bucket` to `_sendCoverRequest`; the worker must reject values outside: + +```javascript +const COVER_BUCKETS = new Set(['cached', 'pending', 'books']); +``` + +and resolve `media-covers///.bin`. Preserve the current copied `ArrayBuffer` transfer so caller bytes are not detached. + +- [ ] **Step 4: Run focused tests and worker syntax check** + +Run: + +```bash +cd app +flutter test test/services/book_cover_storage_test.dart test/media/media_storage_scope_test.dart --reporter expanded +node --check web/book_worker.js +``` + +Expected: all focused tests pass and Node exits 0. + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/media/cover_storage_bucket.dart app/lib/media/media_storage_scope.dart app/lib/services/book_import_service_stub.dart app/lib/services/book_import_service.dart app/web/book_worker.js app/test/services/book_cover_storage_test.dart app/test/media/media_storage_scope_test.dart +git commit -m "feat: add pending and guest cover storage" +``` + +### Task 2: Make queued cover uploads reference pending files + +**Files:** +- Modify: `app/lib/media/media_upload_queue.dart` +- Modify: `app/test/media/media_upload_queue_test.dart` + +- [ ] **Step 1: Write failing queue persistence and restore tests** + +Add: + +```dart +test('cover task persists metadata without cover bytes', () async { + final prefs = await SharedPreferences.getInstance(); + final queue = await _activeQueue(prefs); + final book = _book(filePath: 'book-1'); + + await queue.enqueueCover(book: book, filename: 'cover.jpg', contentType: 'image/jpeg'); + + final stored = prefs.getString('media_upload_queue:official--user-1')!; + expect(stored, isNot(contains('cover_base64'))); + expect(stored, isNot(contains(base64Encode([1, 2, 3])))); +}); + +test('restored cover task reads pending filesystem bytes', () async { + final prefs = await SharedPreferences.getInstance(); + final first = await _activeQueue(prefs); + final book = _book(filePath: 'book-1'); + await first.enqueueCover(book: book, filename: 'cover.jpg', contentType: 'image/jpeg'); + + final restored = await _activeQueue(prefs); + Uint8List? uploaded; + await restored.processPending( + dataStore: _dataStoreContaining(book), + readBookFile: (_) async => null, + readPendingCover: (_, bookId) async => bookId == book.id ? Uint8List.fromList([1, 2, 3]) : null, + uploadMedia: (payload) async { + uploaded = payload.bytes; + return _asset(assetId: 'cover-1', bookId: book.id, kind: MediaKind.coverImage); + }, + ); + expect(uploaded, [1, 2, 3]); +}); +``` + +Keep a compatibility test proving an already persisted `cover_base64` task can drain once, but ensure new tasks never serialize that field. + +- [ ] **Step 2: Run the queue tests and verify RED** + +Run: + +```bash +cd app +flutter test test/media/media_upload_queue_test.dart --reporter expanded +``` + +Expected: FAIL because `enqueueCover` still requires bytes and `processPending` has no `readPendingCover` dependency. + +- [ ] **Step 3: Implement metadata-only cover tasks** + +Add: + +```dart +typedef PendingCoverReader = Future Function(MediaStorageScope scope, String bookId); +``` + +Change new cover enqueueing to omit `coverBase64`. Pass `readPendingCover` into processing and resolve bytes as: + +```dart +if (task.kind == MediaKind.coverImage) { + final legacy = task.coverBase64; + return legacy == null ? readPendingCover(scope, task.bookId) : base64Decode(legacy); +} +``` + +Build `toJson()` with conditional entries so `cover_base64` is absent for new tasks. Retain nullable legacy parsing only until existing development queues have drained. Keep the current `_processAgain` behavior and its regression test: a PowerSync callback arriving during a failed upload must trigger a fresh pass. + +- [ ] **Step 4: Run queue tests and verify GREEN** + +Run: + +```bash +cd app +flutter test test/media/media_upload_queue_test.dart --reporter expanded +``` + +Expected: all queue tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/media/media_upload_queue.dart app/test/media/media_upload_queue_test.dart +git commit -m "fix: queue cover file references instead of bytes" +``` + +### Task 3: Persist imported covers before saving book metadata + +**Files:** +- Create: `app/lib/services/book_import_commit_service.dart` +- Create: `app/test/services/book_import_commit_service_test.dart` +- Modify: `app/lib/widgets/add_book/import_book_sheet.dart` + +- [ ] **Step 1: Write failing guest and account commit tests** + +Define tests around a small commit service with injected storage callbacks. The account test must assert call order and metadata: + +```dart +test('account import stores pending cover and saves no inline cover metadata', () async { + final calls = []; + final result = _resultWithCover([1, 2, 3]); + final service = BookImportCommitService( + storePendingCover: (_, bookId, bytes) async => calls.add('store:$bookId:${bytes.length}'), + storeGuestCover: (_, __) async => fail('guest storage must not be used'), + addBook: (book) async { + calls.add('book:${book.id}'); + expect(book.coverUrl, isNull); + }, + enqueueBookFile: (_) async => calls.add('file-task'), + enqueueCover: (_) async => calls.add('cover-task'), + ); + + await service.commit(result: result, filename: 'book.epub', accountScope: _scope); + expect(calls, ['store:${result.bookId}:3', 'book:${result.bookId}', 'file-task', 'cover-task']); +}); + +test('guest import stores permanent local cover without upload tasks', () async { + final calls = []; + final result = _resultWithCover([4, 5]); + final service = BookImportCommitService( + storePendingCover: (_, __, ___) async => fail('pending storage must not be used'), + storeGuestCover: (bookId, bytes) async => calls.add('guest:$bookId:${bytes.length}'), + addBook: (book) async { + calls.add('book:${book.id}'); + expect(book.coverUrl, isNull); + }, + enqueueBookFile: (_) async => fail('guest file must not be enqueued'), + enqueueCover: (_) async => fail('guest cover must not be enqueued'), + ); + + await service.commit(result: result, filename: 'book.epub', accountScope: null); + expect(calls, ['guest:${result.bookId}:2', 'book:${result.bookId}']); +}); +``` + +- [ ] **Step 2: Run the new test and verify RED** + +Run: + +```bash +cd app +flutter test test/services/book_import_commit_service_test.dart --reporter expanded +``` + +Expected: compilation fails because `BookImportCommitService` does not exist. + +- [ ] **Step 3: Implement the import commit boundary** + +The service must: + +```dart +final cover = result.coverImage; +if (cover != null) { + if (accountScope == null) { + await storeGuestCover(result.bookId, cover); + } else { + await storePendingCover(accountScope, result.bookId, cover); + } +} + +final book = Book( + id: result.bookId, + title: result.title, + subtitle: result.subtitle, + author: result.author, + coAuthors: result.coAuthors, + publisher: result.publisher, + description: result.description, + language: result.language, + isbn: result.isbn, + pageCount: result.pageCount, + coverUrl: null, + filePath: localFilePath, + fileFormat: BookFormat.values.where((value) => value.name == result.fileExtension).firstOrNull, + fileSize: result.fileSize, + fileHash: result.fileHash, + addedAt: now, +); +await addBook(book); +``` + +Only account mode enqueues book-file and cover tasks. The cover task contains no bytes. Change `_addToLibrary` to `Future`, determine `accountScope` from authenticated mode, and delegate. Remove `bytesToDataUri` from this import path. Guard post-await navigation with `if (!mounted) return`. + +- [ ] **Step 4: Run import and queue tests** + +Run: + +```bash +cd app +flutter test test/services/book_import_commit_service_test.dart test/media/media_upload_queue_test.dart --reporter expanded +``` + +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/services/book_import_commit_service.dart app/lib/widgets/add_book/import_book_sheet.dart app/test/services/book_import_commit_service_test.dart +git commit -m "feat: persist imported covers outside metadata" +``` + +### Task 4: Render pending and guest covers by book ID + +**Files:** +- Modify: `app/lib/widgets/book/private_book_cover.dart` +- Modify: `app/lib/widgets/book_details/book_cover_image.dart` +- Modify: `app/lib/data/data_store.dart` +- Modify: `app/lib/models/shelf.dart` +- Modify: all ten production `PrivateBookCover`/`BookCoverImage` callers found by `rg -n "PrivateBookCover\\(|BookCoverImage\\(" app/lib` +- Test: `app/test/widgets/book/private_book_cover_test.dart` + +- [ ] **Step 1: Write failing fallback-order widget tests** + +Add injected local loading to keep tests independent of providers: + +```dart +testWidgets('book without media id renders pending local cover', (tester) async { + await tester.pumpWidget(MaterialApp( + home: PrivateBookCover( + bookId: 'book-1', + loadLocalBookCover: (_) async => Uint8List.fromList(pngBytes), + placeholder: const SizedBox(key: Key('placeholder')), + ), + )); + await tester.pumpAndSettle(); + expect(find.byType(Image), findsOneWidget); +}); + +testWidgets('media id takes priority over pending local cover', (tester) async { + var localLoads = 0; + var mediaLoads = 0; + await tester.pumpWidget(MaterialApp( + home: PrivateBookCover( + bookId: 'book-1', + mediaId: 'asset-1', + loadPrivateCover: (_) async { + mediaLoads++; + return Uint8List.fromList(pngBytes); + }, + loadLocalBookCover: (_) async { + localLoads++; + return Uint8List.fromList(pngBytes); + }, + placeholder: const SizedBox(), + ), + )); + await tester.pumpAndSettle(); + expect(mediaLoads, 1); + expect(localLoads, 0); +}); +``` + +Retain the legacy inline-data test solely for old rows; new imports are covered by Task 3 and must not create data URIs. + +- [ ] **Step 2: Run widget tests and verify RED** + +Run: + +```bash +cd app +flutter test test/widgets/book/private_book_cover_test.dart --reporter expanded +``` + +Expected: FAIL because `bookId` and `loadLocalBookCover` are not accepted. + +- [ ] **Step 3: Implement local fallback loading and propagate book IDs** + +Use this order in `PrivateBookCover`: public/legacy URL, `mediaId` cache/download, then local book cover. Provider loading chooses pending account storage when signed into an account library and guest storage otherwise: + +```dart +if (mediaId == null && bookId != null) { + _coverFuture = isAccountLibrary + ? importService.getPendingCoverFile(accountScope, bookId) + : importService.getGuestCoverFile(bookId); +} +``` + +Add `bookId` to `BookCoverImage` and `CoverPreview`. Pass `book.id` from book-backed call sites and `cover.bookId` from shelf mosaics/group previews. Include book ID in `_loadKey` so recycled widgets cannot show another book's pending cover. + +- [ ] **Step 4: Run widget and representative surface tests** + +Run: + +```bash +cd app +flutter test test/widgets/book/private_book_cover_test.dart test/widgets/library/book_card_test.dart test/widgets/library/book_list_item_test.dart --reporter expanded +``` + +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/widgets app/lib/data/data_store.dart app/lib/models app/test/widgets/book/private_book_cover_test.dart +git commit -m "feat: render local covers by book id" +``` + +### Task 5: Promote source-device covers after upload + +**Files:** +- Create: `app/lib/media/cover_upload_persistence.dart` +- Create: `app/test/media/cover_upload_persistence_test.dart` +- Modify: `app/lib/main.dart` +- Modify: `app/test/media/media_cache_service_test.dart` +- Modify: `app/test/media/media_profile_switch_contract_test.dart` + +- [ ] **Step 1: Write a failing promotion orchestration test** + +Create `uploadAndPersistCover` in `cover_upload_persistence.dart` so the behavior is testable without constructing `_PapyrusState`: + +```dart +test('successful cover upload promotes pending bytes into media cache', () async { + final calls = []; + final asset = await uploadAndPersistCover( + scope: _scope, + payload: _coverPayload(bookId: 'book-1', bytes: [1, 2, 3]), + uploadMedia: (_) async => _coverAsset(assetId: 'asset-1', bookId: 'book-1'), + storeCachedCover: (_, mediaId, bytes) async => calls.add('cache:$mediaId:${bytes.length}'), + deletePendingCover: (_, bookId) async => calls.add('delete:$bookId'), + ); + expect(asset.assetId, 'asset-1'); + expect(calls, ['cache:asset-1:3', 'delete:book-1']); +}); +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +cd app +flutter test test/media/cover_upload_persistence_test.dart --reporter expanded +``` + +Expected: compilation fails because `cover_upload_persistence.dart` and `uploadAndPersistCover` do not exist. + +- [ ] **Step 3: Implement best-effort promotion after server success** + +Wrap the repository upload used by `_processMediaUploads`. For `MediaKind.coverImage`, atomically store `payload.bytes` under `asset.assetId`, then delete `pending/`. A local cache failure must not convert an already successful server upload into a duplicate retry; report it through `FlutterError.reportError` and still return the asset. + +The subsequent `_applyUploadedAsset` update sets `coverMediaId`. The source device then reads the promoted cache, while remote devices use the existing `MediaCacheService.ensureCoverCached` download path. + +- [ ] **Step 4: Run promotion, profile, queue, and cache tests** + +```bash +cd app +flutter test test/media --reporter expanded +``` + +Expected: all media tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/main.dart app/lib/media/cover_upload_persistence.dart app/test/media/cover_upload_persistence_test.dart app/test/media/media_cache_service_test.dart app/test/media/media_profile_switch_contract_test.dart +git commit -m "feat: promote uploaded covers into local cache" +``` + +### Task 6: Delete every local cover representation + +**Files:** +- Modify: `app/lib/services/book_delete_cleanup_service.dart` +- Modify: `app/lib/pages/book_details_page.dart` +- Modify: `app/test/services/book_delete_cleanup_service_test.dart` + +- [ ] **Step 1: Write a failing cleanup test** + +```dart +test('delete removes pending guest and cached cover files best effort', () async { + final deleted = []; + await deleteBookWithMediaCleanup( + dataStore: dataStore, + mediaUploadQueue: queue, + bookId: book.id, + coverMediaId: 'asset-1', + deleteBookFile: (_) async {}, + deletePendingCover: (bookId) async => deleted.add('pending:$bookId'), + deleteGuestCover: (bookId) async => deleted.add('guest:$bookId'), + deleteCoverFile: (mediaId) async => deleted.add('cached:$mediaId'), + ); + expect(deleted, ['pending:${book.id}', 'guest:${book.id}', 'cached:asset-1']); +}); +``` + +- [ ] **Step 2: Run cleanup tests and verify RED** + +```bash +cd app +flutter test test/services/book_delete_cleanup_service_test.dart --reporter expanded +``` + +Expected: FAIL because pending and guest deletion callbacks are missing. + +- [ ] **Step 3: Implement best-effort cleanup** + +Add callbacks for pending and guest files. The page supplies only the callbacks appropriate to its current library mode, but the cleanup service remains idempotent and catches each filesystem failure independently before deleting metadata. + +- [ ] **Step 4: Run cleanup and page deletion tests** + +```bash +cd app +flutter test test/services/book_delete_cleanup_service_test.dart test/pages/book_details_delete_test.dart --reporter expanded +``` + +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/services/book_delete_cleanup_service.dart app/lib/pages/book_details_page.dart app/test/services/book_delete_cleanup_service_test.dart +git commit -m "fix: clean pending and guest covers on delete" +``` + +### Task 7: Verify metadata safety, cross-device behavior, and the web runtime + +**Files:** +- Test: `app/test/powersync/powersync_book_mapper_test.dart` +- Test: `app/test/services/book_import_commit_service_test.dart` + +- [ ] **Step 1: Add the final metadata regression assertion** + +Add an assertion to the import commit test and mapper coverage that a newly imported account book serializes with: + +```dart +expect(book.coverUrl, isNull); +expect(book.coverMediaId, isNull); // until upload returns an asset +expect(book.toJson().toString(), isNot(contains('data:image'))); +``` + +Also retain the existing test where a row containing only `cover_media_id` is mapped and rendered through the lazy authenticated loader, representing a second device. + +- [ ] **Step 2: Run full automated verification** + +```bash +cd app +flutter test --reporter expanded +flutter analyze +dart format --output=none --set-exit-if-changed lib test +node --check web/book_worker.js +git diff --check +``` + +Expected: all tests pass, analysis reports no issues, formatting changes zero files, worker syntax exits 0, and diff check is clean. + +- [ ] **Step 3: Perform a clean web restart and manual two-mode smoke test** + +Do a full browser reload or restart the Flutter web process so no old PowerSync worker/BroadcastChannel instance remains. Do not use hot reload for this check. + +Account flow: + +1. Import a covered EPUB while signed in. +2. Confirm the cover remains visible before upload finishes. +3. Confirm the server `books` row is created with `cover_image_url IS NULL`. +4. Confirm `/v1/media` eventually returns 201 and `cover_media_id` becomes non-null. +5. Open the same account in a clean browser profile/device and confirm the first display downloads the cover and a later display uses local OPFS. + +Guest flow: + +1. Import a covered EPUB in fully offline guest mode. +2. Reload the browser and confirm the cover remains visible from guest OPFS. +3. Sign in and confirm the guest book and cover do not appear in the account library. + +Expected: no repeated media 404, no disappearing cover, no newly created `data:image` metadata, and no `LegacyJavaScriptObject` error after a clean worker restart. If the PowerSync type error remains after a clean restart, capture it as a separate dependency/runtime issue rather than masking it in cover code. + +- [ ] **Step 4: Commit final regression coverage if Step 1 changed tests** + +```bash +git add app/test/powersync/powersync_book_mapper_test.dart app/test/services/book_import_commit_service_test.dart +git commit -m "test: guard filesystem-only cover metadata" +``` From 7935715f30a07668be69358ce1dcd70216113435 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 18:35:43 +0300 Subject: [PATCH 16/48] feat: add pending and guest cover storage --- app/lib/media/cover_storage_bucket.dart | 9 ++ app/lib/media/media_storage_scope.dart | 4 + app/lib/services/book_import_service.dart | 62 +++++++++- .../services/book_import_service_stub.dart | 115 ++++++++++++++---- app/test/media/media_storage_scope_test.dart | 6 + .../services/book_cover_storage_test.dart | 73 ++++++++++- app/web/book_worker.js | 49 +++++--- 7 files changed, 269 insertions(+), 49 deletions(-) create mode 100644 app/lib/media/cover_storage_bucket.dart diff --git a/app/lib/media/cover_storage_bucket.dart b/app/lib/media/cover_storage_bucket.dart new file mode 100644 index 0000000..2516c2c --- /dev/null +++ b/app/lib/media/cover_storage_bucket.dart @@ -0,0 +1,9 @@ +enum CoverStorageBucket { + cached('cached'), + pending('pending'), + guestBooks('books'); + + const CoverStorageBucket(this.pathComponent); + + final String pathComponent; +} diff --git a/app/lib/media/media_storage_scope.dart b/app/lib/media/media_storage_scope.dart index 0276c2a..7068eb2 100644 --- a/app/lib/media/media_storage_scope.dart +++ b/app/lib/media/media_storage_scope.dart @@ -8,6 +8,10 @@ class MediaStorageScope { static final RegExp _safePart = RegExp(r'^[a-zA-Z0-9_.-]+$'); + static const localGuest = MediaStorageScope._('local', 'guest'); + + const MediaStorageScope._(this.profileKey, this.userId); + final String profileKey; final String userId; diff --git a/app/lib/services/book_import_service.dart b/app/lib/services/book_import_service.dart index c61ad69..bddbf54 100644 --- a/app/lib/services/book_import_service.dart +++ b/app/lib/services/book_import_service.dart @@ -3,6 +3,7 @@ import 'dart:js_interop'; import 'dart:js_interop_unsafe'; import 'package:flutter/foundation.dart'; +import 'package:papyrus/media/cover_storage_bucket.dart'; import 'package:papyrus/media/media_storage_scope.dart'; import 'package:papyrus/services/book_import_result.dart'; import 'package:uuid/uuid.dart'; @@ -262,7 +263,54 @@ class BookImportService { } Future getCoverFile(MediaStorageScope scope, String mediaId) async { - final obj = await _sendCoverRequest(type: 'getCover', scope: scope, mediaId: mediaId); + return _getCoverFile(scope, CoverStorageBucket.cached, mediaId); + } + + Future storeCoverFile(MediaStorageScope scope, String mediaId, Uint8List bytes) async { + await _storeCoverFile(scope, CoverStorageBucket.cached, mediaId, bytes); + } + + Future deleteCoverFile(MediaStorageScope scope, String mediaId) async { + await _deleteCoverFile(scope, CoverStorageBucket.cached, mediaId); + } + + Future getPendingCoverFile(MediaStorageScope scope, String bookId) async { + return _getCoverFile(scope, CoverStorageBucket.pending, bookId); + } + + Future storePendingCoverFile(MediaStorageScope scope, String bookId, Uint8List bytes) async { + await _storeCoverFile(scope, CoverStorageBucket.pending, bookId, bytes); + } + + Future deletePendingCoverFile(MediaStorageScope scope, String bookId) async { + await _deleteCoverFile(scope, CoverStorageBucket.pending, bookId); + } + + Future getGuestCoverFile(String bookId) async { + return _getCoverFile(MediaStorageScope.localGuest, CoverStorageBucket.guestBooks, bookId); + } + + Future storeGuestCoverFile(String bookId, Uint8List bytes) async { + await _storeCoverFile(MediaStorageScope.localGuest, CoverStorageBucket.guestBooks, bookId, bytes); + } + + Future deleteGuestCoverFile(String bookId) async { + await _deleteCoverFile(MediaStorageScope.localGuest, CoverStorageBucket.guestBooks, bookId); + } + + Future promotePendingCoverFile( + MediaStorageScope scope, { + required String bookId, + required String mediaId, + }) async { + final bytes = await _getCoverFile(scope, CoverStorageBucket.pending, bookId); + if (bytes == null) return; + await _storeCoverFile(scope, CoverStorageBucket.cached, mediaId, bytes); + await _deleteCoverFile(scope, CoverStorageBucket.pending, bookId); + } + + Future _getCoverFile(MediaStorageScope scope, CoverStorageBucket bucket, String id) async { + final obj = await _sendCoverRequest(type: 'getCover', scope: scope, bucket: bucket, mediaId: id); final fileDataJs = obj['fileData']; if (fileDataJs == null || fileDataJs.isNull || fileDataJs.isUndefined) { return null; @@ -270,16 +318,16 @@ class BookImportService { return (fileDataJs as JSArrayBuffer).toDart.asUint8List(); } - Future storeCoverFile(MediaStorageScope scope, String mediaId, Uint8List bytes) async { - await _sendCoverRequest(type: 'storeCover', scope: scope, mediaId: mediaId, bytes: bytes); + Future _storeCoverFile(MediaStorageScope scope, CoverStorageBucket bucket, String id, Uint8List bytes) async { + await _sendCoverRequest(type: 'storeCover', scope: scope, bucket: bucket, mediaId: id, bytes: bytes); } - Future deleteCoverFile(MediaStorageScope scope, String mediaId) async { - await _sendCoverRequest(type: 'deleteCover', scope: scope, mediaId: mediaId); + Future _deleteCoverFile(MediaStorageScope scope, CoverStorageBucket bucket, String id) async { + await _sendCoverRequest(type: 'deleteCover', scope: scope, bucket: bucket, mediaId: id); } Future clearCoverFiles(MediaStorageScope scope) async { - await _sendCoverRequest(type: 'clearCovers', scope: scope); + await _sendCoverRequest(type: 'clearCovers', scope: scope, bucket: CoverStorageBucket.cached); } /// Terminates the Web Worker and releases resources. @@ -300,6 +348,7 @@ class BookImportService { Future _sendCoverRequest({ required String type, required MediaStorageScope scope, + required CoverStorageBucket bucket, String? mediaId, Uint8List? bytes, }) async { @@ -312,6 +361,7 @@ class BookImportService { message['type'] = type.toJS; message['requestId'] = requestId.toJS; message['scopeKey'] = scope.persistenceKey.toJS; + message['bucket'] = bucket.pathComponent.toJS; if (mediaId != null) message['mediaId'] = mediaId.toJS; if (bytes == null) { diff --git a/app/lib/services/book_import_service_stub.dart b/app/lib/services/book_import_service_stub.dart index 29a70f4..ec99568 100644 --- a/app/lib/services/book_import_service_stub.dart +++ b/app/lib/services/book_import_service_stub.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:crypto/crypto.dart'; +import 'package:papyrus/media/cover_storage_bucket.dart'; import 'package:papyrus/media/media_storage_scope.dart'; import 'package:papyrus/services/book_import_result.dart'; import 'package:papyrus/services/file_metadata_service.dart'; @@ -106,32 +107,52 @@ class BookImportService { /// Returns a persistently cached private cover for [scope], when present. Future getCoverFile(MediaStorageScope scope, String mediaId) async { - final file = await _coverFile(scope, mediaId); - if (!await file.exists()) return null; - return file.readAsBytes(); + return _getCoverFile(scope, CoverStorageBucket.cached, mediaId); } /// Atomically stores private cover bytes in the selected account scope. Future storeCoverFile(MediaStorageScope scope, String mediaId, Uint8List bytes) async { - final file = await _coverFile(scope, mediaId); - final tempFile = File('${file.path}.tmp'); - await tempFile.writeAsBytes(bytes, flush: true); - try { - await tempFile.rename(file.path); - } on FileSystemException { - if (await file.exists()) { - await file.delete(); - } - await tempFile.rename(file.path); - } + await _storeCoverFile(scope, CoverStorageBucket.cached, mediaId, bytes); } /// Deletes one cached private cover without affecting other scopes. Future deleteCoverFile(MediaStorageScope scope, String mediaId) async { - final file = await _coverFile(scope, mediaId); - if (await file.exists()) { - await file.delete(); - } + await _deleteCoverFile(scope, CoverStorageBucket.cached, mediaId); + } + + Future getPendingCoverFile(MediaStorageScope scope, String bookId) async { + return _getCoverFile(scope, CoverStorageBucket.pending, bookId); + } + + Future storePendingCoverFile(MediaStorageScope scope, String bookId, Uint8List bytes) async { + await _storeCoverFile(scope, CoverStorageBucket.pending, bookId, bytes); + } + + Future deletePendingCoverFile(MediaStorageScope scope, String bookId) async { + await _deleteCoverFile(scope, CoverStorageBucket.pending, bookId); + } + + Future getGuestCoverFile(String bookId) async { + return _getCoverFile(MediaStorageScope.localGuest, CoverStorageBucket.guestBooks, bookId); + } + + Future storeGuestCoverFile(String bookId, Uint8List bytes) async { + await _storeCoverFile(MediaStorageScope.localGuest, CoverStorageBucket.guestBooks, bookId, bytes); + } + + Future deleteGuestCoverFile(String bookId) async { + await _deleteCoverFile(MediaStorageScope.localGuest, CoverStorageBucket.guestBooks, bookId); + } + + Future promotePendingCoverFile( + MediaStorageScope scope, { + required String bookId, + required String mediaId, + }) async { + final bytes = await _getCoverFile(scope, CoverStorageBucket.pending, bookId); + if (bytes == null) return; + await _storeCoverFile(scope, CoverStorageBucket.cached, mediaId, bytes); + await _deleteCoverFile(scope, CoverStorageBucket.pending, bookId); } /// Deletes all cached private covers for one server/account scope. @@ -155,15 +176,63 @@ class BookImportService { return booksDir; } - Future _coverFile(MediaStorageScope scope, String mediaId) async { - if (!_safeFilePart.hasMatch(mediaId)) { - throw ArgumentError.value(mediaId, 'mediaId', 'Media id contains unsafe characters'); + Future _getCoverFile(MediaStorageScope scope, CoverStorageBucket bucket, String id) async { + final file = await _coverFile(scope, bucket, id); + if (!await file.exists()) return null; + return file.readAsBytes(); + } + + Future _storeCoverFile(MediaStorageScope scope, CoverStorageBucket bucket, String id, Uint8List bytes) async { + final file = await _coverFile(scope, bucket, id); + final tempFile = File('${file.path}.tmp'); + await tempFile.writeAsBytes(bytes, flush: true); + try { + await tempFile.rename(file.path); + } on FileSystemException { + if (await file.exists()) { + await file.delete(); + } + await tempFile.rename(file.path); } - final directory = await _coverDirectory(scope, create: true); - return File(p.join(directory!.path, '$mediaId.bin')); + } + + Future _deleteCoverFile(MediaStorageScope scope, CoverStorageBucket bucket, String id) async { + final file = await _coverFile(scope, bucket, id); + if (await file.exists()) { + await file.delete(); + } + } + + Future _coverFile(MediaStorageScope scope, CoverStorageBucket bucket, String id) async { + _validateFilePart(scope.persistenceKey, 'scope'); + _validateFilePart(bucket.pathComponent, 'bucket'); + _validateFilePart(id, 'id'); + final directory = await _coverBucketDirectory(scope, bucket, create: true); + return File(p.join(directory!.path, '$id.bin')); + } + + void _validateFilePart(String value, String name) { + if (!_safeFilePart.hasMatch(value)) { + throw ArgumentError.value(value, name, '$name contains unsafe characters'); + } + } + + Future _coverBucketDirectory( + MediaStorageScope scope, + CoverStorageBucket bucket, { + required bool create, + }) async { + final scopeDirectory = await _coverDirectory(scope, create: create); + if (scopeDirectory == null) return null; + final directory = Directory(p.join(scopeDirectory.path, bucket.pathComponent)); + if (create && !await directory.exists()) { + await directory.create(recursive: true); + } + return directory; } Future _coverDirectory(MediaStorageScope scope, {required bool create}) async { + _validateFilePart(scope.persistenceKey, 'scope'); final appDir = await getApplicationSupportDirectory(); final directory = Directory(p.join(appDir.path, 'media-covers', scope.persistenceKey)); if (create && !await directory.exists()) { diff --git a/app/test/media/media_storage_scope_test.dart b/app/test/media/media_storage_scope_test.dart index b4baa89..7bb5f4b 100644 --- a/app/test/media/media_storage_scope_test.dart +++ b/app/test/media/media_storage_scope_test.dart @@ -2,6 +2,12 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/media/media_storage_scope.dart'; void main() { + test('local guest scope has a stable isolated key', () { + expect(MediaStorageScope.localGuest.profileKey, 'local'); + expect(MediaStorageScope.localGuest.userId, 'guest'); + expect(MediaStorageScope.localGuest.persistenceKey, 'local--guest'); + }); + test('scope key isolates server and user', () { final first = MediaStorageScope(profileKey: 'official', userId: 'user-1'); final second = MediaStorageScope(profileKey: 'custom-deadbeef', userId: 'user-1'); diff --git a/app/test/services/book_cover_storage_test.dart b/app/test/services/book_cover_storage_test.dart index 2ca5eae..28b1fac 100644 --- a/app/test/services/book_cover_storage_test.dart +++ b/app/test/services/book_cover_storage_test.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/media/cover_storage_bucket.dart'; import 'package:papyrus/media/media_storage_scope.dart'; import 'package:papyrus/services/book_import_service_stub.dart'; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; @@ -46,6 +47,68 @@ void main() { expect(await service.getCoverFile(second, 'asset-1'), isNull); }); + test('cached and pending covers use separate bucket files', () async { + final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + + await service.storeCoverFile(scope, 'shared-id', Uint8List.fromList([1])); + await service.storePendingCoverFile(scope, 'shared-id', Uint8List.fromList([2])); + + expect(await service.getCoverFile(scope, 'shared-id'), Uint8List.fromList([1])); + expect(await service.getPendingCoverFile(scope, 'shared-id'), Uint8List.fromList([2])); + expect( + File( + '${root.path}/media-covers/${scope.persistenceKey}/' + '${CoverStorageBucket.cached.pathComponent}/shared-id.bin', + ).existsSync(), + isTrue, + ); + expect( + File( + '${root.path}/media-covers/${scope.persistenceKey}/' + '${CoverStorageBucket.pending.pathComponent}/shared-id.bin', + ).existsSync(), + isTrue, + ); + }); + + test('guest covers use the local guest books namespace', () async { + await service.storeGuestCoverFile('book-1', Uint8List.fromList([3, 4])); + + expect(await service.getGuestCoverFile('book-1'), Uint8List.fromList([3, 4])); + expect( + File( + '${root.path}/media-covers/${MediaStorageScope.localGuest.persistenceKey}/' + '${CoverStorageBucket.guestBooks.pathComponent}/book-1.bin', + ).existsSync(), + isTrue, + ); + + await service.deleteGuestCoverFile('book-1'); + await service.deleteGuestCoverFile('book-1'); + expect(await service.getGuestCoverFile('book-1'), isNull); + }); + + test('promoting pending cover writes cache before removing pending', () async { + final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + await service.storePendingCoverFile(scope, 'book-1', Uint8List.fromList([5, 6])); + + await service.promotePendingCoverFile(scope, bookId: 'book-1', mediaId: 'asset-1'); + + expect(await service.getCoverFile(scope, 'asset-1'), Uint8List.fromList([5, 6])); + expect(await service.getPendingCoverFile(scope, 'book-1'), isNull); + }); + + test('deleting pending covers is idempotent', () async { + final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + + await service.deletePendingCoverFile(scope, 'book-1'); + await service.storePendingCoverFile(scope, 'book-1', Uint8List.fromList([7])); + await service.deletePendingCoverFile(scope, 'book-1'); + await service.deletePendingCoverFile(scope, 'book-1'); + + expect(await service.getPendingCoverFile(scope, 'book-1'), isNull); + }); + test('storing a cover atomically replaces existing bytes', () async { final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); @@ -71,10 +134,12 @@ void main() { final second = MediaStorageScope(profileKey: 'official', userId: 'user-2'); await service.storeCoverFile(first, 'asset-1', Uint8List.fromList([1])); + await service.storePendingCoverFile(first, 'book-1', Uint8List.fromList([3])); await service.storeCoverFile(second, 'asset-2', Uint8List.fromList([2])); await service.clearCoverFiles(first); expect(await service.getCoverFile(first, 'asset-1'), isNull); + expect(await service.getPendingCoverFile(first, 'book-1'), isNull); expect(await service.getCoverFile(second, 'asset-2'), Uint8List.fromList([2])); }); @@ -82,14 +147,19 @@ void main() { final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); expect(() => service.storeCoverFile(scope, '../asset', Uint8List.fromList([1])), throwsArgumentError); + expect(() => service.storePendingCoverFile(scope, r'book\1', Uint8List.fromList([1])), throwsArgumentError); + expect(() => service.storeGuestCoverFile('../book', Uint8List.fromList([1])), throwsArgumentError); }); - test('book worker declares scoped cover cache actions', () { + test('book worker declares scoped bucket cover actions and validates buckets', () { final source = File('web/book_worker.js').readAsStringSync(); for (final action in ['getCover', 'storeCover', 'deleteCover', 'clearCovers']) { expect(source, contains("case '$action':")); } + expect(source, contains("new Set(['cached', 'pending', 'books'])")); + expect(source, contains("validateCoverBucket(bucket)")); + expect(source, contains("getDirectoryHandle(bucket, { create })")); }); test('web cover writes transfer a copy so caller bytes remain renderable', () { @@ -100,6 +170,7 @@ void main() { ); expect(helper, contains('final actualBytes = Uint8List.fromList(bytes);')); + expect(helper, contains("message['bucket'] = bucket.pathComponent.toJS;")); expect(helper, isNot(contains('bytes.offsetInBytes == 0 && bytes.lengthInBytes == bytes.buffer.lengthInBytes'))); }); } diff --git a/app/web/book_worker.js b/app/web/book_worker.js index 12f9ec7..ee0131b 100644 --- a/app/web/book_worker.js +++ b/app/web/book_worker.js @@ -9,10 +9,10 @@ * { type: 'delete', bookId } * { type: 'getFile', bookId } * { type: 'storeFile', format, bookId, fileData: ArrayBuffer } - * { type: 'getCover', requestId, scopeKey, mediaId } - * { type: 'storeCover', requestId, scopeKey, mediaId, fileData: ArrayBuffer } - * { type: 'deleteCover', requestId, scopeKey, mediaId } - * { type: 'clearCovers', requestId, scopeKey } + * { type: 'getCover', requestId, scopeKey, bucket, mediaId } + * { type: 'storeCover', requestId, scopeKey, bucket, mediaId, fileData: ArrayBuffer } + * { type: 'deleteCover', requestId, scopeKey, bucket, mediaId } + * { type: 'clearCovers', requestId, scopeKey, bucket } * * Outgoing: * { type: 'success', action: 'process', bookId, metadata, coverData, coverMimeType, fileSize, fileHash } @@ -126,8 +126,8 @@ async function handleStoreFile(msg) { } async function handleGetCover(msg) { - const { requestId, scopeKey, mediaId } = msg; - const bytes = await opfsReadCover(scopeKey, mediaId); + const { requestId, scopeKey, bucket, mediaId } = msg; + const bytes = await opfsReadCover(scopeKey, bucket, mediaId); const fileData = bytes ? bytes.buffer : null; postMessage( { type: 'success', action: 'getCover', requestId, fileData }, @@ -136,19 +136,20 @@ async function handleGetCover(msg) { } async function handleStoreCover(msg) { - const { requestId, scopeKey, mediaId, fileData } = msg; - await opfsWriteCover(scopeKey, mediaId, new Uint8Array(fileData)); + const { requestId, scopeKey, bucket, mediaId, fileData } = msg; + await opfsWriteCover(scopeKey, bucket, mediaId, new Uint8Array(fileData)); postMessage({ type: 'success', action: 'storeCover', requestId }); } async function handleDeleteCover(msg) { - const { requestId, scopeKey, mediaId } = msg; - await opfsDeleteCover(scopeKey, mediaId); + const { requestId, scopeKey, bucket, mediaId } = msg; + await opfsDeleteCover(scopeKey, bucket, mediaId); postMessage({ type: 'success', action: 'deleteCover', requestId }); } async function handleClearCovers(msg) { - const { requestId, scopeKey } = msg; + const { requestId, scopeKey, bucket } = msg; + validateCoverBucket(bucket); await opfsClearCovers(scopeKey); postMessage({ type: 'success', action: 'clearCovers', requestId }); } @@ -626,31 +627,41 @@ function validateFilePart(value, fieldName) { } } -async function opfsCoverDirectory(scopeKey, create) { +const COVER_BUCKETS = new Set(['cached', 'pending', 'books']); + +function validateCoverBucket(bucket) { + if (!COVER_BUCKETS.has(bucket)) { + throw new Error('bucket is not supported'); + } +} + +async function opfsCoverDirectory(scopeKey, bucket, create) { validateFilePart(scopeKey, 'scopeKey'); + validateCoverBucket(bucket); const root = await navigator.storage.getDirectory(); let coversRoot; try { coversRoot = await root.getDirectoryHandle('media-covers', { create }); - return await coversRoot.getDirectoryHandle(scopeKey, { create }); + const scopeDirectory = await coversRoot.getDirectoryHandle(scopeKey, { create }); + return await scopeDirectory.getDirectoryHandle(bucket, { create }); } catch (_) { if (!create) return null; throw _; } } -async function opfsWriteCover(scopeKey, mediaId, uint8Array) { +async function opfsWriteCover(scopeKey, bucket, mediaId, uint8Array) { validateFilePart(mediaId, 'mediaId'); - const directory = await opfsCoverDirectory(scopeKey, true); + const directory = await opfsCoverDirectory(scopeKey, bucket, true); const fileHandle = await directory.getFileHandle(`${mediaId}.bin`, { create: true }); const writable = await fileHandle.createWritable(); await writable.write(uint8Array); await writable.close(); } -async function opfsReadCover(scopeKey, mediaId) { +async function opfsReadCover(scopeKey, bucket, mediaId) { validateFilePart(mediaId, 'mediaId'); - const directory = await opfsCoverDirectory(scopeKey, false); + const directory = await opfsCoverDirectory(scopeKey, bucket, false); if (!directory) return null; try { const fileHandle = await directory.getFileHandle(`${mediaId}.bin`, { create: false }); @@ -661,9 +672,9 @@ async function opfsReadCover(scopeKey, mediaId) { } } -async function opfsDeleteCover(scopeKey, mediaId) { +async function opfsDeleteCover(scopeKey, bucket, mediaId) { validateFilePart(mediaId, 'mediaId'); - const directory = await opfsCoverDirectory(scopeKey, false); + const directory = await opfsCoverDirectory(scopeKey, bucket, false); if (!directory) return; try { await directory.removeEntry(`${mediaId}.bin`); From 1ad02819efef3928d66e4cfdce7fcffb46aa08ca Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 18:47:00 +0300 Subject: [PATCH 17/48] fix: make cover promotion race safe --- app/lib/services/book_import_service.dart | 13 +- .../services/book_import_service_stub.dart | 141 +++++++++++++++--- .../services/book_cover_storage_test.dart | 81 +++++++++- app/web/book_worker.js | 75 ++++++++-- 4 files changed, 271 insertions(+), 39 deletions(-) diff --git a/app/lib/services/book_import_service.dart b/app/lib/services/book_import_service.dart index bddbf54..0e4306a 100644 --- a/app/lib/services/book_import_service.dart +++ b/app/lib/services/book_import_service.dart @@ -303,10 +303,13 @@ class BookImportService { required String bookId, required String mediaId, }) async { - final bytes = await _getCoverFile(scope, CoverStorageBucket.pending, bookId); - if (bytes == null) return; - await _storeCoverFile(scope, CoverStorageBucket.cached, mediaId, bytes); - await _deleteCoverFile(scope, CoverStorageBucket.pending, bookId); + await _sendCoverRequest( + type: 'promoteCover', + scope: scope, + bucket: CoverStorageBucket.pending, + mediaId: bookId, + targetMediaId: mediaId, + ); } Future _getCoverFile(MediaStorageScope scope, CoverStorageBucket bucket, String id) async { @@ -350,6 +353,7 @@ class BookImportService { required MediaStorageScope scope, required CoverStorageBucket bucket, String? mediaId, + String? targetMediaId, Uint8List? bytes, }) async { final requestId = const Uuid().v4(); @@ -363,6 +367,7 @@ class BookImportService { message['scopeKey'] = scope.persistenceKey.toJS; message['bucket'] = bucket.pathComponent.toJS; if (mediaId != null) message['mediaId'] = mediaId.toJS; + if (targetMediaId != null) message['targetMediaId'] = targetMediaId.toJS; if (bytes == null) { worker.postMessage(message); diff --git a/app/lib/services/book_import_service_stub.dart b/app/lib/services/book_import_service_stub.dart index ec99568..3ef7ae6 100644 --- a/app/lib/services/book_import_service_stub.dart +++ b/app/lib/services/book_import_service_stub.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; @@ -20,6 +21,7 @@ class BookImportService { static final RegExp _safeFilePart = RegExp(r'^[a-zA-Z0-9_.-]+$'); final _metadataService = FileMetadataService(); + static final Map> _coverOperations = {}; /// Imports a book file: extracts metadata and stores the file locally. /// @@ -107,41 +109,86 @@ class BookImportService { /// Returns a persistently cached private cover for [scope], when present. Future getCoverFile(MediaStorageScope scope, String mediaId) async { - return _getCoverFile(scope, CoverStorageBucket.cached, mediaId); + return _withCoverLock( + scope, + CoverStorageBucket.cached, + mediaId, + () => _readCoverFile(scope, CoverStorageBucket.cached, mediaId), + ); } /// Atomically stores private cover bytes in the selected account scope. Future storeCoverFile(MediaStorageScope scope, String mediaId, Uint8List bytes) async { - await _storeCoverFile(scope, CoverStorageBucket.cached, mediaId, bytes); + await _withCoverLock( + scope, + CoverStorageBucket.cached, + mediaId, + () => _writeCoverFile(scope, CoverStorageBucket.cached, mediaId, bytes), + ); } /// Deletes one cached private cover without affecting other scopes. Future deleteCoverFile(MediaStorageScope scope, String mediaId) async { - await _deleteCoverFile(scope, CoverStorageBucket.cached, mediaId); + await _withCoverLock( + scope, + CoverStorageBucket.cached, + mediaId, + () => _removeCoverFile(scope, CoverStorageBucket.cached, mediaId), + ); } Future getPendingCoverFile(MediaStorageScope scope, String bookId) async { - return _getCoverFile(scope, CoverStorageBucket.pending, bookId); + return _withCoverLock( + scope, + CoverStorageBucket.pending, + bookId, + () => _readCoverFile(scope, CoverStorageBucket.pending, bookId), + ); } Future storePendingCoverFile(MediaStorageScope scope, String bookId, Uint8List bytes) async { - await _storeCoverFile(scope, CoverStorageBucket.pending, bookId, bytes); + await _withCoverLock( + scope, + CoverStorageBucket.pending, + bookId, + () => _writeCoverFile(scope, CoverStorageBucket.pending, bookId, bytes), + ); } Future deletePendingCoverFile(MediaStorageScope scope, String bookId) async { - await _deleteCoverFile(scope, CoverStorageBucket.pending, bookId); + await _withCoverLock( + scope, + CoverStorageBucket.pending, + bookId, + () => _removeCoverFile(scope, CoverStorageBucket.pending, bookId), + ); } Future getGuestCoverFile(String bookId) async { - return _getCoverFile(MediaStorageScope.localGuest, CoverStorageBucket.guestBooks, bookId); + return _withCoverLock( + MediaStorageScope.localGuest, + CoverStorageBucket.guestBooks, + bookId, + () => _readCoverFile(MediaStorageScope.localGuest, CoverStorageBucket.guestBooks, bookId), + ); } Future storeGuestCoverFile(String bookId, Uint8List bytes) async { - await _storeCoverFile(MediaStorageScope.localGuest, CoverStorageBucket.guestBooks, bookId, bytes); + await _withCoverLock( + MediaStorageScope.localGuest, + CoverStorageBucket.guestBooks, + bookId, + () => _writeCoverFile(MediaStorageScope.localGuest, CoverStorageBucket.guestBooks, bookId, bytes), + ); } Future deleteGuestCoverFile(String bookId) async { - await _deleteCoverFile(MediaStorageScope.localGuest, CoverStorageBucket.guestBooks, bookId); + await _withCoverLock( + MediaStorageScope.localGuest, + CoverStorageBucket.guestBooks, + bookId, + () => _removeCoverFile(MediaStorageScope.localGuest, CoverStorageBucket.guestBooks, bookId), + ); } Future promotePendingCoverFile( @@ -149,10 +196,17 @@ class BookImportService { required String bookId, required String mediaId, }) async { - final bytes = await _getCoverFile(scope, CoverStorageBucket.pending, bookId); - if (bytes == null) return; - await _storeCoverFile(scope, CoverStorageBucket.cached, mediaId, bytes); - await _deleteCoverFile(scope, CoverStorageBucket.pending, bookId); + await _withCoverLock(scope, CoverStorageBucket.pending, bookId, () async { + final bytes = await _readCoverFile(scope, CoverStorageBucket.pending, bookId); + if (bytes == null) return; + await _withCoverLock( + scope, + CoverStorageBucket.cached, + mediaId, + () => _writeCoverFile(scope, CoverStorageBucket.cached, mediaId, bytes), + ); + await _removeCoverFile(scope, CoverStorageBucket.pending, bookId); + }); } /// Deletes all cached private covers for one server/account scope. @@ -176,27 +230,72 @@ class BookImportService { return booksDir; } - Future _getCoverFile(MediaStorageScope scope, CoverStorageBucket bucket, String id) async { + Future _withCoverLock( + MediaStorageScope scope, + CoverStorageBucket bucket, + String id, + Future Function() operation, + ) { + final key = '${scope.persistenceKey}|${bucket.pathComponent}|$id'; + final previous = _coverOperations[key] ?? Future.value(); + final completer = Completer(); + late final Future current; + current = previous.then((_) async { + try { + completer.complete(await operation()); + } catch (error, stackTrace) { + completer.completeError(error, stackTrace); + } + }); + _coverOperations[key] = current; + unawaited( + current.whenComplete(() { + if (identical(_coverOperations[key], current)) { + _coverOperations.remove(key); + } + }), + ); + return completer.future; + } + + Future _readCoverFile(MediaStorageScope scope, CoverStorageBucket bucket, String id) async { final file = await _coverFile(scope, bucket, id); if (!await file.exists()) return null; return file.readAsBytes(); } - Future _storeCoverFile(MediaStorageScope scope, CoverStorageBucket bucket, String id, Uint8List bytes) async { + Future _writeCoverFile(MediaStorageScope scope, CoverStorageBucket bucket, String id, Uint8List bytes) async { final file = await _coverFile(scope, bucket, id); - final tempFile = File('${file.path}.tmp'); - await tempFile.writeAsBytes(bytes, flush: true); + final operationId = const Uuid().v4(); + final tempFile = File('${file.path}.$operationId.tmp'); + final backupFile = File('${file.path}.$operationId.bak'); + var priorMoved = false; + var replacementInstalled = false; try { - await tempFile.rename(file.path); - } on FileSystemException { + await tempFile.writeAsBytes(bytes, flush: true); if (await file.exists()) { - await file.delete(); + await file.rename(backupFile.path); + priorMoved = true; } await tempFile.rename(file.path); + replacementInstalled = true; + } catch (_) { + if (priorMoved && await backupFile.exists()) { + await backupFile.rename(file.path); + priorMoved = false; + } + rethrow; + } finally { + if (await tempFile.exists()) { + await tempFile.delete(); + } + if (replacementInstalled && await backupFile.exists()) { + await backupFile.delete(); + } } } - Future _deleteCoverFile(MediaStorageScope scope, CoverStorageBucket bucket, String id) async { + Future _removeCoverFile(MediaStorageScope scope, CoverStorageBucket bucket, String id) async { final file = await _coverFile(scope, bucket, id); if (await file.exists()) { await file.delete(); diff --git a/app/test/services/book_cover_storage_test.dart b/app/test/services/book_cover_storage_test.dart index 28b1fac..c13760e 100644 --- a/app/test/services/book_cover_storage_test.dart +++ b/app/test/services/book_cover_storage_test.dart @@ -119,6 +119,40 @@ void main() { expect(root.listSync(recursive: true).whereType().where((file) => file.path.endsWith('.tmp')), isEmpty); }); + test('concurrent same-key stores leave one complete value and no temporary files', () async { + final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + final secondService = BookImportService(); + final candidates = List.generate(12, (index) => Uint8List.fromList(List.filled(4096, index))); + + await Future.wait( + candidates.indexed.map( + (candidate) => (candidate.$1.isEven ? service : secondService).storeCoverFile(scope, 'asset-1', candidate.$2), + ), + ); + + final stored = await service.getCoverFile(scope, 'asset-1'); + expect(candidates.any((bytes) => _bytesEqual(bytes, stored)), isTrue); + expect( + root + .listSync(recursive: true) + .whereType() + .where((file) => file.path.endsWith('.tmp') || file.path.endsWith('.bak')), + isEmpty, + ); + }); + + test('pending store queued during promotion preserves the newer generation', () async { + final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + await service.storePendingCoverFile(scope, 'book-1', Uint8List.fromList([1])); + + final promotion = service.promotePendingCoverFile(scope, bookId: 'book-1', mediaId: 'asset-1'); + final newerStore = service.storePendingCoverFile(scope, 'book-1', Uint8List.fromList([2])); + await Future.wait([promotion, newerStore]); + + expect(await service.getCoverFile(scope, 'asset-1'), Uint8List.fromList([1])); + expect(await service.getPendingCoverFile(scope, 'book-1'), Uint8List.fromList([2])); + }); + test('deleting a cover is idempotent', () async { final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); await service.storeCoverFile(scope, 'asset-1', Uint8List.fromList([1])); @@ -154,12 +188,49 @@ void main() { test('book worker declares scoped bucket cover actions and validates buckets', () { final source = File('web/book_worker.js').readAsStringSync(); - for (final action in ['getCover', 'storeCover', 'deleteCover', 'clearCovers']) { + for (final action in ['getCover', 'storeCover', 'deleteCover', 'promoteCover', 'clearCovers']) { expect(source, contains("case '$action':")); } expect(source, contains("new Set(['cached', 'pending', 'books'])")); expect(source, contains("validateCoverBucket(bucket)")); expect(source, contains("getDirectoryHandle(bucket, { create })")); + expect(source, contains('withCoverLock(')); + }); + + test('book worker rethrows cover storage failures other than not found', () async { + final result = await Process.run('node', const [ + '-e', + r''' +const fs = require('fs'); +const vm = require('vm'); +const source = fs.readFileSync('web/book_worker.js', 'utf8'); +const failure = new Error('storage denied'); +failure.name = 'NotAllowedError'; +const context = { + self: {}, + postMessage() {}, + navigator: { + storage: { + async getDirectory() { + return { async getDirectoryHandle() { throw failure; } }; + }, + }, + }, +}; +vm.createContext(context); +vm.runInContext(source, context); +(async () => { + const results = await Promise.allSettled([ + vm.runInContext("opfsReadCover('scope', 'cached', 'id')", context), + vm.runInContext("opfsDeleteCover('scope', 'cached', 'id')", context), + vm.runInContext("opfsClearCovers('scope')", context), + ]); + if (results.some((result) => result.status !== 'rejected')) process.exit(1); +})().catch(() => process.exit(2)); +''', + ]); + + expect(result.exitCode, 0, reason: '${result.stdout}\n${result.stderr}'); }); test('web cover writes transfer a copy so caller bytes remain renderable', () { @@ -174,3 +245,11 @@ void main() { expect(helper, isNot(contains('bytes.offsetInBytes == 0 && bytes.lengthInBytes == bytes.buffer.lengthInBytes'))); }); } + +bool _bytesEqual(Uint8List first, Uint8List? second) { + if (second == null || first.length != second.length) return false; + for (var index = 0; index < first.length; index++) { + if (first[index] != second[index]) return false; + } + return true; +} diff --git a/app/web/book_worker.js b/app/web/book_worker.js index ee0131b..4de4533 100644 --- a/app/web/book_worker.js +++ b/app/web/book_worker.js @@ -12,6 +12,7 @@ * { type: 'getCover', requestId, scopeKey, bucket, mediaId } * { type: 'storeCover', requestId, scopeKey, bucket, mediaId, fileData: ArrayBuffer } * { type: 'deleteCover', requestId, scopeKey, bucket, mediaId } + * { type: 'promoteCover', requestId, scopeKey, bucket, mediaId, targetMediaId } * { type: 'clearCovers', requestId, scopeKey, bucket } * * Outgoing: @@ -53,6 +54,9 @@ self.onmessage = async (event) => { case 'deleteCover': await handleDeleteCover(msg); break; + case 'promoteCover': + await handlePromoteCover(msg); + break; case 'clearCovers': await handleClearCovers(msg); break; @@ -137,16 +141,37 @@ async function handleGetCover(msg) { async function handleStoreCover(msg) { const { requestId, scopeKey, bucket, mediaId, fileData } = msg; - await opfsWriteCover(scopeKey, bucket, mediaId, new Uint8Array(fileData)); + await withCoverLock(scopeKey, bucket, mediaId, () => + opfsWriteCover(scopeKey, bucket, mediaId, new Uint8Array(fileData)), + ); postMessage({ type: 'success', action: 'storeCover', requestId }); } async function handleDeleteCover(msg) { const { requestId, scopeKey, bucket, mediaId } = msg; - await opfsDeleteCover(scopeKey, bucket, mediaId); + await withCoverLock(scopeKey, bucket, mediaId, () => + opfsDeleteCover(scopeKey, bucket, mediaId), + ); postMessage({ type: 'success', action: 'deleteCover', requestId }); } +async function handlePromoteCover(msg) { + const { requestId, scopeKey, bucket, mediaId, targetMediaId } = msg; + if (bucket !== 'pending') { + throw new Error('promoteCover requires the pending bucket'); + } + validateFilePart(targetMediaId, 'targetMediaId'); + await withCoverLock(scopeKey, bucket, mediaId, async () => { + const bytes = await opfsReadCover(scopeKey, bucket, mediaId); + if (!bytes) return; + await withCoverLock(scopeKey, 'cached', targetMediaId, () => + opfsWriteCover(scopeKey, 'cached', targetMediaId, bytes), + ); + await opfsDeleteCover(scopeKey, bucket, mediaId); + }); + postMessage({ type: 'success', action: 'promoteCover', requestId }); +} + async function handleClearCovers(msg) { const { requestId, scopeKey, bucket } = msg; validateCoverBucket(bucket); @@ -628,6 +653,7 @@ function validateFilePart(value, fieldName) { } const COVER_BUCKETS = new Set(['cached', 'pending', 'books']); +const coverOperations = new Map(); function validateCoverBucket(bucket) { if (!COVER_BUCKETS.has(bucket)) { @@ -635,6 +661,27 @@ function validateCoverBucket(bucket) { } } +async function withCoverLock(scopeKey, bucket, mediaId, operation) { + validateFilePart(scopeKey, 'scopeKey'); + validateCoverBucket(bucket); + validateFilePart(mediaId, 'mediaId'); + const key = `${scopeKey}|${bucket}|${mediaId}`; + const previous = coverOperations.get(key) || Promise.resolve(); + const current = previous.catch(() => {}).then(operation); + coverOperations.set(key, current); + try { + return await current; + } finally { + if (coverOperations.get(key) === current) { + coverOperations.delete(key); + } + } +} + +function isNotFoundError(error) { + return error != null && error.name === 'NotFoundError'; +} + async function opfsCoverDirectory(scopeKey, bucket, create) { validateFilePart(scopeKey, 'scopeKey'); validateCoverBucket(bucket); @@ -644,9 +691,9 @@ async function opfsCoverDirectory(scopeKey, bucket, create) { coversRoot = await root.getDirectoryHandle('media-covers', { create }); const scopeDirectory = await coversRoot.getDirectoryHandle(scopeKey, { create }); return await scopeDirectory.getDirectoryHandle(bucket, { create }); - } catch (_) { - if (!create) return null; - throw _; + } catch (error) { + if (!create && isNotFoundError(error)) return null; + throw error; } } @@ -667,8 +714,9 @@ async function opfsReadCover(scopeKey, bucket, mediaId) { const fileHandle = await directory.getFileHandle(`${mediaId}.bin`, { create: false }); const file = await fileHandle.getFile(); return new Uint8Array(await file.arrayBuffer()); - } catch (_) { - return null; + } catch (error) { + if (isNotFoundError(error)) return null; + throw error; } } @@ -678,8 +726,8 @@ async function opfsDeleteCover(scopeKey, bucket, mediaId) { if (!directory) return; try { await directory.removeEntry(`${mediaId}.bin`); - } catch (_) { - // Deletion is idempotent. + } catch (error) { + if (!isNotFoundError(error)) throw error; } } @@ -689,13 +737,14 @@ async function opfsClearCovers(scopeKey) { let coversRoot; try { coversRoot = await root.getDirectoryHandle('media-covers', { create: false }); - } catch (_) { - return; + } catch (error) { + if (isNotFoundError(error)) return; + throw error; } try { await coversRoot.removeEntry(scopeKey, { recursive: true }); - } catch (_) { - // Clearing an absent scope is successful. + } catch (error) { + if (!isNotFoundError(error)) throw error; } } From 112c3e9f7967bc5655e0cd5a76e1f511aeb34c30 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 18:53:19 +0300 Subject: [PATCH 18/48] fix: coordinate cover writes across workers --- .../services/book_import_service_stub.dart | 17 ----- .../services/book_cover_storage_test.dart | 56 ++++++++++++++++- app/web/book_worker.js | 62 +++++++++++++------ 3 files changed, 99 insertions(+), 36 deletions(-) diff --git a/app/lib/services/book_import_service_stub.dart b/app/lib/services/book_import_service_stub.dart index 3ef7ae6..3969923 100644 --- a/app/lib/services/book_import_service_stub.dart +++ b/app/lib/services/book_import_service_stub.dart @@ -268,30 +268,13 @@ class BookImportService { final file = await _coverFile(scope, bucket, id); final operationId = const Uuid().v4(); final tempFile = File('${file.path}.$operationId.tmp'); - final backupFile = File('${file.path}.$operationId.bak'); - var priorMoved = false; - var replacementInstalled = false; try { await tempFile.writeAsBytes(bytes, flush: true); - if (await file.exists()) { - await file.rename(backupFile.path); - priorMoved = true; - } await tempFile.rename(file.path); - replacementInstalled = true; - } catch (_) { - if (priorMoved && await backupFile.exists()) { - await backupFile.rename(file.path); - priorMoved = false; - } - rethrow; } finally { if (await tempFile.exists()) { await tempFile.delete(); } - if (replacementInstalled && await backupFile.exists()) { - await backupFile.delete(); - } } } diff --git a/app/test/services/book_cover_storage_test.dart b/app/test/services/book_cover_storage_test.dart index c13760e..484d737 100644 --- a/app/test/services/book_cover_storage_test.dart +++ b/app/test/services/book_cover_storage_test.dart @@ -141,6 +141,18 @@ void main() { ); }); + test('native replacement uses direct rename without moving the existing cache aside', () { + final source = File('lib/services/book_import_service_stub.dart').readAsStringSync(); + final writer = source.substring( + source.indexOf('Future _writeCoverFile'), + source.indexOf('Future _removeCoverFile'), + ); + + expect(writer, contains('await tempFile.rename(file.path);')); + expect(writer, isNot(contains('backupFile'))); + expect(writer, isNot(contains("await file.rename"))); + }); + test('pending store queued during promotion preserves the newer generation', () async { final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); await service.storePendingCoverFile(scope, 'book-1', Uint8List.fromList([1])); @@ -194,7 +206,49 @@ void main() { expect(source, contains("new Set(['cached', 'pending', 'books'])")); expect(source, contains("validateCoverBucket(bucket)")); expect(source, contains("getDirectoryHandle(bucket, { create })")); - expect(source, contains('withCoverLock(')); + expect(source, contains('withCoverLocks(')); + expect(source, contains('navigator.locks.request')); + expect(source, contains('coverLockName(')); + }); + + test('book worker derives shared Web Lock names from cover keys', () async { + final result = await Process.run('node', const [ + '-e', + r''' +const fs = require('fs'); +const vm = require('vm'); +const source = fs.readFileSync('web/book_worker.js', 'utf8'); +const requested = []; +const context = { + self: {}, + postMessage() {}, + navigator: { + locks: { + async request(name, callback) { + requested.push(name); + return callback(); + }, + }, + }, +}; +vm.createContext(context); +vm.runInContext(source, context); +(async () => { + const first = vm.runInContext("coverLockName('scope', 'pending', 'book-1')", context); + const second = vm.runInContext("coverLockName('scope', 'pending', 'book-1')", context); + const cached = vm.runInContext("coverLockName('scope', 'cached', 'asset-1')", context); + await vm.runInContext( + "withCoverLocks([['scope', 'pending', 'book-1'], ['scope', 'cached', 'asset-1']], async () => {})", + context, + ); + if (first !== second || first === cached) process.exit(1); + if (requested.length !== 2 || requested[0] > requested[1]) process.exit(2); + if (!requested.includes(first) || !requested.includes(cached)) process.exit(3); +})().catch(() => process.exit(4)); +''', + ]); + + expect(result.exitCode, 0, reason: '${result.stdout}\n${result.stderr}'); }); test('book worker rethrows cover storage failures other than not found', () async { diff --git a/app/web/book_worker.js b/app/web/book_worker.js index 4de4533..5dc4a69 100644 --- a/app/web/book_worker.js +++ b/app/web/book_worker.js @@ -141,7 +141,7 @@ async function handleGetCover(msg) { async function handleStoreCover(msg) { const { requestId, scopeKey, bucket, mediaId, fileData } = msg; - await withCoverLock(scopeKey, bucket, mediaId, () => + await withCoverLocks([[scopeKey, bucket, mediaId]], () => opfsWriteCover(scopeKey, bucket, mediaId, new Uint8Array(fileData)), ); postMessage({ type: 'success', action: 'storeCover', requestId }); @@ -149,7 +149,7 @@ async function handleStoreCover(msg) { async function handleDeleteCover(msg) { const { requestId, scopeKey, bucket, mediaId } = msg; - await withCoverLock(scopeKey, bucket, mediaId, () => + await withCoverLocks([[scopeKey, bucket, mediaId]], () => opfsDeleteCover(scopeKey, bucket, mediaId), ); postMessage({ type: 'success', action: 'deleteCover', requestId }); @@ -161,14 +161,18 @@ async function handlePromoteCover(msg) { throw new Error('promoteCover requires the pending bucket'); } validateFilePart(targetMediaId, 'targetMediaId'); - await withCoverLock(scopeKey, bucket, mediaId, async () => { - const bytes = await opfsReadCover(scopeKey, bucket, mediaId); - if (!bytes) return; - await withCoverLock(scopeKey, 'cached', targetMediaId, () => - opfsWriteCover(scopeKey, 'cached', targetMediaId, bytes), - ); - await opfsDeleteCover(scopeKey, bucket, mediaId); - }); + await withCoverLocks( + [ + [scopeKey, bucket, mediaId], + [scopeKey, 'cached', targetMediaId], + ], + async () => { + const bytes = await opfsReadCover(scopeKey, bucket, mediaId); + if (!bytes) return; + await opfsWriteCover(scopeKey, 'cached', targetMediaId, bytes); + await opfsDeleteCover(scopeKey, bucket, mediaId); + }, + ); postMessage({ type: 'success', action: 'promoteCover', requestId }); } @@ -653,7 +657,7 @@ function validateFilePart(value, fieldName) { } const COVER_BUCKETS = new Set(['cached', 'pending', 'books']); -const coverOperations = new Map(); +const inWorkerCoverOperations = new Map(); function validateCoverBucket(bucket) { if (!COVER_BUCKETS.has(bucket)) { @@ -661,19 +665,41 @@ function validateCoverBucket(bucket) { } } -async function withCoverLock(scopeKey, bucket, mediaId, operation) { +function coverLockName(scopeKey, bucket, mediaId) { validateFilePart(scopeKey, 'scopeKey'); validateCoverBucket(bucket); validateFilePart(mediaId, 'mediaId'); - const key = `${scopeKey}|${bucket}|${mediaId}`; - const previous = coverOperations.get(key) || Promise.resolve(); - const current = previous.catch(() => {}).then(operation); - coverOperations.set(key, current); + return `papyrus:cover:${scopeKey}:${bucket}:${mediaId}`; +} + +async function withCoverLocks(coverKeys, operation) { + const lockNames = [...new Set(coverKeys.map((key) => coverLockName(...key)))].sort(); + if (navigator.locks && typeof navigator.locks.request === 'function') { + return requestWebCoverLocks(lockNames, 0, operation); + } + return requestInWorkerCoverLocks(lockNames, 0, operation); +} + +async function requestWebCoverLocks(lockNames, index, operation) { + if (index === lockNames.length) return operation(); + return navigator.locks.request(lockNames[index], () => + requestWebCoverLocks(lockNames, index + 1, operation), + ); +} + +async function requestInWorkerCoverLocks(lockNames, index, operation) { + if (index === lockNames.length) return operation(); + const lockName = lockNames[index]; + const previous = inWorkerCoverOperations.get(lockName) || Promise.resolve(); + const current = previous + .catch(() => {}) + .then(() => requestInWorkerCoverLocks(lockNames, index + 1, operation)); + inWorkerCoverOperations.set(lockName, current); try { return await current; } finally { - if (coverOperations.get(key) === current) { - coverOperations.delete(key); + if (inWorkerCoverOperations.get(lockName) === current) { + inWorkerCoverOperations.delete(lockName); } } } From 7ed2e3e962b138edad142656e7737524d6bf7a5b Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 18:58:19 +0300 Subject: [PATCH 19/48] fix: queue cover file references instead of bytes --- app/lib/media/media_upload_queue.dart | 42 +++-- app/test/media/media_upload_queue_test.dart | 182 +++++++++++++++++++- 2 files changed, 205 insertions(+), 19 deletions(-) diff --git a/app/lib/media/media_upload_queue.dart b/app/lib/media/media_upload_queue.dart index a0272b8..11ae6f5 100644 --- a/app/lib/media/media_upload_queue.dart +++ b/app/lib/media/media_upload_queue.dart @@ -9,6 +9,7 @@ import 'package:papyrus/models/book.dart'; import 'package:shared_preferences/shared_preferences.dart'; typedef BookFileReader = Future Function(String bookId); +typedef PendingCoverReader = Future Function(MediaStorageScope scope, String bookId); typedef MediaUploader = Future Function(MediaUploadPayload payload); typedef MediaWorkAvailableCallback = Future Function(); @@ -56,7 +57,7 @@ class MediaUploadTask { 'filename': filename, 'content_type': contentType, 'status': status.name, - 'cover_base64': coverBase64, + if (coverBase64 != null) 'cover_base64': coverBase64, 'error_message': errorMessage, }; } @@ -86,6 +87,7 @@ class MediaUploadQueue extends ChangeNotifier { MediaStorageScope? _activeScope; MediaStorageUsage? _storageUsage; Future? _processing; + bool _processAgain = false; Future _mutationTail = Future.value(); Map _taskVersions = {}; @@ -124,12 +126,7 @@ class MediaUploadQueue extends ChangeNotifier { ); } - Future enqueueCover({ - required Book book, - required String filename, - required String contentType, - required Uint8List bytes, - }) { + Future enqueueCover({required Book book, required String filename, required String contentType}) { return _enqueue( MediaUploadTask( id: '${book.id}:cover_image', @@ -138,7 +135,6 @@ class MediaUploadQueue extends ChangeNotifier { filename: filename, contentType: contentType, status: MediaUploadTaskStatus.pending, - coverBase64: base64Encode(bytes), ), ); } @@ -182,10 +178,14 @@ class MediaUploadQueue extends ChangeNotifier { Future processPending({ required DataStore dataStore, required BookFileReader readBookFile, + required PendingCoverReader readPendingCover, required MediaUploader uploadMedia, }) { final inFlight = _processing; - if (inFlight != null) return inFlight; + if (inFlight != null) { + _processAgain = true; + return inFlight; + } final scope = _activeScope; if (scope == null) return Future.value(); @@ -194,7 +194,16 @@ class MediaUploadQueue extends ChangeNotifier { _processing = operation; unawaited(() async { try { - await _processPending(scope: scope, dataStore: dataStore, readBookFile: readBookFile, uploadMedia: uploadMedia); + do { + _processAgain = false; + await _processPending( + scope: scope, + dataStore: dataStore, + readBookFile: readBookFile, + readPendingCover: readPendingCover, + uploadMedia: uploadMedia, + ); + } while (_processAgain && _activeScope == scope); _clearProcessing(operation); completer.complete(); } catch (error, stackTrace) { @@ -209,6 +218,7 @@ class MediaUploadQueue extends ChangeNotifier { required MediaStorageScope scope, required DataStore dataStore, required BookFileReader readBookFile, + required PendingCoverReader readPendingCover, required MediaUploader uploadMedia, }) async { final processedVersions = {}; @@ -226,7 +236,7 @@ class MediaUploadQueue extends ChangeNotifier { final version = _taskVersions[task.id] ?? 0; processedVersions.add('${task.id}:$version'); - final bytes = await _bytesForTask(task, readBookFile); + final bytes = await _bytesForTask(task, scope, readBookFile, readPendingCover); if (bytes == null) { await _replaceTaskIfCurrent( scope, @@ -340,10 +350,16 @@ class MediaUploadQueue extends ChangeNotifier { } } - Future _bytesForTask(MediaUploadTask task, BookFileReader readBookFile) async { + Future _bytesForTask( + MediaUploadTask task, + MediaStorageScope scope, + BookFileReader readBookFile, + PendingCoverReader readPendingCover, + ) async { if (task.kind == MediaKind.coverImage) { final coverBase64 = task.coverBase64; - return coverBase64 == null ? null : base64Decode(coverBase64); + if (coverBase64 != null) return base64Decode(coverBase64); + return readPendingCover(scope, task.bookId); } return readBookFile(task.bookId); } diff --git a/app/test/media/media_upload_queue_test.dart b/app/test/media/media_upload_queue_test.dart index f3df1fb..1c9d463 100644 --- a/app/test/media/media_upload_queue_test.dart +++ b/app/test/media/media_upload_queue_test.dart @@ -29,6 +29,7 @@ void main() { await queue.processPending( dataStore: dataStore, readBookFile: (bookId) async => Uint8List.fromList('epub bytes'.codeUnits), + readPendingCover: (_, _) async => null, uploadMedia: (payload) async { expect(payload.bookId, book.id); expect(payload.kind, MediaKind.bookFile); @@ -54,6 +55,7 @@ void main() { await queue.processPending( dataStore: dataStore, readBookFile: (bookId) async => Uint8List.fromList('epub bytes'.codeUnits), + readPendingCover: (_, _) async => null, uploadMedia: (payload) async => throw const MediaUploadException.storageFull(), ); @@ -77,6 +79,7 @@ void main() { await queue.processPending( dataStore: dataStore, readBookFile: (bookId) async => Uint8List.fromList('epub bytes'.codeUnits), + readPendingCover: (_, _) async => null, uploadMedia: (payload) async => throw const MediaUploadException.storageFull(), ); @@ -92,12 +95,7 @@ void main() { 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.enqueueCover(book: book, filename: 'cover.jpg', contentType: 'image/jpeg'); await queue.removeTasksForBook(book.id); @@ -105,6 +103,135 @@ void main() { expect(prefs.getString('media_upload_queue:official--user-1'), '[]'); }); + test('enqueueCover persists metadata without cover bytes', () async { + final prefs = await SharedPreferences.getInstance(); + final queue = await _activeQueue(prefs); + final book = _book(filePath: 'book-1'); + + await queue.enqueueCover(book: book, filename: 'cover.jpg', contentType: 'image/jpeg'); + + final stored = jsonDecode(prefs.getString('media_upload_queue:official--user-1')!) as List; + final task = stored.single as Map; + expect(task, isNot(contains('cover_base64'))); + expect(prefs.getString('media_upload_queue:official--user-1'), isNot(contains(base64Encode('cover'.codeUnits)))); + }); + + test('restored cover task reads scoped pending file and stores returned media id', () async { + final prefs = await SharedPreferences.getInstance(); + final repository = InMemoryBookRepository(); + final dataStore = DataStore(bookRepository: repository); + final book = _book(filePath: 'book-1'); + await repository.upsert(book); + await pumpEventQueue(); + final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + final originalQueue = MediaUploadQueue(prefs); + await originalQueue.activateScope(scope); + await originalQueue.enqueueCover(book: book, filename: 'cover.jpg', contentType: 'image/jpeg'); + final restoredQueue = MediaUploadQueue(prefs); + await restoredQueue.activateScope(scope); + final coverBytes = Uint8List.fromList('cover file'.codeUnits); + + await restoredQueue.processPending( + dataStore: dataStore, + readBookFile: (_) async => null, + readPendingCover: (requestedScope, bookId) async { + expect(requestedScope, scope); + expect(bookId, book.id); + return coverBytes; + }, + uploadMedia: (payload) async { + expect(payload.kind, MediaKind.coverImage); + expect(payload.bytes, coverBytes); + return _asset(assetId: 'cover-asset', bookId: book.id, kind: MediaKind.coverImage); + }, + ); + + expect(restoredQueue.pendingTasks, isEmpty); + expect(dataStore.getBook(book.id)?.coverMediaId, 'cover-asset'); + }); + + test('missing pending cover file leaves task pending with a local file error', () async { + final prefs = await SharedPreferences.getInstance(); + final repository = InMemoryBookRepository(); + final dataStore = DataStore(bookRepository: repository); + final book = _book(filePath: 'book-1'); + await repository.upsert(book); + await pumpEventQueue(); + final queue = await _activeQueue(prefs); + await queue.enqueueCover(book: book, filename: 'cover.jpg', contentType: 'image/jpeg'); + var uploads = 0; + + await queue.processPending( + dataStore: dataStore, + readBookFile: (_) async => null, + readPendingCover: (_, _) async => null, + uploadMedia: (_) async { + uploads++; + return _asset(assetId: 'cover-asset', bookId: book.id, kind: MediaKind.coverImage); + }, + ); + + expect(uploads, 0); + expect(queue.pendingTasks, hasLength(1)); + expect(queue.pendingTasks.single.status, MediaUploadTaskStatus.pending); + expect(queue.pendingTasks.single.errorMessage, 'Local file not found'); + }); + + test('legacy embedded cover bytes survive failure and are drained on retry', () async { + final prefs = await SharedPreferences.getInstance(); + final repository = InMemoryBookRepository(); + final dataStore = DataStore(bookRepository: repository); + final book = _book(filePath: 'book-1'); + await repository.upsert(book); + await pumpEventQueue(); + final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + final coverBytes = Uint8List.fromList('legacy cover'.codeUnits); + await prefs.setString( + 'media_upload_queue:${scope.persistenceKey}', + jsonEncode([ + { + 'id': '${book.id}:cover_image', + 'book_id': book.id, + 'kind': 'cover_image', + 'filename': 'cover.jpg', + 'content_type': 'image/jpeg', + 'status': 'pending', + 'cover_base64': base64Encode(coverBytes), + 'error_message': null, + }, + ]), + ); + final queue = MediaUploadQueue(prefs); + await queue.activateScope(scope); + var attempts = 0; + + Future process() => queue.processPending( + dataStore: dataStore, + readBookFile: (_) async => null, + readPendingCover: (_, _) async => throw StateError('legacy task must use embedded bytes'), + uploadMedia: (payload) async { + attempts++; + expect(payload.bytes, coverBytes); + if (attempts == 1) { + throw const MediaUploadException.storageFull(); + } + return _asset(assetId: 'cover-asset', bookId: book.id, kind: MediaKind.coverImage); + }, + ); + + await process(); + final failedTask = + (jsonDecode(prefs.getString('media_upload_queue:${scope.persistenceKey}')!) as List).single + as Map; + expect(failedTask['cover_base64'], base64Encode(coverBytes)); + await queue.retryFailed(); + await process(); + + expect(attempts, 2); + expect(queue.pendingTasks, isEmpty); + expect(dataStore.getBook(book.id)?.coverMediaId, 'cover-asset'); + }); + test('pending uploads are isolated and restored per media scope', () async { final prefs = await SharedPreferences.getInstance(); final queue = MediaUploadQueue(prefs); @@ -153,6 +280,7 @@ void main() { return queue.processPending( dataStore: dataStore, readBookFile: (_) async => Uint8List.fromList([1, 2, 3]), + readPendingCover: (_, _) async => null, uploadMedia: (_) { uploads++; return gate.future; @@ -171,6 +299,45 @@ void main() { expect(uploads, 1); }); + test('processing requested during a failed upload runs another pass', () 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 = await _activeQueue(prefs); + await queue.enqueueBookFile(book: book, filename: 'book.epub', contentType: 'application/epub+zip'); + final firstAttemptStarted = Completer(); + final releaseFirstAttempt = Completer(); + var attempts = 0; + + Future process() => queue.processPending( + dataStore: dataStore, + readBookFile: (_) async => Uint8List.fromList([1]), + readPendingCover: (_, _) async => null, + uploadMedia: (payload) async { + attempts++; + if (attempts == 1) { + firstAttemptStarted.complete(); + await releaseFirstAttempt.future; + throw StateError('book has not synced yet'); + } + return _asset(assetId: 'file-asset', bookId: payload.bookId, kind: payload.kind); + }, + ); + + final first = process(); + await firstAttemptStarted.future; + final retrySignal = process(); + releaseFirstAttempt.complete(); + await Future.wait([first, retrySignal]); + + expect(attempts, 2); + expect(queue.pendingTasks, isEmpty); + expect(dataStore.getBook(book.id)?.fileMediaId, 'file-asset'); + }); + test('enqueue during an upload is persisted and drained before processing completes', () async { final prefs = await SharedPreferences.getInstance(); final repository = InMemoryBookRepository(); @@ -188,6 +355,7 @@ void main() { Future process() => queue.processPending( dataStore: dataStore, readBookFile: (_) async => Uint8List.fromList([1]), + readPendingCover: (_, _) async => null, uploadMedia: (payload) async { uploadedBookIds.add(payload.bookId); if (payload.bookId == firstBook.id) { @@ -236,6 +404,7 @@ void main() { final processing = queue.processPending( dataStore: dataStore, readBookFile: (_) async => Uint8List.fromList([1]), + readPendingCover: (_, _) async => null, uploadMedia: (_) async { uploadStarted.complete(); await releaseUpload.future; @@ -288,6 +457,7 @@ void main() { await queue.processPending( dataStore: dataStore, readBookFile: (_) async => Uint8List.fromList([1]), + readPendingCover: (_, _) async => null, uploadMedia: (_) async => throw const MediaUploadException.storageFull(), ); From 13f7618ccf7ce57205c041f2d8761bbfc389fc31 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 19:01:13 +0300 Subject: [PATCH 20/48] fix: wire pending cover queue readers --- app/lib/main.dart | 1 + .../widgets/add_book/import_book_sheet.dart | 7 ++++++- .../media_profile_switch_contract_test.dart | 20 +++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/app/lib/main.dart b/app/lib/main.dart index 761ed73..535e83f 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -227,6 +227,7 @@ class _PapyrusState extends State { await _mediaUploadQueue.processPending( dataStore: _dataStore, readBookFile: _bookImportService.getBookFile, + readPendingCover: _bookImportService.getPendingCoverFile, uploadMedia: (payload) async { try { return await repository.uploadMedia(payload); diff --git a/app/lib/widgets/add_book/import_book_sheet.dart b/app/lib/widgets/add_book/import_book_sheet.dart index 26f4770..ce63ecc 100644 --- a/app/lib/widgets/add_book/import_book_sheet.dart +++ b/app/lib/widgets/add_book/import_book_sheet.dart @@ -208,6 +208,7 @@ class _ImportContentState extends State<_ImportContent> { if (!isOnlineAccount) return; final queue = context.read(); + final importService = context.read(); await queue.enqueueBookFile( book: book, filename: _filename ?? '${book.id}.${result.fileExtension}', @@ -216,11 +217,15 @@ class _ImportContentState extends State<_ImportContent> { final coverImage = result.coverImage; if (coverImage != null) { + final scope = queue.activeScope; + if (scope == null) { + throw StateError('Cannot queue cover upload without an active media storage scope'); + } + await importService.storePendingCoverFile(scope, book.id, coverImage); await queue.enqueueCover( book: book, filename: '${book.id}-cover.${_coverExtension(result.coverMimeType)}', contentType: result.coverMimeType ?? 'image/jpeg', - bytes: coverImage, ); } } diff --git a/app/test/media/media_profile_switch_contract_test.dart b/app/test/media/media_profile_switch_contract_test.dart index 4e91f4c..5cef2bf 100644 --- a/app/test/media/media_profile_switch_contract_test.dart +++ b/app/test/media/media_profile_switch_contract_test.dart @@ -24,4 +24,24 @@ void main() { expect(RegExp(r'_switchingSyncProfile').allMatches(processor), hasLength(greaterThanOrEqualTo(2))); expect(processor, contains('!identical(repository, _authRepository)')); }); + + test('production cover queue integration stores scoped bytes before metadata', () { + final mainSource = File('lib/main.dart').readAsStringSync(); + final processor = mainSource.substring( + mainSource.indexOf('Future _processMediaUploads()'), + mainSource.indexOf('@override\n Widget build'), + ); + expect(processor, contains('readPendingCover: _bookImportService.getPendingCoverFile')); + + final importSource = File('lib/widgets/add_book/import_book_sheet.dart').readAsStringSync(); + final enqueue = importSource.substring( + importSource.indexOf('Future _enqueueOnlineMediaUploads'), + importSource.indexOf('String _contentTypeForExtension'), + ); + expect(enqueue, contains('final scope = queue.activeScope;')); + expect(enqueue, contains("throw StateError('Cannot queue cover upload without an active media storage scope')")); + expect(enqueue, contains('storePendingCoverFile(scope, book.id, coverImage)')); + expect(enqueue.indexOf('storePendingCoverFile'), lessThan(enqueue.indexOf('queue.enqueueCover'))); + expect(enqueue, isNot(contains('bytes: coverImage'))); + }); } From ec56e377f054d85785e7b1ef98a90d0800cd57ab Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 19:09:49 +0300 Subject: [PATCH 21/48] fix: isolate queued media read failures --- app/lib/media/media_upload_queue.dart | 22 ++++++++--------- app/test/media/media_upload_queue_test.dart | 27 +++++++++++++++++++++ 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/app/lib/media/media_upload_queue.dart b/app/lib/media/media_upload_queue.dart index 11ae6f5..5c45bbb 100644 --- a/app/lib/media/media_upload_queue.dart +++ b/app/lib/media/media_upload_queue.dart @@ -236,18 +236,18 @@ class MediaUploadQueue extends ChangeNotifier { final version = _taskVersions[task.id] ?? 0; processedVersions.add('${task.id}:$version'); - final bytes = await _bytesForTask(task, scope, readBookFile, readPendingCover); - if (bytes == null) { - await _replaceTaskIfCurrent( - scope, - task, - version, - task.copyWith(status: MediaUploadTaskStatus.pending, errorMessage: 'Local file not found'), - ); - continue; - } - try { + final bytes = await _bytesForTask(task, scope, readBookFile, readPendingCover); + if (bytes == null) { + await _replaceTaskIfCurrent( + scope, + task, + version, + task.copyWith(status: MediaUploadTaskStatus.pending, errorMessage: 'Local file not found'), + ); + continue; + } + final asset = await uploadMedia( MediaUploadPayload( bookId: task.bookId, diff --git a/app/test/media/media_upload_queue_test.dart b/app/test/media/media_upload_queue_test.dart index 1c9d463..fbb1068 100644 --- a/app/test/media/media_upload_queue_test.dart +++ b/app/test/media/media_upload_queue_test.dart @@ -177,6 +177,33 @@ void main() { expect(queue.pendingTasks.single.errorMessage, 'Local file not found'); }); + test('pending cover read failure does not block later uploads', () async { + final prefs = await SharedPreferences.getInstance(); + final dataStore = DataStore(); + final coverBook = _book(id: '11111111-1111-1111-1111-111111111111', filePath: 'cover-book'); + final fileBook = _book(id: '22222222-2222-2222-2222-222222222222', filePath: 'file-book'); + final queue = await _activeQueue(prefs); + await queue.enqueueCover(book: coverBook, filename: 'cover.jpg', contentType: 'image/jpeg'); + await queue.enqueueBookFile(book: fileBook, filename: 'book.epub', contentType: 'application/epub+zip'); + final uploadedBookIds = []; + + await queue.processPending( + dataStore: dataStore, + readBookFile: (_) async => Uint8List.fromList([1, 2, 3]), + readPendingCover: (_, _) async => throw StateError('pending cover read failed'), + uploadMedia: (payload) async { + uploadedBookIds.add(payload.bookId); + return _asset(assetId: 'file-asset', bookId: payload.bookId, kind: payload.kind); + }, + ); + + expect(uploadedBookIds, [fileBook.id]); + expect(queue.pendingTasks, hasLength(1)); + expect(queue.pendingTasks.single.bookId, coverBook.id); + expect(queue.pendingTasks.single.status, MediaUploadTaskStatus.pending); + expect(queue.pendingTasks.single.errorMessage, contains('pending cover read failed')); + }); + test('legacy embedded cover bytes survive failure and are drained on retry', () async { final prefs = await SharedPreferences.getInstance(); final repository = InMemoryBookRepository(); From dae456d5755ddcef0130cd5483693fc491581d69 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 19:17:41 +0300 Subject: [PATCH 22/48] feat: persist imported covers outside metadata --- .../services/book_import_commit_service.dart | 112 +++++++++++ .../widgets/add_book/import_book_sheet.dart | 128 ++++--------- .../media_profile_switch_contract_test.dart | 21 +- .../book_import_commit_service_test.dart | 180 ++++++++++++++++++ 4 files changed, 344 insertions(+), 97 deletions(-) create mode 100644 app/lib/services/book_import_commit_service.dart create mode 100644 app/test/services/book_import_commit_service_test.dart diff --git a/app/lib/services/book_import_commit_service.dart b/app/lib/services/book_import_commit_service.dart new file mode 100644 index 0000000..3b09e1a --- /dev/null +++ b/app/lib/services/book_import_commit_service.dart @@ -0,0 +1,112 @@ +import 'dart:typed_data'; + +import 'package:papyrus/media/media_storage_scope.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/services/book_import_result.dart'; + +typedef PendingCoverStore = Future Function(MediaStorageScope scope, String bookId, Uint8List bytes); +typedef GuestCoverStore = Future Function(String bookId, Uint8List bytes); +typedef BookAdder = void Function(Book book); +typedef MediaEnqueuer = + Future Function({required Book book, required String filename, required String contentType}); + +/// Commits an imported book and its local media in a safe, deterministic order. +class BookImportCommitService { + const BookImportCommitService({ + required PendingCoverStore storePendingCover, + required GuestCoverStore storeGuestCover, + required BookAdder addBook, + required MediaEnqueuer enqueueBookFile, + required MediaEnqueuer enqueueCover, + }) : _storePendingCover = storePendingCover, + _storeGuestCover = storeGuestCover, + _addBook = addBook, + _enqueueBookFile = enqueueBookFile, + _enqueueCover = enqueueCover; + + final PendingCoverStore _storePendingCover; + final GuestCoverStore _storeGuestCover; + final BookAdder _addBook; + final MediaEnqueuer _enqueueBookFile; + final MediaEnqueuer _enqueueCover; + + Future commit({ + required BookImportResult result, + required String sourceFilename, + required DateTime addedAt, + required String localFilePath, + MediaStorageScope? accountScope, + }) async { + final book = Book( + id: result.bookId, + title: result.title, + subtitle: result.subtitle, + author: result.author, + coAuthors: result.coAuthors, + publisher: result.publisher, + description: result.description, + language: result.language, + isbn: result.isbn, + isbn13: result.isbn13, + pageCount: result.pageCount, + coverUrl: null, + coverMediaId: null, + filePath: localFilePath, + fileFormat: _bookFormat(result.fileExtension), + fileSize: result.fileSize, + fileHash: result.fileHash, + addedAt: addedAt, + ); + + final coverImage = result.coverImage; + if (coverImage != null) { + if (accountScope != null) { + await _storePendingCover(accountScope, book.id, coverImage); + } else { + await _storeGuestCover(book.id, coverImage); + } + } + + _addBook(book); + + if (accountScope != null) { + await _enqueueBookFile(book: book, filename: sourceFilename, contentType: _bookContentType(result.fileExtension)); + if (coverImage != null) { + await _enqueueCover( + book: book, + filename: '${book.id}-cover.${_coverExtension(result.coverMimeType)}', + contentType: result.coverMimeType ?? 'image/jpeg', + ); + } + } + + return book; + } +} + +BookFormat? _bookFormat(String extension) { + for (final format in BookFormat.values) { + if (format.name == extension) return format; + } + return null; +} + +String _bookContentType(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', + }; +} diff --git a/app/lib/widgets/add_book/import_book_sheet.dart b/app/lib/widgets/add_book/import_book_sheet.dart index ce63ecc..6cdf782 100644 --- a/app/lib/widgets/add_book/import_book_sheet.dart +++ b/app/lib/widgets/add_book/import_book_sheet.dart @@ -5,14 +5,13 @@ 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_commit_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/utils/image_utils.dart'; import 'package:papyrus/widgets/shared/bottom_sheet_handle.dart'; import 'package:provider/provider.dart'; @@ -157,97 +156,52 @@ class _ImportContentState extends State<_ImportContent> { } } - void _addToLibrary() { + Future _addToLibrary() async { final result = _result; if (result == null) return; - final dataStore = context.read(); - final now = DateTime.now(); - - String? coverUrl; - if (result.coverImage != null) { - coverUrl = bytesToDataUri(result.coverImage!); - } - - final ext = result.fileExtension; - final filePath = kIsWeb - ? 'opfs://books/${result.bookId}.$ext' - : result.bookId; // Native resolves via BookImportService.getBookFile - - final book = Book( - id: result.bookId, - title: result.title, - subtitle: result.subtitle, - author: result.author, - coAuthors: result.coAuthors, - publisher: result.publisher, - description: result.description, - language: result.language, - isbn: result.isbn, - pageCount: result.pageCount, - coverUrl: coverUrl, - filePath: filePath, - fileFormat: BookFormat.values.where((f) => f.name == ext).firstOrNull, - fileSize: result.fileSize, - fileHash: result.fileHash, - addedAt: now, - ); - - 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(); - final importService = context.read(); - await queue.enqueueBookFile( - book: book, - filename: _filename ?? '${book.id}.${result.fileExtension}', - contentType: _contentTypeForExtension(result.fileExtension), - ); - - final coverImage = result.coverImage; - if (coverImage != null) { - final scope = queue.activeScope; - if (scope == null) { - throw StateError('Cannot queue cover upload without an active media storage scope'); + try { + final dataStore = context.read(); + final queue = context.read(); + final importService = context.read(); + final isOnlineAccount = + context.read().isSignedIn && + context.read().mode == LibraryDatabaseMode.authenticated; + final accountScope = isOnlineAccount ? queue.activeScope : null; + if (isOnlineAccount && accountScope == null) { + throw StateError('Cannot import account media without an active media storage scope'); } - await importService.storePendingCoverFile(scope, book.id, coverImage); - await queue.enqueueCover( - book: book, - filename: '${book.id}-cover.${_coverExtension(result.coverMimeType)}', - contentType: result.coverMimeType ?? 'image/jpeg', - ); - } - } - 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', - }; - } + final ext = result.fileExtension; + final filePath = kIsWeb + ? 'opfs://books/${result.bookId}.$ext' + : result.bookId; // Native resolves via BookImportService.getBookFile + final commitService = BookImportCommitService( + storePendingCover: importService.storePendingCoverFile, + storeGuestCover: importService.storeGuestCoverFile, + addBook: dataStore.addBook, + enqueueBookFile: queue.enqueueBookFile, + enqueueCover: queue.enqueueCover, + ); + final book = await commitService.commit( + result: result, + sourceFilename: _filename ?? '${result.bookId}.$ext', + addedAt: DateTime.now(), + localFilePath: filePath, + accountScope: accountScope, + ); - String _coverExtension(String? contentType) { - return switch (contentType) { - 'image/png' => 'png', - 'image/webp' => 'webp', - 'image/gif' => 'gif', - _ => 'jpg', - }; + if (!mounted) return; + final messenger = ScaffoldMessenger.of(context); + Navigator.of(context).pop(); + messenger.showSnackBar(SnackBar(content: Text('Added "${book.title}" to library'))); + } catch (error) { + if (!mounted) return; + setState(() { + _state = _ImportState.error; + _errorMessage = error.toString(); + }); + } } @override diff --git a/app/test/media/media_profile_switch_contract_test.dart b/app/test/media/media_profile_switch_contract_test.dart index 5cef2bf..47890f3 100644 --- a/app/test/media/media_profile_switch_contract_test.dart +++ b/app/test/media/media_profile_switch_contract_test.dart @@ -25,7 +25,7 @@ void main() { expect(processor, contains('!identical(repository, _authRepository)')); }); - test('production cover queue integration stores scoped bytes before metadata', () { + test('production import delegates scoped cover persistence and queueing to the commit boundary', () { final mainSource = File('lib/main.dart').readAsStringSync(); final processor = mainSource.substring( mainSource.indexOf('Future _processMediaUploads()'), @@ -34,14 +34,15 @@ void main() { expect(processor, contains('readPendingCover: _bookImportService.getPendingCoverFile')); final importSource = File('lib/widgets/add_book/import_book_sheet.dart').readAsStringSync(); - final enqueue = importSource.substring( - importSource.indexOf('Future _enqueueOnlineMediaUploads'), - importSource.indexOf('String _contentTypeForExtension'), - ); - expect(enqueue, contains('final scope = queue.activeScope;')); - expect(enqueue, contains("throw StateError('Cannot queue cover upload without an active media storage scope')")); - expect(enqueue, contains('storePendingCoverFile(scope, book.id, coverImage)')); - expect(enqueue.indexOf('storePendingCoverFile'), lessThan(enqueue.indexOf('queue.enqueueCover'))); - expect(enqueue, isNot(contains('bytes: coverImage'))); + final commitStart = importSource.indexOf('Future _addToLibrary()'); + final commit = importSource.substring(commitStart, importSource.indexOf('@override\n Widget build', commitStart)); + expect(commit, contains('final accountScope = isOnlineAccount ? queue.activeScope : null;')); + expect(commit, contains("throw StateError('Cannot import account media without an active media storage scope')")); + expect(commit, contains('storePendingCover: importService.storePendingCoverFile')); + expect(commit, contains('storeGuestCover: importService.storeGuestCoverFile')); + expect(commit, contains('enqueueBookFile: queue.enqueueBookFile')); + expect(commit, contains('enqueueCover: queue.enqueueCover')); + expect(commit, contains('accountScope: accountScope')); + expect(commit, isNot(contains('bytesToDataUri'))); }); } diff --git a/app/test/services/book_import_commit_service_test.dart b/app/test/services/book_import_commit_service_test.dart new file mode 100644 index 0000000..55e792c --- /dev/null +++ b/app/test/services/book_import_commit_service_test.dart @@ -0,0 +1,180 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/media/media_storage_scope.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:papyrus/services/book_import_commit_service.dart'; +import 'package:papyrus/services/book_import_result.dart'; + +void main() { + final addedAt = DateTime.utc(2026, 7, 11, 12, 30); + final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + + test('account import persists cover before metadata and enqueues metadata-only uploads in order', () async { + final calls = []; + Book? addedBook; + final service = BookImportCommitService( + storePendingCover: (actualScope, bookId, bytes) async { + expect(actualScope, scope); + expect(bookId, 'book-1'); + expect(bytes, Uint8List.fromList([1, 2, 3])); + calls.add('store pending cover'); + }, + storeGuestCover: (_, _) async => fail('guest cover must not be stored for an account'), + addBook: (book) { + addedBook = book; + calls.add('add book'); + }, + enqueueBookFile: ({required book, required filename, required contentType}) async { + expect(book, same(addedBook)); + expect(filename, 'original.epub'); + expect(contentType, 'application/epub+zip'); + calls.add('enqueue book file'); + }, + enqueueCover: ({required book, required filename, required contentType}) async { + expect(book, same(addedBook)); + expect(filename, 'book-1-cover.png'); + expect(contentType, 'image/png'); + calls.add('enqueue cover'); + }, + ); + + final book = await service.commit( + result: _result(coverImage: Uint8List.fromList([1, 2, 3]), coverMimeType: 'image/png'), + sourceFilename: 'original.epub', + addedAt: addedAt, + localFilePath: 'opfs://books/book-1.epub', + accountScope: scope, + ); + + expect(calls, ['store pending cover', 'add book', 'enqueue book file', 'enqueue cover']); + expect(book.coverUrl, isNull); + expect(book.coverMediaId, isNull); + expect(jsonEncode(book.toJson()), isNot(contains('data:image'))); + expect(book.toJson()['cover_image_url'], isNull); + expect(book.toJson()['cover_media_id'], isNull); + expect(book.id, 'book-1'); + expect(book.title, 'Imported title'); + expect(book.subtitle, 'Imported subtitle'); + expect(book.author, 'Primary author'); + expect(book.coAuthors, ['Second author']); + expect(book.publisher, 'Publisher'); + expect(book.description, 'Description'); + expect(book.language, 'lt'); + expect(book.isbn, 'isbn-10'); + expect(book.isbn13, 'isbn-13'); + expect(book.pageCount, 321); + expect(book.filePath, 'opfs://books/book-1.epub'); + expect(book.fileFormat, BookFormat.epub); + expect(book.fileSize, 1234); + expect(book.fileHash, 'abc123'); + expect(book.addedAt, addedAt); + }); + + test('guest import stores permanent cover before adding and never enqueues media', () async { + final calls = []; + final service = BookImportCommitService( + storePendingCover: (_, _, _) async => fail('pending cover must not be stored for a guest'), + storeGuestCover: (bookId, bytes) async { + expect(bookId, 'book-1'); + expect(bytes, Uint8List.fromList([4, 5])); + calls.add('store guest cover'); + }, + addBook: (book) => calls.add('add book'), + enqueueBookFile: ({required book, required filename, required contentType}) async { + fail('guest book file must not be enqueued'); + }, + enqueueCover: ({required book, required filename, required contentType}) async { + fail('guest cover must not be enqueued'); + }, + ); + + final book = await service.commit( + result: _result(coverImage: Uint8List.fromList([4, 5])), + sourceFilename: 'original.epub', + addedAt: addedAt, + localFilePath: 'book-1', + ); + + expect(calls, ['store guest cover', 'add book']); + expect(book.coverUrl, isNull); + expect(book.filePath, 'book-1'); + }); + + test('no-cover import skips cover work and follows account book-file ordering', () async { + final calls = []; + final service = BookImportCommitService( + storePendingCover: (_, _, _) async => fail('cover storage must be skipped'), + storeGuestCover: (_, _) async => fail('cover storage must be skipped'), + addBook: (_) => calls.add('add book'), + enqueueBookFile: ({required book, required filename, required contentType}) async { + calls.add('enqueue book file'); + }, + enqueueCover: ({required book, required filename, required contentType}) async { + fail('cover enqueue must be skipped'); + }, + ); + + await service.commit( + result: _result(), + sourceFilename: 'original.epub', + addedAt: addedAt, + localFilePath: 'book-1', + accountScope: scope, + ); + + expect(calls, ['add book', 'enqueue book file']); + }); + + test('cover persistence failure aborts metadata and queue operations', () async { + final failure = StateError('disk full'); + var added = false; + var enqueued = false; + final service = BookImportCommitService( + storePendingCover: (_, _, _) async => throw failure, + storeGuestCover: (_, _) async => throw failure, + addBook: (_) => added = true, + enqueueBookFile: ({required book, required filename, required contentType}) async { + enqueued = true; + }, + enqueueCover: ({required book, required filename, required contentType}) async { + enqueued = true; + }, + ); + + await expectLater( + service.commit( + result: _result(coverImage: Uint8List.fromList([9])), + sourceFilename: 'original.epub', + addedAt: addedAt, + localFilePath: 'book-1', + accountScope: scope, + ), + throwsA(same(failure)), + ); + expect(added, isFalse); + expect(enqueued, isFalse); + }); +} + +BookImportResult _result({Uint8List? coverImage, String? coverMimeType}) { + return BookImportResult( + bookId: 'book-1', + title: 'Imported title', + subtitle: 'Imported subtitle', + author: 'Primary author', + coAuthors: const ['Second author'], + publisher: 'Publisher', + description: 'Description', + language: 'lt', + isbn: 'isbn-10', + isbn13: 'isbn-13', + pageCount: 321, + coverImage: coverImage, + coverMimeType: coverMimeType, + fileSize: 1234, + fileHash: 'abc123', + fileExtension: 'epub', + ); +} From 77220287ddc02667af1a4a902db48bb5c8086406 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 19:31:23 +0300 Subject: [PATCH 23/48] fix: make import cover commit atomic --- app/lib/media/media_upload_queue.dart | 57 +++- .../services/book_import_commit_service.dart | 87 +++++-- .../widgets/add_book/import_book_sheet.dart | 51 ++-- .../media_profile_switch_contract_test.dart | 19 +- app/test/media/media_upload_queue_test.dart | 55 ++++ .../book_import_commit_service_test.dart | 246 +++++++++++++++--- 6 files changed, 433 insertions(+), 82 deletions(-) diff --git a/app/lib/media/media_upload_queue.dart b/app/lib/media/media_upload_queue.dart index 5c45bbb..b3dcb85 100644 --- a/app/lib/media/media_upload_queue.dart +++ b/app/lib/media/media_upload_queue.dart @@ -139,6 +139,54 @@ class MediaUploadQueue extends ChangeNotifier { ); } + Future enqueueImportedBookMedia({ + required MediaStorageScope scope, + required Book book, + required String filename, + required String contentType, + String? coverFilename, + String? coverContentType, + }) async { + if ((coverFilename == null) != (coverContentType == null)) { + throw ArgumentError('Cover filename and content type must be provided together'); + } + + final tasks = [ + MediaUploadTask( + id: '${book.id}:book_file', + bookId: book.id, + kind: MediaKind.bookFile, + filename: filename, + contentType: contentType, + status: MediaUploadTaskStatus.pending, + ), + if (coverFilename != null) + MediaUploadTask( + id: '${book.id}:cover_image', + bookId: book.id, + kind: MediaKind.coverImage, + filename: coverFilename, + contentType: coverContentType!, + status: MediaUploadTaskStatus.pending, + ), + ]; + + await _withMutation(() async { + if (_activeScope != scope) { + throw StateError('Authenticated media upload scope changed before import commit'); + } + final taskIds = tasks.map((task) => task.id).toSet(); + final nextTasks = [..._tasks.where((task) => !taskIds.contains(task.id)), ...tasks]; + await _saveTasks(scope, nextTasks); + for (final task in tasks) { + _advanceTaskVersion(task.id); + } + _tasks = nextTasks; + notifyListeners(); + }); + await onWorkAvailable?.call(); + } + Future retryFailed({String? bookId}) async { final scope = _requireActiveScope(); var retriedAny = false; @@ -394,8 +442,13 @@ class MediaUploadQueue extends ChangeNotifier { } } - Future _save(MediaStorageScope scope) { - return _prefs.setString(_storageKey(scope), jsonEncode(_tasks.map((task) => task.toJson()).toList())); + Future _save(MediaStorageScope scope) => _saveTasks(scope, _tasks); + + Future _saveTasks(MediaStorageScope scope, List tasks) async { + final saved = await _prefs.setString(_storageKey(scope), jsonEncode(tasks.map((task) => task.toJson()).toList())); + if (!saved) { + throw StateError('Could not persist media upload queue'); + } } String _storageKey(MediaStorageScope scope) => '$_storageKeyPrefix${scope.persistenceKey}'; diff --git a/app/lib/services/book_import_commit_service.dart b/app/lib/services/book_import_commit_service.dart index 3b09e1a..f02d917 100644 --- a/app/lib/services/book_import_commit_service.dart +++ b/app/lib/services/book_import_commit_service.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:typed_data'; import 'package:papyrus/media/media_storage_scope.dart'; @@ -6,29 +7,45 @@ import 'package:papyrus/services/book_import_result.dart'; typedef PendingCoverStore = Future Function(MediaStorageScope scope, String bookId, Uint8List bytes); typedef GuestCoverStore = Future Function(String bookId, Uint8List bytes); +typedef PendingCoverDelete = FutureOr Function(MediaStorageScope scope, String bookId); +typedef GuestCoverDelete = FutureOr Function(String bookId); typedef BookAdder = void Function(Book book); -typedef MediaEnqueuer = - Future Function({required Book book, required String filename, required String contentType}); +typedef BookDelete = FutureOr Function(String bookId); +typedef ImportedBookMediaEnqueuer = + Future Function({ + required MediaStorageScope scope, + required Book book, + required String filename, + required String contentType, + String? coverFilename, + String? coverContentType, + }); /// Commits an imported book and its local media in a safe, deterministic order. class BookImportCommitService { const BookImportCommitService({ required PendingCoverStore storePendingCover, required GuestCoverStore storeGuestCover, + required PendingCoverDelete deletePendingCover, + required GuestCoverDelete deleteGuestCover, required BookAdder addBook, - required MediaEnqueuer enqueueBookFile, - required MediaEnqueuer enqueueCover, + required BookDelete deleteBook, + required ImportedBookMediaEnqueuer enqueueImportedBookMedia, }) : _storePendingCover = storePendingCover, _storeGuestCover = storeGuestCover, + _deletePendingCover = deletePendingCover, + _deleteGuestCover = deleteGuestCover, _addBook = addBook, - _enqueueBookFile = enqueueBookFile, - _enqueueCover = enqueueCover; + _deleteBook = deleteBook, + _enqueueImportedBookMedia = enqueueImportedBookMedia; final PendingCoverStore _storePendingCover; final GuestCoverStore _storeGuestCover; + final PendingCoverDelete _deletePendingCover; + final GuestCoverDelete _deleteGuestCover; final BookAdder _addBook; - final MediaEnqueuer _enqueueBookFile; - final MediaEnqueuer _enqueueCover; + final BookDelete _deleteBook; + final ImportedBookMediaEnqueuer _enqueueImportedBookMedia; Future commit({ required BookImportResult result, @@ -59,28 +76,54 @@ class BookImportCommitService { ); final coverImage = result.coverImage; - if (coverImage != null) { - if (accountScope != null) { - await _storePendingCover(accountScope, book.id, coverImage); - } else { - await _storeGuestCover(book.id, coverImage); + var coverStored = false; + var metadataAdded = false; + try { + if (coverImage != null) { + if (accountScope != null) { + await _storePendingCover(accountScope, book.id, coverImage); + } else { + await _storeGuestCover(book.id, coverImage); + } + coverStored = true; } - } - _addBook(book); + _addBook(book); + metadataAdded = true; - if (accountScope != null) { - await _enqueueBookFile(book: book, filename: sourceFilename, contentType: _bookContentType(result.fileExtension)); - if (coverImage != null) { - await _enqueueCover( + if (accountScope != null) { + await _enqueueImportedBookMedia( + scope: accountScope, book: book, - filename: '${book.id}-cover.${_coverExtension(result.coverMimeType)}', - contentType: result.coverMimeType ?? 'image/jpeg', + filename: sourceFilename, + contentType: _bookContentType(result.fileExtension), + coverFilename: coverImage == null ? null : '${book.id}-cover.${_coverExtension(result.coverMimeType)}', + coverContentType: coverImage == null ? null : result.coverMimeType ?? 'image/jpeg', ); } + + return book; + } catch (error, stackTrace) { + if (metadataAdded) { + await _bestEffort(() => _deleteBook(book.id)); + } + if (coverStored) { + if (accountScope != null) { + await _bestEffort(() => _deletePendingCover(accountScope, book.id)); + } else { + await _bestEffort(() => _deleteGuestCover(book.id)); + } + } + Error.throwWithStackTrace(error, stackTrace); } + } +} - return book; +Future _bestEffort(FutureOr Function() compensation) async { + try { + await compensation(); + } catch (_) { + // Preserve the import failure; compensation is intentionally best effort. } } diff --git a/app/lib/widgets/add_book/import_book_sheet.dart b/app/lib/widgets/add_book/import_book_sheet.dart index 6cdf782..fb68de9 100644 --- a/app/lib/widgets/add_book/import_book_sheet.dart +++ b/app/lib/widgets/add_book/import_book_sheet.dart @@ -5,6 +5,7 @@ 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'; @@ -105,9 +106,10 @@ class _ImportContentState extends State<_ImportContent> { } bool _picking = false; + bool _committing = false; Future _pickAndProcess() async { - if (_picking) return; + if (_picking || _committing) return; _picking = true; try { @@ -157,9 +159,12 @@ class _ImportContentState extends State<_ImportContent> { } Future _addToLibrary() async { + if (_committing) return; final result = _result; if (result == null) return; + setState(() => _committing = true); + Book? committedBook; try { final dataStore = context.read(); final queue = context.read(); @@ -179,29 +184,35 @@ class _ImportContentState extends State<_ImportContent> { final commitService = BookImportCommitService( storePendingCover: importService.storePendingCoverFile, storeGuestCover: importService.storeGuestCoverFile, + deletePendingCover: importService.deletePendingCoverFile, + deleteGuestCover: importService.deleteGuestCoverFile, addBook: dataStore.addBook, - enqueueBookFile: queue.enqueueBookFile, - enqueueCover: queue.enqueueCover, + deleteBook: dataStore.deleteBook, + enqueueImportedBookMedia: queue.enqueueImportedBookMedia, ); - final book = await commitService.commit( + committedBook = await commitService.commit( result: result, sourceFilename: _filename ?? '${result.bookId}.$ext', addedAt: DateTime.now(), localFilePath: filePath, accountScope: accountScope, ); - - if (!mounted) return; - final messenger = ScaffoldMessenger.of(context); - Navigator.of(context).pop(); - messenger.showSnackBar(SnackBar(content: Text('Added "${book.title}" to library'))); } catch (error) { if (!mounted) return; setState(() { _state = _ImportState.error; _errorMessage = error.toString(); }); + } finally { + if (mounted) { + setState(() => _committing = false); + } } + + if (!mounted || committedBook == null) return; + final messenger = ScaffoldMessenger.of(context); + Navigator.of(context).pop(); + messenger.showSnackBar(SnackBar(content: Text('Added "${committedBook.title}" to library'))); } @override @@ -257,7 +268,7 @@ class _ImportContentState extends State<_ImportContent> { ), const SizedBox(height: Spacing.lg), FilledButton.icon( - onPressed: _pickAndProcess, + onPressed: _committing ? null : _pickAndProcess, icon: const Icon(Icons.folder_open), label: const Text('Browse files'), ), @@ -342,19 +353,21 @@ class _ImportContentState extends State<_ImportContent> { children: [ Expanded( child: OutlinedButton( - onPressed: () { - setState(() { - _state = _ImportState.idle; - _result = null; - _filename = null; - }); - }, + onPressed: _committing + ? null + : () { + setState(() { + _state = _ImportState.idle; + _result = null; + _filename = null; + }); + }, child: const Text('Pick different file'), ), ), const SizedBox(width: Spacing.md), Expanded( - child: FilledButton(onPressed: _addToLibrary, child: const Text('Add to library')), + child: FilledButton(onPressed: _committing ? null : _addToLibrary, child: const Text('Add to library')), ), ], ), @@ -383,7 +396,7 @@ class _ImportContentState extends State<_ImportContent> { textAlign: TextAlign.center, ), const SizedBox(height: Spacing.lg), - FilledButton(onPressed: _pickAndProcess, child: const Text('Try again')), + FilledButton(onPressed: _committing ? null : _pickAndProcess, child: const Text('Try again')), ], ), ); diff --git a/app/test/media/media_profile_switch_contract_test.dart b/app/test/media/media_profile_switch_contract_test.dart index 47890f3..466a40d 100644 --- a/app/test/media/media_profile_switch_contract_test.dart +++ b/app/test/media/media_profile_switch_contract_test.dart @@ -40,9 +40,24 @@ void main() { expect(commit, contains("throw StateError('Cannot import account media without an active media storage scope')")); expect(commit, contains('storePendingCover: importService.storePendingCoverFile')); expect(commit, contains('storeGuestCover: importService.storeGuestCoverFile')); - expect(commit, contains('enqueueBookFile: queue.enqueueBookFile')); - expect(commit, contains('enqueueCover: queue.enqueueCover')); + expect(commit, contains('deletePendingCover: importService.deletePendingCoverFile')); + expect(commit, contains('deleteGuestCover: importService.deleteGuestCoverFile')); + expect(commit, contains('deleteBook: dataStore.deleteBook')); + expect(commit, contains('enqueueImportedBookMedia: queue.enqueueImportedBookMedia')); expect(commit, contains('accountScope: accountScope')); expect(commit, isNot(contains('bytesToDataUri'))); }); + + test('import commit guard prevents repeat commits and disables mutable actions', () { + final source = File('lib/widgets/add_book/import_book_sheet.dart').readAsStringSync(); + final commitStart = source.indexOf('Future _addToLibrary()'); + final commit = source.substring(commitStart, source.indexOf('@override\n Widget build', commitStart)); + + expect(commit, contains('if (_committing) return;')); + expect(commit, contains('_committing = true')); + expect(commit, contains('_committing = false')); + expect(source, contains('onPressed: _committing ? null : _addToLibrary')); + expect(RegExp(r'onPressed: _committing \? null : _pickAndProcess').allMatches(source), hasLength(2)); + expect(source, contains('onPressed: _committing\n ? null')); + }); } diff --git a/app/test/media/media_upload_queue_test.dart b/app/test/media/media_upload_queue_test.dart index fbb1068..82f90e5 100644 --- a/app/test/media/media_upload_queue_test.dart +++ b/app/test/media/media_upload_queue_test.dart @@ -469,6 +469,61 @@ void main() { expect(jsonDecode(storedAtCallback!) as List, hasLength(1)); }); + test('enqueueImportedBookMedia persists both tasks under captured scope before one callback', () async { + final prefs = await SharedPreferences.getInstance(); + final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + var callbacks = 0; + List? storedAtCallback; + final queue = MediaUploadQueue( + prefs, + onWorkAvailable: () async { + callbacks++; + storedAtCallback = jsonDecode(prefs.getString('media_upload_queue:${scope.persistenceKey}')!) as List; + }, + ); + await queue.activateScope(scope); + + await queue.enqueueImportedBookMedia( + scope: scope, + book: _book(filePath: 'book-1', fileSize: 10, fileHash: 'hash'), + filename: 'book.epub', + contentType: 'application/epub+zip', + coverFilename: 'book-cover.jpg', + coverContentType: 'image/jpeg', + ); + + expect(callbacks, 1); + expect(storedAtCallback, hasLength(2)); + expect(queue.pendingTasks.map((task) => task.kind), [MediaKind.bookFile, MediaKind.coverImage]); + }); + + test('enqueueImportedBookMedia rejects a switched scope without writing tasks', () async { + final prefs = await SharedPreferences.getInstance(); + final capturedScope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + final switchedScope = MediaStorageScope(profileKey: 'official', userId: 'user-2'); + var callbacks = 0; + final queue = MediaUploadQueue(prefs, onWorkAvailable: () async => callbacks++); + await queue.activateScope(capturedScope); + await queue.activateScope(switchedScope); + + await expectLater( + queue.enqueueImportedBookMedia( + scope: capturedScope, + book: _book(filePath: 'book-1', fileSize: 10, fileHash: 'hash'), + filename: 'book.epub', + contentType: 'application/epub+zip', + coverFilename: 'book-cover.jpg', + coverContentType: 'image/jpeg', + ), + throwsStateError, + ); + + expect(queue.pendingTasks, isEmpty); + expect(prefs.getString('media_upload_queue:${capturedScope.persistenceKey}'), isNull); + expect(prefs.getString('media_upload_queue:${switchedScope.persistenceKey}'), isNull); + expect(callbacks, 0); + }); + test('retry invokes work callback only when failed tasks become pending', () async { final prefs = await SharedPreferences.getInstance(); var callbacks = 0; diff --git a/app/test/services/book_import_commit_service_test.dart b/app/test/services/book_import_commit_service_test.dart index 55e792c..d5b265c 100644 --- a/app/test/services/book_import_commit_service_test.dart +++ b/app/test/services/book_import_commit_service_test.dart @@ -2,42 +2,56 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/media/media_upload_queue.dart'; import 'package:papyrus/media/media_storage_scope.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/services/book_import_commit_service.dart'; import 'package:papyrus/services/book_import_result.dart'; +import 'package:shared_preferences/shared_preferences.dart'; void main() { final addedAt = DateTime.utc(2026, 7, 11, 12, 30); - final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + final accountScope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); test('account import persists cover before metadata and enqueues metadata-only uploads in order', () async { final calls = []; Book? addedBook; final service = BookImportCommitService( storePendingCover: (actualScope, bookId, bytes) async { - expect(actualScope, scope); + expect(actualScope, accountScope); expect(bookId, 'book-1'); expect(bytes, Uint8List.fromList([1, 2, 3])); calls.add('store pending cover'); }, storeGuestCover: (_, _) async => fail('guest cover must not be stored for an account'), + deletePendingCover: (_, _) async => fail('compensation must not run'), + deleteGuestCover: (_) async => fail('compensation must not run'), addBook: (book) { addedBook = book; calls.add('add book'); }, - enqueueBookFile: ({required book, required filename, required contentType}) async { - expect(book, same(addedBook)); - expect(filename, 'original.epub'); - expect(contentType, 'application/epub+zip'); - calls.add('enqueue book file'); - }, - enqueueCover: ({required book, required filename, required contentType}) async { - expect(book, same(addedBook)); - expect(filename, 'book-1-cover.png'); - expect(contentType, 'image/png'); - calls.add('enqueue cover'); - }, + deleteBook: (_) => fail('compensation must not run'), + enqueueImportedBookMedia: + ({ + required scope, + required book, + required filename, + required contentType, + coverFilename, + coverContentType, + }) async { + expect(scope, accountScope); + expect(book, same(addedBook)); + expect(filename, 'original.epub'); + expect(contentType, 'application/epub+zip'); + expect(coverFilename, 'book-1-cover.png'); + expect(coverContentType, 'image/png'); + calls.add('enqueue imported media'); + }, ); final book = await service.commit( @@ -45,10 +59,10 @@ void main() { sourceFilename: 'original.epub', addedAt: addedAt, localFilePath: 'opfs://books/book-1.epub', - accountScope: scope, + accountScope: accountScope, ); - expect(calls, ['store pending cover', 'add book', 'enqueue book file', 'enqueue cover']); + expect(calls, ['store pending cover', 'add book', 'enqueue imported media']); expect(book.coverUrl, isNull); expect(book.coverMediaId, isNull); expect(jsonEncode(book.toJson()), isNot(contains('data:image'))); @@ -81,13 +95,21 @@ void main() { expect(bytes, Uint8List.fromList([4, 5])); calls.add('store guest cover'); }, + deletePendingCover: (_, _) async => fail('compensation must not run'), + deleteGuestCover: (_) async => fail('compensation must not run'), addBook: (book) => calls.add('add book'), - enqueueBookFile: ({required book, required filename, required contentType}) async { - fail('guest book file must not be enqueued'); - }, - enqueueCover: ({required book, required filename, required contentType}) async { - fail('guest cover must not be enqueued'); - }, + deleteBook: (_) => fail('compensation must not run'), + enqueueImportedBookMedia: + ({ + required scope, + required book, + required filename, + required contentType, + coverFilename, + coverContentType, + }) async { + fail('guest media must not be enqueued'); + }, ); final book = await service.commit( @@ -107,13 +129,24 @@ void main() { final service = BookImportCommitService( storePendingCover: (_, _, _) async => fail('cover storage must be skipped'), storeGuestCover: (_, _) async => fail('cover storage must be skipped'), + deletePendingCover: (_, _) async => fail('compensation must not run'), + deleteGuestCover: (_) async => fail('compensation must not run'), addBook: (_) => calls.add('add book'), - enqueueBookFile: ({required book, required filename, required contentType}) async { - calls.add('enqueue book file'); - }, - enqueueCover: ({required book, required filename, required contentType}) async { - fail('cover enqueue must be skipped'); - }, + deleteBook: (_) => fail('compensation must not run'), + enqueueImportedBookMedia: + ({ + required scope, + required book, + required filename, + required contentType, + coverFilename, + coverContentType, + }) async { + expect(scope, accountScope); + expect(coverFilename, isNull); + expect(coverContentType, isNull); + calls.add('enqueue imported media'); + }, ); await service.commit( @@ -121,10 +154,10 @@ void main() { sourceFilename: 'original.epub', addedAt: addedAt, localFilePath: 'book-1', - accountScope: scope, + accountScope: accountScope, ); - expect(calls, ['add book', 'enqueue book file']); + expect(calls, ['add book', 'enqueue imported media']); }); test('cover persistence failure aborts metadata and queue operations', () async { @@ -134,13 +167,21 @@ void main() { final service = BookImportCommitService( storePendingCover: (_, _, _) async => throw failure, storeGuestCover: (_, _) async => throw failure, + deletePendingCover: (_, _) async => fail('unstored cover must not be deleted'), + deleteGuestCover: (_) async => fail('unstored cover must not be deleted'), addBook: (_) => added = true, - enqueueBookFile: ({required book, required filename, required contentType}) async { - enqueued = true; - }, - enqueueCover: ({required book, required filename, required contentType}) async { - enqueued = true; - }, + deleteBook: (_) => fail('unadded book must not be deleted'), + enqueueImportedBookMedia: + ({ + required scope, + required book, + required filename, + required contentType, + coverFilename, + coverContentType, + }) async { + enqueued = true; + }, ); await expectLater( @@ -149,13 +190,144 @@ void main() { sourceFilename: 'original.epub', addedAt: addedAt, localFilePath: 'book-1', - accountScope: scope, + accountScope: accountScope, ), throwsA(same(failure)), ); expect(added, isFalse); expect(enqueued, isFalse); }); + + test('account add failure deletes the stored pending cover and preserves the original error', () async { + final failure = StateError('add failed'); + final calls = []; + final service = BookImportCommitService( + storePendingCover: (_, _, _) async => calls.add('store pending cover'), + storeGuestCover: (_, _) async => fail('guest cover must not be stored'), + deletePendingCover: (_, _) async { + calls.add('delete pending cover'); + throw StateError('cleanup failed'); + }, + deleteGuestCover: (_) async => fail('guest cover must not be deleted'), + addBook: (_) { + calls.add('add book'); + throw failure; + }, + deleteBook: (_) => fail('failed add must not delete metadata'), + enqueueImportedBookMedia: + ({ + required scope, + required book, + required filename, + required contentType, + coverFilename, + coverContentType, + }) async { + fail('media must not be enqueued'); + }, + ); + + await expectLater( + service.commit( + result: _result(coverImage: Uint8List.fromList([1])), + sourceFilename: 'original.epub', + addedAt: addedAt, + localFilePath: 'book-1', + accountScope: accountScope, + ), + throwsA(same(failure)), + ); + expect(calls, ['store pending cover', 'add book', 'delete pending cover']); + }); + + test('guest add failure deletes the stored guest cover', () async { + final failure = StateError('add failed'); + final calls = []; + final service = BookImportCommitService( + storePendingCover: (_, _, _) async => fail('pending cover must not be stored'), + storeGuestCover: (_, _) async => calls.add('store guest cover'), + deletePendingCover: (_, _) async => fail('pending cover must not be deleted'), + deleteGuestCover: (_) async => calls.add('delete guest cover'), + addBook: (_) { + calls.add('add book'); + throw failure; + }, + deleteBook: (_) => fail('failed add must not delete metadata'), + enqueueImportedBookMedia: + ({ + required scope, + required book, + required filename, + required contentType, + coverFilename, + coverContentType, + }) async { + fail('guest media must not be enqueued'); + }, + ); + + await expectLater( + service.commit( + result: _result(coverImage: Uint8List.fromList([1])), + sourceFilename: 'original.epub', + addedAt: addedAt, + localFilePath: 'book-1', + ), + throwsA(same(failure)), + ); + expect(calls, ['store guest cover', 'add book', 'delete guest cover']); + }); + + test('batch failure deletes metadata and pending cover in order without leaving a task', () async { + final calls = []; + final prefs = await SharedPreferences.getInstance(); + final switchedScope = MediaStorageScope(profileKey: 'official', userId: 'user-2'); + final queue = MediaUploadQueue(prefs); + await queue.activateScope(accountScope); + await queue.activateScope(switchedScope); + final service = BookImportCommitService( + storePendingCover: (_, _, _) async => calls.add('store pending cover'), + storeGuestCover: (_, _) async => fail('guest cover must not be stored'), + deletePendingCover: (_, _) async => calls.add('delete pending cover'), + deleteGuestCover: (_) async => fail('guest cover must not be deleted'), + addBook: (_) => calls.add('add book'), + deleteBook: (_) => calls.add('delete book'), + enqueueImportedBookMedia: + ({ + required scope, + required book, + required filename, + required contentType, + coverFilename, + coverContentType, + }) async { + calls.add('enqueue imported media'); + await queue.enqueueImportedBookMedia( + scope: scope, + book: book, + filename: filename, + contentType: contentType, + coverFilename: coverFilename, + coverContentType: coverContentType, + ); + }, + ); + + await expectLater( + service.commit( + result: _result(coverImage: Uint8List.fromList([1])), + sourceFilename: 'original.epub', + addedAt: addedAt, + localFilePath: 'book-1', + accountScope: accountScope, + ), + throwsStateError, + ); + expect(calls, ['store pending cover', 'add book', 'enqueue imported media', 'delete book', 'delete pending cover']); + expect(queue.pendingTasks, isEmpty); + expect(prefs.getString('media_upload_queue:${accountScope.persistenceKey}'), isNull); + expect(prefs.getString('media_upload_queue:${switchedScope.persistenceKey}'), isNull); + }); } BookImportResult _result({Uint8List? coverImage, String? coverMimeType}) { From fb1e1786041940536be8e15fb267fa80eda38ae8 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 19:39:39 +0300 Subject: [PATCH 24/48] fix: await import metadata compensation --- app/lib/data/data_store.dart | 14 +- app/lib/media/media_upload_queue.dart | 17 ++- .../services/book_import_commit_service.dart | 4 +- .../widgets/add_book/import_book_sheet.dart | 4 +- .../data/book_repository_data_store_test.dart | 50 +++++++ .../media_profile_switch_contract_test.dart | 3 +- app/test/media/media_upload_queue_test.dart | 29 +++- .../book_import_commit_service_test.dart | 132 ++++++++++++++++++ 8 files changed, 242 insertions(+), 11 deletions(-) diff --git a/app/lib/data/data_store.dart b/app/lib/data/data_store.dart index ec9724d..51dbc85 100644 --- a/app/lib/data/data_store.dart +++ b/app/lib/data/data_store.dart @@ -93,13 +93,15 @@ class DataStore extends ChangeNotifier { } void addBook(Book book) { + unawaited(addBookAndWait(book)); + } + + Future addBookAndWait(Book book) async { final repository = _bookRepository; if (repository == null) { throw StateError('Book repository is not initialized'); } - _books[book.id] = book; - notifyListeners(); - unawaited(repository.upsert(book)); + await repository.upsert(book); } void updateBook(Book book) { @@ -113,11 +115,15 @@ class DataStore extends ChangeNotifier { } void deleteBook(String id) { + unawaited(deleteBookAndWait(id)); + } + + Future deleteBookAndWait(String id) async { final repository = _bookRepository; if (repository == null) { throw StateError('Book repository is not initialized'); } - unawaited(repository.delete(id)); + await repository.delete(id); } void replaceBooksFromSync(List books) { diff --git a/app/lib/media/media_upload_queue.dart b/app/lib/media/media_upload_queue.dart index b3dcb85..da5ccbf 100644 --- a/app/lib/media/media_upload_queue.dart +++ b/app/lib/media/media_upload_queue.dart @@ -184,7 +184,7 @@ class MediaUploadQueue extends ChangeNotifier { _tasks = nextTasks; notifyListeners(); }); - await onWorkAvailable?.call(); + await _notifyWorkAvailableBestEffort(); } Future retryFailed({String? bookId}) async { @@ -340,6 +340,21 @@ class MediaUploadQueue extends ChangeNotifier { await onWorkAvailable?.call(); } + Future _notifyWorkAvailableBestEffort() async { + try { + await onWorkAvailable?.call(); + } catch (error, stackTrace) { + FlutterError.reportError( + FlutterErrorDetails( + exception: error, + stack: stackTrace, + library: 'papyrus media upload queue', + context: ErrorDescription('while notifying work for a committed import batch'), + ), + ); + } + } + Future _removeTaskIfCurrent(MediaStorageScope scope, MediaUploadTask task, int version) { return _withMutation(() async { final index = _currentTaskIndex(task, version); diff --git a/app/lib/services/book_import_commit_service.dart b/app/lib/services/book_import_commit_service.dart index f02d917..65fe4f3 100644 --- a/app/lib/services/book_import_commit_service.dart +++ b/app/lib/services/book_import_commit_service.dart @@ -9,7 +9,7 @@ typedef PendingCoverStore = Future Function(MediaStorageScope scope, Strin typedef GuestCoverStore = Future Function(String bookId, Uint8List bytes); typedef PendingCoverDelete = FutureOr Function(MediaStorageScope scope, String bookId); typedef GuestCoverDelete = FutureOr Function(String bookId); -typedef BookAdder = void Function(Book book); +typedef BookAdder = FutureOr Function(Book book); typedef BookDelete = FutureOr Function(String bookId); typedef ImportedBookMediaEnqueuer = Future Function({ @@ -88,7 +88,7 @@ class BookImportCommitService { coverStored = true; } - _addBook(book); + await _addBook(book); metadataAdded = true; if (accountScope != null) { diff --git a/app/lib/widgets/add_book/import_book_sheet.dart b/app/lib/widgets/add_book/import_book_sheet.dart index fb68de9..8cf2d62 100644 --- a/app/lib/widgets/add_book/import_book_sheet.dart +++ b/app/lib/widgets/add_book/import_book_sheet.dart @@ -186,8 +186,8 @@ class _ImportContentState extends State<_ImportContent> { storeGuestCover: importService.storeGuestCoverFile, deletePendingCover: importService.deletePendingCoverFile, deleteGuestCover: importService.deleteGuestCoverFile, - addBook: dataStore.addBook, - deleteBook: dataStore.deleteBook, + addBook: dataStore.addBookAndWait, + deleteBook: dataStore.deleteBookAndWait, enqueueImportedBookMedia: queue.enqueueImportedBookMedia, ); committedBook = await commitService.commit( diff --git a/app/test/data/book_repository_data_store_test.dart b/app/test/data/book_repository_data_store_test.dart index 2b9ead1..8574794 100644 --- a/app/test/data/book_repository_data_store_test.dart +++ b/app/test/data/book_repository_data_store_test.dart @@ -9,9 +9,15 @@ class FakeBookRepository implements BookRepository { final StreamController> controller = StreamController>.broadcast(); final List upserts = []; final List deletes = []; + Completer? upsertGate; + Completer? deleteGate; + Object? upsertError; + Object? deleteError; @override Future delete(String id) async { + await deleteGate?.future; + if (deleteError != null) throw deleteError!; deletes.add(id); } @@ -22,6 +28,8 @@ class FakeBookRepository implements BookRepository { @override Future upsert(Book book) async { + await upsertGate?.future; + if (upsertError != null) throw upsertError!; upserts.add(book); } @@ -68,4 +76,46 @@ void main() { await store.disposeBookRepository(); await repository.controller.close(); }); + + test('awaitable book mutations complete only after repository writes finish', () async { + final repository = FakeBookRepository() + ..upsertGate = Completer() + ..deleteGate = Completer(); + final store = DataStore(bookRepository: repository); + final book = _book('one', 'First'); + + var addCompleted = false; + final add = store.addBookAndWait(book).then((_) => addCompleted = true); + await pumpEventQueue(); + expect(addCompleted, isFalse); + repository.upsertGate!.complete(); + await add; + expect(repository.upserts, [book]); + + var deleteCompleted = false; + final delete = store.deleteBookAndWait(book.id).then((_) => deleteCompleted = true); + await pumpEventQueue(); + expect(deleteCompleted, isFalse); + repository.deleteGate!.complete(); + await delete; + expect(repository.deletes, [book.id]); + + await store.disposeBookRepository(); + await repository.controller.close(); + }); + + test('awaitable book mutations surface repository errors', () async { + final addFailure = StateError('upsert failed'); + final deleteFailure = StateError('delete failed'); + final repository = FakeBookRepository() + ..upsertError = addFailure + ..deleteError = deleteFailure; + final store = DataStore(bookRepository: repository); + + await expectLater(store.addBookAndWait(_book('one', 'First')), throwsA(same(addFailure))); + await expectLater(store.deleteBookAndWait('one'), throwsA(same(deleteFailure))); + + await store.disposeBookRepository(); + await repository.controller.close(); + }); } diff --git a/app/test/media/media_profile_switch_contract_test.dart b/app/test/media/media_profile_switch_contract_test.dart index 466a40d..7b6dc51 100644 --- a/app/test/media/media_profile_switch_contract_test.dart +++ b/app/test/media/media_profile_switch_contract_test.dart @@ -42,7 +42,8 @@ void main() { expect(commit, contains('storeGuestCover: importService.storeGuestCoverFile')); expect(commit, contains('deletePendingCover: importService.deletePendingCoverFile')); expect(commit, contains('deleteGuestCover: importService.deleteGuestCoverFile')); - expect(commit, contains('deleteBook: dataStore.deleteBook')); + expect(commit, contains('addBook: dataStore.addBookAndWait')); + expect(commit, contains('deleteBook: dataStore.deleteBookAndWait')); expect(commit, contains('enqueueImportedBookMedia: queue.enqueueImportedBookMedia')); expect(commit, contains('accountScope: accountScope')); expect(commit, isNot(contains('bytesToDataUri'))); diff --git a/app/test/media/media_upload_queue_test.dart b/app/test/media/media_upload_queue_test.dart index 82f90e5..5a8ce66 100644 --- a/app/test/media/media_upload_queue_test.dart +++ b/app/test/media/media_upload_queue_test.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/data/repositories/book_repository.dart'; @@ -524,6 +524,33 @@ void main() { expect(callbacks, 0); }); + test('enqueueImportedBookMedia reports callback failure without rolling back durable tasks', () async { + final prefs = await SharedPreferences.getInstance(); + final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + final failure = StateError('processor unavailable'); + final reportedErrors = []; + final previousOnError = FlutterError.onError; + FlutterError.onError = reportedErrors.add; + addTearDown(() => FlutterError.onError = previousOnError); + final queue = MediaUploadQueue(prefs, onWorkAvailable: () async => throw failure); + await queue.activateScope(scope); + + await queue.enqueueImportedBookMedia( + scope: scope, + book: _book(filePath: 'book-1', fileSize: 10, fileHash: 'hash'), + filename: 'book.epub', + contentType: 'application/epub+zip', + coverFilename: 'book-cover.jpg', + coverContentType: 'image/jpeg', + ); + + expect(queue.pendingTasks, hasLength(2)); + final stored = jsonDecode(prefs.getString('media_upload_queue:${scope.persistenceKey}')!) as List; + expect(stored, hasLength(2)); + expect(reportedErrors, hasLength(1)); + expect(reportedErrors.single.exception, same(failure)); + }); + test('retry invokes work callback only when failed tasks become pending', () async { final prefs = await SharedPreferences.getInstance(); var callbacks = 0; diff --git a/app/test/services/book_import_commit_service_test.dart b/app/test/services/book_import_commit_service_test.dart index d5b265c..f83cb3a 100644 --- a/app/test/services/book_import_commit_service_test.dart +++ b/app/test/services/book_import_commit_service_test.dart @@ -1,7 +1,10 @@ +import 'dart:async'; import 'dart:convert'; 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_upload_queue.dart'; import 'package:papyrus/media/media_storage_scope.dart'; import 'package:papyrus/models/book.dart'; @@ -328,6 +331,135 @@ void main() { expect(prefs.getString('media_upload_queue:${accountScope.persistenceKey}'), isNull); expect(prefs.getString('media_upload_queue:${switchedScope.persistenceKey}'), isNull); }); + + test('enqueue failure waits for metadata rollback and leaves repository empty', () async { + final failure = StateError('enqueue failed'); + final repository = _GatedDeleteBookRepository(); + final dataStore = DataStore(bookRepository: repository); + final service = BookImportCommitService( + storePendingCover: (_, _, _) async => fail('cover storage must be skipped'), + storeGuestCover: (_, _) async => fail('cover storage must be skipped'), + deletePendingCover: (_, _) async => fail('cover cleanup must be skipped'), + deleteGuestCover: (_) async => fail('cover cleanup must be skipped'), + addBook: dataStore.addBookAndWait, + deleteBook: dataStore.deleteBookAndWait, + enqueueImportedBookMedia: + ({ + required scope, + required book, + required filename, + required contentType, + coverFilename, + coverContentType, + }) async { + expect(await repository.getById(book.id), same(book)); + throw failure; + }, + ); + + var completed = false; + final commit = service.commit( + result: _result(), + sourceFilename: 'original.epub', + addedAt: addedAt, + localFilePath: 'book-1', + accountScope: accountScope, + ); + commit.then((_) => completed = true, onError: (_) => completed = true); + + await repository.deleteStarted.future; + expect(completed, isFalse); + expect(await repository.getById('book-1'), isNotNull); + + repository.allowDelete.complete(); + await expectLater(commit, throwsA(same(failure))); + expect(await repository.getById('book-1'), isNull); + + await dataStore.disposeBookRepository(); + await repository.dispose(); + }); + + test('account enqueue waits for metadata upsert to finish', () async { + final repository = _GatedDeleteBookRepository(gateUpsert: true); + final dataStore = DataStore(bookRepository: repository); + var enqueueStarted = false; + final service = BookImportCommitService( + storePendingCover: (_, _, _) async => fail('cover storage must be skipped'), + storeGuestCover: (_, _) async => fail('cover storage must be skipped'), + deletePendingCover: (_, _) async => fail('cover cleanup must be skipped'), + deleteGuestCover: (_) async => fail('cover cleanup must be skipped'), + addBook: dataStore.addBookAndWait, + deleteBook: dataStore.deleteBookAndWait, + enqueueImportedBookMedia: + ({ + required scope, + required book, + required filename, + required contentType, + coverFilename, + coverContentType, + }) async { + enqueueStarted = true; + }, + ); + + final commit = service.commit( + result: _result(), + sourceFilename: 'original.epub', + addedAt: addedAt, + localFilePath: 'book-1', + accountScope: accountScope, + ); + await repository.upsertStarted!.future; + expect(enqueueStarted, isFalse); + + repository.allowUpsert!.complete(); + await commit; + expect(enqueueStarted, isTrue); + + await dataStore.disposeBookRepository(); + await repository.dispose(); + }); +} + +class _GatedDeleteBookRepository implements BookRepository { + _GatedDeleteBookRepository({bool gateUpsert = false}) + : upsertStarted = gateUpsert ? Completer() : null, + allowUpsert = gateUpsert ? Completer() : null; + + final _books = {}; + final _changes = StreamController>.broadcast(sync: true); + final deleteStarted = Completer(); + final allowDelete = Completer(); + final Completer? upsertStarted; + final Completer? allowUpsert; + + @override + Future delete(String id) async { + deleteStarted.complete(); + await allowDelete.future; + _books.remove(id); + _changes.add(List.unmodifiable(_books.values)); + } + + @override + Future getById(String id) async => _books[id]; + + @override + Future upsert(Book book) async { + upsertStarted?.complete(); + await allowUpsert?.future; + _books[book.id] = book; + _changes.add(List.unmodifiable(_books.values)); + } + + @override + Stream> watchAll() async* { + yield List.unmodifiable(_books.values); + yield* _changes.stream; + } + + Future dispose() => _changes.close(); } BookImportResult _result({Uint8List? coverImage, String? coverMimeType}) { From ebfc66079b4d5a167a2e5969a4353379e020f87a Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 19:43:26 +0300 Subject: [PATCH 25/48] fix: preserve synchronous data store mutations --- app/lib/data/data_store.dart | 14 +++++++++-- .../data/book_repository_data_store_test.dart | 24 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/app/lib/data/data_store.dart b/app/lib/data/data_store.dart index 51dbc85..631f4cc 100644 --- a/app/lib/data/data_store.dart +++ b/app/lib/data/data_store.dart @@ -93,7 +93,13 @@ class DataStore extends ChangeNotifier { } void addBook(Book book) { - unawaited(addBookAndWait(book)); + final repository = _bookRepository; + if (repository == null) { + throw StateError('Book repository is not initialized'); + } + _books[book.id] = book; + notifyListeners(); + unawaited(repository.upsert(book)); } Future addBookAndWait(Book book) async { @@ -115,7 +121,11 @@ class DataStore extends ChangeNotifier { } void deleteBook(String id) { - unawaited(deleteBookAndWait(id)); + final repository = _bookRepository; + if (repository == null) { + throw StateError('Book repository is not initialized'); + } + unawaited(repository.delete(id)); } Future deleteBookAndWait(String id) async { diff --git a/app/test/data/book_repository_data_store_test.dart b/app/test/data/book_repository_data_store_test.dart index 8574794..7e55490 100644 --- a/app/test/data/book_repository_data_store_test.dart +++ b/app/test/data/book_repository_data_store_test.dart @@ -77,6 +77,30 @@ void main() { await repository.controller.close(); }); + test('legacy addBook updates the snapshot and notifies listeners before returning', () async { + final repository = FakeBookRepository(); + final store = DataStore(bookRepository: repository); + final book = _book('one', 'First'); + var notifications = 0; + store.addListener(() => notifications++); + + store.addBook(book); + + expect(store.getBook(book.id), same(book)); + expect(notifications, 1); + + await store.disposeBookRepository(); + await repository.controller.close(); + }); + + test('legacy addBook and deleteBook reject a missing repository synchronously', () async { + final store = DataStore(); + await store.disposeBookRepository(); + + expect(() => store.addBook(_book('one', 'First')), throwsStateError); + expect(() => store.deleteBook('one'), throwsStateError); + }); + test('awaitable book mutations complete only after repository writes finish', () async { final repository = FakeBookRepository() ..upsertGate = Completer() From b91137fb26dd8adcad36b61755270092941729cb Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 19:55:26 +0300 Subject: [PATCH 26/48] fix: bind imports to captured library repository --- app/lib/data/data_store.dart | 35 +++- .../services/book_import_commit_service.dart | 17 +- .../widgets/add_book/import_book_sheet.dart | 18 ++- .../data/book_repository_data_store_test.dart | 81 ++++++++++ .../media_profile_switch_contract_test.dart | 8 +- .../book_import_commit_service_test.dart | 150 ++++++++++++++++++ 6 files changed, 293 insertions(+), 16 deletions(-) diff --git a/app/lib/data/data_store.dart b/app/lib/data/data_store.dart index 631f4cc..3743baa 100644 --- a/app/lib/data/data_store.dart +++ b/app/lib/data/data_store.dart @@ -92,6 +92,16 @@ class DataStore extends ChangeNotifier { _bookRepository = null; } + BookRepository requireBookRepository() { + final repository = _bookRepository; + if (repository == null) { + throw StateError('Book repository is not initialized'); + } + return repository; + } + + bool isBookRepositoryCurrent(BookRepository repository) => identical(_bookRepository, repository); + void addBook(Book book) { final repository = _bookRepository; if (repository == null) { @@ -103,11 +113,16 @@ class DataStore extends ChangeNotifier { } Future addBookAndWait(Book book) async { - final repository = _bookRepository; - if (repository == null) { - throw StateError('Book repository is not initialized'); - } + final repository = requireBookRepository(); + await addBookToRepositoryAndWait(repository, book); + } + + Future addBookToRepositoryAndWait(BookRepository repository, Book book) async { await repository.upsert(book); + if (isBookRepositoryCurrent(repository) && !identical(_books[book.id], book)) { + _books[book.id] = book; + notifyListeners(); + } } void updateBook(Book book) { @@ -129,11 +144,15 @@ class DataStore extends ChangeNotifier { } Future deleteBookAndWait(String id) async { - final repository = _bookRepository; - if (repository == null) { - throw StateError('Book repository is not initialized'); - } + final repository = requireBookRepository(); + await deleteBookFromRepositoryAndWait(repository, id); + } + + Future deleteBookFromRepositoryAndWait(BookRepository repository, String id) async { await repository.delete(id); + if (isBookRepositoryCurrent(repository) && _books.remove(id) != null) { + notifyListeners(); + } } void replaceBooksFromSync(List books) { diff --git a/app/lib/services/book_import_commit_service.dart b/app/lib/services/book_import_commit_service.dart index 65fe4f3..0fe0095 100644 --- a/app/lib/services/book_import_commit_service.dart +++ b/app/lib/services/book_import_commit_service.dart @@ -11,6 +11,7 @@ typedef PendingCoverDelete = FutureOr Function(MediaStorageScope scope, St typedef GuestCoverDelete = FutureOr Function(String bookId); typedef BookAdder = FutureOr Function(Book book); typedef BookDelete = FutureOr Function(String bookId); +typedef LibraryContextValidator = bool Function(); typedef ImportedBookMediaEnqueuer = Future Function({ required MediaStorageScope scope, @@ -31,13 +32,15 @@ class BookImportCommitService { required BookAdder addBook, required BookDelete deleteBook, required ImportedBookMediaEnqueuer enqueueImportedBookMedia, + LibraryContextValidator isLibraryContextCurrent = _alwaysCurrent, }) : _storePendingCover = storePendingCover, _storeGuestCover = storeGuestCover, _deletePendingCover = deletePendingCover, _deleteGuestCover = deleteGuestCover, _addBook = addBook, _deleteBook = deleteBook, - _enqueueImportedBookMedia = enqueueImportedBookMedia; + _enqueueImportedBookMedia = enqueueImportedBookMedia, + _isLibraryContextCurrent = isLibraryContextCurrent; final PendingCoverStore _storePendingCover; final GuestCoverStore _storeGuestCover; @@ -46,6 +49,7 @@ class BookImportCommitService { final BookAdder _addBook; final BookDelete _deleteBook; final ImportedBookMediaEnqueuer _enqueueImportedBookMedia; + final LibraryContextValidator _isLibraryContextCurrent; Future commit({ required BookImportResult result, @@ -88,8 +92,11 @@ class BookImportCommitService { coverStored = true; } + _ensureLibraryContextCurrent(); + await _addBook(book); metadataAdded = true; + _ensureLibraryContextCurrent(); if (accountScope != null) { await _enqueueImportedBookMedia( @@ -117,8 +124,16 @@ class BookImportCommitService { Error.throwWithStackTrace(error, stackTrace); } } + + void _ensureLibraryContextCurrent() { + if (!_isLibraryContextCurrent()) { + throw StateError('Library context changed during book import'); + } + } } +bool _alwaysCurrent() => true; + Future _bestEffort(FutureOr Function() compensation) async { try { await compensation(); diff --git a/app/lib/widgets/add_book/import_book_sheet.dart b/app/lib/widgets/add_book/import_book_sheet.dart index 8cf2d62..7974787 100644 --- a/app/lib/widgets/add_book/import_book_sheet.dart +++ b/app/lib/widgets/add_book/import_book_sheet.dart @@ -167,11 +167,12 @@ class _ImportContentState extends State<_ImportContent> { Book? committedBook; try { final dataStore = context.read(); + final bookRepository = dataStore.requireBookRepository(); final queue = context.read(); final importService = context.read(); - final isOnlineAccount = - context.read().isSignedIn && - context.read().mode == LibraryDatabaseMode.authenticated; + final authProvider = context.read(); + final powerSyncService = context.read(); + final isOnlineAccount = authProvider.isSignedIn && powerSyncService.mode == LibraryDatabaseMode.authenticated; final accountScope = isOnlineAccount ? queue.activeScope : null; if (isOnlineAccount && accountScope == null) { throw StateError('Cannot import account media without an active media storage scope'); @@ -186,9 +187,16 @@ class _ImportContentState extends State<_ImportContent> { storeGuestCover: importService.storeGuestCoverFile, deletePendingCover: importService.deletePendingCoverFile, deleteGuestCover: importService.deleteGuestCoverFile, - addBook: dataStore.addBookAndWait, - deleteBook: dataStore.deleteBookAndWait, + addBook: (book) => dataStore.addBookToRepositoryAndWait(bookRepository, book), + deleteBook: (bookId) => dataStore.deleteBookFromRepositoryAndWait(bookRepository, bookId), enqueueImportedBookMedia: queue.enqueueImportedBookMedia, + isLibraryContextCurrent: () { + final currentIsOnlineAccount = + authProvider.isSignedIn && powerSyncService.mode == LibraryDatabaseMode.authenticated; + return dataStore.isBookRepositoryCurrent(bookRepository) && + currentIsOnlineAccount == isOnlineAccount && + queue.activeScope == accountScope; + }, ); committedBook = await commitService.commit( result: result, diff --git a/app/test/data/book_repository_data_store_test.dart b/app/test/data/book_repository_data_store_test.dart index 7e55490..b1ecdcb 100644 --- a/app/test/data/book_repository_data_store_test.dart +++ b/app/test/data/book_repository_data_store_test.dart @@ -1,9 +1,14 @@ import 'dart:async'; +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_storage_scope.dart'; +import 'package:papyrus/media/media_upload_queue.dart'; import 'package:papyrus/models/book.dart'; +import 'package:shared_preferences/shared_preferences.dart'; class FakeBookRepository implements BookRepository { final StreamController> controller = StreamController>.broadcast(); @@ -42,6 +47,10 @@ Book _book(String id, String title) { } void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + test('repository stream is the source of the DataStore book snapshot', () async { final repository = FakeBookRepository(); final store = DataStore(); @@ -142,4 +151,76 @@ void main() { await store.disposeBookRepository(); await repository.controller.close(); }); + + test('repository-bound add is observable before a delayed watch stream emits', () async { + final repository = FakeBookRepository(); + final store = DataStore(bookRepository: repository); + final captured = store.requireBookRepository(); + final book = _book('one', 'First'); + + await store.addBookToRepositoryAndWait(captured, book); + + expect(store.isBookRepositoryCurrent(captured), isTrue); + expect(store.getBook(book.id), same(book)); + + await store.disposeBookRepository(); + await repository.controller.close(); + }); + + test('repository-bound delete targets captured repository after active repository changes', () async { + final first = FakeBookRepository(); + final second = FakeBookRepository(); + final store = DataStore(bookRepository: first); + final captured = store.requireBookRepository(); + final book = _book('one', 'First'); + await store.addBookToRepositoryAndWait(captured, book); + await store.attachBookRepository(second); + + await store.deleteBookFromRepositoryAndWait(captured, book.id); + + expect(first.deletes, [book.id]); + expect(second.deletes, isEmpty); + expect(store.isBookRepositoryCurrent(captured), isFalse); + + await store.disposeBookRepository(); + await first.controller.close(); + await second.controller.close(); + }); + + test('immediate queue upload sees repository-bound add before delayed watch stream', () async { + final repository = FakeBookRepository(); + final store = DataStore(bookRepository: repository); + final captured = store.requireBookRepository(); + final book = _book('one', 'First').copyWith(filePath: 'one.epub', fileSize: 3, fileHash: 'hash'); + await store.addBookToRepositoryAndWait(captured, book); + final prefs = await SharedPreferences.getInstance(); + final queue = MediaUploadQueue(prefs); + await queue.activateScope(MediaStorageScope(profileKey: 'official', userId: 'user-1')); + await queue.enqueueBookFile(book: book, filename: 'one.epub', contentType: 'application/epub+zip'); + + await queue.processPending( + dataStore: store, + readBookFile: (_) async => Uint8List.fromList([1, 2, 3]), + readPendingCover: (_, _) async => null, + uploadMedia: (payload) async => MediaAsset( + assetId: 'file-media', + ownerUserId: 'user-1', + bookId: payload.bookId, + kind: MediaKind.bookFile, + originalFilename: payload.filename, + contentType: payload.contentType, + extension: 'epub', + sizeBytes: payload.bytes.length, + sha256: 'hash', + storagePath: 'books/file-media.epub', + ), + ); + + expect(store.getBook(book.id)?.fileMediaId, 'file-media'); + await pumpEventQueue(); + expect(repository.upserts.last.fileMediaId, 'file-media'); + + await store.disposeBookRepository(); + await repository.controller.close(); + }); } diff --git a/app/test/media/media_profile_switch_contract_test.dart b/app/test/media/media_profile_switch_contract_test.dart index 7b6dc51..152ead5 100644 --- a/app/test/media/media_profile_switch_contract_test.dart +++ b/app/test/media/media_profile_switch_contract_test.dart @@ -42,9 +42,13 @@ void main() { expect(commit, contains('storeGuestCover: importService.storeGuestCoverFile')); expect(commit, contains('deletePendingCover: importService.deletePendingCoverFile')); expect(commit, contains('deleteGuestCover: importService.deleteGuestCoverFile')); - expect(commit, contains('addBook: dataStore.addBookAndWait')); - expect(commit, contains('deleteBook: dataStore.deleteBookAndWait')); + expect(commit, contains('final bookRepository = dataStore.requireBookRepository();')); + expect(commit, contains('dataStore.addBookToRepositoryAndWait(bookRepository, book)')); + expect(commit, contains('dataStore.deleteBookFromRepositoryAndWait(bookRepository, bookId)')); expect(commit, contains('enqueueImportedBookMedia: queue.enqueueImportedBookMedia')); + expect(commit, contains('isLibraryContextCurrent: ()')); + expect(commit, contains('dataStore.isBookRepositoryCurrent(bookRepository)')); + expect(commit, contains('queue.activeScope == accountScope')); expect(commit, contains('accountScope: accountScope')); expect(commit, isNot(contains('bytesToDataUri'))); }); diff --git a/app/test/services/book_import_commit_service_test.dart b/app/test/services/book_import_commit_service_test.dart index f83cb3a..13bf522 100644 --- a/app/test/services/book_import_commit_service_test.dart +++ b/app/test/services/book_import_commit_service_test.dart @@ -420,6 +420,156 @@ void main() { await dataStore.disposeBookRepository(); await repository.dispose(); }); + + test('guest repository switch during cover persistence aborts before adding metadata', () async { + final first = _GatedDeleteBookRepository(); + final second = _GatedDeleteBookRepository(); + final dataStore = DataStore(bookRepository: first); + final captured = dataStore.requireBookRepository(); + final coverStarted = Completer(); + final allowCover = Completer(); + var guestCoverDeleted = false; + final service = BookImportCommitService( + storePendingCover: (_, _, _) async => fail('pending cover must not be stored'), + storeGuestCover: (_, _) async { + coverStarted.complete(); + await allowCover.future; + }, + deletePendingCover: (_, _) async => fail('pending cover must not be deleted'), + deleteGuestCover: (_) async => guestCoverDeleted = true, + addBook: (book) => dataStore.addBookToRepositoryAndWait(captured, book), + deleteBook: (id) => dataStore.deleteBookFromRepositoryAndWait(captured, id), + enqueueImportedBookMedia: + ({ + required scope, + required book, + required filename, + required contentType, + coverFilename, + coverContentType, + }) async => fail('guest media must not be enqueued'), + isLibraryContextCurrent: () => dataStore.isBookRepositoryCurrent(captured), + ); + + final commit = service.commit( + result: _result(coverImage: Uint8List.fromList([1])), + sourceFilename: 'original.epub', + addedAt: addedAt, + localFilePath: 'book-1', + ); + await coverStarted.future; + await dataStore.attachBookRepository(second); + allowCover.complete(); + + await expectLater(commit, throwsStateError); + expect(await first.getById('book-1'), isNull); + expect(await second.getById('book-1'), isNull); + expect(guestCoverDeleted, isTrue); + + await dataStore.disposeBookRepository(); + await first.dispose(); + await second.dispose(); + }); + + test('account repository and scope switch during cover persistence leaves B untouched', () async { + final first = _GatedDeleteBookRepository(); + final second = _GatedDeleteBookRepository(); + final dataStore = DataStore(bookRepository: first); + final captured = dataStore.requireBookRepository(); + final prefs = await SharedPreferences.getInstance(); + final queue = MediaUploadQueue(prefs); + final secondScope = MediaStorageScope(profileKey: 'official', userId: 'user-2'); + await queue.activateScope(accountScope); + final coverStarted = Completer(); + final allowCover = Completer(); + MediaStorageScope? deletedCoverScope; + final service = BookImportCommitService( + storePendingCover: (_, _, _) async { + coverStarted.complete(); + await allowCover.future; + }, + storeGuestCover: (_, _) async => fail('guest cover must not be stored'), + deletePendingCover: (scope, _) async => deletedCoverScope = scope, + deleteGuestCover: (_) async => fail('guest cover must not be deleted'), + addBook: (book) => dataStore.addBookToRepositoryAndWait(captured, book), + deleteBook: (id) => dataStore.deleteBookFromRepositoryAndWait(captured, id), + enqueueImportedBookMedia: queue.enqueueImportedBookMedia, + isLibraryContextCurrent: () => dataStore.isBookRepositoryCurrent(captured) && queue.activeScope == accountScope, + ); + + final commit = service.commit( + result: _result(coverImage: Uint8List.fromList([1])), + sourceFilename: 'original.epub', + addedAt: addedAt, + localFilePath: 'book-1', + accountScope: accountScope, + ); + await coverStarted.future; + await dataStore.attachBookRepository(second); + await queue.activateScope(secondScope); + allowCover.complete(); + + await expectLater(commit, throwsStateError); + expect(await first.getById('book-1'), isNull); + expect(await second.getById('book-1'), isNull); + expect(queue.pendingTasks, isEmpty); + expect(deletedCoverScope, accountScope); + + await dataStore.disposeBookRepository(); + await first.dispose(); + await second.dispose(); + }); + + test('context loss after add compensates metadata in captured repository', () async { + final first = _GatedDeleteBookRepository(); + final second = _GatedDeleteBookRepository(); + first.allowDelete.complete(); + final dataStore = DataStore(bookRepository: first); + final captured = dataStore.requireBookRepository(); + var enqueued = false; + final service = BookImportCommitService( + storePendingCover: (_, _, _) async => fail('cover storage must be skipped'), + storeGuestCover: (_, _) async => fail('cover storage must be skipped'), + deletePendingCover: (_, _) async => fail('cover cleanup must be skipped'), + deleteGuestCover: (_) async => fail('cover cleanup must be skipped'), + addBook: (book) async { + await dataStore.addBookToRepositoryAndWait(captured, book); + await dataStore.attachBookRepository(second); + }, + deleteBook: (id) => dataStore.deleteBookFromRepositoryAndWait(captured, id), + enqueueImportedBookMedia: + ({ + required scope, + required book, + required filename, + required contentType, + coverFilename, + coverContentType, + }) async { + enqueued = true; + }, + isLibraryContextCurrent: () => dataStore.isBookRepositoryCurrent(captured), + ); + + await expectLater( + service.commit( + result: _result(), + sourceFilename: 'original.epub', + addedAt: addedAt, + localFilePath: 'book-1', + accountScope: accountScope, + ), + throwsStateError, + ); + + expect(await first.getById('book-1'), isNull); + expect(await second.getById('book-1'), isNull); + expect(enqueued, isFalse); + + await dataStore.disposeBookRepository(); + await first.dispose(); + await second.dispose(); + }); } class _GatedDeleteBookRepository implements BookRepository { From fed071d78e2d0d65751cf2e9eae3ce05b1f89cf5 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 20:08:44 +0300 Subject: [PATCH 27/48] feat: render local covers by book id --- app/lib/data/data_store.dart | 2 +- app/lib/models/shelf.dart | 3 +- app/lib/pages/annotations_page.dart | 1 + app/lib/pages/bookmarks_page.dart | 1 + app/lib/pages/notes_page.dart | 1 + app/lib/widgets/book/private_book_cover.dart | 119 ++++++++++--- .../book_details/book_cover_image.dart | 17 +- app/lib/widgets/book_details/book_header.dart | 2 + .../context_menu/book_context_menu.dart | 7 +- .../dashboard/continue_reading_card.dart | 1 + .../dashboard/recently_added_section.dart | 1 + app/lib/widgets/library/book_card.dart | 1 + app/lib/widgets/library/book_list_item.dart | 1 + app/lib/widgets/shared/book_group_header.dart | 5 + .../widgets/shelves/move_to_shelf_sheet.dart | 7 +- app/lib/widgets/shelves/shelf_card.dart | 1 + .../widgets/topics/manage_topics_sheet.dart | 7 +- .../data/book_repository_data_store_test.dart | 18 ++ .../widgets/book/private_book_cover_test.dart | 159 ++++++++++++++++++ .../book_details/book_cover_image_test.dart | 17 ++ 20 files changed, 341 insertions(+), 30 deletions(-) create mode 100644 app/test/widgets/book_details/book_cover_image_test.dart diff --git a/app/lib/data/data_store.dart b/app/lib/data/data_store.dart index 3743baa..8d0a196 100644 --- a/app/lib/data/data_store.dart +++ b/app/lib/data/data_store.dart @@ -226,7 +226,7 @@ class DataStore extends ChangeNotifier { final books = getBooksInShelf(shelfId); return books .take(limit) - .map((b) => CoverPreview(url: b.coverUrl, mediaId: b.coverMediaId, title: b.title)) + .map((b) => CoverPreview(bookId: b.id, url: b.coverUrl, mediaId: b.coverMediaId, title: b.title)) .toList(); } diff --git a/app/lib/models/shelf.dart b/app/lib/models/shelf.dart index 1a87bff..8211176 100644 --- a/app/lib/models/shelf.dart +++ b/app/lib/models/shelf.dart @@ -5,11 +5,12 @@ typedef ShelfData = Shelf; /// Lightweight cover preview data for shelf mosaic. class CoverPreview { + final String bookId; final String? url; final String? mediaId; final String title; - const CoverPreview({this.url, this.mediaId, required this.title}); + const CoverPreview({required this.bookId, this.url, this.mediaId, required this.title}); } /// Data model for a book shelf (collection). diff --git a/app/lib/pages/annotations_page.dart b/app/lib/pages/annotations_page.dart index 17f37e1..51ab2ee 100644 --- a/app/lib/pages/annotations_page.dart +++ b/app/lib/pages/annotations_page.dart @@ -276,6 +276,7 @@ class _AnnotationsPageState extends State { final isCollapsed = _collapsedGroups.contains(entry.key); items.add( BookGroupHeader( + bookId: entry.key, bookTitle: provider.getBookTitle(entry.key), coverUrl: provider.getBookCoverUrl(entry.key), coverMediaId: provider.getBookCoverMediaId(entry.key), diff --git a/app/lib/pages/bookmarks_page.dart b/app/lib/pages/bookmarks_page.dart index 7527c38..4d99e63 100644 --- a/app/lib/pages/bookmarks_page.dart +++ b/app/lib/pages/bookmarks_page.dart @@ -288,6 +288,7 @@ class _BookmarksPageState extends State { final isCollapsed = _collapsedGroups.contains(entry.key); items.add( BookGroupHeader( + bookId: entry.key, bookTitle: provider.getBookTitle(entry.key), coverUrl: provider.getBookCoverUrl(entry.key), coverMediaId: provider.getBookCoverMediaId(entry.key), diff --git a/app/lib/pages/notes_page.dart b/app/lib/pages/notes_page.dart index 088b8d5..715c063 100644 --- a/app/lib/pages/notes_page.dart +++ b/app/lib/pages/notes_page.dart @@ -267,6 +267,7 @@ class _NotesPageState extends State { final isCollapsed = _collapsedGroups.contains(entry.key); items.add( BookGroupHeader( + bookId: entry.key, bookTitle: provider.getBookTitle(entry.key), coverUrl: provider.getBookCoverUrl(entry.key), coverMediaId: provider.getBookCoverMediaId(entry.key), diff --git a/app/lib/widgets/book/private_book_cover.dart b/app/lib/widgets/book/private_book_cover.dart index e21fa0a..d2c771c 100644 --- a/app/lib/widgets/book/private_book_cover.dart +++ b/app/lib/widgets/book/private_book_cover.dart @@ -11,23 +11,28 @@ import 'package:papyrus/services/book_import_service_stub.dart' import 'package:provider/provider.dart'; typedef PrivateCoverLoader = Future Function(String mediaId); +typedef LocalBookCoverLoader = Future Function(String bookId); /// Renders a public cover URL or a lazily persisted authenticated cover. class PrivateBookCover extends StatefulWidget { const PrivateBookCover({ super.key, + this.bookId, this.imageUrl, this.mediaId, this.fit = BoxFit.cover, required this.placeholder, this.loadPrivateCover, + this.loadLocalBookCover, }); + final String? bookId; final String? imageUrl; final String? mediaId; final BoxFit fit; final Widget placeholder; final PrivateCoverLoader? loadPrivateCover; + final LocalBookCoverLoader? loadLocalBookCover; @override State createState() => _PrivateBookCoverState(); @@ -46,7 +51,7 @@ class _PrivateBookCoverState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); - if (widget.loadPrivateCover == null) { + if (!_hasInjectedLoaderForCurrentSource) { _configureProviderLoader(); } } @@ -56,55 +61,123 @@ class _PrivateBookCoverState extends State { super.didUpdateWidget(oldWidget); if (oldWidget.imageUrl != widget.imageUrl || oldWidget.mediaId != widget.mediaId || - !identical(oldWidget.loadPrivateCover, widget.loadPrivateCover)) { + oldWidget.bookId != widget.bookId || + (oldWidget.loadPrivateCover == null) != (widget.loadPrivateCover == null) || + (oldWidget.loadLocalBookCover == null) != (widget.loadLocalBookCover == null)) { _coverFuture = null; _loadKey = null; _configureInjectedLoader(); - if (widget.loadPrivateCover == null) { + if (!_hasInjectedLoaderForCurrentSource) { _configureProviderLoader(); } } } void _configureInjectedLoader() { - final mediaId = widget.mediaId; - final loader = widget.loadPrivateCover; - if (_hasPublicUrl || mediaId == null || mediaId.isEmpty || loader == null) return; - final key = 'injected:$mediaId'; + if (_hasPublicUrl) return; + + final mediaId = _usableMediaId; + if (mediaId != null) { + final loader = widget.loadPrivateCover; + if (loader == null) return; + final key = 'injected-media:$mediaId'; + if (_loadKey == key) return; + _loadKey = key; + _coverFuture = loader(mediaId); + return; + } + + final bookId = _usableBookId; + final loader = widget.loadLocalBookCover; + if (bookId == null || loader == null) return; + final key = 'injected-local:$bookId'; if (_loadKey == key) return; _loadKey = key; - _coverFuture = loader(mediaId); + _coverFuture = loader(bookId); } void _configureProviderLoader() { - final mediaId = widget.mediaId; - if (_hasPublicUrl || mediaId == null || mediaId.isEmpty) return; + try { + _configureProviderLoaderFromContext(); + } on ProviderNotFoundException { + // Standalone cover surfaces can still render their placeholder. + } + } + + void _configureProviderLoaderFromContext() { + if (_hasPublicUrl) return; + final mediaId = _usableMediaId; final authProvider = context.read(); final user = authProvider.user; - if (!authProvider.isSignedIn || authProvider.isOfflineMode || user == null) return; + if (mediaId != null) { + if (!authProvider.isSignedIn || authProvider.isOfflineMode || user == null) return; + + final syncSettings = context.read(); + final scope = MediaStorageScope(profileKey: syncSettings.activeProfileKey, userId: user.userId); + final key = '${scope.persistenceKey}:$mediaId'; + if (_loadKey == key) return; + + final importService = context.read(); + final cacheService = context.read(); + _loadKey = key; + _coverFuture = cacheService.ensureCoverCached( + scope: scope, + mediaId: mediaId, + readLocalCover: importService.getCoverFile, + writeLocalCover: importService.storeCoverFile, + downloadMedia: authProvider.downloadMedia, + ); + return; + } - final syncSettings = context.read(); - final scope = MediaStorageScope(profileKey: syncSettings.activeProfileKey, userId: user.userId); - final key = '${scope.persistenceKey}:$mediaId'; - if (_loadKey == key) return; + final bookId = _usableBookId; + if (bookId == null) return; final importService = context.read(); - final cacheService = context.read(); + if (authProvider.isSignedIn && !authProvider.isOfflineMode && user != null) { + final syncSettings = context.read(); + final scope = MediaStorageScope(profileKey: syncSettings.activeProfileKey, userId: user.userId); + final key = 'local:$bookId:account:${scope.persistenceKey}'; + if (_loadKey == key) return; + _loadKey = key; + _coverFuture = importService.getPendingCoverFile(scope, bookId); + return; + } + + final key = 'local:$bookId:guest:no-scope'; + if (_loadKey == key) return; _loadKey = key; - _coverFuture = cacheService.ensureCoverCached( - scope: scope, - mediaId: mediaId, - readLocalCover: importService.getCoverFile, - writeLocalCover: importService.storeCoverFile, - downloadMedia: authProvider.downloadMedia, - ); + _coverFuture = importService.getGuestCoverFile(bookId); } bool get _hasPublicUrl => widget.imageUrl != null && widget.imageUrl!.isNotEmpty; + bool get _hasInlineDataUri => widget.imageUrl?.startsWith('data:') ?? false; + String? get _usableMediaId => widget.mediaId == null || widget.mediaId!.isEmpty ? null : widget.mediaId; + String? get _usableBookId => widget.bookId == null || widget.bookId!.isEmpty ? null : widget.bookId; + + bool get _hasInjectedLoaderForCurrentSource { + if (_hasPublicUrl) return true; + if (_usableMediaId != null) return widget.loadPrivateCover != null; + if (_usableBookId != null) return widget.loadLocalBookCover != null; + return true; + } @override Widget build(BuildContext context) { + if (_hasInlineDataUri) { + try { + final data = Uri.parse(widget.imageUrl!).data; + if (data != null && data.mimeType.startsWith('image/')) { + final bytes = data.contentAsBytes(); + return Image.memory(bytes, fit: widget.fit, errorBuilder: (_, _, _) => widget.placeholder); + } + } on FormatException { + return widget.placeholder; + } + return widget.placeholder; + } + if (_hasPublicUrl) { return CachedNetworkImage( imageUrl: widget.imageUrl!, diff --git a/app/lib/widgets/book_details/book_cover_image.dart b/app/lib/widgets/book_details/book_cover_image.dart index de31372..553745b 100644 --- a/app/lib/widgets/book_details/book_cover_image.dart +++ b/app/lib/widgets/book_details/book_cover_image.dart @@ -19,12 +19,20 @@ enum BookCoverSize { /// Book cover image widget with size variants and placeholder. class BookCoverImage extends StatelessWidget { + final String? bookId; final String? imageUrl; final String? mediaId; final String? bookTitle; final BookCoverSize size; - const BookCoverImage({super.key, this.imageUrl, this.mediaId, this.bookTitle, this.size = BookCoverSize.medium}); + const BookCoverImage({ + super.key, + this.bookId, + this.imageUrl, + this.mediaId, + this.bookTitle, + this.size = BookCoverSize.medium, + }); @override Widget build(BuildContext context) { @@ -45,7 +53,12 @@ class BookCoverImage extends StatelessWidget { } Widget _buildImage(BuildContext context, ColorScheme colorScheme) { - return PrivateBookCover(imageUrl: imageUrl, mediaId: mediaId, placeholder: _buildPlaceholder(context, colorScheme)); + return PrivateBookCover( + bookId: bookId, + imageUrl: imageUrl, + mediaId: mediaId, + placeholder: _buildPlaceholder(context, colorScheme), + ); } Widget _buildPlaceholder(BuildContext context, ColorScheme colorScheme) { diff --git a/app/lib/widgets/book_details/book_header.dart b/app/lib/widgets/book_details/book_header.dart index dd68e95..7a32c34 100644 --- a/app/lib/widgets/book_details/book_header.dart +++ b/app/lib/widgets/book_details/book_header.dart @@ -41,6 +41,7 @@ class BookHeader extends StatelessWidget { children: [ // Cover image BookCoverImage( + bookId: book.id, imageUrl: book.coverURL, mediaId: book.coverMediaId, bookTitle: book.title, @@ -117,6 +118,7 @@ class BookHeader extends StatelessWidget { // Cover image (centered) BookCoverImage( + bookId: book.id, imageUrl: book.coverURL, mediaId: book.coverMediaId, bookTitle: book.title, diff --git a/app/lib/widgets/context_menu/book_context_menu.dart b/app/lib/widgets/context_menu/book_context_menu.dart index 1d65d3c..378c7e3 100644 --- a/app/lib/widgets/context_menu/book_context_menu.dart +++ b/app/lib/widgets/context_menu/book_context_menu.dart @@ -455,7 +455,12 @@ class _BookContextBottomSheet extends StatelessWidget { color: colorScheme.surfaceContainerHighest, child: Icon(Icons.menu_book, color: colorScheme.onSurfaceVariant), ); - return PrivateBookCover(imageUrl: book.coverURL, mediaId: book.coverMediaId, placeholder: placeholder); + return PrivateBookCover( + bookId: book.id, + imageUrl: book.coverURL, + mediaId: book.coverMediaId, + placeholder: placeholder, + ); } } diff --git a/app/lib/widgets/dashboard/continue_reading_card.dart b/app/lib/widgets/dashboard/continue_reading_card.dart index b80d9d7..329f26f 100644 --- a/app/lib/widgets/dashboard/continue_reading_card.dart +++ b/app/lib/widgets/dashboard/continue_reading_card.dart @@ -182,6 +182,7 @@ class ContinueReadingCard extends StatelessWidget { ), clipBehavior: Clip.antiAlias, child: PrivateBookCover( + bookId: book?.id, imageUrl: book?.coverURL, mediaId: book?.coverMediaId, placeholder: _buildCoverPlaceholder(context), diff --git a/app/lib/widgets/dashboard/recently_added_section.dart b/app/lib/widgets/dashboard/recently_added_section.dart index d7c7ff0..1c399a3 100644 --- a/app/lib/widgets/dashboard/recently_added_section.dart +++ b/app/lib/widgets/dashboard/recently_added_section.dart @@ -88,6 +88,7 @@ class RecentlyAddedSection extends StatelessWidget { ), clipBehavior: Clip.antiAlias, child: PrivateBookCover( + bookId: book.id, imageUrl: book.coverURL, mediaId: book.coverMediaId, placeholder: _buildCoverPlaceholder(context, book), diff --git a/app/lib/widgets/library/book_card.dart b/app/lib/widgets/library/book_card.dart index 22bbd64..71aa85e 100644 --- a/app/lib/widgets/library/book_card.dart +++ b/app/lib/widgets/library/book_card.dart @@ -179,6 +179,7 @@ class _BookCardState extends State { Widget _buildCover(BuildContext context) { return PrivateBookCover( + bookId: widget.book.id, imageUrl: widget.book.coverURL, mediaId: widget.book.coverMediaId, placeholder: _buildPlaceholder(context), diff --git a/app/lib/widgets/library/book_list_item.dart b/app/lib/widgets/library/book_list_item.dart index a2b674c..7a670ff 100644 --- a/app/lib/widgets/library/book_list_item.dart +++ b/app/lib/widgets/library/book_list_item.dart @@ -174,6 +174,7 @@ class _BookListItemState extends State { Widget _buildCover(BuildContext context) { return PrivateBookCover( + bookId: widget.book.id, imageUrl: widget.book.coverURL, mediaId: widget.book.coverMediaId, placeholder: _buildPlaceholder(context), diff --git a/app/lib/widgets/shared/book_group_header.dart b/app/lib/widgets/shared/book_group_header.dart index d379187..e20d974 100644 --- a/app/lib/widgets/shared/book_group_header.dart +++ b/app/lib/widgets/shared/book_group_header.dart @@ -7,6 +7,9 @@ import 'package:papyrus/widgets/book/private_book_cover.dart'; /// /// Used in notes, annotations, and bookmarks pages to group items by book. class BookGroupHeader extends StatelessWidget { + /// The source book ID used to load an imported local cover. + final String bookId; + /// The book title to display. final String bookTitle; @@ -30,6 +33,7 @@ class BookGroupHeader extends StatelessWidget { const BookGroupHeader({ super.key, + required this.bookId, required this.bookTitle, this.coverUrl, this.coverMediaId, @@ -60,6 +64,7 @@ class BookGroupHeader extends StatelessWidget { width: 32, height: 48, child: PrivateBookCover( + bookId: bookId, imageUrl: coverUrl, mediaId: coverMediaId, placeholder: Container( diff --git a/app/lib/widgets/shelves/move_to_shelf_sheet.dart b/app/lib/widgets/shelves/move_to_shelf_sheet.dart index 8642b40..fe8378d 100644 --- a/app/lib/widgets/shelves/move_to_shelf_sheet.dart +++ b/app/lib/widgets/shelves/move_to_shelf_sheet.dart @@ -206,7 +206,12 @@ class _MoveToShelfSheetState extends State { child: Icon(Icons.menu_book, color: colorScheme.onSurfaceVariant, size: 20), ); if (book == null) return placeholder; - return PrivateBookCover(imageUrl: book.coverURL, mediaId: book.coverMediaId, placeholder: placeholder); + return PrivateBookCover( + bookId: book.id, + imageUrl: book.coverURL, + mediaId: book.coverMediaId, + placeholder: placeholder, + ); } Widget _buildShelfTile(BuildContext context, Shelf shelf) { diff --git a/app/lib/widgets/shelves/shelf_card.dart b/app/lib/widgets/shelves/shelf_card.dart index eeea3f1..4fdf75c 100644 --- a/app/lib/widgets/shelves/shelf_card.dart +++ b/app/lib/widgets/shelves/shelf_card.dart @@ -217,6 +217,7 @@ class _ShelfCardState extends State { Widget _buildCoverImage(CoverPreview cover, ColorScheme colorScheme) { return PrivateBookCover( + bookId: cover.bookId, imageUrl: cover.url, mediaId: cover.mediaId, placeholder: _buildCoverPlaceholder(colorScheme, cover.title), diff --git a/app/lib/widgets/topics/manage_topics_sheet.dart b/app/lib/widgets/topics/manage_topics_sheet.dart index 8fadd9c..4dc9220 100644 --- a/app/lib/widgets/topics/manage_topics_sheet.dart +++ b/app/lib/widgets/topics/manage_topics_sheet.dart @@ -202,7 +202,12 @@ class _ManageTopicsSheetState extends State { child: Icon(Icons.menu_book, color: colorScheme.onSurfaceVariant, size: 20), ); if (book == null) return placeholder; - return PrivateBookCover(imageUrl: book.coverURL, mediaId: book.coverMediaId, placeholder: placeholder); + return PrivateBookCover( + bookId: book.id, + imageUrl: book.coverURL, + mediaId: book.coverMediaId, + placeholder: placeholder, + ); } Widget _buildEmptyState(BuildContext context) { diff --git a/app/test/data/book_repository_data_store_test.dart b/app/test/data/book_repository_data_store_test.dart index b1ecdcb..cfef554 100644 --- a/app/test/data/book_repository_data_store_test.dart +++ b/app/test/data/book_repository_data_store_test.dart @@ -8,6 +8,7 @@ import 'package:papyrus/media/media_models.dart'; import 'package:papyrus/media/media_storage_scope.dart'; import 'package:papyrus/media/media_upload_queue.dart'; import 'package:papyrus/models/book.dart'; +import 'package:papyrus/models/shelf.dart'; import 'package:shared_preferences/shared_preferences.dart'; class FakeBookRepository implements BookRepository { @@ -187,6 +188,23 @@ void main() { await second.controller.close(); }); + test('shelf cover previews retain the source book id', () async { + final repository = FakeBookRepository(); + final store = DataStore(bookRepository: repository); + final book = _book('book-1', 'First'); + final createdAt = DateTime.utc(2026, 1, 1); + final shelf = Shelf(id: 'shelf-1', name: 'Shelf', createdAt: createdAt, updatedAt: createdAt); + + store.addBook(book); + store.addShelf(shelf); + store.addBookToShelf(book.id, shelf.id); + + expect(store.getCoverPreviewsForShelf(shelf.id).single.bookId, book.id); + + await store.disposeBookRepository(); + await repository.controller.close(); + }); + test('immediate queue upload sees repository-bound add before delayed watch stream', () async { final repository = FakeBookRepository(); final store = DataStore(bookRepository: repository); diff --git a/app/test/widgets/book/private_book_cover_test.dart b/app/test/widgets/book/private_book_cover_test.dart index 781f106..735b0ab 100644 --- a/app/test/widgets/book/private_book_cover_test.dart +++ b/app/test/widgets/book/private_book_cover_test.dart @@ -31,6 +31,165 @@ void main() { expect(loads, 1); }); + testWidgets('private cover renders a local cover loaded by book id', (tester) async { + final loadedBookIds = []; + + await tester.pumpWidget( + MaterialApp( + home: PrivateBookCover( + bookId: 'book-1', + loadLocalBookCover: (bookId) async { + loadedBookIds.add(bookId); + return Uint8List.fromList(pngBytes); + }, + placeholder: const SizedBox(key: Key('placeholder')), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(Image), findsOneWidget); + expect(find.byKey(const Key('placeholder')), findsNothing); + expect(loadedBookIds, ['book-1']); + }); + + testWidgets('media id takes priority over a local book cover', (tester) async { + var privateLoads = 0; + var localLoads = 0; + + await tester.pumpWidget( + MaterialApp( + home: PrivateBookCover( + bookId: 'book-1', + mediaId: 'asset-1', + loadPrivateCover: (_) async { + privateLoads++; + return Uint8List.fromList(pngBytes); + }, + loadLocalBookCover: (_) async { + localLoads++; + return Uint8List.fromList(pngBytes); + }, + placeholder: const SizedBox(key: Key('placeholder')), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(privateLoads, 1); + expect(localLoads, 0); + }); + + testWidgets('local cover keeps one load future across rebuilds', (tester) async { + var loads = 0; + Future loader(String _) async { + loads++; + return Uint8List.fromList(pngBytes); + } + + Widget build(String bookId) { + return MaterialApp( + home: PrivateBookCover( + bookId: bookId, + loadLocalBookCover: loader, + placeholder: const SizedBox(key: Key('placeholder')), + ), + ); + } + + await tester.pumpWidget(build('book-1')); + await tester.pumpAndSettle(); + await tester.pumpWidget(build('book-1')); + await tester.pumpAndSettle(); + + expect(loads, 1); + }); + + testWidgets('local cover reloads when book id changes', (tester) async { + final loadedBookIds = []; + Future loader(String bookId) async { + loadedBookIds.add(bookId); + return Uint8List.fromList(pngBytes); + } + + Widget build(String bookId) { + return MaterialApp( + home: PrivateBookCover( + bookId: bookId, + loadLocalBookCover: loader, + placeholder: const SizedBox(key: Key('placeholder')), + ), + ); + } + + await tester.pumpWidget(build('book-1')); + await tester.pumpAndSettle(); + await tester.pumpWidget(build('book-2')); + await tester.pumpAndSettle(); + + expect(loadedBookIds, ['book-1', 'book-2']); + }); + + testWidgets('local cover falls back to placeholder when there are no bytes', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: PrivateBookCover( + bookId: 'book-1', + loadLocalBookCover: (_) async => null, + placeholder: const SizedBox(key: Key('placeholder')), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('placeholder')), findsOneWidget); + expect(find.byType(Image), findsNothing); + }); + + testWidgets('book id without providers keeps the standalone placeholder', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: PrivateBookCover( + bookId: 'book-1', + placeholder: SizedBox(key: Key('placeholder')), + ), + ), + ); + + expect(find.byKey(const Key('placeholder')), findsOneWidget); + }); + + testWidgets('inline imported cover renders from memory instead of the network cache', (tester) async { + final dataUri = Uri.dataFromBytes(pngBytes, mimeType: 'image/png').toString(); + + await tester.pumpWidget( + MaterialApp( + home: PrivateBookCover( + imageUrl: dataUri, + placeholder: const SizedBox(key: Key('placeholder')), + ), + ), + ); + + final image = tester.widget(find.byType(Image)); + expect(image.image, isA()); + expect(find.byKey(const Key('placeholder')), findsNothing); + }); + + testWidgets('malformed inline imported cover renders the placeholder', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: PrivateBookCover( + imageUrl: 'data:not-valid', + placeholder: const SizedBox(key: Key('placeholder')), + ), + ), + ); + + expect(find.byKey(const Key('placeholder')), findsOneWidget); + expect(find.byType(Image), findsNothing); + }); + testWidgets('private cover keeps one load future across rebuilds', (tester) async { var loads = 0; Future loader(String _) async { diff --git a/app/test/widgets/book_details/book_cover_image_test.dart b/app/test/widgets/book_details/book_cover_image_test.dart new file mode 100644 index 0000000..a65a096 --- /dev/null +++ b/app/test/widgets/book_details/book_cover_image_test.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/widgets/book/private_book_cover.dart'; +import 'package:papyrus/widgets/book_details/book_cover_image.dart'; + +void main() { + testWidgets('book cover image forwards the book id to its renderer', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: BookCoverImage(bookId: 'book-1', imageUrl: 'https://example.test/cover.jpg', bookTitle: 'Book'), + ), + ); + + final cover = tester.widget(find.byType(PrivateBookCover)); + expect(cover.bookId, 'book-1'); + }); +} From 3f8c1420469f799c5b40c9abfd808b0701af7444 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 20:18:51 +0300 Subject: [PATCH 28/48] fix: reload local covers across library contexts --- app/lib/widgets/book/private_book_cover.dart | 41 ++- .../widgets/book/private_book_cover_test.dart | 252 ++++++++++++++++++ app/test/widgets/library/book_card_test.dart | 7 + .../widgets/library/book_list_item_test.dart | 7 + .../shared/book_group_header_test.dart | 26 ++ 5 files changed, 320 insertions(+), 13 deletions(-) create mode 100644 app/test/widgets/shared/book_group_header_test.dart diff --git a/app/lib/widgets/book/private_book_cover.dart b/app/lib/widgets/book/private_book_cover.dart index d2c771c..570ca53 100644 --- a/app/lib/widgets/book/private_book_cover.dart +++ b/app/lib/widgets/book/private_book_cover.dart @@ -12,6 +12,8 @@ import 'package:provider/provider.dart'; typedef PrivateCoverLoader = Future Function(String mediaId); typedef LocalBookCoverLoader = Future Function(String bookId); +typedef PendingBookCoverLoader = Future Function(MediaStorageScope scope, String bookId); +typedef GuestBookCoverLoader = Future Function(String bookId); /// Renders a public cover URL or a lazily persisted authenticated cover. class PrivateBookCover extends StatefulWidget { @@ -24,6 +26,8 @@ class PrivateBookCover extends StatefulWidget { required this.placeholder, this.loadPrivateCover, this.loadLocalBookCover, + this.loadPendingBookCover, + this.loadGuestBookCover, }); final String? bookId; @@ -33,6 +37,8 @@ class PrivateBookCover extends StatefulWidget { final Widget placeholder; final PrivateCoverLoader? loadPrivateCover; final LocalBookCoverLoader? loadLocalBookCover; + final PendingBookCoverLoader? loadPendingBookCover; + final GuestBookCoverLoader? loadGuestBookCover; @override State createState() => _PrivateBookCoverState(); @@ -52,7 +58,7 @@ class _PrivateBookCoverState extends State { void didChangeDependencies() { super.didChangeDependencies(); if (!_hasInjectedLoaderForCurrentSource) { - _configureProviderLoader(); + _configureProviderLoader(subscribe: true); } } @@ -62,8 +68,10 @@ class _PrivateBookCoverState extends State { if (oldWidget.imageUrl != widget.imageUrl || oldWidget.mediaId != widget.mediaId || oldWidget.bookId != widget.bookId || - (oldWidget.loadPrivateCover == null) != (widget.loadPrivateCover == null) || - (oldWidget.loadLocalBookCover == null) != (widget.loadLocalBookCover == null)) { + !identical(oldWidget.loadPrivateCover, widget.loadPrivateCover) || + !identical(oldWidget.loadLocalBookCover, widget.loadLocalBookCover) || + !identical(oldWidget.loadPendingBookCover, widget.loadPendingBookCover) || + !identical(oldWidget.loadGuestBookCover, widget.loadGuestBookCover)) { _coverFuture = null; _loadKey = null; _configureInjectedLoader(); @@ -96,24 +104,30 @@ class _PrivateBookCoverState extends State { _coverFuture = loader(bookId); } - void _configureProviderLoader() { + void _configureProviderLoader({bool subscribe = false}) { try { - _configureProviderLoaderFromContext(); + final authProvider = subscribe ? context.watch() : context.read(); + final user = authProvider.user; + final needsAccountScope = + user != null && !authProvider.isOfflineMode && (_usableMediaId == null || authProvider.isSignedIn); + final syncSettings = needsAccountScope + ? (subscribe ? context.watch() : context.read()) + : null; + _configureProviderLoaderFromContext(authProvider, syncSettings); } on ProviderNotFoundException { // Standalone cover surfaces can still render their placeholder. } } - void _configureProviderLoaderFromContext() { + void _configureProviderLoaderFromContext(AuthProvider authProvider, SyncSettingsProvider? syncSettings) { if (_hasPublicUrl) return; final mediaId = _usableMediaId; - final authProvider = context.read(); final user = authProvider.user; if (mediaId != null) { if (!authProvider.isSignedIn || authProvider.isOfflineMode || user == null) return; - final syncSettings = context.read(); + if (syncSettings == null) return; final scope = MediaStorageScope(profileKey: syncSettings.activeProfileKey, userId: user.userId); final key = '${scope.persistenceKey}:$mediaId'; if (_loadKey == key) return; @@ -134,21 +148,22 @@ class _PrivateBookCoverState extends State { final bookId = _usableBookId; if (bookId == null) return; - final importService = context.read(); - if (authProvider.isSignedIn && !authProvider.isOfflineMode && user != null) { - final syncSettings = context.read(); + if (!authProvider.isOfflineMode && user != null) { + if (syncSettings == null) return; final scope = MediaStorageScope(profileKey: syncSettings.activeProfileKey, userId: user.userId); final key = 'local:$bookId:account:${scope.persistenceKey}'; if (_loadKey == key) return; _loadKey = key; - _coverFuture = importService.getPendingCoverFile(scope, bookId); + final loader = widget.loadPendingBookCover ?? context.read().getPendingCoverFile; + _coverFuture = loader(scope, bookId); return; } final key = 'local:$bookId:guest:no-scope'; if (_loadKey == key) return; _loadKey = key; - _coverFuture = importService.getGuestCoverFile(bookId); + final loader = widget.loadGuestBookCover ?? context.read().getGuestCoverFile; + _coverFuture = loader(bookId); } bool get _hasPublicUrl => widget.imageUrl != null && widget.imageUrl!.isNotEmpty; diff --git a/app/test/widgets/book/private_book_cover_test.dart b/app/test/widgets/book/private_book_cover_test.dart index 735b0ab..5080d8a 100644 --- a/app/test/widgets/book/private_book_cover_test.dart +++ b/app/test/widgets/book/private_book_cover_test.dart @@ -1,15 +1,60 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter/material.dart'; 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/media/media_storage_scope.dart'; +import 'package:papyrus/providers/auth_provider.dart'; +import 'package:papyrus/providers/sync_settings_provider.dart'; import 'package:papyrus/widgets/book/private_book_cover.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _MemoryRefreshTokenStorage implements RefreshTokenStorage { + String? value; + + @override + Future delete() async => value = null; + + @override + Future read() async => value; + + @override + Future write(String refreshToken) async => value = refreshToken; +} + +class _GatedAuthRepository extends AuthRepository { + _GatedAuthRepository(this.tokens) + : super( + apiClient: AuthApiClient(config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.test'))), + tokenStore: TokenStore(_MemoryRefreshTokenStorage()), + ); + + final AuthTokens tokens; + Completer? refreshGate; + + @override + Future bootstrap() async => tokens; + + @override + Future refresh() => refreshGate?.future ?? Future.value(tokens); +} void main() { final pngBytes = base64Decode( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', ); + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + testWidgets('private cover renders lazily loaded bytes', (tester) async { var loads = 0; await tester.pumpWidget( @@ -130,6 +175,161 @@ void main() { expect(loadedBookIds, ['book-1', 'book-2']); }); + testWidgets('local cover reloads when a non-null loader is replaced', (tester) async { + final firstBytes = Uint8List.fromList(pngBytes); + final secondBytes = Uint8List.fromList(pngBytes); + Future firstLoader(String _) async => firstBytes; + Future secondLoader(String _) async => secondBytes; + + Widget build(LocalBookCoverLoader loader) { + return MaterialApp( + home: PrivateBookCover( + bookId: 'book-1', + loadLocalBookCover: loader, + placeholder: const SizedBox(key: Key('placeholder')), + ), + ); + } + + await tester.pumpWidget(build(firstLoader)); + await tester.pumpAndSettle(); + await tester.pumpWidget(build(secondLoader)); + await tester.pumpAndSettle(); + + final image = tester.widget(find.byType(Image)); + expect(identical((image.image as MemoryImage).bytes, secondBytes), isTrue); + }); + + testWidgets('private cover reloads when a non-null loader is replaced', (tester) async { + final firstBytes = Uint8List.fromList(pngBytes); + final secondBytes = Uint8List.fromList(pngBytes); + Future firstLoader(String _) async => firstBytes; + Future secondLoader(String _) async => secondBytes; + + Widget build(PrivateCoverLoader loader) { + return MaterialApp( + home: PrivateBookCover( + mediaId: 'asset-1', + loadPrivateCover: loader, + placeholder: const SizedBox(key: Key('placeholder')), + ), + ); + } + + await tester.pumpWidget(build(firstLoader)); + await tester.pumpAndSettle(); + await tester.pumpWidget(build(secondLoader)); + await tester.pumpAndSettle(); + + final image = tester.widget(find.byType(Image)); + expect(identical((image.image as MemoryImage).bytes, secondBytes), isTrue); + }); + + testWidgets('refreshing account reads pending cover and never guest cover', (tester) async { + final harness = await _buildProviderHarness(); + final refreshGate = Completer(); + harness.repository.refreshGate = refreshGate; + final refresh = harness.authProvider.refresh(); + await tester.pump(); + expect(harness.authProvider.user, isNotNull); + expect(harness.authProvider.isSignedIn, isFalse); + var pendingLoads = 0; + var guestLoads = 0; + + await tester.pumpWidget( + harness.wrap( + PrivateBookCover( + bookId: 'book-1', + loadPendingBookCover: (_, _) async { + pendingLoads++; + return Uint8List.fromList(pngBytes); + }, + loadGuestBookCover: (_) async { + guestLoads++; + return Uint8List.fromList(pngBytes); + }, + placeholder: const SizedBox(key: Key('placeholder')), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(pendingLoads, 1); + expect(guestLoads, 0); + refreshGate.complete(harness.repository.tokens); + await refresh; + }); + + testWidgets('mode changes reload guest and account local covers', (tester) async { + final harness = await _buildProviderHarness(offline: true); + final guestBytes = Uint8List.fromList(pngBytes); + final accountBytes = Uint8List.fromList(pngBytes); + var guestLoads = 0; + var pendingLoads = 0; + + await tester.pumpWidget( + harness.wrap( + PrivateBookCover( + bookId: 'book-1', + loadPendingBookCover: (_, _) async { + pendingLoads++; + return accountBytes; + }, + loadGuestBookCover: (_) async { + guestLoads++; + return guestBytes; + }, + placeholder: const SizedBox(key: Key('placeholder')), + ), + ), + ); + await tester.pumpAndSettle(); + expect(guestLoads, 1); + expect(pendingLoads, 0); + expect(identical((tester.widget(find.byType(Image)).image as MemoryImage).bytes, guestBytes), isTrue); + + harness.authProvider.setOfflineMode(false); + await harness.authProvider.bootstrap(); + await tester.pumpAndSettle(); + expect(guestLoads, 1); + expect(pendingLoads, 1); + expect(identical((tester.widget(find.byType(Image)).image as MemoryImage).bytes, accountBytes), isTrue); + + harness.authProvider.setOfflineMode(true); + await tester.pumpAndSettle(); + expect(guestLoads, 2); + expect(pendingLoads, 1); + expect(identical((tester.widget(find.byType(Image)).image as MemoryImage).bytes, guestBytes), isTrue); + }); + + testWidgets('profile changes reload pending cover in the new scope', (tester) async { + final harness = await _buildProviderHarness(); + final officialBytes = Uint8List.fromList(pngBytes); + final customBytes = Uint8List.fromList(pngBytes); + final scopes = []; + + await tester.pumpWidget( + harness.wrap( + PrivateBookCover( + bookId: 'book-1', + loadPendingBookCover: (scope, _) async { + scopes.add(scope); + return scope.profileKey == SyncSettingsProvider.officialServerId ? officialBytes : customBytes; + }, + loadGuestBookCover: (_) async => null, + placeholder: const SizedBox(key: Key('placeholder')), + ), + ), + ); + await tester.pumpAndSettle(); + + harness.syncSettings.setCustomServerUrls(apiUrl: 'https://custom.test', powerSyncUrl: 'https://sync.custom.test'); + await tester.pumpAndSettle(); + + expect(scopes.map((scope) => scope.profileKey), [SyncSettingsProvider.officialServerId, startsWith('custom-')]); + expect(identical((tester.widget(find.byType(Image)).image as MemoryImage).bytes, customBytes), isTrue); + }); + testWidgets('local cover falls back to placeholder when there are no bytes', (tester) async { await tester.pumpWidget( MaterialApp( @@ -247,3 +447,55 @@ void main() { expect(find.byType(Image), findsNothing); }); } + +class _ProviderHarness { + const _ProviderHarness({required this.repository, required this.authProvider, required this.syncSettings}); + + final _GatedAuthRepository repository; + final AuthProvider authProvider; + final SyncSettingsProvider syncSettings; + + Widget wrap(Widget child) { + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: authProvider), + ChangeNotifierProvider.value(value: syncSettings), + ], + child: MaterialApp(home: child), + ); + } +} + +Future<_ProviderHarness> _buildProviderHarness({bool offline = false}) async { + final prefs = await SharedPreferences.getInstance(); + final tokens = AuthTokens( + accessToken: 'access-token', + refreshToken: 'refresh-token', + tokenType: 'Bearer', + expiresIn: 3600, + user: PapyrusUser( + userId: 'user-1', + email: 'reader@example.com', + displayName: 'Reader', + avatarUrl: null, + emailVerified: true, + createdAt: null, + lastLoginAt: null, + ), + ); + final repository = _GatedAuthRepository(tokens); + final authProvider = AuthProvider(prefs, repository: repository, bootstrapOnCreate: false); + if (offline) { + authProvider.setOfflineMode(true); + } else { + await authProvider.bootstrap(); + } + final syncSettings = SyncSettingsProvider( + prefs, + officialConfig: PapyrusApiConfig( + serverBaseUri: Uri.parse('https://api.test'), + powerSyncServiceUri: Uri.parse('https://sync.test'), + ), + ); + return _ProviderHarness(repository: repository, authProvider: authProvider, syncSettings: syncSettings); +} diff --git a/app/test/widgets/library/book_card_test.dart b/app/test/widgets/library/book_card_test.dart index a9d4729..2068bca 100644 --- a/app/test/widgets/library/book_card_test.dart +++ b/app/test/widgets/library/book_card_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/models/book.dart'; +import 'package:papyrus/widgets/book/private_book_cover.dart'; import 'package:papyrus/widgets/library/book_card.dart'; import '../../helpers/test_helpers.dart'; @@ -120,6 +121,12 @@ void main() { expect(find.byIcon(Icons.menu_book), findsOneWidget); }); + testWidgets('forwards the book id to the cover renderer', (tester) async { + await tester.pumpWidget(buildCard()); + + expect(tester.widget(find.byType(PrivateBookCover)).bookId, testBook.id); + }); + testWidgets('renders Card widget', (tester) async { await tester.pumpWidget(buildCard()); expect(find.byType(Card), findsOneWidget); diff --git a/app/test/widgets/library/book_list_item_test.dart b/app/test/widgets/library/book_list_item_test.dart index bbe597a..226902b 100644 --- a/app/test/widgets/library/book_list_item_test.dart +++ b/app/test/widgets/library/book_list_item_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/models/book.dart'; +import 'package:papyrus/widgets/book/private_book_cover.dart'; import 'package:papyrus/widgets/library/book_list_item.dart'; import '../../helpers/test_helpers.dart'; @@ -85,6 +86,12 @@ void main() { expect(find.byIcon(Icons.menu_book), findsOneWidget); }); + testWidgets('forwards the book id to the cover renderer', (tester) async { + await tester.pumpWidget(buildListItem()); + + expect(tester.widget(find.byType(PrivateBookCover)).bookId, testBook.id); + }); + testWidgets('displays physical format label', (tester) async { final physicalBook = testBook.copyWith(isPhysical: true); await tester.pumpWidget(buildListItem(book: physicalBook)); diff --git a/app/test/widgets/shared/book_group_header_test.dart b/app/test/widgets/shared/book_group_header_test.dart new file mode 100644 index 0000000..dc4924e --- /dev/null +++ b/app/test/widgets/shared/book_group_header_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/widgets/book/private_book_cover.dart'; +import 'package:papyrus/widgets/shared/book_group_header.dart'; + +void main() { + testWidgets('book group header forwards the grouped book id to its cover', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: BookGroupHeader( + bookId: 'book-1', + bookTitle: 'Book', + coverUrl: 'https://example.test/cover.jpg', + count: 1, + itemLabel: 'note', + isCollapsed: false, + onToggle: () {}, + ), + ), + ), + ); + + expect(tester.widget(find.byType(PrivateBookCover)).bookId, 'book-1'); + }); +} From cd2dde89f5a2eaa1066f26e1a6d8d381725a166e Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 20:23:49 +0300 Subject: [PATCH 29/48] fix: subscribe after local cover source transition --- app/lib/widgets/book/private_book_cover.dart | 4 ++ .../widgets/book/private_book_cover_test.dart | 42 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/app/lib/widgets/book/private_book_cover.dart b/app/lib/widgets/book/private_book_cover.dart index 570ca53..8f958c4 100644 --- a/app/lib/widgets/book/private_book_cover.dart +++ b/app/lib/widgets/book/private_book_cover.dart @@ -180,6 +180,10 @@ class _PrivateBookCoverState extends State { @override Widget build(BuildContext context) { + if (!_hasInjectedLoaderForCurrentSource) { + _configureProviderLoader(subscribe: true); + } + if (_hasInlineDataUri) { try { final data = Uri.parse(widget.imageUrl!).data; diff --git a/app/test/widgets/book/private_book_cover_test.dart b/app/test/widgets/book/private_book_cover_test.dart index 5080d8a..448b713 100644 --- a/app/test/widgets/book/private_book_cover_test.dart +++ b/app/test/widgets/book/private_book_cover_test.dart @@ -330,6 +330,48 @@ void main() { expect(identical((tester.widget(find.byType(Image)).image as MemoryImage).bytes, customBytes), isTrue); }); + testWidgets('injected to provider transition subscribes to later profile changes', (tester) async { + final harness = await _buildProviderHarness(); + final injectedBytes = Uint8List.fromList(pngBytes); + final officialBytes = Uint8List.fromList(pngBytes); + final customBytes = Uint8List.fromList(pngBytes); + final scopes = []; + + Future localLoader(String _) async => injectedBytes; + Future pendingLoader(MediaStorageScope scope, String _) async { + scopes.add(scope); + return scope.profileKey == SyncSettingsProvider.officialServerId ? officialBytes : customBytes; + } + + Widget build({required bool injected}) { + return harness.wrap( + PrivateBookCover( + bookId: 'book-1', + loadLocalBookCover: injected ? localLoader : null, + loadPendingBookCover: pendingLoader, + loadGuestBookCover: (_) async => null, + placeholder: const SizedBox(key: Key('placeholder')), + ), + ); + } + + await tester.pumpWidget(build(injected: true)); + await tester.pumpAndSettle(); + expect(scopes, isEmpty); + expect(identical((tester.widget(find.byType(Image)).image as MemoryImage).bytes, injectedBytes), isTrue); + + await tester.pumpWidget(build(injected: false)); + await tester.pumpAndSettle(); + expect(scopes.map((scope) => scope.profileKey), [SyncSettingsProvider.officialServerId]); + expect(identical((tester.widget(find.byType(Image)).image as MemoryImage).bytes, officialBytes), isTrue); + + harness.syncSettings.setCustomServerUrls(apiUrl: 'https://custom.test', powerSyncUrl: 'https://sync.custom.test'); + await tester.pumpAndSettle(); + + expect(scopes.map((scope) => scope.profileKey), [SyncSettingsProvider.officialServerId, startsWith('custom-')]); + expect(identical((tester.widget(find.byType(Image)).image as MemoryImage).bytes, customBytes), isTrue); + }); + testWidgets('local cover falls back to placeholder when there are no bytes', (tester) async { await tester.pumpWidget( MaterialApp( From a726987dae9325164de04b4c2545d6d558d53346 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 20:33:29 +0300 Subject: [PATCH 30/48] feat: promote uploaded covers into local cache --- app/lib/main.dart | 29 +++-- app/lib/media/cover_upload_persistence.dart | 31 +++++ .../media/cover_upload_persistence_test.dart | 109 ++++++++++++++++++ .../media_profile_switch_contract_test.dart | 13 +++ 4 files changed, 173 insertions(+), 9 deletions(-) create mode 100644 app/lib/media/cover_upload_persistence.dart create mode 100644 app/test/media/cover_upload_persistence_test.dart diff --git a/app/lib/main.dart b/app/lib/main.dart index 535e83f..b0519a5 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -7,6 +7,7 @@ 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/cover_upload_persistence.dart'; import 'package:papyrus/media/media_cache_service.dart'; import 'package:papyrus/media/media_models.dart'; import 'package:papyrus/media/media_storage_scope.dart'; @@ -228,16 +229,26 @@ class _PapyrusState extends State { dataStore: _dataStore, readBookFile: _bookImportService.getBookFile, readPendingCover: _bookImportService.getPendingCoverFile, - uploadMedia: (payload) async { - try { - return await repository.uploadMedia(payload); - } on AuthApiException catch (error) { - if (error.statusCode == 409) { - throw const MediaUploadException.storageFull(); + uploadMedia: (payload) => uploadAndPersistCover( + scope: scope, + payload: payload, + uploadMedia: (payload) async { + try { + return await repository.uploadMedia(payload); + } on AuthApiException catch (error) { + if (error.statusCode == 409) { + throw const MediaUploadException.storageFull(); + } + rethrow; } - rethrow; - } - }, + }, + promotePendingCover: _bookImportService.promotePendingCoverFile, + onPromotionError: (error, stackTrace) { + FlutterError.reportError( + FlutterErrorDetails(exception: error, stack: stackTrace, library: 'papyrus cover promotion'), + ); + }, + ), ); if (identical(repository, _authRepository) && _mediaUploadQueue.activeScope == scope) { await _refreshMediaUsage(); diff --git a/app/lib/media/cover_upload_persistence.dart b/app/lib/media/cover_upload_persistence.dart new file mode 100644 index 0000000..7571f6c --- /dev/null +++ b/app/lib/media/cover_upload_persistence.dart @@ -0,0 +1,31 @@ +import 'package:papyrus/media/media_models.dart'; +import 'package:papyrus/media/media_storage_scope.dart'; + +typedef CoverMediaUploader = Future Function(MediaUploadPayload payload); +typedef PendingCoverPromoter = + Future Function(MediaStorageScope scope, {required String bookId, required String mediaId}); +typedef CoverPromotionErrorHandler = void Function(Object error, StackTrace stackTrace); + +/// Uploads one media payload and promotes a successfully uploaded cover into +/// the source device's persistent media cache. +/// +/// Promotion is deliberately best-effort: once the server accepts the upload, +/// a local filesystem failure must not cause the same media to be uploaded +/// again. The normal lazy cache path can download it later by media ID. +Future uploadAndPersistCover({ + required MediaStorageScope scope, + required MediaUploadPayload payload, + required CoverMediaUploader uploadMedia, + required PendingCoverPromoter promotePendingCover, + required CoverPromotionErrorHandler onPromotionError, +}) async { + final asset = await uploadMedia(payload); + if (payload.kind != MediaKind.coverImage) return asset; + + try { + await promotePendingCover(scope, bookId: payload.bookId, mediaId: asset.assetId); + } catch (error, stackTrace) { + onPromotionError(error, stackTrace); + } + return asset; +} diff --git a/app/test/media/cover_upload_persistence_test.dart b/app/test/media/cover_upload_persistence_test.dart new file mode 100644 index 0000000..a621399 --- /dev/null +++ b/app/test/media/cover_upload_persistence_test.dart @@ -0,0 +1,109 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/media/cover_upload_persistence.dart'; +import 'package:papyrus/media/media_models.dart'; +import 'package:papyrus/media/media_storage_scope.dart'; + +void main() { + final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + + test('successful cover upload promotes the pending file', () async { + final calls = []; + final payload = _payload(MediaKind.coverImage); + + final result = await uploadAndPersistCover( + scope: scope, + payload: payload, + uploadMedia: (_) async { + calls.add('upload'); + return _asset(MediaKind.coverImage); + }, + promotePendingCover: (actualScope, {required bookId, required mediaId}) async { + calls.add('promote:${actualScope.persistenceKey}:$bookId:$mediaId'); + }, + onPromotionError: (_, _) => fail('promotion must not fail'), + ); + + expect(result.assetId, 'asset-1'); + expect(calls, ['upload', 'promote:official--user-1:book-1:asset-1']); + }); + + test('book-file upload does not touch cover storage', () async { + var promotions = 0; + + final result = await uploadAndPersistCover( + scope: scope, + payload: _payload(MediaKind.bookFile), + uploadMedia: (_) async => _asset(MediaKind.bookFile), + promotePendingCover: (_, {required bookId, required mediaId}) async => promotions++, + onPromotionError: (_, _) {}, + ); + + expect(result.kind, MediaKind.bookFile); + expect(promotions, 0); + }); + + test('promotion failure is reported without retrying successful upload', () async { + var uploads = 0; + Object? reported; + + final result = await uploadAndPersistCover( + scope: scope, + payload: _payload(MediaKind.coverImage), + uploadMedia: (_) async { + uploads++; + return _asset(MediaKind.coverImage); + }, + promotePendingCover: (_, {required bookId, required mediaId}) async { + throw StateError('OPFS unavailable'); + }, + onPromotionError: (error, _) => reported = error, + ); + + expect(result.assetId, 'asset-1'); + expect(uploads, 1); + expect(reported, isA()); + }); + + test('upload failure propagates without promotion', () async { + var promotions = 0; + + await expectLater( + uploadAndPersistCover( + scope: scope, + payload: _payload(MediaKind.coverImage), + uploadMedia: (_) async => throw StateError('server unavailable'), + promotePendingCover: (_, {required bookId, required mediaId}) async => promotions++, + onPromotionError: (_, _) {}, + ), + throwsStateError, + ); + expect(promotions, 0); + }); +} + +MediaUploadPayload _payload(MediaKind kind) { + return MediaUploadPayload( + bookId: 'book-1', + kind: kind, + filename: kind == MediaKind.coverImage ? 'cover.jpg' : 'book.epub', + contentType: kind == MediaKind.coverImage ? 'image/jpeg' : 'application/epub+zip', + bytes: Uint8List.fromList([1, 2, 3]), + ); +} + +MediaAsset _asset(MediaKind kind) { + return MediaAsset( + assetId: 'asset-1', + ownerUserId: 'user-1', + bookId: 'book-1', + kind: kind, + originalFilename: kind == MediaKind.coverImage ? 'cover.jpg' : 'book.epub', + contentType: kind == MediaKind.coverImage ? 'image/jpeg' : 'application/epub+zip', + extension: kind == MediaKind.coverImage ? 'jpg' : 'epub', + sizeBytes: 3, + sha256: 'hash', + storagePath: 'path', + ); +} diff --git a/app/test/media/media_profile_switch_contract_test.dart b/app/test/media/media_profile_switch_contract_test.dart index 152ead5..51e7878 100644 --- a/app/test/media/media_profile_switch_contract_test.dart +++ b/app/test/media/media_profile_switch_contract_test.dart @@ -25,6 +25,19 @@ void main() { expect(processor, contains('!identical(repository, _authRepository)')); }); + test('successful cover upload promotes the captured pending cover best effort', () { + final source = File('lib/main.dart').readAsStringSync(); + final processor = source.substring( + source.indexOf('Future _processMediaUploads()'), + source.indexOf('@override\n Widget build'), + ); + + expect(processor, contains('uploadAndPersistCover(')); + expect(processor, contains('scope: scope')); + expect(processor, contains('promotePendingCover: _bookImportService.promotePendingCoverFile')); + expect(processor, contains("library: 'papyrus cover promotion'")); + }); + test('production import delegates scoped cover persistence and queueing to the commit boundary', () { final mainSource = File('lib/main.dart').readAsStringSync(); final processor = mainSource.substring( From 11151500fb9ddb35c5de945b9bd6b1f245f75c99 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 20:36:08 +0300 Subject: [PATCH 31/48] fix: keep cover promotion diagnostics non-fatal --- app/lib/media/cover_upload_persistence.dart | 6 +++++- app/test/media/cover_upload_persistence_test.dart | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/app/lib/media/cover_upload_persistence.dart b/app/lib/media/cover_upload_persistence.dart index 7571f6c..22979bb 100644 --- a/app/lib/media/cover_upload_persistence.dart +++ b/app/lib/media/cover_upload_persistence.dart @@ -25,7 +25,11 @@ Future uploadAndPersistCover({ try { await promotePendingCover(scope, bookId: payload.bookId, mediaId: asset.assetId); } catch (error, stackTrace) { - onPromotionError(error, stackTrace); + try { + onPromotionError(error, stackTrace); + } catch (_) { + // Reporting must not turn an accepted server upload into a retry. + } } return asset; } diff --git a/app/test/media/cover_upload_persistence_test.dart b/app/test/media/cover_upload_persistence_test.dart index a621399..0b1fc18 100644 --- a/app/test/media/cover_upload_persistence_test.dart +++ b/app/test/media/cover_upload_persistence_test.dart @@ -66,6 +66,20 @@ void main() { expect(reported, isA()); }); + test('a throwing diagnostic reporter cannot turn upload success into a retry', () async { + final result = await uploadAndPersistCover( + scope: scope, + payload: _payload(MediaKind.coverImage), + uploadMedia: (_) async => _asset(MediaKind.coverImage), + promotePendingCover: (_, {required bookId, required mediaId}) async { + throw StateError('cache unavailable'); + }, + onPromotionError: (_, _) => throw StateError('reporter unavailable'), + ); + + expect(result.assetId, 'asset-1'); + }); + test('upload failure propagates without promotion', () async { var promotions = 0; From 196438cf9f1a20b81938b2fa272eb70695cce7b5 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 20:38:18 +0300 Subject: [PATCH 32/48] fix: clean pending and guest covers on delete --- app/lib/pages/book_details_page.dart | 4 + .../services/book_delete_cleanup_service.dart | 27 ++++--- app/test/pages/book_details_delete_test.dart | 80 ++++++++++++++++++- .../book_delete_cleanup_service_test.dart | 8 +- 4 files changed, 106 insertions(+), 13 deletions(-) diff --git a/app/lib/pages/book_details_page.dart b/app/lib/pages/book_details_page.dart index bfc1580..c3cfb11 100644 --- a/app/lib/pages/book_details_page.dart +++ b/app/lib/pages/book_details_page.dart @@ -620,6 +620,10 @@ class _BookDetailsPageState extends State with SingleTickerProv bookId: book.id, coverMediaId: book.coverMediaId, deleteBookFile: importService.deleteBookFile, + deletePendingCover: mediaScope == null + ? null + : (bookId) => importService.deletePendingCoverFile(mediaScope, bookId), + deleteGuestCover: mediaScope == null ? importService.deleteGuestCoverFile : null, deleteCoverFile: mediaScope == null ? null : (mediaId) => importService.deleteCoverFile(mediaScope, mediaId), ); diff --git a/app/lib/services/book_delete_cleanup_service.dart b/app/lib/services/book_delete_cleanup_service.dart index ac5294a..3f35f71 100644 --- a/app/lib/services/book_delete_cleanup_service.dart +++ b/app/lib/services/book_delete_cleanup_service.dart @@ -2,6 +2,7 @@ import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/media/media_upload_queue.dart'; typedef DeleteBookFile = Future Function(String bookId); +typedef DeleteBookCover = Future Function(String bookId); typedef DeleteCoverFile = Future Function(String mediaId); Future deleteBookWithMediaCleanup({ @@ -10,20 +11,28 @@ Future deleteBookWithMediaCleanup({ required String bookId, required DeleteBookFile deleteBookFile, String? coverMediaId, + DeleteBookCover? deletePendingCover, + DeleteBookCover? deleteGuestCover, DeleteCoverFile? deleteCoverFile, }) async { await mediaUploadQueue.removeTasksForBook(bookId); - try { - await deleteBookFile(bookId); - } catch (_) { - // Local cache cleanup is best-effort; the synced book deletion remains authoritative. + await _bestEffort(() => deleteBookFile(bookId)); + if (deletePendingCover != null) { + await _bestEffort(() => deletePendingCover(bookId)); + } + if (deleteGuestCover != null) { + await _bestEffort(() => deleteGuestCover(bookId)); } if (coverMediaId != null && deleteCoverFile != null) { - try { - await deleteCoverFile(coverMediaId); - } catch (_) { - // A missing or inaccessible local cover must not block book deletion. - } + await _bestEffort(() => deleteCoverFile(coverMediaId)); } dataStore.deleteBook(bookId); } + +Future _bestEffort(Future Function() cleanup) async { + try { + await cleanup(); + } catch (_) { + // Local cleanup must not block the authoritative metadata deletion. + } +} diff --git a/app/test/pages/book_details_delete_test.dart b/app/test/pages/book_details_delete_test.dart index 1560782..79a32c9 100644 --- a/app/test/pages/book_details_delete_test.dart +++ b/app/test/pages/book_details_delete_test.dart @@ -28,7 +28,13 @@ void main() { final mediaUploadQueue = MediaUploadQueue(prefs); await mediaUploadQueue.activateScope(MediaStorageScope(profileKey: 'official', userId: 'user-1')); final importService = _RecordingBookImportService(); - final book = Book(id: 'book-1', title: 'Delete Me', author: 'Author', addedAt: DateTime.utc(2026)); + final book = Book( + id: 'book-1', + title: 'Delete Me', + author: 'Author', + coverMediaId: 'cover-1', + addedAt: DateTime.utc(2026), + ); await repository.upsert(book); await tester.pump(); @@ -72,11 +78,65 @@ void main() { await tester.pumpAndSettle(); expect(importService.deletedBookIds, [book.id]); + expect(importService.deletedPendingCovers, ['official--user-1:${book.id}']); + expect(importService.deletedGuestCovers, isEmpty); + expect(importService.deletedCachedCovers, ['official--user-1:cover-1']); expect(mediaUploadQueue.pendingTasks, isEmpty); expect(await repository.getById(book.id), isNull); expect(find.text('Library books'), findsOneWidget); }); + testWidgets('guest deletion removes only the permanent guest cover', (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 Guest', 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(); + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Delete')); + await tester.pumpAndSettle(); + + expect(importService.deletedGuestCovers, [book.id]); + expect(importService.deletedPendingCovers, isEmpty); + expect(importService.deletedCachedCovers, isEmpty); + expect(await repository.getById(book.id), isNull); + }); + testWidgets('details overflow menu shows download before delete', (tester) async { final prefs = await SharedPreferences.getInstance(); final repository = InMemoryBookRepository(); @@ -188,6 +248,9 @@ void main() { class _RecordingBookImportService extends BookImportService { final List deletedBookIds = []; + final List deletedPendingCovers = []; + final List deletedGuestCovers = []; + final List deletedCachedCovers = []; final Map bookFiles = {}; @override @@ -195,6 +258,21 @@ class _RecordingBookImportService extends BookImportService { deletedBookIds.add(bookId); } + @override + Future deletePendingCoverFile(MediaStorageScope scope, String bookId) async { + deletedPendingCovers.add('${scope.persistenceKey}:$bookId'); + } + + @override + Future deleteGuestCoverFile(String bookId) async { + deletedGuestCovers.add(bookId); + } + + @override + Future deleteCoverFile(MediaStorageScope scope, String mediaId) async { + deletedCachedCovers.add('${scope.persistenceKey}:$mediaId'); + } + @override Future getBookFile(String bookId) async { return bookFiles[bookId]; diff --git a/app/test/services/book_delete_cleanup_service_test.dart b/app/test/services/book_delete_cleanup_service_test.dart index 9ebdb39..3dc9485 100644 --- a/app/test/services/book_delete_cleanup_service_test.dart +++ b/app/test/services/book_delete_cleanup_service_test.dart @@ -12,7 +12,7 @@ void main() { SharedPreferences.setMockInitialValues({}); }); - test('deleteBookWithMediaCleanup removes queued uploads and cached file before deleting book', () async { + test('deleteBookWithMediaCleanup removes every local cover representation before deleting book', () async { final prefs = await SharedPreferences.getInstance(); final repository = InMemoryBookRepository(); final dataStore = DataStore(bookRepository: repository); @@ -38,13 +38,15 @@ void main() { bookId: book.id, coverMediaId: book.coverMediaId, deleteBookFile: (bookId) async => deletedLocalFiles.add(bookId), - deleteCoverFile: (mediaId) async => deletedCovers.add(mediaId), + deletePendingCover: (bookId) async => deletedCovers.add('pending:$bookId'), + deleteGuestCover: (bookId) async => deletedCovers.add('guest:$bookId'), + deleteCoverFile: (mediaId) async => deletedCovers.add('cached:$mediaId'), ); await pumpEventQueue(); expect(queue.pendingTasks, isEmpty); expect(deletedLocalFiles, [book.id]); - expect(deletedCovers, ['cover-1']); + expect(deletedCovers, ['pending:${book.id}', 'guest:${book.id}', 'cached:cover-1']); expect(dataStore.getBook(book.id), isNull); expect(await repository.getById(book.id), isNull); }); From 4f8870d559abee0c69ed0776d4d5bc716c437b96 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 21:12:33 +0300 Subject: [PATCH 33/48] fix: preserve uploaded cover ids across stale sync --- app/lib/data/data_store.dart | 16 ++++++++++++++-- .../data/book_repository_data_store_test.dart | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/app/lib/data/data_store.dart b/app/lib/data/data_store.dart index 8d0a196..8a9d1a3 100644 --- a/app/lib/data/data_store.dart +++ b/app/lib/data/data_store.dart @@ -156,10 +156,22 @@ class DataStore extends ChangeNotifier { } void replaceBooksFromSync(List books) { - final syncedIds = books.map((book) => book.id).toSet(); + final mergedBooks = books + .map((book) { + final localBook = _books[book.id]; + if (book.coverMediaId == null && localBook?.coverMediaId != null) { + // PowerSync can briefly emit the downloaded server row before its + // pending local media-reference update is acknowledged. Keep the + // established local reference through that transient null snapshot. + return book.copyWith(coverMediaId: localBook!.coverMediaId); + } + return book; + }) + .toList(growable: false); + final syncedIds = mergedBooks.map((book) => book.id).toSet(); _books ..clear() - ..addEntries(books.map((book) => MapEntry(book.id, book))); + ..addEntries(mergedBooks.map((book) => MapEntry(book.id, book))); _bookShelfRelations.removeWhere((relation) => !syncedIds.contains(relation.bookId)); _bookTagRelations.removeWhere((relation) => !syncedIds.contains(relation.bookId)); _annotations.removeWhere((key, annotation) => !syncedIds.contains(annotation.bookId)); diff --git a/app/test/data/book_repository_data_store_test.dart b/app/test/data/book_repository_data_store_test.dart index cfef554..609ef19 100644 --- a/app/test/data/book_repository_data_store_test.dart +++ b/app/test/data/book_repository_data_store_test.dart @@ -70,6 +70,24 @@ void main() { await repository.controller.close(); }); + test('stale sync snapshots do not clear an uploaded cover media id', () async { + final repository = FakeBookRepository(); + final store = DataStore(bookRepository: repository); + final syncedBook = _book('one', 'First'); + + repository.controller.add([syncedBook]); + await pumpEventQueue(); + + store.updateBook(syncedBook.copyWith(coverMediaId: 'cover-asset')); + repository.controller.add([syncedBook]); + await pumpEventQueue(); + + expect(store.getBook(syncedBook.id)?.coverMediaId, 'cover-asset'); + + await store.disposeBookRepository(); + await repository.controller.close(); + }); + test('book mutations delegate to the active repository', () async { final repository = FakeBookRepository(); final store = DataStore(); From 02999e70b0836690845556ab02dfcc603881200d Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 21:13:38 +0300 Subject: [PATCH 34/48] docs: design stable local cover image cache --- .../2026-07-11-cover-image-cache-design.md | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-11-cover-image-cache-design.md diff --git a/docs/superpowers/specs/2026-07-11-cover-image-cache-design.md b/docs/superpowers/specs/2026-07-11-cover-image-cache-design.md new file mode 100644 index 0000000..dcf1828 --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-cover-image-cache-design.md @@ -0,0 +1,95 @@ +# Stable Local Cover Image Cache Design + +- Status: Approved design +- Date: 2026-07-11 +- Audience: Papyrus client engineers + +## Overview + +Papyrus persists private cover bytes in filesystem storage or browser OPFS. A newly mounted `PrivateBookCover` currently reads those bytes asynchronously and renders its placeholder until the read completes. Navigating between the library, book details, shelves, and list layouts therefore produces a visible cover flash even when the cover was already displayed moments earlier. + +This design gives each local cover a stable Flutter image-provider identity. Flutter's bounded least-recently-used `ImageCache` can then reuse the decoded image across widget instances and page transitions, while filesystem storage remains authoritative across app restarts. + +## Goals + +- Keep a previously displayed cover visually stable across page and layout transitions. +- Reuse Flutter's existing bounded decoded-image cache instead of adding an unbounded byte cache. +- Support account cached covers, account pending covers, and permanent guest covers. +- Preserve the existing local-first and cross-device storage behavior. +- Keep the first load and error behavior deterministic and testable. + +## Non-goals + +- Persist decoded images outside the current app session. +- Replace OPFS or native filesystem cover storage. +- Change server media endpoints, PowerSync schemas, or media synchronization. +- Preload every cover in a large library. +- Resize or recompress imported covers in this change. + +## Constraints + +- Cover bytes must not be stored in SQL, PowerSync rows, or `SharedPreferences`. +- Cache keys must include the server/account scope so private covers cannot cross profiles or users. +- Guest covers must remain isolated from authenticated libraries. +- A replaced cover must receive a different key or explicitly evict the previous key. +- Book removal and local-cache clearing must evict the associated decoded image where practical; stale entries remain bounded by Flutter's LRU limit if an explicit key is unavailable. + +## Proposed design + +### Stable provider + +Add a filesystem-backed `ImageProvider` for private covers. Its immutable key contains: + +- storage scope persistence key; +- cover bucket (`cached`, `pending`, or guest `books`); +- file identifier (media ID or book ID). + +The provider loads encoded bytes through the existing `BookImportService` APIs and decodes them through Flutter's image pipeline. Equal provider keys resolve through the same `ImageStreamCompleter`, allowing Flutter's global `ImageCache` to reuse decoded pixels across newly mounted widgets. + +Public HTTP covers continue using `CachedNetworkImage`; inline legacy data URIs keep their compatibility path. + +### Rendering + +`PrivateBookCover` chooses one provider in the existing priority order: + +1. public or legacy inline cover; +2. authenticated cached cover by media ID; +3. authenticated pending cover by book ID; +4. guest permanent cover by book ID. + +For filesystem-backed covers, the widget layers the placeholder behind `Image(image: provider)`. The placeholder is visible only until the first successful decode. If the same provider key was already decoded, the cached image stream supplies the frame without another filesystem read or placeholder transition. + +### Cache ownership and limits + +Papyrus does not add a second raw-byte LRU. Flutter's `ImageCache` remains the session-memory owner and uses its configured LRU entry and byte limits. OPFS or native filesystem storage remains the persistent cache and offline source of truth. + +### Invalidation + +- Promotion changes identity from `pending/` to `cached/`; the already rendered frame remains visible while the new provider resolves. +- Replacing a cover produces a new media ID and therefore a new cache key. +- Switching server profile, account, or guest mode changes the scope component and cannot reuse another library's private image. +- Deleting a book or clearing local authenticated cover files evicts known provider keys when the caller has them. Flutter's LRU bounds any unreachable decoded entry that cannot be targeted directly. + +## Failure behavior + +- Missing local bytes produce the existing placeholder. +- A cached account cover that is absent locally continues through `MediaCacheService.ensureCoverCached`, which performs the authenticated lazy download and writes the result to local storage. +- Decode and filesystem errors remain local to the cover widget and do not break the surrounding library page. +- A failed replacement does not evict a previously decoded provider until the new provider has produced a frame. + +## Testing + +- Two separately mounted widgets with the same provider key invoke the filesystem loader once. +- Navigating from a library cover to a details cover with the same key reuses the cached image stream. +- Different account scopes and storage buckets do not share cache entries. +- Media-ID replacement uses a new provider key. +- Missing bytes and decode failures render the placeholder. +- Existing pending, guest, authenticated lazy-download, profile-switch, and metadata-regression tests remain green. + +## Rollout and rollback + +The change is client-only and requires no data migration. Rollout consists of the provider implementation, `PrivateBookCover` integration, focused widget tests, and a browser navigation smoke test. Rollback restores the existing `FutureBuilder` renderer; persisted cover files and synchronized metadata remain compatible. + +## Open questions + +None for this iteration. Cover thumbnail generation and image-size optimization can be evaluated separately if profiling shows decode memory pressure. From 3f79205bfe539c7713cf78d6f31b56af83cd9b11 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 21:17:17 +0300 Subject: [PATCH 35/48] docs: plan stable local cover image cache --- ...26-07-11-stable-local-cover-image-cache.md | 369 ++++++++++++++++++ 1 file changed, 369 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-11-stable-local-cover-image-cache.md diff --git a/docs/superpowers/plans/2026-07-11-stable-local-cover-image-cache.md b/docs/superpowers/plans/2026-07-11-stable-local-cover-image-cache.md new file mode 100644 index 0000000..ddb6914 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-stable-local-cover-image-cache.md @@ -0,0 +1,369 @@ +# Stable Local Cover Image Cache Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Eliminate cover flashes between Papyrus pages by giving filesystem-backed covers stable Flutter image-cache identities. + +**Architecture:** Add a `LocalCoverImageProvider` whose immutable key contains storage scope, bucket, and file ID. `PrivateBookCover` will resolve local account, pending, and guest covers through that provider so Flutter's bounded global `ImageCache` reuses decoded frames across widget instances; OPFS/native files remain authoritative and existing authenticated lazy download remains the cached-media loader. + +**Tech Stack:** Flutter/Dart, `ImageProvider`, `MultiFrameImageStreamCompleter`, Flutter `ImageCache`, native filesystem, browser OPFS, widget/unit tests. + +--- + +## File structure + +- Create `app/lib/media/local_cover_image_provider.dart`: stable cover key, asynchronous byte decoding, and targeted cache eviction. +- Create `app/test/media/local_cover_image_provider_test.dart`: key isolation, loader reuse, failures, and eviction. +- Modify `app/lib/widgets/book/private_book_cover.dart`: replace provider-backed `FutureBuilder` reads with stable local image providers while retaining public and injected compatibility paths. +- Modify `app/test/widgets/book/private_book_cover_test.dart`: verify separately mounted library/details widgets reuse the decoded image without another storage read or placeholder frame. +- Modify `app/lib/services/book_import_service.dart`: evict web cover keys when same-identity files are overwritten or deleted. +- Modify `app/lib/services/book_import_service_stub.dart`: matching native eviction behavior. +- Modify `app/test/services/book_cover_storage_test.dart`: verify storage replacement and deletion invalidate decoded keys. + +### Task 1: Add a stable filesystem-backed cover image provider + +**Files:** +- Create: `app/lib/media/local_cover_image_provider.dart` +- Create: `app/test/media/local_cover_image_provider_test.dart` + +- [ ] **Step 1: Write failing provider cache-key tests** + +Create tests around a valid one-pixel PNG and clear `PaintingBinding.instance.imageCache` in setup/teardown: + +```dart +testWidgets('equal local cover keys reuse one filesystem load', (tester) async { + var loads = 0; + Future load() async { + loads++; + return pngBytes; + } + + final first = LocalCoverImageProvider( + scopeKey: 'official--user-1', + bucket: CoverStorageBucket.cached, + fileId: 'asset-1', + loadBytes: load, + ); + final second = LocalCoverImageProvider( + scopeKey: 'official--user-1', + bucket: CoverStorageBucket.cached, + fileId: 'asset-1', + loadBytes: load, + ); + + await tester.pumpWidget(MaterialApp(home: Image(image: first))); + await tester.pumpAndSettle(); + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + await tester.pumpWidget(MaterialApp(home: Image(image: second))); + await tester.pumpAndSettle(); + + expect(loads, 1); +}); + +test('scope bucket and file id participate in key equality', () { + expect(_provider(scope: 'one', bucket: CoverStorageBucket.cached, id: 'x').key, + isNot(_provider(scope: 'two', bucket: CoverStorageBucket.cached, id: 'x').key)); + expect(_provider(scope: 'one', bucket: CoverStorageBucket.cached, id: 'x').key, + isNot(_provider(scope: 'one', bucket: CoverStorageBucket.pending, id: 'x').key)); + expect(_provider(scope: 'one', bucket: CoverStorageBucket.cached, id: 'x').key, + isNot(_provider(scope: 'one', bucket: CoverStorageBucket.cached, id: 'y').key)); +}); +``` + +Add tests that null bytes report an image-stream error and `evict` causes the next equal provider to invoke its loader again. + +- [ ] **Step 2: Run the provider test and verify RED** + +Run: + +```bash +cd app +flutter test test/media/local_cover_image_provider_test.dart --reporter expanded +``` + +Expected: compilation fails because `LocalCoverImageProvider` does not exist. + +- [ ] **Step 3: Implement the stable key and provider** + +Create an immutable key and provider using Flutter 3.41's `loadImage` API: + +```dart +@immutable +class LocalCoverImageKey { + const LocalCoverImageKey({required this.scopeKey, required this.bucket, required this.fileId}); + + final String scopeKey; + final CoverStorageBucket bucket; + final String fileId; + + @override + bool operator ==(Object other) => + other is LocalCoverImageKey && + other.scopeKey == scopeKey && + other.bucket == bucket && + other.fileId == fileId; + + @override + int get hashCode => Object.hash(scopeKey, bucket, fileId); +} + +class LocalCoverImageProvider extends ImageProvider { + LocalCoverImageProvider({ + required String scopeKey, + required CoverStorageBucket bucket, + required String fileId, + required this.loadBytes, + }) : key = LocalCoverImageKey(scopeKey: scopeKey, bucket: bucket, fileId: fileId); + + final LocalCoverImageKey key; + final Future Function() loadBytes; + + @override + Future obtainKey(ImageConfiguration configuration) => SynchronousFuture(key); + + @override + ImageStreamCompleter loadImage(LocalCoverImageKey key, ImageDecoderCallback decode) { + return MultiFrameImageStreamCompleter(codec: _load(decode), scale: 1, debugLabel: key.toString()); + } + + Future _load(ImageDecoderCallback decode) async { + final bytes = await loadBytes(); + if (bytes == null || bytes.isEmpty) throw StateError('Local cover file was not found'); + return decode(await ui.ImmutableBuffer.fromUint8List(bytes)); + } + + static bool evictKey({required String scopeKey, required CoverStorageBucket bucket, required String fileId}) { + return PaintingBinding.instance.imageCache.evict( + LocalCoverImageKey(scopeKey: scopeKey, bucket: bucket, fileId: fileId), + includeLive: true, + ); + } +} +``` + +Implement `toString` for diagnostics. Keep the loader out of equality so new widget instances with the same storage identity share Flutter's cache entry. + +- [ ] **Step 4: Run provider tests and analysis** + +Run: + +```bash +cd app +flutter test test/media/local_cover_image_provider_test.dart --reporter expanded +flutter analyze +``` + +Expected: all provider tests pass and analysis reports no issues. + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/media/local_cover_image_provider.dart app/test/media/local_cover_image_provider_test.dart +git commit -m "feat: add stable local cover image provider" +``` + +### Task 2: Render production local covers through Flutter's image cache + +**Files:** +- Modify: `app/lib/widgets/book/private_book_cover.dart` +- Modify: `app/test/widgets/book/private_book_cover_test.dart` + +- [ ] **Step 1: Write the failing cross-page reuse test** + +Extend the provider harness with `Provider` and `Provider`. Use a recording import service whose cached-cover loader returns valid PNG bytes. Mount a library-style cover, unmount it, and mount a details-style cover with the same book/media identity: + +```dart +testWidgets('new page reuses decoded private cover without reading storage again', (tester) async { + final harness = await _buildProviderHarness(); + final importService = _RecordingCoverImportService(pngBytes); + + Widget page(Key key) => harness.wrapWithCoverServices( + PrivateBookCover( + key: key, + bookId: 'book-1', + mediaId: 'asset-1', + placeholder: const SizedBox(key: Key('placeholder')), + ), + importService: importService, + ); + + await tester.pumpWidget(page(const Key('library-cover'))); + await tester.pumpAndSettle(); + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + await tester.pumpWidget(page(const Key('details-cover'))); + await tester.pump(); + + expect(find.byKey(const Key('placeholder')), findsNothing); + expect(importService.cachedReads, 1); +}); +``` + +Add equivalent tests for pending account and guest keys, plus a profile-key change test proving it performs a new read. + +- [ ] **Step 2: Run the widget test and verify RED** + +Run: + +```bash +cd app +flutter test test/widgets/book/private_book_cover_test.dart --reporter expanded +``` + +Expected: the second widget performs another asynchronous storage read and exposes the placeholder during its first frame. + +- [ ] **Step 3: Route provider-backed sources through `LocalCoverImageProvider`** + +Retain the current injected-loader `FutureBuilder` path for isolated tests and compatibility. Add `_localImageProvider` and configure it for production sources: + +```dart +_localImageProvider = LocalCoverImageProvider( + scopeKey: scope.persistenceKey, + bucket: CoverStorageBucket.cached, + fileId: mediaId, + loadBytes: () => cacheService.ensureCoverCached( + scope: scope, + mediaId: mediaId, + readLocalCover: importService.getCoverFile, + writeLocalCover: importService.storeCoverFile, + downloadMedia: authProvider.downloadMedia, + ), +); +``` + +Use `CoverStorageBucket.pending` with `getPendingCoverFile` for account book-ID covers and `CoverStorageBucket.guestBooks` with `MediaStorageScope.localGuest.persistenceKey` for guest covers. + +Render the provider with the existing placeholder contract: + +```dart +return Image( + image: provider, + fit: widget.fit, + gaplessPlayback: true, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + return wasSynchronouslyLoaded || frame != null ? child : widget.placeholder; + }, + errorBuilder: (_, _, _) => widget.placeholder, +); +``` + +Clear `_localImageProvider` whenever book ID, media ID, URL, injected loader, auth mode, or profile scope changes. Public URLs and legacy inline data remain unchanged. + +- [ ] **Step 4: Run cover and representative surface tests** + +Run: + +```bash +cd app +flutter test \ + test/widgets/book/private_book_cover_test.dart \ + test/widgets/library/book_card_test.dart \ + test/widgets/library/book_list_item_test.dart \ + test/widgets/book_details/book_cover_image_test.dart \ + --reporter expanded +``` + +Expected: all tests pass, including one storage read across separately mounted pages. + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/widgets/book/private_book_cover.dart app/test/widgets/book/private_book_cover_test.dart +git commit -m "fix: reuse decoded covers across page transitions" +``` + +### Task 3: Invalidate decoded covers when local files are removed + +**Files:** +- Modify: `app/lib/services/book_import_service.dart` +- Modify: `app/lib/services/book_import_service_stub.dart` +- Modify: `app/test/services/book_cover_storage_test.dart` + +- [ ] **Step 1: Write failing invalidation tests** + +Populate `PaintingBinding.instance.imageCache` with a `LocalCoverImageProvider`, then delete the corresponding filesystem cover. Resolve an equal provider again and assert the loader runs a second time. Cover cached, pending, and guest identities; also assert promotion evicts the pending book-ID key. + +- [ ] **Step 2: Run storage tests and verify RED** + +Run: + +```bash +cd app +flutter test test/services/book_cover_storage_test.dart --reporter expanded +``` + +Expected: cached decoded entries survive filesystem deletion and the loader is not called again. + +- [ ] **Step 3: Evict deterministic keys after successful removal** + +After successful store/delete operations, call: + +```dart +LocalCoverImageProvider.evictKey( + scopeKey: scope.persistenceKey, + bucket: bucket, + fileId: id, +); +``` + +Promotion evicts `pending/` after the cached write succeeds. Do not evict after ordinary store operations: a lazy authenticated download stores the same key that is currently resolving, and evicting it would defeat reuse on the next page. Cover replacement already receives a new media ID. Apply matching deletion and promotion behavior to web and native services. Filesystem failure must not evict a still-valid decoded image. + +- [ ] **Step 4: Run storage, media, and cover tests** + +Run: + +```bash +cd app +flutter test test/services/book_cover_storage_test.dart test/media test/widgets/book/private_book_cover_test.dart --reporter expanded +flutter analyze +node --check web/book_worker.js +``` + +Expected: all tests pass, analysis is clean, and worker syntax remains valid. + +- [ ] **Step 5: Commit** + +```bash +git add app/lib/services/book_import_service.dart app/lib/services/book_import_service_stub.dart app/test/services/book_cover_storage_test.dart +git commit -m "fix: evict decoded covers after local mutation" +``` + +### Task 4: Final verification and browser smoke test + +**Files:** +- No production files expected beyond Tasks 1-3. + +- [ ] **Step 1: Run complete automated verification** + +```bash +cd app +flutter test --reporter compact +flutter analyze +dart format --output=none --set-exit-if-changed lib test +node --check web/book_worker.js +git diff --check +``` + +Expected: all tests pass, analysis reports no issues, formatting changes zero files, JavaScript syntax is valid, and the diff check is clean. + +- [ ] **Step 2: Perform live navigation verification** + +With a signed-in library containing a covered book: + +1. Open the library grid and wait for the cover to render once. +2. Open book details and confirm the cover appears without a placeholder flash. +3. Navigate back to the library and confirm the same behavior. +4. Switch between grid and list layouts and confirm no storage reread for the same key. +5. Repeat with a guest book after a first render. + +Use the existing CDP cover trace to confirm subsequent mounts do not issue another `getCover` request for an image still held by Flutter's image cache. + +- [ ] **Step 3: Review branch state** + +```bash +git status --short --branch +git log --oneline -8 +``` + +Expected: the worktree is clean and the new commits are present on `codex/media-storage-pipeline`. From 4630bdef015b1b58d6b56bbc3135f94f3c4afbd6 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 21:20:40 +0300 Subject: [PATCH 36/48] feat: add stable local cover image provider --- app/lib/media/local_cover_image_provider.dart | 71 +++++++++++ .../local_cover_image_provider_test.dart | 118 ++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 app/lib/media/local_cover_image_provider.dart create mode 100644 app/test/media/local_cover_image_provider_test.dart diff --git a/app/lib/media/local_cover_image_provider.dart b/app/lib/media/local_cover_image_provider.dart new file mode 100644 index 0000000..0c35a4e --- /dev/null +++ b/app/lib/media/local_cover_image_provider.dart @@ -0,0 +1,71 @@ +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; + +import 'cover_storage_bucket.dart'; + +@immutable +class LocalCoverImageKey { + const LocalCoverImageKey({required this.scopeKey, required this.bucket, required this.fileId}); + + final String scopeKey; + final CoverStorageBucket bucket; + final String fileId; + + @override + bool operator ==(Object other) { + return other is LocalCoverImageKey && + other.scopeKey == scopeKey && + other.bucket == bucket && + other.fileId == fileId; + } + + @override + int get hashCode => Object.hash(scopeKey, bucket, fileId); + + @override + String toString() { + return 'LocalCoverImageKey(' + 'scopeKey: $scopeKey, bucket: $bucket, fileId: $fileId)'; + } +} + +class LocalCoverImageProvider extends ImageProvider { + LocalCoverImageProvider({ + required String scopeKey, + required CoverStorageBucket bucket, + required String fileId, + required this.loadBytes, + }) : key = LocalCoverImageKey(scopeKey: scopeKey, bucket: bucket, fileId: fileId); + + final LocalCoverImageKey key; + final Future Function() loadBytes; + + @override + Future obtainKey(ImageConfiguration configuration) { + return SynchronousFuture(key); + } + + @override + ImageStreamCompleter loadImage(LocalCoverImageKey key, ImageDecoderCallback decode) { + return MultiFrameImageStreamCompleter(codec: _load(decode), scale: 1, debugLabel: key.toString()); + } + + Future _load(ImageDecoderCallback decode) async { + final bytes = await loadBytes(); + if (bytes == null || bytes.isEmpty) { + throw StateError('Local cover file was not found'); + } + + final buffer = await ui.ImmutableBuffer.fromUint8List(bytes); + return decode(buffer); + } + + static bool evictKey({required String scopeKey, required CoverStorageBucket bucket, required String fileId}) { + return PaintingBinding.instance.imageCache.evict( + LocalCoverImageKey(scopeKey: scopeKey, bucket: bucket, fileId: fileId), + includeLive: true, + ); + } +} diff --git a/app/test/media/local_cover_image_provider_test.dart b/app/test/media/local_cover_image_provider_test.dart new file mode 100644 index 0000000..1e9e49c --- /dev/null +++ b/app/test/media/local_cover_image_provider_test.dart @@ -0,0 +1,118 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/media/cover_storage_bucket.dart'; +import 'package:papyrus/media/local_cover_image_provider.dart'; + +void main() { + setUp(_clearImageCache); + tearDown(_clearImageCache); + + testWidgets('equal local cover keys reuse one filesystem load', (tester) async { + var loads = 0; + + final first = _provider( + loadBytes: () async { + loads++; + return _pngBytes; + }, + ); + final second = _provider( + loadBytes: () async { + loads++; + return _pngBytes; + }, + ); + + await _pumpImage(tester, first); + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + await _pumpImage(tester, second); + + expect(loads, 1); + }); + + test('scope, bucket, and file ID participate in key equality', () { + final key = _provider().key; + + expect(_provider().key, key); + expect(_provider(scopeKey: 'official--user-2').key, isNot(key)); + expect(_provider(bucket: CoverStorageBucket.pending).key, isNot(key)); + expect(_provider(fileId: 'asset-2').key, isNot(key)); + }); + + testWidgets('null bytes surface an image-stream error', (tester) async { + Object? streamError; + final stream = _provider(loadBytes: () async => null).resolve(ImageConfiguration.empty); + final listener = ImageStreamListener( + (_, _) {}, + onError: (Object error, StackTrace? stackTrace) { + streamError = error; + }, + ); + + stream.addListener(listener); + await tester.pump(); + await tester.pump(); + stream.removeListener(listener); + + expect(streamError, isA()); + }); + + testWidgets('evicting a key makes an equal provider load again', (tester) async { + var loads = 0; + Future load() async { + loads++; + return _pngBytes; + } + + await _pumpImage(tester, _provider(loadBytes: load)); + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + + expect( + LocalCoverImageProvider.evictKey( + scopeKey: 'official--user-1', + bucket: CoverStorageBucket.cached, + fileId: 'asset-1', + ), + isTrue, + ); + + await _pumpImage(tester, _provider(loadBytes: load)); + + expect(loads, 2); + }); +} + +final Uint8List _pngBytes = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk' + '+A8AAQUBAScY42YAAAAASUVORK5CYII=', +); + +LocalCoverImageProvider _provider({ + String scopeKey = 'official--user-1', + CoverStorageBucket bucket = CoverStorageBucket.cached, + String fileId = 'asset-1', + Future Function()? loadBytes, +}) { + return LocalCoverImageProvider( + scopeKey: scopeKey, + bucket: bucket, + fileId: fileId, + loadBytes: loadBytes ?? () async => _pngBytes, + ); +} + +Future _pumpImage(WidgetTester tester, LocalCoverImageProvider provider) async { + await tester.pumpWidget(MaterialApp(home: Image(image: provider))); + await tester.pumpAndSettle(); +} + +void _clearImageCache() { + final cache = PaintingBinding.instance.imageCache; + cache.clear(); + cache.clearLiveImages(); +} From 02965dd2335f2f2704136dc9cbe5023a273dc627 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 21:32:46 +0300 Subject: [PATCH 37/48] fix: reuse decoded covers across page transitions --- app/lib/widgets/book/private_book_cover.dart | 80 ++++++++-- .../widgets/book/private_book_cover_test.dart | 151 ++++++++++++++++-- 2 files changed, 207 insertions(+), 24 deletions(-) diff --git a/app/lib/widgets/book/private_book_cover.dart b/app/lib/widgets/book/private_book_cover.dart index 8f958c4..ea3edef 100644 --- a/app/lib/widgets/book/private_book_cover.dart +++ b/app/lib/widgets/book/private_book_cover.dart @@ -2,6 +2,8 @@ import 'dart:typed_data'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:papyrus/media/cover_storage_bucket.dart'; +import 'package:papyrus/media/local_cover_image_provider.dart'; import 'package:papyrus/media/media_cache_service.dart'; import 'package:papyrus/media/media_storage_scope.dart'; import 'package:papyrus/providers/auth_provider.dart'; @@ -46,6 +48,7 @@ class PrivateBookCover extends StatefulWidget { class _PrivateBookCoverState extends State { Future? _coverFuture; + LocalCoverImageProvider? _localCoverProvider; String? _loadKey; @override @@ -73,6 +76,7 @@ class _PrivateBookCoverState extends State { !identical(oldWidget.loadPendingBookCover, widget.loadPendingBookCover) || !identical(oldWidget.loadGuestBookCover, widget.loadGuestBookCover)) { _coverFuture = null; + _localCoverProvider = null; _loadKey = null; _configureInjectedLoader(); if (!_hasInjectedLoaderForCurrentSource) { @@ -125,22 +129,34 @@ class _PrivateBookCoverState extends State { final mediaId = _usableMediaId; final user = authProvider.user; if (mediaId != null) { - if (!authProvider.isSignedIn || authProvider.isOfflineMode || user == null) return; + if (!authProvider.isSignedIn || authProvider.isOfflineMode || user == null) { + _clearProviderLoader(); + return; + } - if (syncSettings == null) return; + if (syncSettings == null) { + _clearProviderLoader(); + return; + } final scope = MediaStorageScope(profileKey: syncSettings.activeProfileKey, userId: user.userId); - final key = '${scope.persistenceKey}:$mediaId'; + final key = '${scope.persistenceKey}:${CoverStorageBucket.cached.name}:$mediaId'; if (_loadKey == key) return; final importService = context.read(); final cacheService = context.read(); _loadKey = key; - _coverFuture = cacheService.ensureCoverCached( - scope: scope, - mediaId: mediaId, - readLocalCover: importService.getCoverFile, - writeLocalCover: importService.storeCoverFile, - downloadMedia: authProvider.downloadMedia, + _coverFuture = null; + _localCoverProvider = LocalCoverImageProvider( + scopeKey: scope.persistenceKey, + bucket: CoverStorageBucket.cached, + fileId: mediaId, + loadBytes: () => cacheService.ensureCoverCached( + scope: scope, + mediaId: mediaId, + readLocalCover: importService.getCoverFile, + writeLocalCover: importService.storeCoverFile, + downloadMedia: authProvider.downloadMedia, + ), ); return; } @@ -149,21 +165,43 @@ class _PrivateBookCoverState extends State { if (bookId == null) return; if (!authProvider.isOfflineMode && user != null) { - if (syncSettings == null) return; + if (syncSettings == null) { + _clearProviderLoader(); + return; + } final scope = MediaStorageScope(profileKey: syncSettings.activeProfileKey, userId: user.userId); - final key = 'local:$bookId:account:${scope.persistenceKey}'; + final key = '${scope.persistenceKey}:${CoverStorageBucket.pending.name}:$bookId'; if (_loadKey == key) return; _loadKey = key; final loader = widget.loadPendingBookCover ?? context.read().getPendingCoverFile; - _coverFuture = loader(scope, bookId); + _coverFuture = null; + _localCoverProvider = LocalCoverImageProvider( + scopeKey: scope.persistenceKey, + bucket: CoverStorageBucket.pending, + fileId: bookId, + loadBytes: () => loader(scope, bookId), + ); return; } - final key = 'local:$bookId:guest:no-scope'; + final guestScopeKey = MediaStorageScope.localGuest.persistenceKey; + final key = '$guestScopeKey:${CoverStorageBucket.guestBooks.name}:$bookId'; if (_loadKey == key) return; _loadKey = key; final loader = widget.loadGuestBookCover ?? context.read().getGuestCoverFile; - _coverFuture = loader(bookId); + _coverFuture = null; + _localCoverProvider = LocalCoverImageProvider( + scopeKey: guestScopeKey, + bucket: CoverStorageBucket.guestBooks, + fileId: bookId, + loadBytes: () => loader(bookId), + ); + } + + void _clearProviderLoader() { + _loadKey = null; + _coverFuture = null; + _localCoverProvider = null; } bool get _hasPublicUrl => widget.imageUrl != null && widget.imageUrl!.isNotEmpty; @@ -205,6 +243,20 @@ class _PrivateBookCoverState extends State { ); } + final localCoverProvider = _localCoverProvider; + if (localCoverProvider != null) { + return Image( + image: localCoverProvider, + fit: widget.fit, + gaplessPlayback: true, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) return child; + return widget.placeholder; + }, + errorBuilder: (_, _, _) => widget.placeholder, + ); + } + final coverFuture = _coverFuture; if (coverFuture == null) return widget.placeholder; diff --git a/app/test/widgets/book/private_book_cover_test.dart b/app/test/widgets/book/private_book_cover_test.dart index 448b713..3b1711c 100644 --- a/app/test/widgets/book/private_book_cover_test.dart +++ b/app/test/widgets/book/private_book_cover_test.dart @@ -9,9 +9,14 @@ 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/media/cover_storage_bucket.dart'; +import 'package:papyrus/media/local_cover_image_provider.dart'; +import 'package:papyrus/media/media_cache_service.dart'; import 'package:papyrus/media/media_storage_scope.dart'; import 'package:papyrus/providers/auth_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/widgets/book/private_book_cover.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -53,6 +58,58 @@ void main() { setUp(() { SharedPreferences.setMockInitialValues({}); + final cache = PaintingBinding.instance.imageCache; + cache.clear(); + cache.clearLiveImages(); + }); + + tearDown(() { + final cache = PaintingBinding.instance.imageCache; + cache.clear(); + cache.clearLiveImages(); + }); + + testWidgets('provider-backed media cover reuses its decoded image across pages', (tester) async { + final importService = _RecordingBookImportService(Uint8List.fromList(pngBytes)); + final harness = await _buildProviderHarness(importService: importService); + + PrivateBookCover cover() { + return const PrivateBookCover( + bookId: 'book-1', + mediaId: 'asset-1', + placeholder: SizedBox(key: Key('placeholder')), + ); + } + + await tester.pumpWidget(harness.wrap(Align(child: cover()))); + final firstPageImage = tester.widget(find.byType(Image)); + await tester.runAsync(() async { + final firstFrame = Completer(); + final stream = firstPageImage.image.resolve(ImageConfiguration.empty); + late ImageStreamListener listener; + listener = ImageStreamListener( + (_, _) { + if (!firstFrame.isCompleted) firstFrame.complete(); + }, + onError: (Object error, StackTrace? stackTrace) { + if (!firstFrame.isCompleted) firstFrame.completeError(error, stackTrace); + }, + ); + stream.addListener(listener); + await firstFrame.future; + stream.removeListener(listener); + }); + await tester.pump(); + expect(importService.cachedCoverReads, 1); + + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + await tester.pumpWidget(harness.wrap(Center(child: cover()))); + await tester.pump(); + + expect(importService.cachedCoverReads, 1); + expect(find.byKey(const Key('placeholder')), findsNothing); + expect(find.byType(Image), findsOneWidget); }); testWidgets('private cover renders lazily loaded bytes', (tester) async { @@ -286,20 +343,35 @@ void main() { await tester.pumpAndSettle(); expect(guestLoads, 1); expect(pendingLoads, 0); - expect(identical((tester.widget(find.byType(Image)).image as MemoryImage).bytes, guestBytes), isTrue); + _expectLocalCoverKey( + tester, + scopeKey: MediaStorageScope.localGuest.persistenceKey, + bucket: CoverStorageBucket.guestBooks, + fileId: 'book-1', + ); harness.authProvider.setOfflineMode(false); await harness.authProvider.bootstrap(); await tester.pumpAndSettle(); expect(guestLoads, 1); expect(pendingLoads, 1); - expect(identical((tester.widget(find.byType(Image)).image as MemoryImage).bytes, accountBytes), isTrue); + _expectLocalCoverKey( + tester, + scopeKey: '${SyncSettingsProvider.officialServerId}--user-1', + bucket: CoverStorageBucket.pending, + fileId: 'book-1', + ); harness.authProvider.setOfflineMode(true); await tester.pumpAndSettle(); - expect(guestLoads, 2); + expect(guestLoads, 1); expect(pendingLoads, 1); - expect(identical((tester.widget(find.byType(Image)).image as MemoryImage).bytes, guestBytes), isTrue); + _expectLocalCoverKey( + tester, + scopeKey: MediaStorageScope.localGuest.persistenceKey, + bucket: CoverStorageBucket.guestBooks, + fileId: 'book-1', + ); }); testWidgets('profile changes reload pending cover in the new scope', (tester) async { @@ -327,7 +399,13 @@ void main() { await tester.pumpAndSettle(); expect(scopes.map((scope) => scope.profileKey), [SyncSettingsProvider.officialServerId, startsWith('custom-')]); - expect(identical((tester.widget(find.byType(Image)).image as MemoryImage).bytes, customBytes), isTrue); + final customScope = scopes.last; + _expectLocalCoverKey( + tester, + scopeKey: customScope.persistenceKey, + bucket: CoverStorageBucket.pending, + fileId: 'book-1', + ); }); testWidgets('injected to provider transition subscribes to later profile changes', (tester) async { @@ -363,13 +441,23 @@ void main() { await tester.pumpWidget(build(injected: false)); await tester.pumpAndSettle(); expect(scopes.map((scope) => scope.profileKey), [SyncSettingsProvider.officialServerId]); - expect(identical((tester.widget(find.byType(Image)).image as MemoryImage).bytes, officialBytes), isTrue); + _expectLocalCoverKey( + tester, + scopeKey: scopes.single.persistenceKey, + bucket: CoverStorageBucket.pending, + fileId: 'book-1', + ); harness.syncSettings.setCustomServerUrls(apiUrl: 'https://custom.test', powerSyncUrl: 'https://sync.custom.test'); await tester.pumpAndSettle(); expect(scopes.map((scope) => scope.profileKey), [SyncSettingsProvider.officialServerId, startsWith('custom-')]); - expect(identical((tester.widget(find.byType(Image)).image as MemoryImage).bytes, customBytes), isTrue); + _expectLocalCoverKey( + tester, + scopeKey: scopes.last.persistenceKey, + bucket: CoverStorageBucket.pending, + fileId: 'book-1', + ); }); testWidgets('local cover falls back to placeholder when there are no bytes', (tester) async { @@ -490,25 +578,49 @@ void main() { }); } +void _expectLocalCoverKey( + WidgetTester tester, { + required String scopeKey, + required CoverStorageBucket bucket, + required String fileId, +}) { + final provider = tester.widget(find.byType(Image)).image as LocalCoverImageProvider; + expect(provider.key, LocalCoverImageKey(scopeKey: scopeKey, bucket: bucket, fileId: fileId)); +} + class _ProviderHarness { - const _ProviderHarness({required this.repository, required this.authProvider, required this.syncSettings}); + const _ProviderHarness({ + required this.repository, + required this.authProvider, + required this.syncSettings, + required this.importService, + required this.cacheService, + }); final _GatedAuthRepository repository; final AuthProvider authProvider; final SyncSettingsProvider syncSettings; + final BookImportService importService; + final MediaCacheService cacheService; Widget wrap(Widget child) { return MultiProvider( providers: [ ChangeNotifierProvider.value(value: authProvider), ChangeNotifierProvider.value(value: syncSettings), + Provider.value(value: importService), + Provider.value(value: cacheService), ], child: MaterialApp(home: child), ); } } -Future<_ProviderHarness> _buildProviderHarness({bool offline = false}) async { +Future<_ProviderHarness> _buildProviderHarness({ + bool offline = false, + BookImportService? importService, + MediaCacheService? cacheService, +}) async { final prefs = await SharedPreferences.getInstance(); final tokens = AuthTokens( accessToken: 'access-token', @@ -539,5 +651,24 @@ Future<_ProviderHarness> _buildProviderHarness({bool offline = false}) async { powerSyncServiceUri: Uri.parse('https://sync.test'), ), ); - return _ProviderHarness(repository: repository, authProvider: authProvider, syncSettings: syncSettings); + return _ProviderHarness( + repository: repository, + authProvider: authProvider, + syncSettings: syncSettings, + importService: importService ?? BookImportService(), + cacheService: cacheService ?? MediaCacheService(), + ); +} + +class _RecordingBookImportService extends BookImportService { + _RecordingBookImportService(this.coverBytes); + + final Uint8List coverBytes; + int cachedCoverReads = 0; + + @override + Future getCoverFile(MediaStorageScope scope, String mediaId) async { + cachedCoverReads++; + return coverBytes; + } } From 21d254ae2b4fc8275d105530227038c95b8ae919 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 21:37:30 +0300 Subject: [PATCH 38/48] test: cover pending and guest page cache reuse --- .../widgets/book/private_book_cover_test.dart | 100 ++++++++++++++---- 1 file changed, 82 insertions(+), 18 deletions(-) diff --git a/app/test/widgets/book/private_book_cover_test.dart b/app/test/widgets/book/private_book_cover_test.dart index 3b1711c..f31ba8d 100644 --- a/app/test/widgets/book/private_book_cover_test.dart +++ b/app/test/widgets/book/private_book_cover_test.dart @@ -82,24 +82,7 @@ void main() { } await tester.pumpWidget(harness.wrap(Align(child: cover()))); - final firstPageImage = tester.widget(find.byType(Image)); - await tester.runAsync(() async { - final firstFrame = Completer(); - final stream = firstPageImage.image.resolve(ImageConfiguration.empty); - late ImageStreamListener listener; - listener = ImageStreamListener( - (_, _) { - if (!firstFrame.isCompleted) firstFrame.complete(); - }, - onError: (Object error, StackTrace? stackTrace) { - if (!firstFrame.isCompleted) firstFrame.completeError(error, stackTrace); - }, - ); - stream.addListener(listener); - await firstFrame.future; - stream.removeListener(listener); - }); - await tester.pump(); + await _waitForDecodedCover(tester); expect(importService.cachedCoverReads, 1); await tester.pumpWidget(const SizedBox()); @@ -112,6 +95,66 @@ void main() { expect(find.byType(Image), findsOneWidget); }); + testWidgets('provider-backed pending cover reuses its decoded image across pages', (tester) async { + final harness = await _buildProviderHarness(); + var pendingReads = 0; + Future loadPending(MediaStorageScope scope, String bookId) async { + pendingReads++; + return Uint8List.fromList(pngBytes); + } + + PrivateBookCover cover() { + return PrivateBookCover( + bookId: 'book-1', + loadPendingBookCover: loadPending, + placeholder: const SizedBox(key: Key('placeholder')), + ); + } + + await tester.pumpWidget(harness.wrap(Align(child: cover()))); + await _waitForDecodedCover(tester); + expect(pendingReads, 1); + + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + await tester.pumpWidget(harness.wrap(Center(child: cover()))); + await tester.pump(); + + expect(pendingReads, 1); + expect(find.byKey(const Key('placeholder')), findsNothing); + expect(find.byType(Image), findsOneWidget); + }); + + testWidgets('provider-backed guest cover reuses its decoded image across pages', (tester) async { + final harness = await _buildProviderHarness(offline: true); + var guestReads = 0; + Future loadGuest(String bookId) async { + guestReads++; + return Uint8List.fromList(pngBytes); + } + + PrivateBookCover cover() { + return PrivateBookCover( + bookId: 'book-1', + loadGuestBookCover: loadGuest, + placeholder: const SizedBox(key: Key('placeholder')), + ); + } + + await tester.pumpWidget(harness.wrap(Align(child: cover()))); + await _waitForDecodedCover(tester); + expect(guestReads, 1); + + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + await tester.pumpWidget(harness.wrap(Center(child: cover()))); + await tester.pump(); + + expect(guestReads, 1); + expect(find.byKey(const Key('placeholder')), findsNothing); + expect(find.byType(Image), findsOneWidget); + }); + testWidgets('private cover renders lazily loaded bytes', (tester) async { var loads = 0; await tester.pumpWidget( @@ -578,6 +621,27 @@ void main() { }); } +Future _waitForDecodedCover(WidgetTester tester) async { + final image = tester.widget(find.byType(Image)); + await tester.runAsync(() async { + final firstFrame = Completer(); + final stream = image.image.resolve(ImageConfiguration.empty); + late ImageStreamListener listener; + listener = ImageStreamListener( + (_, _) { + if (!firstFrame.isCompleted) firstFrame.complete(); + }, + onError: (Object error, StackTrace? stackTrace) { + if (!firstFrame.isCompleted) firstFrame.completeError(error, stackTrace); + }, + ); + stream.addListener(listener); + await firstFrame.future; + stream.removeListener(listener); + }); + await tester.pump(); +} + void _expectLocalCoverKey( WidgetTester tester, { required String scopeKey, From 8b5361370fe52a4da5b885a02a0aaf8650fdafa7 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 21:48:04 +0300 Subject: [PATCH 39/48] fix: evict decoded covers after local removal --- app/lib/services/book_import_service.dart | 7 + .../services/book_import_service_stub.dart | 2 + .../services/book_cover_storage_test.dart | 198 ++++++++++++++++++ 3 files changed, 207 insertions(+) diff --git a/app/lib/services/book_import_service.dart b/app/lib/services/book_import_service.dart index 0e4306a..9cc5380 100644 --- a/app/lib/services/book_import_service.dart +++ b/app/lib/services/book_import_service.dart @@ -4,6 +4,7 @@ import 'dart:js_interop_unsafe'; import 'package:flutter/foundation.dart'; import 'package:papyrus/media/cover_storage_bucket.dart'; +import 'package:papyrus/media/local_cover_image_provider.dart'; import 'package:papyrus/media/media_storage_scope.dart'; import 'package:papyrus/services/book_import_result.dart'; import 'package:uuid/uuid.dart'; @@ -310,6 +311,11 @@ class BookImportService { mediaId: bookId, targetMediaId: mediaId, ); + LocalCoverImageProvider.evictKey( + scopeKey: scope.persistenceKey, + bucket: CoverStorageBucket.pending, + fileId: bookId, + ); } Future _getCoverFile(MediaStorageScope scope, CoverStorageBucket bucket, String id) async { @@ -327,6 +333,7 @@ class BookImportService { Future _deleteCoverFile(MediaStorageScope scope, CoverStorageBucket bucket, String id) async { await _sendCoverRequest(type: 'deleteCover', scope: scope, bucket: bucket, mediaId: id); + LocalCoverImageProvider.evictKey(scopeKey: scope.persistenceKey, bucket: bucket, fileId: id); } Future clearCoverFiles(MediaStorageScope scope) async { diff --git a/app/lib/services/book_import_service_stub.dart b/app/lib/services/book_import_service_stub.dart index 3969923..60f10d1 100644 --- a/app/lib/services/book_import_service_stub.dart +++ b/app/lib/services/book_import_service_stub.dart @@ -4,6 +4,7 @@ import 'dart:typed_data'; import 'package:crypto/crypto.dart'; import 'package:papyrus/media/cover_storage_bucket.dart'; +import 'package:papyrus/media/local_cover_image_provider.dart'; import 'package:papyrus/media/media_storage_scope.dart'; import 'package:papyrus/services/book_import_result.dart'; import 'package:papyrus/services/file_metadata_service.dart'; @@ -283,6 +284,7 @@ class BookImportService { if (await file.exists()) { await file.delete(); } + LocalCoverImageProvider.evictKey(scopeKey: scope.persistenceKey, bucket: bucket, fileId: id); } Future _coverFile(MediaStorageScope scope, CoverStorageBucket bucket, String id) async { diff --git a/app/test/services/book_cover_storage_test.dart b/app/test/services/book_cover_storage_test.dart index 484d737..33aaa44 100644 --- a/app/test/services/book_cover_storage_test.dart +++ b/app/test/services/book_cover_storage_test.dart @@ -1,8 +1,11 @@ +import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/media/cover_storage_bucket.dart'; +import 'package:papyrus/media/local_cover_image_provider.dart'; import 'package:papyrus/media/media_storage_scope.dart'; import 'package:papyrus/services/book_import_service_stub.dart'; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; @@ -25,12 +28,14 @@ void main() { late BookImportService service; setUp(() async { + _clearImageCache(); root = await Directory.systemTemp.createTemp('papyrus-cover-cache-'); PathProviderPlatform.instance = _FakePathProvider(root); service = BookImportService(); }); tearDown(() async { + _clearImageCache(); if (root.existsSync()) { await root.delete(recursive: true); } @@ -175,6 +180,117 @@ void main() { expect(await service.getCoverFile(scope, 'asset-1'), isNull); }); + for (final deletion in <({String name, MediaStorageScope scope, CoverStorageBucket bucket, String id})>[ + ( + name: 'cached', + scope: MediaStorageScope(profileKey: 'official', userId: 'user-1'), + bucket: CoverStorageBucket.cached, + id: 'asset-1', + ), + ( + name: 'pending', + scope: MediaStorageScope(profileKey: 'official', userId: 'user-1'), + bucket: CoverStorageBucket.pending, + id: 'book-1', + ), + (name: 'guest', scope: MediaStorageScope.localGuest, bucket: CoverStorageBucket.guestBooks, id: 'book-1'), + ]) { + testWidgets('deleting a ${deletion.name} cover evicts its decoded image', (tester) async { + var loads = 0; + Future loadBytes() async { + loads++; + return _pngBytes; + } + + await tester.runAsync(() => _storeCover(service, deletion.scope, deletion.bucket, deletion.id)); + await _pumpCover(tester, deletion.scope, deletion.bucket, deletion.id, loadBytes); + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + + await tester.runAsync(() => _deleteCover(service, deletion.scope, deletion.bucket, deletion.id)); + await _pumpCover(tester, deletion.scope, deletion.bucket, deletion.id, loadBytes); + + expect(loads, 2); + }); + } + + testWidgets('promotion evicts pending decoded image but preserves cached target', (tester) async { + final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + var pendingLoads = 0; + var cachedLoads = 0; + + await tester.runAsync(() async { + await service.storePendingCoverFile(scope, 'book-1', _pngBytes); + await service.storeCoverFile(scope, 'asset-1', _pngBytes); + }); + await _pumpCover(tester, scope, CoverStorageBucket.pending, 'book-1', () async { + pendingLoads++; + return _pngBytes; + }); + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + await _pumpCover(tester, scope, CoverStorageBucket.cached, 'asset-1', () async { + cachedLoads++; + return _pngBytes; + }); + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + + await tester.runAsync(() => service.promotePendingCoverFile(scope, bookId: 'book-1', mediaId: 'asset-1')); + await _pumpCover(tester, scope, CoverStorageBucket.pending, 'book-1', () async { + pendingLoads++; + return _pngBytes; + }); + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + await _pumpCover(tester, scope, CoverStorageBucket.cached, 'asset-1', () async { + cachedLoads++; + return _pngBytes; + }); + + expect(pendingLoads, 2); + expect(cachedLoads, 1); + }); + + testWidgets('ordinary cover stores do not evict a decoded image', (tester) async { + final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + var loads = 0; + Future loadBytes() async { + loads++; + return _pngBytes; + } + + await _pumpCover(tester, scope, CoverStorageBucket.cached, 'asset-1', loadBytes); + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + await tester.runAsync(() => service.storeCoverFile(scope, 'asset-1', _pngBytes)); + await _pumpCover(tester, scope, CoverStorageBucket.cached, 'asset-1', loadBytes); + + expect(loads, 1); + }); + + testWidgets('filesystem deletion failure preserves a decoded image', (tester) async { + final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); + var loads = 0; + Future loadBytes() async { + loads++; + return _pngBytes; + } + + await _pumpCover(tester, scope, CoverStorageBucket.cached, 'asset-1', loadBytes); + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + + final blocker = File('${root.path}/not-a-directory')..writeAsStringSync('blocked'); + PathProviderPlatform.instance = _FakePathProvider(Directory(blocker.path)); + await tester.runAsync( + () => expectLater(service.deleteCoverFile(scope, 'asset-1'), throwsA(isA())), + ); + await _pumpCover(tester, scope, CoverStorageBucket.cached, 'asset-1', loadBytes); + + expect(loads, 1); + }); + test('clearing covers removes only the selected scope', () async { final first = MediaStorageScope(profileKey: 'official', userId: 'user-1'); final second = MediaStorageScope(profileKey: 'official', userId: 'user-2'); @@ -298,6 +414,88 @@ vm.runInContext(source, context); expect(helper, contains("message['bucket'] = bucket.pathComponent.toJS;")); expect(helper, isNot(contains('bytes.offsetInBytes == 0 && bytes.lengthInBytes == bytes.buffer.lengthInBytes'))); }); + + test('web cover mutations evict only successfully removed decoded keys', () { + final source = File('lib/services/book_import_service.dart').readAsStringSync(); + final deleteHelper = source.substring( + source.indexOf('Future _deleteCoverFile'), + source.indexOf('Future clearCoverFiles'), + ); + final promotion = source.substring( + source.indexOf('Future promotePendingCoverFile'), + source.indexOf('Future _getCoverFile'), + ); + final storeHelper = source.substring( + source.indexOf('Future _storeCoverFile'), + source.indexOf('Future _deleteCoverFile'), + ); + + expect(source, contains("import 'package:papyrus/media/local_cover_image_provider.dart';")); + expect( + deleteHelper.indexOf("await _sendCoverRequest(type: 'deleteCover'"), + lessThan(deleteHelper.indexOf('LocalCoverImageProvider.evictKey(')), + ); + expect(deleteHelper, contains('scopeKey: scope.persistenceKey')); + expect(deleteHelper, contains('bucket: bucket')); + expect(deleteHelper, contains('fileId: id')); + expect( + promotion.indexOf("await _sendCoverRequest(\n type: 'promoteCover'"), + lessThan(promotion.indexOf('LocalCoverImageProvider.evictKey(')), + ); + expect(promotion, contains('bucket: CoverStorageBucket.pending')); + expect(promotion, contains('fileId: bookId')); + expect(promotion, isNot(contains('fileId: mediaId'))); + expect(storeHelper, isNot(contains('LocalCoverImageProvider.evictKey('))); + }); +} + +final Uint8List _pngBytes = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk' + '+A8AAQUBAScY42YAAAAASUVORK5CYII=', +); + +Future _storeCover(BookImportService service, MediaStorageScope scope, CoverStorageBucket bucket, String id) { + return switch (bucket) { + CoverStorageBucket.cached => service.storeCoverFile(scope, id, _pngBytes), + CoverStorageBucket.pending => service.storePendingCoverFile(scope, id, _pngBytes), + CoverStorageBucket.guestBooks => service.storeGuestCoverFile(id, _pngBytes), + }; +} + +Future _deleteCover(BookImportService service, MediaStorageScope scope, CoverStorageBucket bucket, String id) { + return switch (bucket) { + CoverStorageBucket.cached => service.deleteCoverFile(scope, id), + CoverStorageBucket.pending => service.deletePendingCoverFile(scope, id), + CoverStorageBucket.guestBooks => service.deleteGuestCoverFile(id), + }; +} + +Future _pumpCover( + WidgetTester tester, + MediaStorageScope scope, + CoverStorageBucket bucket, + String id, + Future Function() loadBytes, +) async { + await tester.pumpWidget( + MaterialApp( + home: Image( + image: LocalCoverImageProvider( + scopeKey: scope.persistenceKey, + bucket: bucket, + fileId: id, + loadBytes: loadBytes, + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +void _clearImageCache() { + final cache = PaintingBinding.instance.imageCache; + cache.clear(); + cache.clearLiveImages(); } bool _bytesEqual(Uint8List first, Uint8List? second) { From 795bfba764170858609a36deefcd569872d5c6f8 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 11 Jul 2026 21:56:56 +0300 Subject: [PATCH 40/48] fix: preserve pending cover cache on no-op promotion --- app/lib/services/book_import_service.dart | 6 +- .../services/book_cover_storage_test.dart | 93 ++++++++++++++++--- app/web/book_worker.js | 8 +- 3 files changed, 88 insertions(+), 19 deletions(-) diff --git a/app/lib/services/book_import_service.dart b/app/lib/services/book_import_service.dart index 9cc5380..c1c91f0 100644 --- a/app/lib/services/book_import_service.dart +++ b/app/lib/services/book_import_service.dart @@ -304,13 +304,17 @@ class BookImportService { required String bookId, required String mediaId, }) async { - await _sendCoverRequest( + final obj = await _sendCoverRequest( type: 'promoteCover', scope: scope, bucket: CoverStorageBucket.pending, mediaId: bookId, targetMediaId: mediaId, ); + final promoted = obj['promoted']; + if (promoted == null || promoted.isNull || promoted.isUndefined || !(promoted as JSBoolean).toDart) { + return; + } LocalCoverImageProvider.evictKey( scopeKey: scope.persistenceKey, bucket: CoverStorageBucket.pending, diff --git a/app/test/services/book_cover_storage_test.dart b/app/test/services/book_cover_storage_test.dart index 33aaa44..700420e 100644 --- a/app/test/services/book_cover_storage_test.dart +++ b/app/test/services/book_cover_storage_test.dart @@ -204,10 +204,10 @@ void main() { await tester.runAsync(() => _storeCover(service, deletion.scope, deletion.bucket, deletion.id)); await _pumpCover(tester, deletion.scope, deletion.bucket, deletion.id, loadBytes); - await tester.pumpWidget(const SizedBox()); - await tester.pump(); await tester.runAsync(() => _deleteCover(service, deletion.scope, deletion.bucket, deletion.id)); + await tester.pumpWidget(const SizedBox()); + await tester.pump(); await _pumpCover(tester, deletion.scope, deletion.bucket, deletion.id, loadBytes); expect(loads, 2); @@ -223,20 +223,41 @@ void main() { await service.storePendingCoverFile(scope, 'book-1', _pngBytes); await service.storeCoverFile(scope, 'asset-1', _pngBytes); }); - await _pumpCover(tester, scope, CoverStorageBucket.pending, 'book-1', () async { - pendingLoads++; - return _pngBytes; - }); - await tester.pumpWidget(const SizedBox()); - await tester.pump(); - await _pumpCover(tester, scope, CoverStorageBucket.cached, 'asset-1', () async { - cachedLoads++; - return _pngBytes; - }); - await tester.pumpWidget(const SizedBox()); - await tester.pump(); + await tester.pumpWidget( + MaterialApp( + home: Row( + children: [ + Image( + image: LocalCoverImageProvider( + scopeKey: scope.persistenceKey, + bucket: CoverStorageBucket.pending, + fileId: 'book-1', + loadBytes: () async { + pendingLoads++; + return _pngBytes; + }, + ), + ), + Image( + image: LocalCoverImageProvider( + scopeKey: scope.persistenceKey, + bucket: CoverStorageBucket.cached, + fileId: 'asset-1', + loadBytes: () async { + cachedLoads++; + return _pngBytes; + }, + ), + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); await tester.runAsync(() => service.promotePendingCoverFile(scope, bookId: 'book-1', mediaId: 'asset-1')); + await tester.pumpWidget(const SizedBox()); + await tester.pump(); await _pumpCover(tester, scope, CoverStorageBucket.pending, 'book-1', () async { pendingLoads++; return _pngBytes; @@ -403,6 +424,45 @@ vm.runInContext(source, context); expect(result.exitCode, 0, reason: '${result.stdout}\n${result.stderr}'); }); + test('book worker reports whether pending cover promotion occurred', () async { + final result = await Process.run('node', const [ + '-e', + r''' +const fs = require('fs'); +const vm = require('vm'); +const source = fs.readFileSync('web/book_worker.js', 'utf8'); +const messages = []; +const calls = []; +const context = { + self: {}, + postMessage(message) { messages.push(message); }, + navigator: { locks: { async request(_, callback) { return callback(); } } }, +}; +vm.createContext(context); +vm.runInContext(source, context); +(async () => { + context.opfsReadCover = async () => null; + context.opfsWriteCover = async () => calls.push('write'); + context.opfsDeleteCover = async () => calls.push('delete'); + await context.handlePromoteCover({ + requestId: 'missing', scopeKey: 'scope', bucket: 'pending', mediaId: 'book-1', targetMediaId: 'asset-1', + }); + if (messages.at(-1).promoted !== false) process.exit(1); + if (calls.length !== 0) process.exit(2); + + context.opfsReadCover = async () => new Uint8Array([1]); + await context.handlePromoteCover({ + requestId: 'present', scopeKey: 'scope', bucket: 'pending', mediaId: 'book-1', targetMediaId: 'asset-1', + }); + if (messages.at(-1).promoted !== true) process.exit(3); + if (calls.join(',') !== 'write,delete') process.exit(4); +})().catch(() => process.exit(5)); +''', + ]); + + expect(result.exitCode, 0, reason: '${result.stdout}\n${result.stderr}'); + }); + test('web cover writes transfer a copy so caller bytes remain renderable', () { final source = File('lib/services/book_import_service.dart').readAsStringSync(); final helper = source.substring( @@ -440,8 +500,11 @@ vm.runInContext(source, context); expect(deleteHelper, contains('fileId: id')); expect( promotion.indexOf("await _sendCoverRequest(\n type: 'promoteCover'"), - lessThan(promotion.indexOf('LocalCoverImageProvider.evictKey(')), + lessThan(promotion.indexOf("obj['promoted']")), ); + expect(promotion.indexOf("obj['promoted']"), lessThan(promotion.indexOf('LocalCoverImageProvider.evictKey('))); + expect(promotion, contains('!(promoted as JSBoolean).toDart')); + expect(promotion.indexOf('return;'), lessThan(promotion.indexOf('LocalCoverImageProvider.evictKey('))); expect(promotion, contains('bucket: CoverStorageBucket.pending')); expect(promotion, contains('fileId: bookId')); expect(promotion, isNot(contains('fileId: mediaId'))); diff --git a/app/web/book_worker.js b/app/web/book_worker.js index 5dc4a69..31c70cc 100644 --- a/app/web/book_worker.js +++ b/app/web/book_worker.js @@ -20,6 +20,7 @@ * { type: 'success', action: 'delete', bookId } * { type: 'success', action: 'getFile', bookId, fileData } * { type: 'success', action: 'storeFile', bookId } + * { type: 'success', action: 'promoteCover', requestId, promoted } * { type: 'error', message } */ @@ -161,19 +162,20 @@ async function handlePromoteCover(msg) { throw new Error('promoteCover requires the pending bucket'); } validateFilePart(targetMediaId, 'targetMediaId'); - await withCoverLocks( + const promoted = await withCoverLocks( [ [scopeKey, bucket, mediaId], [scopeKey, 'cached', targetMediaId], ], async () => { const bytes = await opfsReadCover(scopeKey, bucket, mediaId); - if (!bytes) return; + if (!bytes) return false; await opfsWriteCover(scopeKey, 'cached', targetMediaId, bytes); await opfsDeleteCover(scopeKey, bucket, mediaId); + return true; }, ); - postMessage({ type: 'success', action: 'promoteCover', requestId }); + postMessage({ type: 'success', action: 'promoteCover', requestId, promoted }); } async function handleClearCovers(msg) { From 1fa596fb39bee290c2e306ec3b98d2ba96da110e Mon Sep 17 00:00:00 2001 From: Eoic Date: Sun, 12 Jul 2026 15:55:09 +0300 Subject: [PATCH 41/48] feat: enhance cover image handling with book and media IDs --- app/lib/pages/book_edit_page.dart | 2 ++ app/lib/services/book_import_service.dart | 11 +-------- .../services/book_import_service_stub.dart | 5 +++- .../widgets/book_edit/cover_image_picker.dart | 14 +++++++++++ .../services/book_cover_storage_test.dart | 16 ++++--------- .../book_edit/cover_image_picker_test.dart | 24 +++++++++++++++++++ 6 files changed, 49 insertions(+), 23 deletions(-) create mode 100644 app/test/widgets/book_edit/cover_image_picker_test.dart diff --git a/app/lib/pages/book_edit_page.dart b/app/lib/pages/book_edit_page.dart index 61eba4a..7e6f134 100644 --- a/app/lib/pages/book_edit_page.dart +++ b/app/lib/pages/book_edit_page.dart @@ -505,6 +505,8 @@ class _BookEditPageState extends State { Widget _buildCoverSection(BuildContext context, BookEditProvider provider, {required bool isDesktop}) { return CoverImagePicker( + bookId: provider.editedBook?.id, + mediaId: provider.editedBook?.coverMediaId, initialUrl: provider.editedBook?.coverUrl, initialBytes: provider.coverImageBytes, isDesktop: isDesktop, diff --git a/app/lib/services/book_import_service.dart b/app/lib/services/book_import_service.dart index c1c91f0..244ca78 100644 --- a/app/lib/services/book_import_service.dart +++ b/app/lib/services/book_import_service.dart @@ -304,22 +304,13 @@ class BookImportService { required String bookId, required String mediaId, }) async { - final obj = await _sendCoverRequest( + await _sendCoverRequest( type: 'promoteCover', scope: scope, bucket: CoverStorageBucket.pending, mediaId: bookId, targetMediaId: mediaId, ); - final promoted = obj['promoted']; - if (promoted == null || promoted.isNull || promoted.isUndefined || !(promoted as JSBoolean).toDart) { - return; - } - LocalCoverImageProvider.evictKey( - scopeKey: scope.persistenceKey, - bucket: CoverStorageBucket.pending, - fileId: bookId, - ); } Future _getCoverFile(MediaStorageScope scope, CoverStorageBucket bucket, String id) async { diff --git a/app/lib/services/book_import_service_stub.dart b/app/lib/services/book_import_service_stub.dart index 60f10d1..c697562 100644 --- a/app/lib/services/book_import_service_stub.dart +++ b/app/lib/services/book_import_service_stub.dart @@ -206,7 +206,10 @@ class BookImportService { mediaId, () => _writeCoverFile(scope, CoverStorageBucket.cached, mediaId, bytes), ); - await _removeCoverFile(scope, CoverStorageBucket.pending, bookId); + final pendingFile = await _coverFile(scope, CoverStorageBucket.pending, bookId); + if (await pendingFile.exists()) { + await pendingFile.delete(); + } }); } diff --git a/app/lib/widgets/book_edit/cover_image_picker.dart b/app/lib/widgets/book_edit/cover_image_picker.dart index a818651..94f2706 100644 --- a/app/lib/widgets/book_edit/cover_image_picker.dart +++ b/app/lib/widgets/book_edit/cover_image_picker.dart @@ -2,9 +2,12 @@ import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/book/private_book_cover.dart'; /// Widget for picking book cover images from file or URL. class CoverImagePicker extends StatefulWidget { + final String? bookId; + final String? mediaId; final String? initialUrl; final Uint8List? initialBytes; final void Function(String? url) onUrlChanged; @@ -17,6 +20,8 @@ class CoverImagePicker extends StatefulWidget { const CoverImagePicker({ super.key, + this.bookId, + this.mediaId, this.initialUrl, this.initialBytes, required this.onUrlChanged, @@ -225,6 +230,15 @@ class _CoverImagePickerState extends State { ); } + if (widget.bookId?.isNotEmpty == true || widget.mediaId?.isNotEmpty == true) { + return PrivateBookCover( + bookId: widget.bookId, + mediaId: widget.mediaId, + fit: BoxFit.cover, + placeholder: _buildPlaceholder(context), + ); + } + return _buildPlaceholder(context); } diff --git a/app/test/services/book_cover_storage_test.dart b/app/test/services/book_cover_storage_test.dart index 700420e..473b302 100644 --- a/app/test/services/book_cover_storage_test.dart +++ b/app/test/services/book_cover_storage_test.dart @@ -214,7 +214,7 @@ void main() { }); } - testWidgets('promotion evicts pending decoded image but preserves cached target', (tester) async { + testWidgets('promotion preserves decoded pending and cached covers', (tester) async { final scope = MediaStorageScope(profileKey: 'official', userId: 'user-1'); var pendingLoads = 0; var cachedLoads = 0; @@ -269,7 +269,7 @@ void main() { return _pngBytes; }); - expect(pendingLoads, 2); + expect(pendingLoads, 1); expect(cachedLoads, 1); }); @@ -498,16 +498,8 @@ vm.runInContext(source, context); expect(deleteHelper, contains('scopeKey: scope.persistenceKey')); expect(deleteHelper, contains('bucket: bucket')); expect(deleteHelper, contains('fileId: id')); - expect( - promotion.indexOf("await _sendCoverRequest(\n type: 'promoteCover'"), - lessThan(promotion.indexOf("obj['promoted']")), - ); - expect(promotion.indexOf("obj['promoted']"), lessThan(promotion.indexOf('LocalCoverImageProvider.evictKey('))); - expect(promotion, contains('!(promoted as JSBoolean).toDart')); - expect(promotion.indexOf('return;'), lessThan(promotion.indexOf('LocalCoverImageProvider.evictKey('))); - expect(promotion, contains('bucket: CoverStorageBucket.pending')); - expect(promotion, contains('fileId: bookId')); - expect(promotion, isNot(contains('fileId: mediaId'))); + expect(promotion, contains("await _sendCoverRequest(\n type: 'promoteCover'")); + expect(promotion, isNot(contains('LocalCoverImageProvider.evictKey('))); expect(storeHelper, isNot(contains('LocalCoverImageProvider.evictKey('))); }); } diff --git a/app/test/widgets/book_edit/cover_image_picker_test.dart b/app/test/widgets/book_edit/cover_image_picker_test.dart new file mode 100644 index 0000000..5c47ebe --- /dev/null +++ b/app/test/widgets/book_edit/cover_image_picker_test.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/widgets/book/private_book_cover.dart'; +import 'package:papyrus/widgets/book_edit/cover_image_picker.dart'; + +void main() { + testWidgets('server-backed cover uses the private media renderer', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: CoverImagePicker( + bookId: 'book-1', + mediaId: 'cover-1', + coverWidth: 200, + onUrlChanged: (_) {}, + onFileChanged: (_) {}, + ), + ), + ); + + final cover = tester.widget(find.byType(PrivateBookCover)); + expect(cover.bookId, 'book-1'); + expect(cover.mediaId, 'cover-1'); + }); +} From e7788ddf3cff6446fc1b4a445bdda2d18c7ceedb Mon Sep 17 00:00:00 2001 From: Eoic Date: Sun, 12 Jul 2026 15:59:37 +0300 Subject: [PATCH 42/48] refactor: rename PrivateBookCover to CoverImage and update references --- app/lib/widgets/book/private_book_cover.dart | 10 ++-- .../book_details/book_cover_image.dart | 6 +-- app/lib/widgets/book_details/book_header.dart | 4 +- .../widgets/book_edit/cover_image_picker.dart | 2 +- .../context_menu/book_context_menu.dart | 7 +-- .../dashboard/continue_reading_card.dart | 2 +- .../dashboard/recently_added_section.dart | 2 +- app/lib/widgets/library/book_card.dart | 2 +- app/lib/widgets/library/book_list_item.dart | 2 +- app/lib/widgets/shared/book_group_header.dart | 2 +- .../widgets/shelves/move_to_shelf_sheet.dart | 7 +-- app/lib/widgets/shelves/shelf_card.dart | 2 +- .../widgets/topics/manage_topics_sheet.dart | 7 +-- .../widgets/book/private_book_cover_test.dart | 48 +++++++++---------- .../book_details/book_cover_image_test.dart | 4 +- .../book_edit/cover_image_picker_test.dart | 2 +- app/test/widgets/library/book_card_test.dart | 2 +- .../widgets/library/book_list_item_test.dart | 2 +- .../shared/book_group_header_test.dart | 2 +- .../2026-07-11-local-first-cover-lifecycle.md | 17 +++++-- .../2026-07-11-media-storage-hardening.md | 21 +++++--- ...26-07-11-stable-local-cover-image-cache.md | 8 +++- .../2026-07-11-cover-image-cache-design.md | 6 +-- ...26-07-11-media-storage-hardening-design.md | 2 +- 24 files changed, 87 insertions(+), 82 deletions(-) diff --git a/app/lib/widgets/book/private_book_cover.dart b/app/lib/widgets/book/private_book_cover.dart index ea3edef..6be6d9b 100644 --- a/app/lib/widgets/book/private_book_cover.dart +++ b/app/lib/widgets/book/private_book_cover.dart @@ -18,8 +18,8 @@ typedef PendingBookCoverLoader = Future Function(MediaStorageScope s typedef GuestBookCoverLoader = Future Function(String bookId); /// Renders a public cover URL or a lazily persisted authenticated cover. -class PrivateBookCover extends StatefulWidget { - const PrivateBookCover({ +class CoverImage extends StatefulWidget { + const CoverImage({ super.key, this.bookId, this.imageUrl, @@ -43,10 +43,10 @@ class PrivateBookCover extends StatefulWidget { final GuestBookCoverLoader? loadGuestBookCover; @override - State createState() => _PrivateBookCoverState(); + State createState() => _PrivateBookCoverState(); } -class _PrivateBookCoverState extends State { +class _PrivateBookCoverState extends State { Future? _coverFuture; LocalCoverImageProvider? _localCoverProvider; String? _loadKey; @@ -66,7 +66,7 @@ class _PrivateBookCoverState extends State { } @override - void didUpdateWidget(covariant PrivateBookCover oldWidget) { + void didUpdateWidget(covariant CoverImage oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.imageUrl != widget.imageUrl || oldWidget.mediaId != widget.mediaId || diff --git a/app/lib/widgets/book_details/book_cover_image.dart b/app/lib/widgets/book_details/book_cover_image.dart index 553745b..2811296 100644 --- a/app/lib/widgets/book_details/book_cover_image.dart +++ b/app/lib/widgets/book_details/book_cover_image.dart @@ -18,14 +18,14 @@ enum BookCoverSize { } /// Book cover image widget with size variants and placeholder. -class BookCoverImage extends StatelessWidget { +class CoverImagePreview extends StatelessWidget { final String? bookId; final String? imageUrl; final String? mediaId; final String? bookTitle; final BookCoverSize size; - const BookCoverImage({ + const CoverImagePreview({ super.key, this.bookId, this.imageUrl, @@ -53,7 +53,7 @@ class BookCoverImage extends StatelessWidget { } Widget _buildImage(BuildContext context, ColorScheme colorScheme) { - return PrivateBookCover( + return CoverImage( bookId: bookId, imageUrl: imageUrl, mediaId: mediaId, diff --git a/app/lib/widgets/book_details/book_header.dart b/app/lib/widgets/book_details/book_header.dart index 7a32c34..0aa2103 100644 --- a/app/lib/widgets/book_details/book_header.dart +++ b/app/lib/widgets/book_details/book_header.dart @@ -40,7 +40,7 @@ class BookHeader extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ // Cover image - BookCoverImage( + CoverImagePreview( bookId: book.id, imageUrl: book.coverURL, mediaId: book.coverMediaId, @@ -117,7 +117,7 @@ class BookHeader extends StatelessWidget { const SizedBox(height: Spacing.lg), // Cover image (centered) - BookCoverImage( + CoverImagePreview( bookId: book.id, imageUrl: book.coverURL, mediaId: book.coverMediaId, diff --git a/app/lib/widgets/book_edit/cover_image_picker.dart b/app/lib/widgets/book_edit/cover_image_picker.dart index 94f2706..c6944a6 100644 --- a/app/lib/widgets/book_edit/cover_image_picker.dart +++ b/app/lib/widgets/book_edit/cover_image_picker.dart @@ -231,7 +231,7 @@ class _CoverImagePickerState extends State { } if (widget.bookId?.isNotEmpty == true || widget.mediaId?.isNotEmpty == true) { - return PrivateBookCover( + return CoverImage( bookId: widget.bookId, mediaId: widget.mediaId, fit: BoxFit.cover, diff --git a/app/lib/widgets/context_menu/book_context_menu.dart b/app/lib/widgets/context_menu/book_context_menu.dart index 378c7e3..5a56295 100644 --- a/app/lib/widgets/context_menu/book_context_menu.dart +++ b/app/lib/widgets/context_menu/book_context_menu.dart @@ -455,12 +455,7 @@ class _BookContextBottomSheet extends StatelessWidget { color: colorScheme.surfaceContainerHighest, child: Icon(Icons.menu_book, color: colorScheme.onSurfaceVariant), ); - return PrivateBookCover( - bookId: book.id, - imageUrl: book.coverURL, - mediaId: book.coverMediaId, - placeholder: placeholder, - ); + return CoverImage(bookId: book.id, imageUrl: book.coverURL, mediaId: book.coverMediaId, placeholder: placeholder); } } diff --git a/app/lib/widgets/dashboard/continue_reading_card.dart b/app/lib/widgets/dashboard/continue_reading_card.dart index 329f26f..6908308 100644 --- a/app/lib/widgets/dashboard/continue_reading_card.dart +++ b/app/lib/widgets/dashboard/continue_reading_card.dart @@ -181,7 +181,7 @@ class ContinueReadingCard extends StatelessWidget { borderRadius: BorderRadius.circular(AppRadius.md), ), clipBehavior: Clip.antiAlias, - child: PrivateBookCover( + child: CoverImage( bookId: book?.id, imageUrl: book?.coverURL, mediaId: book?.coverMediaId, diff --git a/app/lib/widgets/dashboard/recently_added_section.dart b/app/lib/widgets/dashboard/recently_added_section.dart index 1c399a3..17f6876 100644 --- a/app/lib/widgets/dashboard/recently_added_section.dart +++ b/app/lib/widgets/dashboard/recently_added_section.dart @@ -87,7 +87,7 @@ class RecentlyAddedSection extends StatelessWidget { ], ), clipBehavior: Clip.antiAlias, - child: PrivateBookCover( + child: CoverImage( bookId: book.id, imageUrl: book.coverURL, mediaId: book.coverMediaId, diff --git a/app/lib/widgets/library/book_card.dart b/app/lib/widgets/library/book_card.dart index 71aa85e..9106550 100644 --- a/app/lib/widgets/library/book_card.dart +++ b/app/lib/widgets/library/book_card.dart @@ -178,7 +178,7 @@ class _BookCardState extends State { } Widget _buildCover(BuildContext context) { - return PrivateBookCover( + return CoverImage( bookId: widget.book.id, imageUrl: widget.book.coverURL, mediaId: widget.book.coverMediaId, diff --git a/app/lib/widgets/library/book_list_item.dart b/app/lib/widgets/library/book_list_item.dart index 7a670ff..b85c7d8 100644 --- a/app/lib/widgets/library/book_list_item.dart +++ b/app/lib/widgets/library/book_list_item.dart @@ -173,7 +173,7 @@ class _BookListItemState extends State { } Widget _buildCover(BuildContext context) { - return PrivateBookCover( + return CoverImage( bookId: widget.book.id, imageUrl: widget.book.coverURL, mediaId: widget.book.coverMediaId, diff --git a/app/lib/widgets/shared/book_group_header.dart b/app/lib/widgets/shared/book_group_header.dart index e20d974..9d6335b 100644 --- a/app/lib/widgets/shared/book_group_header.dart +++ b/app/lib/widgets/shared/book_group_header.dart @@ -63,7 +63,7 @@ class BookGroupHeader extends StatelessWidget { child: SizedBox( width: 32, height: 48, - child: PrivateBookCover( + child: CoverImage( bookId: bookId, imageUrl: coverUrl, mediaId: coverMediaId, diff --git a/app/lib/widgets/shelves/move_to_shelf_sheet.dart b/app/lib/widgets/shelves/move_to_shelf_sheet.dart index fe8378d..6f946e9 100644 --- a/app/lib/widgets/shelves/move_to_shelf_sheet.dart +++ b/app/lib/widgets/shelves/move_to_shelf_sheet.dart @@ -206,12 +206,7 @@ class _MoveToShelfSheetState extends State { child: Icon(Icons.menu_book, color: colorScheme.onSurfaceVariant, size: 20), ); if (book == null) return placeholder; - return PrivateBookCover( - bookId: book.id, - imageUrl: book.coverURL, - mediaId: book.coverMediaId, - placeholder: placeholder, - ); + return CoverImage(bookId: book.id, imageUrl: book.coverURL, mediaId: book.coverMediaId, placeholder: placeholder); } Widget _buildShelfTile(BuildContext context, Shelf shelf) { diff --git a/app/lib/widgets/shelves/shelf_card.dart b/app/lib/widgets/shelves/shelf_card.dart index 4fdf75c..21bdc8b 100644 --- a/app/lib/widgets/shelves/shelf_card.dart +++ b/app/lib/widgets/shelves/shelf_card.dart @@ -216,7 +216,7 @@ class _ShelfCardState extends State { } Widget _buildCoverImage(CoverPreview cover, ColorScheme colorScheme) { - return PrivateBookCover( + return CoverImage( bookId: cover.bookId, imageUrl: cover.url, mediaId: cover.mediaId, diff --git a/app/lib/widgets/topics/manage_topics_sheet.dart b/app/lib/widgets/topics/manage_topics_sheet.dart index 4dc9220..69e592d 100644 --- a/app/lib/widgets/topics/manage_topics_sheet.dart +++ b/app/lib/widgets/topics/manage_topics_sheet.dart @@ -202,12 +202,7 @@ class _ManageTopicsSheetState extends State { child: Icon(Icons.menu_book, color: colorScheme.onSurfaceVariant, size: 20), ); if (book == null) return placeholder; - return PrivateBookCover( - bookId: book.id, - imageUrl: book.coverURL, - mediaId: book.coverMediaId, - placeholder: placeholder, - ); + return CoverImage(bookId: book.id, imageUrl: book.coverURL, mediaId: book.coverMediaId, placeholder: placeholder); } Widget _buildEmptyState(BuildContext context) { diff --git a/app/test/widgets/book/private_book_cover_test.dart b/app/test/widgets/book/private_book_cover_test.dart index f31ba8d..0320d62 100644 --- a/app/test/widgets/book/private_book_cover_test.dart +++ b/app/test/widgets/book/private_book_cover_test.dart @@ -73,8 +73,8 @@ void main() { final importService = _RecordingBookImportService(Uint8List.fromList(pngBytes)); final harness = await _buildProviderHarness(importService: importService); - PrivateBookCover cover() { - return const PrivateBookCover( + CoverImage cover() { + return const CoverImage( bookId: 'book-1', mediaId: 'asset-1', placeholder: SizedBox(key: Key('placeholder')), @@ -103,8 +103,8 @@ void main() { return Uint8List.fromList(pngBytes); } - PrivateBookCover cover() { - return PrivateBookCover( + CoverImage cover() { + return CoverImage( bookId: 'book-1', loadPendingBookCover: loadPending, placeholder: const SizedBox(key: Key('placeholder')), @@ -133,8 +133,8 @@ void main() { return Uint8List.fromList(pngBytes); } - PrivateBookCover cover() { - return PrivateBookCover( + CoverImage cover() { + return CoverImage( bookId: 'book-1', loadGuestBookCover: loadGuest, placeholder: const SizedBox(key: Key('placeholder')), @@ -159,7 +159,7 @@ void main() { var loads = 0; await tester.pumpWidget( MaterialApp( - home: PrivateBookCover( + home: CoverImage( mediaId: 'asset-1', loadPrivateCover: (_) async { loads++; @@ -181,7 +181,7 @@ void main() { await tester.pumpWidget( MaterialApp( - home: PrivateBookCover( + home: CoverImage( bookId: 'book-1', loadLocalBookCover: (bookId) async { loadedBookIds.add(bookId); @@ -204,7 +204,7 @@ void main() { await tester.pumpWidget( MaterialApp( - home: PrivateBookCover( + home: CoverImage( bookId: 'book-1', mediaId: 'asset-1', loadPrivateCover: (_) async { @@ -234,7 +234,7 @@ void main() { Widget build(String bookId) { return MaterialApp( - home: PrivateBookCover( + home: CoverImage( bookId: bookId, loadLocalBookCover: loader, placeholder: const SizedBox(key: Key('placeholder')), @@ -259,7 +259,7 @@ void main() { Widget build(String bookId) { return MaterialApp( - home: PrivateBookCover( + home: CoverImage( bookId: bookId, loadLocalBookCover: loader, placeholder: const SizedBox(key: Key('placeholder')), @@ -283,7 +283,7 @@ void main() { Widget build(LocalBookCoverLoader loader) { return MaterialApp( - home: PrivateBookCover( + home: CoverImage( bookId: 'book-1', loadLocalBookCover: loader, placeholder: const SizedBox(key: Key('placeholder')), @@ -308,7 +308,7 @@ void main() { Widget build(PrivateCoverLoader loader) { return MaterialApp( - home: PrivateBookCover( + home: CoverImage( mediaId: 'asset-1', loadPrivateCover: loader, placeholder: const SizedBox(key: Key('placeholder')), @@ -338,7 +338,7 @@ void main() { await tester.pumpWidget( harness.wrap( - PrivateBookCover( + CoverImage( bookId: 'book-1', loadPendingBookCover: (_, _) async { pendingLoads++; @@ -369,7 +369,7 @@ void main() { await tester.pumpWidget( harness.wrap( - PrivateBookCover( + CoverImage( bookId: 'book-1', loadPendingBookCover: (_, _) async { pendingLoads++; @@ -425,7 +425,7 @@ void main() { await tester.pumpWidget( harness.wrap( - PrivateBookCover( + CoverImage( bookId: 'book-1', loadPendingBookCover: (scope, _) async { scopes.add(scope); @@ -466,7 +466,7 @@ void main() { Widget build({required bool injected}) { return harness.wrap( - PrivateBookCover( + CoverImage( bookId: 'book-1', loadLocalBookCover: injected ? localLoader : null, loadPendingBookCover: pendingLoader, @@ -506,7 +506,7 @@ void main() { testWidgets('local cover falls back to placeholder when there are no bytes', (tester) async { await tester.pumpWidget( MaterialApp( - home: PrivateBookCover( + home: CoverImage( bookId: 'book-1', loadLocalBookCover: (_) async => null, placeholder: const SizedBox(key: Key('placeholder')), @@ -522,7 +522,7 @@ void main() { testWidgets('book id without providers keeps the standalone placeholder', (tester) async { await tester.pumpWidget( const MaterialApp( - home: PrivateBookCover( + home: CoverImage( bookId: 'book-1', placeholder: SizedBox(key: Key('placeholder')), ), @@ -537,7 +537,7 @@ void main() { await tester.pumpWidget( MaterialApp( - home: PrivateBookCover( + home: CoverImage( imageUrl: dataUri, placeholder: const SizedBox(key: Key('placeholder')), ), @@ -552,7 +552,7 @@ void main() { testWidgets('malformed inline imported cover renders the placeholder', (tester) async { await tester.pumpWidget( MaterialApp( - home: PrivateBookCover( + home: CoverImage( imageUrl: 'data:not-valid', placeholder: const SizedBox(key: Key('placeholder')), ), @@ -572,7 +572,7 @@ void main() { Widget build() { return MaterialApp( - home: PrivateBookCover( + home: CoverImage( mediaId: 'asset-1', loadPrivateCover: loader, placeholder: const SizedBox(key: Key('placeholder')), @@ -591,7 +591,7 @@ void main() { testWidgets('private cover falls back to placeholder when the loader has no bytes', (tester) async { await tester.pumpWidget( MaterialApp( - home: PrivateBookCover( + home: CoverImage( mediaId: 'asset-1', loadPrivateCover: (_) async => null, placeholder: const SizedBox(key: Key('placeholder')), @@ -607,7 +607,7 @@ void main() { testWidgets('private cover falls back to placeholder after a load error', (tester) async { await tester.pumpWidget( MaterialApp( - home: PrivateBookCover( + home: CoverImage( mediaId: 'asset-1', loadPrivateCover: (_) async => throw StateError('offline'), placeholder: const SizedBox(key: Key('placeholder')), diff --git a/app/test/widgets/book_details/book_cover_image_test.dart b/app/test/widgets/book_details/book_cover_image_test.dart index a65a096..2bc700a 100644 --- a/app/test/widgets/book_details/book_cover_image_test.dart +++ b/app/test/widgets/book_details/book_cover_image_test.dart @@ -7,11 +7,11 @@ void main() { testWidgets('book cover image forwards the book id to its renderer', (tester) async { await tester.pumpWidget( const MaterialApp( - home: BookCoverImage(bookId: 'book-1', imageUrl: 'https://example.test/cover.jpg', bookTitle: 'Book'), + home: CoverImagePreview(bookId: 'book-1', imageUrl: 'https://example.test/cover.jpg', bookTitle: 'Book'), ), ); - final cover = tester.widget(find.byType(PrivateBookCover)); + final cover = tester.widget(find.byType(CoverImage)); expect(cover.bookId, 'book-1'); }); } diff --git a/app/test/widgets/book_edit/cover_image_picker_test.dart b/app/test/widgets/book_edit/cover_image_picker_test.dart index 5c47ebe..6f080dc 100644 --- a/app/test/widgets/book_edit/cover_image_picker_test.dart +++ b/app/test/widgets/book_edit/cover_image_picker_test.dart @@ -17,7 +17,7 @@ void main() { ), ); - final cover = tester.widget(find.byType(PrivateBookCover)); + final cover = tester.widget(find.byType(CoverImage)); expect(cover.bookId, 'book-1'); expect(cover.mediaId, 'cover-1'); }); diff --git a/app/test/widgets/library/book_card_test.dart b/app/test/widgets/library/book_card_test.dart index 2068bca..2c5f76a 100644 --- a/app/test/widgets/library/book_card_test.dart +++ b/app/test/widgets/library/book_card_test.dart @@ -124,7 +124,7 @@ void main() { testWidgets('forwards the book id to the cover renderer', (tester) async { await tester.pumpWidget(buildCard()); - expect(tester.widget(find.byType(PrivateBookCover)).bookId, testBook.id); + expect(tester.widget(find.byType(CoverImage)).bookId, testBook.id); }); testWidgets('renders Card widget', (tester) async { diff --git a/app/test/widgets/library/book_list_item_test.dart b/app/test/widgets/library/book_list_item_test.dart index 226902b..e54aeb4 100644 --- a/app/test/widgets/library/book_list_item_test.dart +++ b/app/test/widgets/library/book_list_item_test.dart @@ -89,7 +89,7 @@ void main() { testWidgets('forwards the book id to the cover renderer', (tester) async { await tester.pumpWidget(buildListItem()); - expect(tester.widget(find.byType(PrivateBookCover)).bookId, testBook.id); + expect(tester.widget(find.byType(CoverImage)).bookId, testBook.id); }); testWidgets('displays physical format label', (tester) async { diff --git a/app/test/widgets/shared/book_group_header_test.dart b/app/test/widgets/shared/book_group_header_test.dart index dc4924e..9c9b21b 100644 --- a/app/test/widgets/shared/book_group_header_test.dart +++ b/app/test/widgets/shared/book_group_header_test.dart @@ -21,6 +21,6 @@ void main() { ), ); - expect(tester.widget(find.byType(PrivateBookCover)).bookId, 'book-1'); + expect(tester.widget(find.byType(CoverImage)).bookId, 'book-1'); }); } diff --git a/docs/superpowers/plans/2026-07-11-local-first-cover-lifecycle.md b/docs/superpowers/plans/2026-07-11-local-first-cover-lifecycle.md index 71dafc1..a45a6c3 100644 --- a/docs/superpowers/plans/2026-07-11-local-first-cover-lifecycle.md +++ b/docs/superpowers/plans/2026-07-11-local-first-cover-lifecycle.md @@ -29,6 +29,7 @@ ### Task 1: Add explicit filesystem cover buckets **Files:** + - Create: `app/lib/media/cover_storage_bucket.dart` - Modify: `app/lib/media/media_storage_scope.dart` - Modify: `app/lib/services/book_import_service_stub.dart` @@ -139,6 +140,7 @@ git commit -m "feat: add pending and guest cover storage" ### Task 2: Make queued cover uploads reference pending files **Files:** + - Modify: `app/lib/media/media_upload_queue.dart` - Modify: `app/test/media/media_upload_queue_test.dart` @@ -233,6 +235,7 @@ git commit -m "fix: queue cover file references instead of bytes" ### Task 3: Persist imported covers before saving book metadata **Files:** + - Create: `app/lib/services/book_import_commit_service.dart` - Create: `app/test/services/book_import_commit_service_test.dart` - Modify: `app/lib/widgets/add_book/import_book_sheet.dart` @@ -348,11 +351,12 @@ git commit -m "feat: persist imported covers outside metadata" ### Task 4: Render pending and guest covers by book ID **Files:** + - Modify: `app/lib/widgets/book/private_book_cover.dart` - Modify: `app/lib/widgets/book_details/book_cover_image.dart` - Modify: `app/lib/data/data_store.dart` - Modify: `app/lib/models/shelf.dart` -- Modify: all ten production `PrivateBookCover`/`BookCoverImage` callers found by `rg -n "PrivateBookCover\\(|BookCoverImage\\(" app/lib` +- Modify: all ten production `CoverImage`/`CoverImagePreview` callers found by `rg -n "CoverImage\\(|CoverImagePreview\\(" app/lib` - Test: `app/test/widgets/book/private_book_cover_test.dart` - [ ] **Step 1: Write failing fallback-order widget tests** @@ -362,7 +366,7 @@ Add injected local loading to keep tests independent of providers: ```dart testWidgets('book without media id renders pending local cover', (tester) async { await tester.pumpWidget(MaterialApp( - home: PrivateBookCover( + home: CoverImage( bookId: 'book-1', loadLocalBookCover: (_) async => Uint8List.fromList(pngBytes), placeholder: const SizedBox(key: Key('placeholder')), @@ -376,7 +380,7 @@ testWidgets('media id takes priority over pending local cover', (tester) async { var localLoads = 0; var mediaLoads = 0; await tester.pumpWidget(MaterialApp( - home: PrivateBookCover( + home: CoverImage( bookId: 'book-1', mediaId: 'asset-1', loadPrivateCover: (_) async { @@ -411,7 +415,7 @@ Expected: FAIL because `bookId` and `loadLocalBookCover` are not accepted. - [ ] **Step 3: Implement local fallback loading and propagate book IDs** -Use this order in `PrivateBookCover`: public/legacy URL, `mediaId` cache/download, then local book cover. Provider loading chooses pending account storage when signed into an account library and guest storage otherwise: +Use this order in `CoverImage`: public/legacy URL, `mediaId` cache/download, then local book cover. Provider loading chooses pending account storage when signed into an account library and guest storage otherwise: ```dart if (mediaId == null && bookId != null) { @@ -421,7 +425,7 @@ if (mediaId == null && bookId != null) { } ``` -Add `bookId` to `BookCoverImage` and `CoverPreview`. Pass `book.id` from book-backed call sites and `cover.bookId` from shelf mosaics/group previews. Include book ID in `_loadKey` so recycled widgets cannot show another book's pending cover. +Add `bookId` to `CoverImagePreview` and `CoverPreview`. Pass `book.id` from book-backed call sites and `cover.bookId` from shelf mosaics/group previews. Include book ID in `_loadKey` so recycled widgets cannot show another book's pending cover. - [ ] **Step 4: Run widget and representative surface tests** @@ -444,6 +448,7 @@ git commit -m "feat: render local covers by book id" ### Task 5: Promote source-device covers after upload **Files:** + - Create: `app/lib/media/cover_upload_persistence.dart` - Create: `app/test/media/cover_upload_persistence_test.dart` - Modify: `app/lib/main.dart` @@ -505,6 +510,7 @@ git commit -m "feat: promote uploaded covers into local cache" ### Task 6: Delete every local cover representation **Files:** + - Modify: `app/lib/services/book_delete_cleanup_service.dart` - Modify: `app/lib/pages/book_details_page.dart` - Modify: `app/test/services/book_delete_cleanup_service_test.dart` @@ -560,6 +566,7 @@ git commit -m "fix: clean pending and guest covers on delete" ### Task 7: Verify metadata safety, cross-device behavior, and the web runtime **Files:** + - Test: `app/test/powersync/powersync_book_mapper_test.dart` - Test: `app/test/services/book_import_commit_service_test.dart` diff --git a/docs/superpowers/plans/2026-07-11-media-storage-hardening.md b/docs/superpowers/plans/2026-07-11-media-storage-hardening.md index 4402683..1f5bf8b 100644 --- a/docs/superpowers/plans/2026-07-11-media-storage-hardening.md +++ b/docs/superpowers/plans/2026-07-11-media-storage-hardening.md @@ -43,6 +43,7 @@ Server files to modify: ### Task 1: Restore the intended context-menu contract **Files:** + - Modify: `app/test/widgets/context_menu/book_context_menu_test.dart:11-45` - Verify: `app/lib/widgets/context_menu/book_context_menu.dart:421-442` @@ -72,6 +73,7 @@ git commit -m "test: align book menu labels" ### Task 2: Define authenticated media scope **Files:** + - Create: `app/lib/media/media_storage_scope.dart` - Create: `app/test/media/media_storage_scope_test.dart` @@ -142,6 +144,7 @@ git commit -m "feat: define scoped media identity" ### Task 3: Add filesystem and OPFS cover-file operations **Files:** + - Modify: `app/lib/services/book_import_service_stub.dart` - Modify: `app/lib/services/book_import_service.dart` - Modify: `app/web/book_worker.js` @@ -245,6 +248,7 @@ git commit -m "feat: persist scoped covers in local files" ### Task 4: Add lazy persistent cover loading and shared rendering **Files:** + - Modify: `app/lib/media/media_cache_service.dart` - Modify: `app/test/media/media_cache_service_test.dart` - Create: `app/lib/widgets/book/private_book_cover.dart` @@ -336,7 +340,7 @@ testWidgets('private cover loads from scoped local cache', (tester) async { var loads = 0; await tester.pumpWidget( MaterialApp( - home: PrivateBookCover( + home: CoverImage( mediaId: 'asset-1', loadPrivateCover: (_) async { loads++; @@ -354,7 +358,7 @@ testWidgets('private cover loads from scoped local cache', (tester) async { testWidgets('private cover falls back to placeholder when signed out', (tester) async { await tester.pumpWidget( const MaterialApp( - home: PrivateBookCover( + home: CoverImage( mediaId: 'asset-1', placeholder: Icon(Icons.menu_book, key: Key('placeholder')), ), @@ -369,15 +373,15 @@ testWidgets('private cover falls back to placeholder when signed out', (tester) Run: `cd app && flutter test test/widgets/book/private_book_cover_test.dart` -Expected: `PrivateBookCover` does not exist. +Expected: `CoverImage` does not exist. -- [ ] **Step 7: Implement `PrivateBookCover` and integrate `BookCoverImage`** +- [ ] **Step 7: Implement `CoverImage` and integrate `CoverImagePreview`** The stateful widget accepts `imageUrl`, `mediaId`, `fit`, and a placeholder builder. It caches its future until `mediaId` or scope changes, uses the active `MediaStorageScope`, reads/writes through `BookImportService`, downloads through `AuthProvider`, and delegates lazy caching to `MediaCacheService`. - [ ] **Step 8: Replace private-cover-blind surfaces** -Use `PrivateBookCover` in library cards/list rows, recently-added and continue-reading dashboard cards, move-to-shelf and manage-topics sheets, and `BookContextMenu`. Pass both `book.coverURL` and `book.coverMediaId`; preserve each surface's existing dimensions, fit, and placeholder styling. +Use `CoverImage` in library cards/list rows, recently-added and continue-reading dashboard cards, move-to-shelf and manage-topics sheets, and `BookContextMenu`. Pass both `book.coverURL` and `book.coverMediaId`; preserve each surface's existing dimensions, fit, and placeholder styling. - [ ] **Step 9: Run focused cover widget tests and analyzer** @@ -397,6 +401,7 @@ git commit -m "feat: render private covers from persistent cache" ### Task 5: Scope upload tasks and make processing single-flight **Files:** + - Modify: `app/lib/media/media_upload_queue.dart` - Modify: `app/test/media/media_upload_queue_test.dart` @@ -483,6 +488,7 @@ git commit -m "fix: scope and serialize media uploads" ### Task 6: Trigger uploads after durable enqueue and coordinate profile changes **Files:** + - Modify: `app/lib/media/media_upload_queue.dart` - Modify: `app/lib/main.dart` - Modify: `app/lib/widgets/add_book/import_book_sheet.dart` @@ -562,6 +568,7 @@ git commit -m "fix: process media immediately after enqueue" ### Task 7: Clean cached covers with book/account cache deletion **Files:** + - Modify: `app/lib/services/book_delete_cleanup_service.dart` - Modify callers in `app/lib/pages/book_details_page.dart`, `app/lib/utils/book_actions.dart`, and `app/lib/utils/bulk_book_actions.dart` - Modify: `app/test/services/book_delete_cleanup_service_test.dart` @@ -612,6 +619,7 @@ git commit -m "fix: clean scoped cover files" ### Task 8: Enforce one server asset per book kind and serialize quota checks **Files:** + - Modify: `../server/papyrus/models/media.py` - Modify: `../server/alembic/versions/c3f8b2a9d1e4_add_media_assets.py` - Modify: `../server/papyrus/services/media.py` @@ -717,6 +725,7 @@ git commit -m "fix: serialize media storage updates" ### Task 9: Full verification and PR readiness **Files:** + - Review all files changed by Tasks 1-8. - [ ] **Step 1: Run client formatting and diff checks** @@ -754,7 +763,7 @@ Check that: - guest activation uses no `MediaStorageScope`; - sign-out does not delete another account's queue/cache; - server switch waits for queue idle and captures the old repository; -- every book-cover surface found by `rg -n "coverURL|coverUrl" app/lib/widgets app/lib/pages` either uses `PrivateBookCover` or intentionally renders a public-only decorative URL; +- every book-cover surface found by `rg -n "coverURL|coverUrl" app/lib/widgets app/lib/pages` either uses `CoverImage` or intentionally renders a public-only decorative URL; - no cover bytes are stored in SQL or SharedPreferences. - [ ] **Step 5: Inspect GitHub checks without changing remote state** diff --git a/docs/superpowers/plans/2026-07-11-stable-local-cover-image-cache.md b/docs/superpowers/plans/2026-07-11-stable-local-cover-image-cache.md index ddb6914..cf15507 100644 --- a/docs/superpowers/plans/2026-07-11-stable-local-cover-image-cache.md +++ b/docs/superpowers/plans/2026-07-11-stable-local-cover-image-cache.md @@ -4,7 +4,7 @@ **Goal:** Eliminate cover flashes between Papyrus pages by giving filesystem-backed covers stable Flutter image-cache identities. -**Architecture:** Add a `LocalCoverImageProvider` whose immutable key contains storage scope, bucket, and file ID. `PrivateBookCover` will resolve local account, pending, and guest covers through that provider so Flutter's bounded global `ImageCache` reuses decoded frames across widget instances; OPFS/native files remain authoritative and existing authenticated lazy download remains the cached-media loader. +**Architecture:** Add a `LocalCoverImageProvider` whose immutable key contains storage scope, bucket, and file ID. `CoverImage` will resolve local account, pending, and guest covers through that provider so Flutter's bounded global `ImageCache` reuses decoded frames across widget instances; OPFS/native files remain authoritative and existing authenticated lazy download remains the cached-media loader. **Tech Stack:** Flutter/Dart, `ImageProvider`, `MultiFrameImageStreamCompleter`, Flutter `ImageCache`, native filesystem, browser OPFS, widget/unit tests. @@ -23,6 +23,7 @@ ### Task 1: Add a stable filesystem-backed cover image provider **Files:** + - Create: `app/lib/media/local_cover_image_provider.dart` - Create: `app/test/media/local_cover_image_provider_test.dart` @@ -166,6 +167,7 @@ git commit -m "feat: add stable local cover image provider" ### Task 2: Render production local covers through Flutter's image cache **Files:** + - Modify: `app/lib/widgets/book/private_book_cover.dart` - Modify: `app/test/widgets/book/private_book_cover_test.dart` @@ -179,7 +181,7 @@ testWidgets('new page reuses decoded private cover without reading storage again final importService = _RecordingCoverImportService(pngBytes); Widget page(Key key) => harness.wrapWithCoverServices( - PrivateBookCover( + CoverImage( key: key, bookId: 'book-1', mediaId: 'asset-1', @@ -276,6 +278,7 @@ git commit -m "fix: reuse decoded covers across page transitions" ### Task 3: Invalidate decoded covers when local files are removed **Files:** + - Modify: `app/lib/services/book_import_service.dart` - Modify: `app/lib/services/book_import_service_stub.dart` - Modify: `app/test/services/book_cover_storage_test.dart` @@ -332,6 +335,7 @@ git commit -m "fix: evict decoded covers after local mutation" ### Task 4: Final verification and browser smoke test **Files:** + - No production files expected beyond Tasks 1-3. - [ ] **Step 1: Run complete automated verification** diff --git a/docs/superpowers/specs/2026-07-11-cover-image-cache-design.md b/docs/superpowers/specs/2026-07-11-cover-image-cache-design.md index dcf1828..c1c7703 100644 --- a/docs/superpowers/specs/2026-07-11-cover-image-cache-design.md +++ b/docs/superpowers/specs/2026-07-11-cover-image-cache-design.md @@ -6,7 +6,7 @@ ## Overview -Papyrus persists private cover bytes in filesystem storage or browser OPFS. A newly mounted `PrivateBookCover` currently reads those bytes asynchronously and renders its placeholder until the read completes. Navigating between the library, book details, shelves, and list layouts therefore produces a visible cover flash even when the cover was already displayed moments earlier. +Papyrus persists private cover bytes in filesystem storage or browser OPFS. A newly mounted `CoverImage` currently reads those bytes asynchronously and renders its placeholder until the read completes. Navigating between the library, book details, shelves, and list layouts therefore produces a visible cover flash even when the cover was already displayed moments earlier. This design gives each local cover a stable Flutter image-provider identity. Flutter's bounded least-recently-used `ImageCache` can then reuse the decoded image across widget instances and page transitions, while filesystem storage remains authoritative across app restarts. @@ -50,7 +50,7 @@ Public HTTP covers continue using `CachedNetworkImage`; inline legacy data URIs ### Rendering -`PrivateBookCover` chooses one provider in the existing priority order: +`CoverImage` chooses one provider in the existing priority order: 1. public or legacy inline cover; 2. authenticated cached cover by media ID; @@ -88,7 +88,7 @@ Papyrus does not add a second raw-byte LRU. Flutter's `ImageCache` remains the s ## Rollout and rollback -The change is client-only and requires no data migration. Rollout consists of the provider implementation, `PrivateBookCover` integration, focused widget tests, and a browser navigation smoke test. Rollback restores the existing `FutureBuilder` renderer; persisted cover files and synchronized metadata remain compatible. +The change is client-only and requires no data migration. Rollout consists of the provider implementation, `CoverImage` integration, focused widget tests, and a browser navigation smoke test. Rollback restores the existing `FutureBuilder` renderer; persisted cover files and synchronized metadata remain compatible. ## Open questions diff --git a/docs/superpowers/specs/2026-07-11-media-storage-hardening-design.md b/docs/superpowers/specs/2026-07-11-media-storage-hardening-design.md index e328c0e..4bcf2d2 100644 --- a/docs/superpowers/specs/2026-07-11-media-storage-hardening-design.md +++ b/docs/superpowers/specs/2026-07-11-media-storage-hardening-design.md @@ -63,7 +63,7 @@ Downloads are coalesced by `(scope, mediaId)` so multiple widgets requesting the ### UI integration -A single reusable private-cover content widget owns the public-URL/private-media/placeholder decision. The existing sized `BookCoverImage` composes it, and library cards, list rows, dashboard cards, shelf/topic sheets, context menus, and other book-cover surfaces use the same content widget. This prevents successful upload from clearing `coverUrl` and making covers disappear outside the details page. +A single reusable private-cover content widget owns the public-URL/private-media/placeholder decision. The existing sized `CoverImagePreview` composes it, and library cards, list rows, dashboard cards, shelf/topic sheets, context menus, and other book-cover surfaces use the same content widget. This prevents successful upload from clearing `coverUrl` and making covers disappear outside the details page. Book deletion removes pending, guest, and cached cover files for that book on a best-effort basis after pending upload tasks are removed. Clearing the active authenticated cache removes the whole scoped cover directory. Clearing authenticated data never removes guest book-file or cover storage. From 634370c41ccfb478884d3579b87cf0a7b32d59ed Mon Sep 17 00:00:00 2001 From: Eoic Date: Sun, 12 Jul 2026 16:43:37 +0300 Subject: [PATCH 43/48] feat: implement hot restart cleanup and enhance book cover handling --- app/lib/data/data_store.dart | 5 + app/lib/main.dart | 3 + app/lib/pages/book_edit_page.dart | 95 ++++++++++++++++--- app/lib/platform/hot_restart_cleanup.dart | 1 + .../platform/hot_restart_cleanup_stub.dart | 5 + app/lib/platform/hot_restart_cleanup_web.dart | 24 +++++ app/lib/providers/book_edit_provider.dart | 14 +-- app/lib/utils/image_utils.dart | 27 ++++-- app/lib/widgets/book/private_book_cover.dart | 29 ++++++ .../platform/hot_restart_cleanup_test.dart | 17 ++++ .../providers/book_edit_provider_test.dart | 74 +++++++++++++++ app/test/utils/image_utils_test.dart | 13 +++ .../widgets/book/private_book_cover_test.dart | 33 ++++++- 13 files changed, 307 insertions(+), 33 deletions(-) create mode 100644 app/lib/platform/hot_restart_cleanup.dart create mode 100644 app/lib/platform/hot_restart_cleanup_stub.dart create mode 100644 app/lib/platform/hot_restart_cleanup_web.dart create mode 100644 app/test/platform/hot_restart_cleanup_test.dart create mode 100644 app/test/providers/book_edit_provider_test.dart create mode 100644 app/test/utils/image_utils_test.dart diff --git a/app/lib/data/data_store.dart b/app/lib/data/data_store.dart index 8a9d1a3..ee64ad7 100644 --- a/app/lib/data/data_store.dart +++ b/app/lib/data/data_store.dart @@ -135,6 +135,11 @@ class DataStore extends ChangeNotifier { unawaited(repository.upsert(book)); } + Future updateBookAndWait(Book book) async { + final repository = requireBookRepository(); + await addBookToRepositoryAndWait(repository, book); + } + void deleteBook(String id) { final repository = _bookRepository; if (repository == null) { diff --git a/app/lib/main.dart b/app/lib/main.dart index b0519a5..40016cb 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -12,6 +12,7 @@ import 'package:papyrus/media/media_cache_service.dart'; import 'package:papyrus/media/media_models.dart'; import 'package:papyrus/media/media_storage_scope.dart'; import 'package:papyrus/media/media_upload_queue.dart'; +import 'package:papyrus/platform/hot_restart_cleanup.dart'; import 'package:papyrus/powersync/powersync_service.dart'; import 'package:papyrus/powersync/papyrus_powersync_connector.dart'; import 'package:papyrus/powersync/sync_profile_switch_queue.dart'; @@ -31,6 +32,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'config/app_router.dart'; Future main() async { + await runPreviousHotRestartCleanup(); WidgetsFlutterBinding.ensureInitialized(); usePathUrlStrategy(); @@ -91,6 +93,7 @@ class _PapyrusState extends State { onUploadComplete: _processMediaUploads, ), ); + registerHotRestartCleanup(_disposeDataServices); unawaited(_dataStore.attachBookRepository(_powerSyncService)); _appRouter = AppRouter(authProvider: _authProvider); _authProvider.addListener(_syncPowerSyncAuthState); diff --git a/app/lib/pages/book_edit_page.dart b/app/lib/pages/book_edit_page.dart index 7e6f134..45eff1c 100644 --- a/app/lib/pages/book_edit_page.dart +++ b/app/lib/pages/book_edit_page.dart @@ -2,9 +2,19 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/media/cover_storage_bucket.dart'; +import 'package:papyrus/media/local_cover_image_provider.dart'; +import 'package:papyrus/media/media_storage_scope.dart'; +import 'package:papyrus/media/media_upload_queue.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/providers/book_edit_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/services/metadata_service.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/utils/image_utils.dart'; import 'package:papyrus/widgets/book_edit/cover_image_picker.dart'; import 'package:papyrus/widgets/book_form/book_date_field.dart'; import 'package:papyrus/widgets/book_form/book_text_field.dart'; @@ -762,22 +772,77 @@ class _BookEditPageState extends State { return; } - final success = await _provider.save(); - if (!mounted || !context.mounted) return; + final coverBytes = _provider.coverImageBytes; + final queue = context.read(); + final authProvider = context.read(); + final powerSyncService = context.read(); + final isOnlineAccount = authProvider.isSignedIn && powerSyncService.mode == LibraryDatabaseMode.authenticated; + final scope = isOnlineAccount ? queue.activeScope : null; - if (success) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('Book updated'), behavior: SnackBarBehavior.floating)); - _navigateToBookDetails(context); - } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(_provider.error ?? 'Failed to save'), - behavior: SnackBarBehavior.floating, - backgroundColor: Theme.of(context).colorScheme.error, - ), - ); + if (isOnlineAccount && scope == null) { + _showSaveError(context, 'Cannot upload the cover without an active media storage scope'); + return; + } + + try { + if (coverBytes != null) { + final book = _provider.editedBook!; + final importService = context.read(); + if (scope == null) { + await importService.storeGuestCoverFile(book.id, coverBytes); + LocalCoverImageProvider.evictKey( + scopeKey: MediaStorageScope.localGuest.persistenceKey, + bucket: CoverStorageBucket.guestBooks, + fileId: book.id, + ); + } else { + await importService.storePendingCoverFile(scope, book.id, coverBytes); + LocalCoverImageProvider.evictKey( + scopeKey: scope.persistenceKey, + bucket: CoverStorageBucket.pending, + fileId: book.id, + ); + } + } + + final success = await _provider.save(); + if (!success) { + if (mounted && context.mounted) { + _showSaveError(context, _provider.error ?? 'Failed to save'); + } + return; + } + + if (coverBytes != null && scope != null) { + final book = _provider.editedBook!; + await queue.enqueueCover( + book: book, + filename: '${book.id}-cover.${imageFileExtension(coverBytes)}', + contentType: imageMimeType(coverBytes), + ); + } + } catch (error) { + if (mounted && context.mounted) { + _showSaveError(context, 'Failed to save cover: $error'); + } + return; } + + if (!mounted || !context.mounted) return; + + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Book updated'), behavior: SnackBarBehavior.floating)); + _navigateToBookDetails(context); + } + + void _showSaveError(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + behavior: SnackBarBehavior.floating, + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); } } diff --git a/app/lib/platform/hot_restart_cleanup.dart b/app/lib/platform/hot_restart_cleanup.dart new file mode 100644 index 0000000..6cb3bf2 --- /dev/null +++ b/app/lib/platform/hot_restart_cleanup.dart @@ -0,0 +1 @@ +export 'hot_restart_cleanup_stub.dart' if (dart.library.js_interop) 'hot_restart_cleanup_web.dart'; diff --git a/app/lib/platform/hot_restart_cleanup_stub.dart b/app/lib/platform/hot_restart_cleanup_stub.dart new file mode 100644 index 0000000..0b267ff --- /dev/null +++ b/app/lib/platform/hot_restart_cleanup_stub.dart @@ -0,0 +1,5 @@ +typedef HotRestartCleanup = Future Function(); + +Future runPreviousHotRestartCleanup() async {} + +void registerHotRestartCleanup(HotRestartCleanup cleanup) {} diff --git a/app/lib/platform/hot_restart_cleanup_web.dart b/app/lib/platform/hot_restart_cleanup_web.dart new file mode 100644 index 0000000..bc09fd7 --- /dev/null +++ b/app/lib/platform/hot_restart_cleanup_web.dart @@ -0,0 +1,24 @@ +import 'dart:js_interop'; + +typedef HotRestartCleanup = Future Function(); + +@JS('__papyrusHotRestartCleanup') +external JSFunction? get _registeredCleanup; + +@JS('__papyrusHotRestartCleanup') +external set _registeredCleanup(JSFunction? cleanup); + +Future runPreviousHotRestartCleanup() async { + final cleanup = _registeredCleanup; + _registeredCleanup = null; + if (cleanup == null) return; + + final result = cleanup.callAsFunction(); + if (result != null) { + await (result as JSPromise).toDart; + } +} + +void registerHotRestartCleanup(HotRestartCleanup cleanup) { + _registeredCleanup = (() => cleanup().toJS).toJS; +} diff --git a/app/lib/providers/book_edit_provider.dart b/app/lib/providers/book_edit_provider.dart index 060b63b..52d5637 100644 --- a/app/lib/providers/book_edit_provider.dart +++ b/app/lib/providers/book_edit_provider.dart @@ -2,7 +2,6 @@ import 'package:flutter/foundation.dart'; import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/services/metadata_service.dart'; -import 'package:papyrus/utils/image_utils.dart'; /// State for metadata fetch operations. enum MetadataFetchState { idle, loading, success, error } @@ -106,17 +105,10 @@ class BookEditProvider extends ChangeNotifier { notifyListeners(); try { - // If we have local cover image bytes, convert to data URI - var bookToSave = _editedBook!; - if (_coverImageBytes != null) { - final dataUri = bytesToDataUri(_coverImageBytes!); - bookToSave = bookToSave.copyWith(coverUrl: dataUri); - _editedBook = bookToSave; - } - - _dataStore!.updateBook(bookToSave); + final bookToSave = _editedBook!; + await _dataStore!.updateBookAndWait(bookToSave); _originalBook = bookToSave; - _coverImageBytes = null; // Clear bytes since they're now in the URL + _coverImageBytes = null; _isSaving = false; notifyListeners(); return true; diff --git a/app/lib/utils/image_utils.dart b/app/lib/utils/image_utils.dart index ce3d114..743fe91 100644 --- a/app/lib/utils/image_utils.dart +++ b/app/lib/utils/image_utils.dart @@ -3,16 +3,31 @@ import 'dart:typed_data'; /// Convert image bytes to a data URI string with auto-detected MIME type. String bytesToDataUri(Uint8List bytes) { - String mimeType = 'image/jpeg'; + final mimeType = imageMimeType(bytes); + final base64Data = base64Encode(bytes); + return 'data:$mimeType;base64,$base64Data'; +} + +/// Detect the supported image MIME type from its signature. +String imageMimeType(Uint8List bytes) { if (bytes.length >= 8) { if (bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47) { - mimeType = 'image/png'; + return 'image/png'; } else if (bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46) { - mimeType = 'image/gif'; + return 'image/gif'; } else if (bytes[0] == 0x52 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x46) { - mimeType = 'image/webp'; + return 'image/webp'; } } - final base64Data = base64Encode(bytes); - return 'data:$mimeType;base64,$base64Data'; + return 'image/jpeg'; +} + +/// Return a filename extension matching [imageMimeType]. +String imageFileExtension(Uint8List bytes) { + return switch (imageMimeType(bytes)) { + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/webp' => 'webp', + _ => 'jpg', + }; } diff --git a/app/lib/widgets/book/private_book_cover.dart b/app/lib/widgets/book/private_book_cover.dart index 6be6d9b..bbe5ba2 100644 --- a/app/lib/widgets/book/private_book_cover.dart +++ b/app/lib/widgets/book/private_book_cover.dart @@ -139,6 +139,35 @@ class _PrivateBookCoverState extends State { return; } final scope = MediaStorageScope(profileKey: syncSettings.activeProfileKey, userId: user.userId); + final bookId = _usableBookId; + if (bookId != null) { + final key = '${scope.persistenceKey}:${CoverStorageBucket.pending.name}:$bookId'; + if (_loadKey == key) return; + + final importService = context.read(); + final cacheService = context.read(); + final pendingLoader = widget.loadPendingBookCover ?? importService.getPendingCoverFile; + _loadKey = key; + _coverFuture = null; + _localCoverProvider = LocalCoverImageProvider( + scopeKey: scope.persistenceKey, + bucket: CoverStorageBucket.pending, + fileId: bookId, + loadBytes: () async { + final pending = await pendingLoader(scope, bookId); + if (pending != null && pending.isNotEmpty) return pending; + return cacheService.ensureCoverCached( + scope: scope, + mediaId: mediaId, + readLocalCover: importService.getCoverFile, + writeLocalCover: importService.storeCoverFile, + downloadMedia: authProvider.downloadMedia, + ); + }, + ); + return; + } + final key = '${scope.persistenceKey}:${CoverStorageBucket.cached.name}:$mediaId'; if (_loadKey == key) return; diff --git a/app/test/platform/hot_restart_cleanup_test.dart b/app/test/platform/hot_restart_cleanup_test.dart new file mode 100644 index 0000000..94dce16 --- /dev/null +++ b/app/test/platform/hot_restart_cleanup_test.dart @@ -0,0 +1,17 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/platform/hot_restart_cleanup.dart'; + +void main() { + setUp(runPreviousHotRestartCleanup); + + test('registered web cleanup runs once before the next app starts', () async { + var calls = 0; + registerHotRestartCleanup(() async => calls++); + + await runPreviousHotRestartCleanup(); + await runPreviousHotRestartCleanup(); + + expect(calls, 1); + }, skip: !kIsWeb); +} diff --git a/app/test/providers/book_edit_provider_test.dart b/app/test/providers/book_edit_provider_test.dart new file mode 100644 index 0000000..0c4420e --- /dev/null +++ b/app/test/providers/book_edit_provider_test.dart @@ -0,0 +1,74 @@ +import 'dart:async'; +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/models/book.dart'; +import 'package:papyrus/providers/book_edit_provider.dart'; + +import '../helpers/test_helpers.dart'; + +void main() { + test('saving a picked cover keeps image bytes out of synced book metadata', () async { + final dataStore = DataStore(); + final original = buildTestBook( + id: 'book-1', + coverUrl: 'https://example.com/original.jpg', + ).copyWith(coverMediaId: 'original-cover'); + dataStore.addBook(original); + + final provider = BookEditProvider()..setDataStore(dataStore); + await provider.loadBook(original.id); + provider.updateCoverFromFile(Uint8List.fromList([1, 2, 3])); + + expect(await provider.save(), isTrue); + + final saved = dataStore.getBook(original.id)!; + expect(saved.coverUrl, isNull); + expect(saved.coverMediaId, 'original-cover'); + expect(provider.coverImageBytes, isNull); + }); + + test('save waits for synced metadata persistence before completing', () async { + final repository = _GatedBookRepository(); + final dataStore = DataStore(bookRepository: repository); + final original = buildTestBook(id: 'book-1'); + dataStore.replaceBooksFromSync([original]); + final provider = BookEditProvider()..setDataStore(dataStore); + await provider.loadBook(original.id); + provider.updateTitle('Updated title'); + + var completed = false; + final save = provider.save().then((result) { + completed = true; + return result; + }); + await repository.upsertStarted.future; + await Future.delayed(Duration.zero); + + expect(completed, isFalse); + repository.allowUpsert.complete(); + expect(await save, isTrue); + }); +} + +class _GatedBookRepository implements BookRepository { + final upsertStarted = Completer(); + final allowUpsert = Completer(); + + @override + Stream> watchAll() => const Stream.empty(); + + @override + Future getById(String id) async => null; + + @override + Future upsert(Book book) async { + upsertStarted.complete(); + await allowUpsert.future; + } + + @override + Future delete(String id) async {} +} diff --git a/app/test/utils/image_utils_test.dart b/app/test/utils/image_utils_test.dart new file mode 100644 index 0000000..3133e01 --- /dev/null +++ b/app/test/utils/image_utils_test.dart @@ -0,0 +1,13 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/utils/image_utils.dart'; + +void main() { + test('detects the content type and extension of picked cover bytes', () { + expect(imageMimeType(Uint8List.fromList([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])), 'image/png'); + expect(imageFileExtension(Uint8List.fromList([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])), 'png'); + expect(imageMimeType(Uint8List.fromList([0xff, 0xd8, 0xff])), 'image/jpeg'); + expect(imageFileExtension(Uint8List.fromList([0xff, 0xd8, 0xff])), 'jpg'); + }); +} diff --git a/app/test/widgets/book/private_book_cover_test.dart b/app/test/widgets/book/private_book_cover_test.dart index 0320d62..61dd9ce 100644 --- a/app/test/widgets/book/private_book_cover_test.dart +++ b/app/test/widgets/book/private_book_cover_test.dart @@ -95,6 +95,29 @@ void main() { expect(find.byType(Image), findsOneWidget); }); + testWidgets('provider-backed pending replacement takes priority over the old media cover', (tester) async { + final importService = _RecordingBookImportService( + Uint8List.fromList(pngBytes), + pendingCoverBytes: Uint8List.fromList(pngBytes), + ); + final harness = await _buildProviderHarness(importService: importService); + + await tester.pumpWidget( + harness.wrap( + const CoverImage( + bookId: 'book-1', + mediaId: 'old-asset', + placeholder: SizedBox(key: Key('placeholder')), + ), + ), + ); + await _waitForDecodedCover(tester); + + expect(importService.pendingCoverReads, 1); + expect(importService.cachedCoverReads, 0); + expect(find.byType(Image), findsOneWidget); + }); + testWidgets('provider-backed pending cover reuses its decoded image across pages', (tester) async { final harness = await _buildProviderHarness(); var pendingReads = 0; @@ -725,14 +748,22 @@ Future<_ProviderHarness> _buildProviderHarness({ } class _RecordingBookImportService extends BookImportService { - _RecordingBookImportService(this.coverBytes); + _RecordingBookImportService(this.coverBytes, {this.pendingCoverBytes}); final Uint8List coverBytes; + final Uint8List? pendingCoverBytes; int cachedCoverReads = 0; + int pendingCoverReads = 0; @override Future getCoverFile(MediaStorageScope scope, String mediaId) async { cachedCoverReads++; return coverBytes; } + + @override + Future getPendingCoverFile(MediaStorageScope scope, String bookId) async { + pendingCoverReads++; + return pendingCoverBytes; + } } From 48b26da76793575cdcf8d5d80eda4724a3b5b869 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sun, 12 Jul 2026 22:59:12 +0300 Subject: [PATCH 44/48] feat: improve loading state handling in DataStore and related components --- app/lib/data/data_store.dart | 3 ++ app/lib/media/media_upload_queue.dart | 14 +----- app/lib/pages/library_page.dart | 34 ++++++------- app/lib/powersync/powersync_service.dart | 34 ++++++------- app/lib/providers/book_details_provider.dart | 2 +- app/lib/providers/book_edit_provider.dart | 2 +- .../data/book_repository_data_store_test.dart | 25 ++++++++-- app/test/media/media_upload_queue_test.dart | 29 +++++++---- app/test/pages/library_page_test.dart | 37 ++++++++++++++ .../powersync/powersync_service_test.dart | 48 +++++++++++++++++++ .../providers/book_details_provider_test.dart | 32 +++++++++++++ .../providers/book_edit_provider_test.dart | 29 +++++++++++ 12 files changed, 225 insertions(+), 64 deletions(-) diff --git a/app/lib/data/data_store.dart b/app/lib/data/data_store.dart index ee64ad7..a9d3444 100644 --- a/app/lib/data/data_store.dart +++ b/app/lib/data/data_store.dart @@ -74,6 +74,9 @@ class DataStore extends ChangeNotifier { Book? getBook(String id) => _books[id]; Future attachBookRepository(BookRepository repository) async { + final wasLoaded = _isLoaded; + _isLoaded = false; + if (wasLoaded) notifyListeners(); await _bookSubscription?.cancel(); _bookRepository = repository; _bookSubscription = repository.watchAll().listen( diff --git a/app/lib/media/media_upload_queue.dart b/app/lib/media/media_upload_queue.dart index da5ccbf..d7af9ba 100644 --- a/app/lib/media/media_upload_queue.dart +++ b/app/lib/media/media_upload_queue.dart @@ -296,7 +296,7 @@ class MediaUploadQueue extends ChangeNotifier { continue; } - final asset = await uploadMedia( + await uploadMedia( MediaUploadPayload( bookId: task.bookId, kind: task.kind, @@ -305,7 +305,6 @@ class MediaUploadQueue extends ChangeNotifier { bytes: bytes, ), ); - _applyUploadedAsset(dataStore, asset); await _removeTaskIfCurrent(scope, task, version); } on MediaUploadException catch (error) { await _replaceTaskIfCurrent( @@ -427,17 +426,6 @@ class MediaUploadQueue extends ChangeNotifier { 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(MediaStorageScope scope) { final raw = _prefs.getString(_storageKey(scope)); if (raw == null || raw.isEmpty) return []; diff --git a/app/lib/pages/library_page.dart b/app/lib/pages/library_page.dart index 66fca51..034e7c6 100644 --- a/app/lib/pages/library_page.dart +++ b/app/lib/pages/library_page.dart @@ -43,12 +43,13 @@ class _LibraryPageState extends State { // Get filtered books from DataStore (single source of truth) final books = _getFilteredBooks(libraryProvider, dataStore); + final isLoading = !dataStore.isLoaded; if (isDesktop) { - return _buildDesktopLayout(context, books, libraryProvider); + return _buildDesktopLayout(context, books, libraryProvider, isLoading); } - return _buildMobileLayout(context, books, libraryProvider); + return _buildMobileLayout(context, books, libraryProvider, isLoading); } List _getFilteredBooks(LibraryProvider provider, DataStore dataStore) { @@ -95,7 +96,7 @@ class _LibraryPageState extends State { // MOBILE LAYOUT // ============================================================================ - Widget _buildMobileLayout(BuildContext context, List books, LibraryProvider libraryProvider) { + Widget _buildMobileLayout(BuildContext context, List books, LibraryProvider libraryProvider, bool isLoading) { final isSelectionMode = libraryProvider.isSelectionMode; return Scaffold( @@ -145,7 +146,7 @@ class _LibraryPageState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - '${books.length} ${books.length == 1 ? 'book' : 'books'}', + isLoading ? 'Loading books…' : '${books.length} ${books.length == 1 ? 'book' : 'books'}', style: Theme.of( context, ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), @@ -156,13 +157,7 @@ class _LibraryPageState extends State { ), // Book grid or list - Expanded( - child: books.isEmpty - ? _buildEmptyState() - : libraryProvider.isListView - ? _buildBookList(context, books) - : BookGrid(books: books, onBookTap: (book) => _navigateToBookDetails(context, book)), - ), + Expanded(child: _buildBookContent(context, books, libraryProvider, isLoading)), ], ), ), @@ -405,7 +400,7 @@ class _LibraryPageState extends State { ); } - Widget _buildDesktopLayout(BuildContext context, List books, LibraryProvider libraryProvider) { + Widget _buildDesktopLayout(BuildContext context, List books, LibraryProvider libraryProvider, bool isLoading) { const double controlHeight = 40.0; final isSelectionMode = libraryProvider.isSelectionMode; @@ -480,13 +475,7 @@ class _LibraryPageState extends State { // Filter chips const LibraryFilterChips(horizontalPadding: Spacing.lg), // Book grid or list - Expanded( - child: books.isEmpty - ? _buildEmptyState() - : libraryProvider.isListView - ? _buildBookList(context, books) - : BookGrid(books: books, onBookTap: (book) => _navigateToBookDetails(context, book)), - ), + Expanded(child: _buildBookContent(context, books, libraryProvider, isLoading)), ], ), ), @@ -494,6 +483,13 @@ class _LibraryPageState extends State { ); } + Widget _buildBookContent(BuildContext context, List books, LibraryProvider libraryProvider, bool isLoading) { + if (isLoading) return const Center(child: CircularProgressIndicator()); + if (books.isEmpty) return _buildEmptyState(); + if (libraryProvider.isListView) return _buildBookList(context, books); + return BookGrid(books: books, onBookTap: (book) => _navigateToBookDetails(context, book)); + } + Widget _buildBookList(BuildContext context, List books) { final libraryProvider = context.watch(); final isSelectionMode = libraryProvider.isSelectionMode; diff --git a/app/lib/powersync/powersync_service.dart b/app/lib/powersync/powersync_service.dart index 1f35dd4..0d5d9ec 100644 --- a/app/lib/powersync/powersync_service.dart +++ b/app/lib/powersync/powersync_service.dart @@ -117,6 +117,7 @@ class PapyrusPowerSyncService implements BookRepository { @override Future getById(String id) async { + await _modeOperation; final database = _requireDatabase(); final row = await database.getOptional('SELECT * FROM books WHERE id = ?', [id]); return row == null ? null : PowerSyncBookMapper.fromRow(Map.from(row)); @@ -149,28 +150,24 @@ class PapyrusPowerSyncService implements BookRepository { await _syncStateController.close(); } - Future _switchMode( - LibraryDatabaseMode mode, { - String? authenticatedUserId, - String? authenticatedProfileKey, - }) async { - await _modeOperation; - if (_mode == mode && - _database != null && - (mode == LibraryDatabaseMode.guest || - (_authenticatedUserId == authenticatedUserId && _authenticatedProfileKey == authenticatedProfileKey))) { - return; - } - - final operation = _performModeSwitch(mode, authenticatedUserId, authenticatedProfileKey); + Future _switchMode(LibraryDatabaseMode mode, {String? authenticatedUserId, String? authenticatedProfileKey}) { + final previousOperation = _modeOperation; + final operation = (() async { + await previousOperation; + if (_mode == mode && + _database != null && + (mode == LibraryDatabaseMode.guest || + (_authenticatedUserId == authenticatedUserId && _authenticatedProfileKey == authenticatedProfileKey))) { + return; + } + await _performModeSwitch(mode, authenticatedUserId, authenticatedProfileKey); + })(); _modeOperation = operation; - try { - await operation; - } finally { + return operation.whenComplete(() { if (identical(_modeOperation, operation)) { _modeOperation = null; } - } + }); } Future _performModeSwitch( @@ -182,7 +179,6 @@ class PapyrusPowerSyncService implements BookRepository { _mode = mode; _authenticatedUserId = authenticatedUserId; _authenticatedProfileKey = authenticatedProfileKey; - _booksController.add(const []); final database = PowerSyncDatabase( schema: mode == LibraryDatabaseMode.guest ? papyrusGuestSchema : papyrusAccountSchema, diff --git a/app/lib/providers/book_details_provider.dart b/app/lib/providers/book_details_provider.dart index 982eb8f..322c7ad 100644 --- a/app/lib/providers/book_details_provider.dart +++ b/app/lib/providers/book_details_provider.dart @@ -114,7 +114,7 @@ class BookDetailsProvider extends ChangeNotifier { // Find book from DataStore (or sample data as fallback) Book? foundBook; if (_dataStore != null) { - foundBook = _dataStore!.getBook(bookId); + foundBook = _dataStore!.getBook(bookId) ?? await _dataStore!.requireBookRepository().getById(bookId); } // Fallback to sample data if not found in DataStore diff --git a/app/lib/providers/book_edit_provider.dart b/app/lib/providers/book_edit_provider.dart index 52d5637..77ef20c 100644 --- a/app/lib/providers/book_edit_provider.dart +++ b/app/lib/providers/book_edit_provider.dart @@ -81,7 +81,7 @@ class BookEditProvider extends ChangeNotifier { notifyListeners(); try { - final book = _dataStore!.getBook(bookId); + final book = _dataStore!.getBook(bookId) ?? await _dataStore!.requireBookRepository().getById(bookId); if (book == null) { _error = 'Book not found'; } else { diff --git a/app/test/data/book_repository_data_store_test.dart b/app/test/data/book_repository_data_store_test.dart index 609ef19..0149bb8 100644 --- a/app/test/data/book_repository_data_store_test.dart +++ b/app/test/data/book_repository_data_store_test.dart @@ -70,6 +70,22 @@ void main() { await repository.controller.close(); }); + test('attaching a repository stays loading until its first snapshot', () async { + final repository = FakeBookRepository(); + final store = DataStore()..loadData(books: [_book('old', 'Old book')]); + expect(store.isLoaded, isTrue); + + await store.attachBookRepository(repository); + + expect(store.isLoaded, isFalse); + repository.controller.add(const []); + await pumpEventQueue(); + expect(store.isLoaded, isTrue); + + await store.disposeBookRepository(); + await repository.controller.close(); + }); + test('stale sync snapshots do not clear an uploaded cover media id', () async { final repository = FakeBookRepository(); final store = DataStore(bookRepository: repository); @@ -223,7 +239,7 @@ void main() { await repository.controller.close(); }); - test('immediate queue upload sees repository-bound add before delayed watch stream', () async { + test('immediate queue upload does not rewrite a repository-bound add', () async { final repository = FakeBookRepository(); final store = DataStore(bookRepository: repository); final captured = store.requireBookRepository(); @@ -252,9 +268,12 @@ void main() { ), ); - expect(store.getBook(book.id)?.fileMediaId, 'file-media'); + expect(queue.pendingTasks, isEmpty); + expect(store.getBook(book.id)?.fileMediaId, isNull); + expect(store.getBook(book.id)?.fileHash, 'hash'); await pumpEventQueue(); - expect(repository.upserts.last.fileMediaId, 'file-media'); + expect(repository.upserts.last.fileMediaId, isNull); + expect(repository.upserts.last.fileHash, 'hash'); await store.disposeBookRepository(); await repository.controller.close(); diff --git a/app/test/media/media_upload_queue_test.dart b/app/test/media/media_upload_queue_test.dart index 5a8ce66..2a2990d 100644 --- a/app/test/media/media_upload_queue_test.dart +++ b/app/test/media/media_upload_queue_test.dart @@ -16,7 +16,7 @@ void main() { SharedPreferences.setMockInitialValues({}); }); - test('processPending uploads book file and stores returned media id on the book', () async { + test('processPending does not rewrite book metadata after server upload', () async { final prefs = await SharedPreferences.getInstance(); final repository = InMemoryBookRepository(); final dataStore = DataStore(bookRepository: repository); @@ -25,8 +25,10 @@ void main() { await pumpEventQueue(); final queue = await _activeQueue(prefs); await queue.enqueueBookFile(book: book, filename: 'book.epub', contentType: 'application/epub+zip'); + final uploadStarted = Completer(); + final uploadResult = Completer(); - await queue.processPending( + final processing = queue.processPending( dataStore: dataStore, readBookFile: (bookId) async => Uint8List.fromList('epub bytes'.codeUnits), readPendingCover: (_, _) async => null, @@ -34,12 +36,26 @@ void main() { 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); + uploadStarted.complete(); + return uploadResult.future; }, ); + await uploadStarted.future; + + // A downloaded server snapshot can replace the in-memory copy while the + // media request is in flight. Upload completion must not write that + // transient copy back over the complete metadata already in PowerSync. + dataStore.replaceBooksFromSync([Book(id: book.id, title: book.title, author: book.author, addedAt: book.addedAt)]); + uploadResult.complete(_asset(assetId: 'file-asset', bookId: book.id, kind: MediaKind.bookFile)); + await processing; + await pumpEventQueue(); expect(queue.pendingTasks, isEmpty); - expect(dataStore.getBook(book.id)?.fileMediaId, 'file-asset'); + final persisted = await repository.getById(book.id); + expect(persisted?.fileFormat, BookFormat.epub); + expect(persisted?.fileSize, 10); + expect(persisted?.fileHash, 'hash'); + expect(persisted?.fileMediaId, isNull); }); test('processPending keeps quota failures visible without dropping local media', () async { @@ -116,7 +132,7 @@ void main() { expect(prefs.getString('media_upload_queue:official--user-1'), isNot(contains(base64Encode('cover'.codeUnits)))); }); - test('restored cover task reads scoped pending file and stores returned media id', () async { + test('restored cover task reads scoped pending file and drains the task', () async { final prefs = await SharedPreferences.getInstance(); final repository = InMemoryBookRepository(); final dataStore = DataStore(bookRepository: repository); @@ -147,7 +163,6 @@ void main() { ); expect(restoredQueue.pendingTasks, isEmpty); - expect(dataStore.getBook(book.id)?.coverMediaId, 'cover-asset'); }); test('missing pending cover file leaves task pending with a local file error', () async { @@ -256,7 +271,6 @@ void main() { expect(attempts, 2); expect(queue.pendingTasks, isEmpty); - expect(dataStore.getBook(book.id)?.coverMediaId, 'cover-asset'); }); test('pending uploads are isolated and restored per media scope', () async { @@ -362,7 +376,6 @@ void main() { expect(attempts, 2); expect(queue.pendingTasks, isEmpty); - expect(dataStore.getBook(book.id)?.fileMediaId, 'file-asset'); }); test('enqueue during an upload is persisted and drained before processing completes', () async { diff --git a/app/test/pages/library_page_test.dart b/app/test/pages/library_page_test.dart index 81cea9b..155c59f 100644 --- a/app/test/pages/library_page_test.dart +++ b/app/test/pages/library_page_test.dart @@ -1,6 +1,9 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; 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/models/book.dart'; import 'package:papyrus/pages/library_page.dart'; import 'package:papyrus/providers/library_provider.dart'; @@ -36,6 +39,24 @@ void main() { // ======================================================================== group('mobile layout', () { + testWidgets('shows loading until the first repository snapshot arrives', (tester) async { + final repository = _ControlledBookRepository(); + final loadingStore = DataStore(bookRepository: repository); + + await tester.pumpWidget(buildPage(store: loadingStore)); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.text('No books found'), findsNothing); + + repository.controller.add(const []); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsNothing); + expect(find.text('No books found'), findsOneWidget); + + await repository.controller.close(); + }); + testWidgets('renders search bar', (tester) async { await tester.pumpWidget(buildPage()); await tester.pumpAndSettle(); @@ -463,3 +484,19 @@ void main() { }); }); } + +class _ControlledBookRepository implements BookRepository { + final StreamController> controller = StreamController>.broadcast(); + + @override + Stream> watchAll() => controller.stream; + + @override + Future getById(String id) async => null; + + @override + Future upsert(Book book) async {} + + @override + Future delete(String id) async {} +} diff --git a/app/test/powersync/powersync_service_test.dart b/app/test/powersync/powersync_service_test.dart index 78b1062..9935557 100644 --- a/app/test/powersync/powersync_service_test.dart +++ b/app/test/powersync/powersync_service_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; @@ -56,6 +57,53 @@ void main() { await second.close(); }); + test('getById waits for database activation', () async { + final allowPathResolution = Completer(); + final first = PapyrusPowerSyncService( + connectorFactory: OfflineConnector.new, + connectAuthenticated: false, + pathResolver: (mode, profileKey, userId) async { + await allowPathResolution.future; + return path.join(directory.path, 'delayed-guest.db'); + }, + ); + + final activation = first.activateGuest(); + final lookup = first.getById('missing-book'); + allowPathResolution.complete(); + + await activation; + expect(await lookup, isNull); + await first.close(); + }); + + test('activation emits the database snapshot instead of a synthetic empty snapshot', () async { + final allowPathResolution = Completer(); + final firstSnapshot = Completer>(); + final first = PapyrusPowerSyncService( + connectorFactory: OfflineConnector.new, + connectAuthenticated: false, + pathResolver: (mode, profileKey, userId) async { + await allowPathResolution.future; + return path.join(directory.path, 'snapshot-guest.db'); + }, + ); + final subscription = first.watchAll().listen((books) { + if (!firstSnapshot.isCompleted) firstSnapshot.complete(books); + }); + + final activation = first.activateGuest(); + await Future.delayed(Duration.zero); + + expect(firstSnapshot.isCompleted, isFalse); + allowPathResolution.complete(); + await activation; + expect(await firstSnapshot.future, isEmpty); + + await subscription.cancel(); + await first.close(); + }); + test('authenticated books are cleared on deactivation', () async { final first = service(); await first.activateAuthenticated('user-one'); diff --git a/app/test/providers/book_details_provider_test.dart b/app/test/providers/book_details_provider_test.dart index df1b793..e393443 100644 --- a/app/test/providers/book_details_provider_test.dart +++ b/app/test/providers/book_details_provider_test.dart @@ -1,5 +1,6 @@ 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/models/annotation.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/providers/book_details_provider.dart'; @@ -75,6 +76,19 @@ void main() { }); group('loadBook', () { + test('loads from repository before the in-memory cache hydrates', () async { + final storedBook = buildTestBook(id: 'repository-book', title: 'Stored Book'); + final repository = _LookupBookRepository(storedBook); + final startupStore = DataStore(bookRepository: repository); + final startupProvider = BookDetailsProvider()..setDataStore(startupStore); + addTearDown(startupProvider.dispose); + + await startupProvider.loadBook(storedBook.id); + + expect(startupProvider.book, storedBook); + expect(startupProvider.error, isNull); + }); + test('loads a book from DataStore', () async { await provider.loadBook('book-1'); @@ -525,3 +539,21 @@ void main() { }); }); } + +class _LookupBookRepository implements BookRepository { + _LookupBookRepository(this.book); + + final Book book; + + @override + Stream> watchAll() => const Stream.empty(); + + @override + Future getById(String id) async => id == book.id ? book : null; + + @override + Future upsert(Book book) async {} + + @override + Future delete(String id) async {} +} diff --git a/app/test/providers/book_edit_provider_test.dart b/app/test/providers/book_edit_provider_test.dart index 0c4420e..0e27428 100644 --- a/app/test/providers/book_edit_provider_test.dart +++ b/app/test/providers/book_edit_provider_test.dart @@ -10,6 +10,17 @@ import 'package:papyrus/providers/book_edit_provider.dart'; import '../helpers/test_helpers.dart'; void main() { + test('loads from repository before the in-memory cache hydrates', () async { + final storedBook = buildTestBook(id: 'repository-book', title: 'Stored Book'); + final dataStore = DataStore(bookRepository: _LookupBookRepository(storedBook)); + final provider = BookEditProvider()..setDataStore(dataStore); + + await provider.loadBook(storedBook.id); + + expect(provider.editedBook, storedBook); + expect(provider.error, isNull); + }); + test('saving a picked cover keeps image bytes out of synced book metadata', () async { final dataStore = DataStore(); final original = buildTestBook( @@ -53,6 +64,24 @@ void main() { }); } +class _LookupBookRepository implements BookRepository { + _LookupBookRepository(this.book); + + final Book book; + + @override + Stream> watchAll() => const Stream.empty(); + + @override + Future getById(String id) async => id == book.id ? book : null; + + @override + Future upsert(Book book) async {} + + @override + Future delete(String id) async {} +} + class _GatedBookRepository implements BookRepository { final upsertStarted = Completer(); final allowUpsert = Completer(); From 9f1f7508618bef535f3ecb78fbddcaac4a2775a9 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sun, 12 Jul 2026 23:05:04 +0300 Subject: [PATCH 45/48] feat: add loading placeholder for cover images during loading state --- app/lib/widgets/book/private_book_cover.dart | 48 ++++++++++++++++++- .../widgets/book/private_book_cover_test.dart | 47 ++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/app/lib/widgets/book/private_book_cover.dart b/app/lib/widgets/book/private_book_cover.dart index bbe5ba2..d15f210 100644 --- a/app/lib/widgets/book/private_book_cover.dart +++ b/app/lib/widgets/book/private_book_cover.dart @@ -268,6 +268,7 @@ class _PrivateBookCoverState extends State { return CachedNetworkImage( imageUrl: widget.imageUrl!, fit: widget.fit, + placeholder: (_, _) => _buildLoadingPlaceholder(context), errorWidget: (_, _, _) => widget.placeholder, ); } @@ -280,7 +281,7 @@ class _PrivateBookCoverState extends State { gaplessPlayback: true, frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { if (wasSynchronouslyLoaded || frame != null) return child; - return widget.placeholder; + return _buildLoadingPlaceholder(context); }, errorBuilder: (_, _, _) => widget.placeholder, ); @@ -292,6 +293,9 @@ class _PrivateBookCoverState extends State { return FutureBuilder( future: coverFuture, builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return _buildLoadingPlaceholder(context); + } final bytes = snapshot.data; if (bytes != null) { return Image.memory(bytes, fit: widget.fit, errorBuilder: (_, _, _) => widget.placeholder); @@ -301,4 +305,46 @@ class _PrivateBookCoverState extends State { }, ); } + + Widget _buildLoadingPlaceholder(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Semantics( + label: 'Loading book cover', + child: Container( + key: const Key('cover-image-loading'), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + border: Border.all(color: colorScheme.outlineVariant), + ), + child: LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < 72 || constraints.maxHeight < 96; + final icon = Icon( + Icons.auto_stories_rounded, + size: compact ? 22 : 36, + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.75), + ); + if (compact) return Center(child: icon); + + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + icon, + const SizedBox(height: 8), + Text( + 'Loading…', + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: colorScheme.onSurfaceVariant, fontWeight: FontWeight.w600), + ), + ], + ), + ); + }, + ), + ), + ); + } } diff --git a/app/test/widgets/book/private_book_cover_test.dart b/app/test/widgets/book/private_book_cover_test.dart index 61dd9ce..b827172 100644 --- a/app/test/widgets/book/private_book_cover_test.dart +++ b/app/test/widgets/book/private_book_cover_test.dart @@ -69,6 +69,53 @@ void main() { cache.clearLiveImages(); }); + testWidgets('shows a visible loading state while local cover bytes load', (tester) async { + final coverBytes = Completer(); + + await tester.pumpWidget( + MaterialApp( + home: SizedBox( + width: 200, + height: 300, + child: CoverImage( + bookId: 'book-1', + loadLocalBookCover: (_) => coverBytes.future, + placeholder: const SizedBox(key: Key('fallback-placeholder')), + ), + ), + ), + ); + + expect(find.byKey(const Key('cover-image-loading')), findsOneWidget); + expect(find.byIcon(Icons.auto_stories_rounded), findsOneWidget); + expect(find.text('Loading…'), findsOneWidget); + expect(find.byKey(const Key('fallback-placeholder')), findsNothing); + + coverBytes.complete(Uint8List.fromList(pngBytes)); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('cover-image-loading')), findsNothing); + expect(find.byType(Image), findsOneWidget); + }); + + testWidgets('public covers use the visible loading state', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: SizedBox( + width: 200, + height: 300, + child: CoverImage( + imageUrl: 'https://example.invalid/cover.jpg', + placeholder: SizedBox(key: Key('fallback-placeholder')), + ), + ), + ), + ); + + expect(find.byKey(const Key('cover-image-loading')), findsOneWidget); + expect(find.text('Loading…'), findsOneWidget); + }); + testWidgets('provider-backed media cover reuses its decoded image across pages', (tester) async { final importService = _RecordingBookImportService(Uint8List.fromList(pngBytes)); final harness = await _buildProviderHarness(importService: importService); From 1f5c86e697e2e56ac43f6ae865b9a988e722e20b Mon Sep 17 00:00:00 2001 From: Eoic Date: Sun, 12 Jul 2026 23:47:46 +0300 Subject: [PATCH 46/48] feat: restructure BookEditPage layout for improved alignment and persistent actions --- app/lib/pages/book_edit_page.dart | 125 +++++++++++------- .../pages/book_edit_page_layout_test.dart | 78 +++++++++++ .../2026-07-12-book-edit-layout-alignment.md | 46 +++++++ 3 files changed, 200 insertions(+), 49 deletions(-) create mode 100644 app/test/pages/book_edit_page_layout_test.dart create mode 100644 docs/superpowers/plans/2026-07-12-book-edit-layout-alignment.md diff --git a/app/lib/pages/book_edit_page.dart b/app/lib/pages/book_edit_page.dart index 45eff1c..1354cf1 100644 --- a/app/lib/pages/book_edit_page.dart +++ b/app/lib/pages/book_edit_page.dart @@ -179,11 +179,18 @@ class _BookEditPageState extends State { : Scaffold( appBar: AppBar( title: const Text('Edit book'), - leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => _handleCancel(context)), + leading: IconButton( + tooltip: 'Back to book details', + icon: const Icon(Icons.arrow_back), + onPressed: () => _handleCancel(context), + ), actions: [ - TextButton( - onPressed: provider.canSave ? () => _handleSave(context) : null, - child: const Text('Save'), + Padding( + padding: const EdgeInsets.only(right: Spacing.sm), + child: FilledButton( + onPressed: provider.canSave ? () => _handleSave(context) : null, + child: const Text('Save'), + ), ), ], ), @@ -200,7 +207,47 @@ class _BookEditPageState extends State { // ============================================================================ Widget _buildDesktopScaffold(BuildContext context, BookEditProvider provider) { - return Form(key: _formKey, child: _buildDesktopLayout(context, provider)); + return Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDesktopPageHeader(context, provider), + const Divider(height: 1), + Expanded(child: _buildDesktopLayout(context, provider)), + ], + ), + ); + } + + Widget _buildDesktopPageHeader(BuildContext context, BookEditProvider provider) { + final textTheme = Theme.of(context).textTheme; + + return SizedBox( + key: const Key('book-edit-desktop-header'), + height: ComponentSizes.appBarHeight, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1120), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Spacing.md), + child: Row( + children: [ + IconButton( + tooltip: 'Back to book details', + onPressed: () => _handleCancel(context), + icon: const Icon(Icons.arrow_back), + ), + const SizedBox(width: Spacing.sm), + Expanded( + child: Text('Edit book', style: textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w600)), + ), + const SizedBox(width: Spacing.lg), + FilledButton(onPressed: provider.canSave ? () => _handleSave(context) : null, child: const Text('Save')), + ], + ), + ), + ), + ); } Widget _buildMobileLayout(BuildContext context, BookEditProvider provider) { @@ -220,55 +267,34 @@ class _BookEditPageState extends State { } Widget _buildDesktopLayout(BuildContext context, BookEditProvider provider) { - return Center( + return Align( + alignment: Alignment.topLeft, child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 1100), + constraints: const BoxConstraints(maxWidth: 1120), child: SingleChildScrollView( - padding: const EdgeInsets.all(Spacing.xl), - child: Column( + padding: const EdgeInsets.all(Spacing.lg), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Left pane - Cover + Metadata - SizedBox( - width: 360, - child: Column( - children: [ - _buildSectionCard( - title: 'Cover', - children: [_buildCoverSection(context, provider, isDesktop: true)], - ), - _buildSectionCard( - title: 'Fetch metadata', - children: [_buildMetadataSection(context, provider)], - ), - ], - ), - ), - const SizedBox(width: Spacing.xl), - // Right pane - Form fields - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: _buildFormSections(context, provider, skipMetadata: true), + // Left pane - Cover + Metadata + SizedBox( + width: 280, + child: Column( + children: [ + _buildSectionCard( + title: 'Cover', + children: [_buildCoverSection(context, provider, isDesktop: true)], ), - ), - ], - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: Spacing.md), - child: const Divider(), + _buildSectionCard(title: 'Fetch metadata', children: [_buildMetadataSection(context, provider)]), + ], + ), ), - const SizedBox(height: Spacing.sm), - Padding( - padding: const EdgeInsets.only(right: Spacing.md), - child: Align( - alignment: Alignment.centerRight, - child: FilledButton( - onPressed: provider.canSave ? () => _handleSave(context) : null, - child: const Text('Save'), - ), + const SizedBox(width: Spacing.xl), + // Right pane - Form fields + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: _buildFormSections(context, provider, skipMetadata: true), ), ), ], @@ -520,6 +546,7 @@ class _BookEditPageState extends State { initialUrl: provider.editedBook?.coverUrl, initialBytes: provider.coverImageBytes, isDesktop: isDesktop, + coverWidth: isDesktop ? 240 : null, onUrlChanged: (url) => _provider.updateCoverUrl(url), onFileChanged: (bytes) => _provider.updateCoverFromFile(bytes), ); diff --git a/app/test/pages/book_edit_page_layout_test.dart b/app/test/pages/book_edit_page_layout_test.dart new file mode 100644 index 0000000..3e9f44a --- /dev/null +++ b/app/test/pages/book_edit_page_layout_test.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/pages/book_edit_page.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/book_edit/cover_image_picker.dart'; + +import '../helpers/test_helpers.dart'; + +void main() { + group('BookEditPage layout', () { + Future pumpPage(WidgetTester tester, {required Size size}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.reset); + + final book = buildTestBook( + id: 'book-1', + title: 'Frankenstein; or, The Modern Prometheus', + author: 'Mary Shelley', + ); + + await tester.pumpWidget( + createTestPage( + page: Theme( + data: AppTheme.dark, + child: const BookEditPage(id: 'book-1'), + ), + dataStore: createTestDataStore(books: [book]), + screenSize: size, + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('desktop uses a left-aligned page header with a persistent Save action', (tester) async { + await pumpPage(tester, size: const Size(1400, 1000)); + + expect(find.text('Edit book'), findsOneWidget); + expect(find.widgetWithText(OutlinedButton, 'Back'), findsNothing); + expect(find.byTooltip('Back to book details'), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'Save'), findsOneWidget); + expect(find.byIcon(Icons.save_outlined), findsNothing); + + final header = find.byKey(const Key('book-edit-desktop-header')); + expect(tester.getSize(header).height, ComponentSizes.appBarHeight); + expect(find.descendant(of: header, matching: find.text('Frankenstein; or, The Modern Prometheus')), findsNothing); + + final coverHeading = tester.getTopLeft(find.text('Cover')); + expect(coverHeading.dx, lessThan(80)); + + final saveButton = find.widgetWithText(FilledButton, 'Save'); + expect(find.ancestor(of: saveButton, matching: find.byType(SingleChildScrollView)), findsNothing); + }); + + testWidgets('desktop keeps the cover and form fields side by side', (tester) async { + await pumpPage(tester, size: const Size(1400, 1000)); + + final coverHeading = tester.getTopLeft(find.text('Cover')); + final formHeading = tester.getTopLeft(find.text('Basic information')); + + expect(formHeading.dx, greaterThan(coverHeading.dx)); + expect((formHeading.dy - coverHeading.dy).abs(), lessThan(4)); + }); + + testWidgets('narrow layouts stack the cover above the form without overflow', (tester) async { + await pumpPage(tester, size: const Size(600, 1600)); + + expect(find.text('Edit book'), findsOneWidget); + expect(find.byType(CoverImagePicker), findsOneWidget); + expect( + tester.getTopLeft(find.byType(CoverImagePicker)).dy, + lessThan(tester.getTopLeft(find.text('Basic information')).dy), + ); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/docs/superpowers/plans/2026-07-12-book-edit-layout-alignment.md b/docs/superpowers/plans/2026-07-12-book-edit-layout-alignment.md new file mode 100644 index 0000000..7bb7de4 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-book-edit-layout-alignment.md @@ -0,0 +1,46 @@ +# Book Edit Layout Alignment Implementation Plan + +**Goal:** Make the book edit page read as the edit state of the book details page by sharing its left content origin, adding persistent page actions, and preserving the existing responsive form behavior. + +**Architecture:** Keep `BookEditPage` and `BookEditProvider` behavior intact. Restructure only the page composition: a fixed desktop header above a left-aligned, width-constrained scroll area; retain the existing mobile app bar and stacked form. Add widget tests around visible controls and relative geometry rather than implementation-specific widget nesting. + +**Tech Stack:** Flutter, Material, Provider, `flutter_test` + +--- + +### Task 1: Add responsive layout regression tests + +**Files:** +- Create: `app/test/pages/book_edit_page_layout_test.dart` +- Reference: `app/test/helpers/test_helpers.dart` +- Reference: `app/lib/pages/book_edit_page.dart` + +1. Add a desktop test that loads a real test book and verifies the page title, Back action, and Save action. +2. Verify the desktop content begins at the normal page margin instead of being horizontally centered. +3. Verify the cover and fields are side by side and Save is outside the scrolling form content. +4. Add a narrow-screen test verifying the cover section precedes the form fields with no horizontal overflow. +5. Run the test and confirm the desktop assertions fail against the current centered layout. + +### Task 2: Restructure the edit page layout + +**Files:** +- Modify: `app/lib/pages/book_edit_page.dart` + +1. Add a compact desktop page header with a back arrow, `Edit book`, and a text-only primary Save action. +2. Keep the header outside the scroll area so Save remains available on long forms. +3. Replace the centered desktop wrapper with a top-left-aligned container using the same desktop page margin as book details. +4. Preserve the two-column form, using a cover column close to the details-page cover position and a constrained overall width. +5. Remove the disconnected bottom Save button. +6. Preserve the current mobile app bar and stacked form behavior, adding stable semantic labels/keys where needed by the regression tests. + +### Task 3: Verify behavior and quality + +**Files:** +- Test: `app/test/pages/book_edit_page_layout_test.dart` +- Test: existing book edit and provider tests + +1. Format changed Dart files. +2. Run the focused layout test. +3. Run existing book edit/provider tests that cover form and cover behavior. +4. Run Flutter analysis for the changed files or application package. +5. Review the final diff to confirm no form data, validation, or persistence behavior changed. From eacc903366fc4bbb2e4ddc9604e30bb2fa8de53e Mon Sep 17 00:00:00 2001 From: Eoic Date: Sun, 12 Jul 2026 23:55:39 +0300 Subject: [PATCH 47/48] feat: add waitUntilLoaded method to DataStore and update providers to handle loading state --- app/lib/data/data_store.dart | 23 +++++++++++ app/lib/providers/book_details_provider.dart | 8 +++- app/lib/providers/book_edit_provider.dart | 8 +++- .../data/book_repository_data_store_test.dart | 18 +++++++++ .../providers/book_details_provider_test.dart | 40 +++++++++++++++++++ .../providers/book_edit_provider_test.dart | 37 +++++++++++++++++ 6 files changed, 132 insertions(+), 2 deletions(-) diff --git a/app/lib/data/data_store.dart b/app/lib/data/data_store.dart index a9d3444..fe864cd 100644 --- a/app/lib/data/data_store.dart +++ b/app/lib/data/data_store.dart @@ -48,6 +48,29 @@ class DataStore extends ChangeNotifier { bool get isLoaded => _isLoaded; + /// Completes when the active book repository has emitted its first snapshot. + /// + /// A direct repository lookup may temporarily return null while persistent + /// storage is still opening. Callers should wait for this before treating a + /// missing ID as authoritative. + Future waitUntilLoaded() { + if (_isLoaded) return Future.value(); + + final completer = Completer(); + late VoidCallback listener; + listener = () { + if (!_isLoaded || completer.isCompleted) return; + removeListener(listener); + completer.complete(); + }; + + addListener(listener); + // Close the gap if loading completed between the initial check and listener + // registration. + listener(); + return completer.future; + } + List get books => _books.values.toList(); /// Get all shelves with computed bookCount and coverPreviews. diff --git a/app/lib/providers/book_details_provider.dart b/app/lib/providers/book_details_provider.dart index 322c7ad..08d69f2 100644 --- a/app/lib/providers/book_details_provider.dart +++ b/app/lib/providers/book_details_provider.dart @@ -114,7 +114,13 @@ class BookDetailsProvider extends ChangeNotifier { // Find book from DataStore (or sample data as fallback) Book? foundBook; if (_dataStore != null) { - foundBook = _dataStore!.getBook(bookId) ?? await _dataStore!.requireBookRepository().getById(bookId); + final dataStore = _dataStore!; + final repository = dataStore.requireBookRepository(); + foundBook = dataStore.getBook(bookId) ?? await repository.getById(bookId); + if (foundBook == null && !dataStore.isLoaded) { + await dataStore.waitUntilLoaded(); + foundBook = dataStore.getBook(bookId) ?? await repository.getById(bookId); + } } // Fallback to sample data if not found in DataStore diff --git a/app/lib/providers/book_edit_provider.dart b/app/lib/providers/book_edit_provider.dart index 77ef20c..75e2ff6 100644 --- a/app/lib/providers/book_edit_provider.dart +++ b/app/lib/providers/book_edit_provider.dart @@ -81,7 +81,13 @@ class BookEditProvider extends ChangeNotifier { notifyListeners(); try { - final book = _dataStore!.getBook(bookId) ?? await _dataStore!.requireBookRepository().getById(bookId); + final dataStore = _dataStore!; + final repository = dataStore.requireBookRepository(); + var book = dataStore.getBook(bookId) ?? await repository.getById(bookId); + if (book == null && !dataStore.isLoaded) { + await dataStore.waitUntilLoaded(); + book = dataStore.getBook(bookId) ?? await repository.getById(bookId); + } if (book == null) { _error = 'Book not found'; } else { diff --git a/app/test/data/book_repository_data_store_test.dart b/app/test/data/book_repository_data_store_test.dart index 0149bb8..7707354 100644 --- a/app/test/data/book_repository_data_store_test.dart +++ b/app/test/data/book_repository_data_store_test.dart @@ -86,6 +86,24 @@ void main() { await repository.controller.close(); }); + test('waitUntilLoaded completes only after the first repository snapshot', () async { + final repository = FakeBookRepository(); + final store = DataStore(bookRepository: repository); + var completed = false; + + final loaded = store.waitUntilLoaded().then((_) => completed = true); + await pumpEventQueue(); + expect(completed, isFalse); + + repository.controller.add([_book('one', 'First')]); + await loaded; + + expect(store.isLoaded, isTrue); + expect(store.getBook('one')?.title, 'First'); + await store.disposeBookRepository(); + await repository.controller.close(); + }); + test('stale sync snapshots do not clear an uploaded cover media id', () async { final repository = FakeBookRepository(); final store = DataStore(bookRepository: repository); diff --git a/app/test/providers/book_details_provider_test.dart b/app/test/providers/book_details_provider_test.dart index e393443..50cc721 100644 --- a/app/test/providers/book_details_provider_test.dart +++ b/app/test/providers/book_details_provider_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/data/repositories/book_repository.dart'; @@ -89,6 +91,28 @@ void main() { expect(startupProvider.error, isNull); }); + test('waits for the first repository snapshot before reporting a missing book', () async { + final storedBook = buildTestBook(id: 'delayed-book', title: 'Delayed Book'); + final repository = _DelayedSnapshotBookRepository(); + final startupStore = DataStore(bookRepository: repository); + final startupProvider = BookDetailsProvider()..setDataStore(startupStore); + addTearDown(startupProvider.dispose); + + final load = startupProvider.loadBook(storedBook.id); + await Future.delayed(Duration.zero); + + expect(startupProvider.isLoading, isTrue); + expect(startupProvider.error, isNull); + + repository.controller.add([storedBook]); + await load; + + expect(startupProvider.book, storedBook); + expect(startupProvider.error, isNull); + await startupStore.disposeBookRepository(); + await repository.controller.close(); + }); + test('loads a book from DataStore', () async { await provider.loadBook('book-1'); @@ -557,3 +581,19 @@ class _LookupBookRepository implements BookRepository { @override Future delete(String id) async {} } + +class _DelayedSnapshotBookRepository implements BookRepository { + final controller = StreamController>.broadcast(); + + @override + Stream> watchAll() => controller.stream; + + @override + Future getById(String id) async => null; + + @override + Future upsert(Book book) async {} + + @override + Future delete(String id) async {} +} diff --git a/app/test/providers/book_edit_provider_test.dart b/app/test/providers/book_edit_provider_test.dart index 0e27428..ec349e1 100644 --- a/app/test/providers/book_edit_provider_test.dart +++ b/app/test/providers/book_edit_provider_test.dart @@ -21,6 +21,27 @@ void main() { expect(provider.error, isNull); }); + test('waits for the first repository snapshot before reporting a missing book', () async { + final storedBook = buildTestBook(id: 'delayed-book', title: 'Delayed Book'); + final repository = _DelayedSnapshotBookRepository(); + final dataStore = DataStore(bookRepository: repository); + final provider = BookEditProvider()..setDataStore(dataStore); + + final load = provider.loadBook(storedBook.id); + await Future.delayed(Duration.zero); + + expect(provider.isLoading, isTrue); + expect(provider.error, isNull); + + repository.controller.add([storedBook]); + await load; + + expect(provider.editedBook, storedBook); + expect(provider.error, isNull); + await dataStore.disposeBookRepository(); + await repository.controller.close(); + }); + test('saving a picked cover keeps image bytes out of synced book metadata', () async { final dataStore = DataStore(); final original = buildTestBook( @@ -82,6 +103,22 @@ class _LookupBookRepository implements BookRepository { Future delete(String id) async {} } +class _DelayedSnapshotBookRepository implements BookRepository { + final controller = StreamController>.broadcast(); + + @override + Stream> watchAll() => controller.stream; + + @override + Future getById(String id) async => null; + + @override + Future upsert(Book book) async {} + + @override + Future delete(String id) async {} +} + class _GatedBookRepository implements BookRepository { final upsertStarted = Completer(); final allowUpsert = Completer(); From f56f2790e2810af2efb748e4247d31b4c596124d Mon Sep 17 00:00:00 2001 From: Eoic Date: Mon, 13 Jul 2026 00:08:56 +0300 Subject: [PATCH 48/48] feat: enhance Book model to support clearing optional fields and update related providers for consistent state management --- app/lib/models/book.dart | 46 +++++++++----- app/lib/pages/book_details_page.dart | 2 +- app/lib/providers/book_edit_provider.dart | 40 +++++++----- app/lib/utils/book_actions.dart | 2 +- app/test/config/app_router_test.dart | 8 +++ .../providers/book_edit_provider_test.dart | 63 +++++++++++++++++++ 6 files changed, 130 insertions(+), 31 deletions(-) diff --git a/app/lib/models/book.dart b/app/lib/models/book.dart index 4211255..ea83902 100644 --- a/app/lib/models/book.dart +++ b/app/lib/models/book.dart @@ -191,20 +191,30 @@ class Book { } /// Create a copy with updated fields. - /// Use `clearCoverUrl: true` to explicitly set coverUrl to null. + /// + /// Nullable fields use explicit `clear...` flags because an omitted nullable + /// argument and an argument set to null are otherwise indistinguishable. Book copyWith({ String? id, String? title, String? subtitle, + bool clearSubtitle = false, String? author, List? coAuthors, String? isbn, + bool clearIsbn = false, String? isbn13, + bool clearIsbn13 = false, DateTime? publicationDate, + bool clearPublicationDate = false, String? publisher, + bool clearPublisher = false, String? language, + bool clearLanguage = false, int? pageCount, + bool clearPageCount = false, String? description, + bool clearDescription = false, String? coverUrl, bool clearCoverUrl = false, String? fileMediaId, @@ -215,18 +225,24 @@ class Book { String? fileHash, bool? isPhysical, String? physicalLocation, + bool clearPhysicalLocation = false, String? lentTo, + bool clearLentTo = false, DateTime? lentAt, + bool clearLentAt = false, ReadingStatus? readingStatus, int? currentPage, double? currentPosition, String? currentCfi, bool? isFavorite, int? rating, + bool clearRating = false, Map? customMetadata, String? seriesId, String? seriesName, + bool clearSeriesName = false, double? seriesNumber, + bool clearSeriesNumber = false, DateTime? addedAt, DateTime? startedAt, DateTime? completedAt, @@ -235,16 +251,16 @@ class Book { return Book( id: id ?? this.id, title: title ?? this.title, - subtitle: subtitle ?? this.subtitle, + subtitle: clearSubtitle ? null : (subtitle ?? this.subtitle), author: author ?? this.author, coAuthors: coAuthors ?? this.coAuthors, - isbn: isbn ?? this.isbn, - isbn13: isbn13 ?? this.isbn13, - publicationDate: publicationDate ?? this.publicationDate, - publisher: publisher ?? this.publisher, - language: language ?? this.language, - pageCount: pageCount ?? this.pageCount, - description: description ?? this.description, + isbn: clearIsbn ? null : (isbn ?? this.isbn), + isbn13: clearIsbn13 ? null : (isbn13 ?? this.isbn13), + publicationDate: clearPublicationDate ? null : (publicationDate ?? this.publicationDate), + publisher: clearPublisher ? null : (publisher ?? this.publisher), + language: clearLanguage ? null : (language ?? this.language), + pageCount: clearPageCount ? null : (pageCount ?? this.pageCount), + description: clearDescription ? null : (description ?? this.description), coverUrl: clearCoverUrl ? null : (coverUrl ?? this.coverUrl), fileMediaId: fileMediaId ?? this.fileMediaId, coverMediaId: coverMediaId ?? this.coverMediaId, @@ -253,19 +269,19 @@ class Book { fileSize: fileSize ?? this.fileSize, fileHash: fileHash ?? this.fileHash, isPhysical: isPhysical ?? this.isPhysical, - physicalLocation: physicalLocation ?? this.physicalLocation, - lentTo: lentTo ?? this.lentTo, - lentAt: lentAt ?? this.lentAt, + physicalLocation: clearPhysicalLocation ? null : (physicalLocation ?? this.physicalLocation), + lentTo: clearLentTo ? null : (lentTo ?? this.lentTo), + lentAt: clearLentAt ? null : (lentAt ?? this.lentAt), readingStatus: readingStatus ?? this.readingStatus, currentPage: currentPage ?? this.currentPage, currentPosition: currentPosition ?? this.currentPosition, currentCfi: currentCfi ?? this.currentCfi, isFavorite: isFavorite ?? this.isFavorite, - rating: rating ?? this.rating, + rating: clearRating ? null : (rating ?? this.rating), customMetadata: customMetadata ?? this.customMetadata, seriesId: seriesId ?? this.seriesId, - seriesName: seriesName ?? this.seriesName, - seriesNumber: seriesNumber ?? this.seriesNumber, + seriesName: clearSeriesName ? null : (seriesName ?? this.seriesName), + seriesNumber: clearSeriesNumber ? null : (seriesNumber ?? this.seriesNumber), addedAt: addedAt ?? this.addedAt, startedAt: startedAt ?? this.startedAt, completedAt: completedAt ?? this.completedAt, diff --git a/app/lib/pages/book_details_page.dart b/app/lib/pages/book_details_page.dart index c3cfb11..844e395 100644 --- a/app/lib/pages/book_details_page.dart +++ b/app/lib/pages/book_details_page.dart @@ -388,7 +388,7 @@ class _BookDetailsPageState extends State with SingleTickerProv void _onEdit() { if (_provider.book != null) { - context.pushNamed('BOOK_EDIT', pathParameters: {'bookId': _provider.book!.id}); + context.goNamed('BOOK_EDIT', pathParameters: {'bookId': _provider.book!.id}); } } diff --git a/app/lib/providers/book_edit_provider.dart b/app/lib/providers/book_edit_provider.dart index 75e2ff6..936ba21 100644 --- a/app/lib/providers/book_edit_provider.dart +++ b/app/lib/providers/book_edit_provider.dart @@ -146,7 +146,8 @@ class BookEditProvider extends ChangeNotifier { void updateSubtitle(String? value) { if (_editedBook == null) return; - _editedBook = _editedBook!.copyWith(subtitle: value?.isEmpty == true ? null : value); + final shouldClear = value == null || value.isEmpty; + _editedBook = _editedBook!.copyWith(subtitle: shouldClear ? null : value, clearSubtitle: shouldClear); notifyListeners(); } @@ -164,37 +165,42 @@ class BookEditProvider extends ChangeNotifier { void updatePublisher(String? value) { if (_editedBook == null) return; - _editedBook = _editedBook!.copyWith(publisher: value?.isEmpty == true ? null : value); + final shouldClear = value == null || value.isEmpty; + _editedBook = _editedBook!.copyWith(publisher: shouldClear ? null : value, clearPublisher: shouldClear); notifyListeners(); } void updateLanguage(String? value) { if (_editedBook == null) return; - _editedBook = _editedBook!.copyWith(language: value?.isEmpty == true ? null : value); + final shouldClear = value == null || value.isEmpty; + _editedBook = _editedBook!.copyWith(language: shouldClear ? null : value, clearLanguage: shouldClear); notifyListeners(); } void updatePageCount(int? value) { if (_editedBook == null) return; - _editedBook = _editedBook!.copyWith(pageCount: value); + _editedBook = _editedBook!.copyWith(pageCount: value, clearPageCount: value == null); notifyListeners(); } void updateIsbn(String? value) { if (_editedBook == null) return; - _editedBook = _editedBook!.copyWith(isbn: value?.isEmpty == true ? null : value); + final shouldClear = value == null || value.isEmpty; + _editedBook = _editedBook!.copyWith(isbn: shouldClear ? null : value, clearIsbn: shouldClear); notifyListeners(); } void updateIsbn13(String? value) { if (_editedBook == null) return; - _editedBook = _editedBook!.copyWith(isbn13: value?.isEmpty == true ? null : value); + final shouldClear = value == null || value.isEmpty; + _editedBook = _editedBook!.copyWith(isbn13: shouldClear ? null : value, clearIsbn13: shouldClear); notifyListeners(); } void updateDescription(String? value) { if (_editedBook == null) return; - _editedBook = _editedBook!.copyWith(description: value?.isEmpty == true ? null : value); + final shouldClear = value == null || value.isEmpty; + _editedBook = _editedBook!.copyWith(description: shouldClear ? null : value, clearDescription: shouldClear); notifyListeners(); } @@ -221,25 +227,26 @@ class BookEditProvider extends ChangeNotifier { void updatePublicationDate(DateTime? value) { if (_editedBook == null) return; - _editedBook = _editedBook!.copyWith(publicationDate: value); + _editedBook = _editedBook!.copyWith(publicationDate: value, clearPublicationDate: value == null); notifyListeners(); } void updateRating(int? value) { if (_editedBook == null) return; - _editedBook = _editedBook!.copyWith(rating: value); + _editedBook = _editedBook!.copyWith(rating: value, clearRating: value == null); notifyListeners(); } void updateSeriesName(String? value) { if (_editedBook == null) return; - _editedBook = _editedBook!.copyWith(seriesName: value?.isEmpty == true ? null : value); + final shouldClear = value == null || value.isEmpty; + _editedBook = _editedBook!.copyWith(seriesName: shouldClear ? null : value, clearSeriesName: shouldClear); notifyListeners(); } void updateSeriesNumber(double? value) { if (_editedBook == null) return; - _editedBook = _editedBook!.copyWith(seriesNumber: value); + _editedBook = _editedBook!.copyWith(seriesNumber: value, clearSeriesNumber: value == null); notifyListeners(); } @@ -251,19 +258,24 @@ class BookEditProvider extends ChangeNotifier { void updatePhysicalLocation(String? value) { if (_editedBook == null) return; - _editedBook = _editedBook!.copyWith(physicalLocation: value?.isEmpty == true ? null : value); + final shouldClear = value == null || value.isEmpty; + _editedBook = _editedBook!.copyWith( + physicalLocation: shouldClear ? null : value, + clearPhysicalLocation: shouldClear, + ); notifyListeners(); } void updateLentTo(String? value) { if (_editedBook == null) return; - _editedBook = _editedBook!.copyWith(lentTo: value?.isEmpty == true ? null : value); + final shouldClear = value == null || value.isEmpty; + _editedBook = _editedBook!.copyWith(lentTo: shouldClear ? null : value, clearLentTo: shouldClear); notifyListeners(); } void updateLentAt(DateTime? value) { if (_editedBook == null) return; - _editedBook = _editedBook!.copyWith(lentAt: value); + _editedBook = _editedBook!.copyWith(lentAt: value, clearLentAt: value == null); notifyListeners(); } diff --git a/app/lib/utils/book_actions.dart b/app/lib/utils/book_actions.dart index 8d7e4ac..3c43d76 100644 --- a/app/lib/utils/book_actions.dart +++ b/app/lib/utils/book_actions.dart @@ -38,7 +38,7 @@ void showBookContextMenu({required BuildContext context, required Book book, Off libraryProvider.toggleFavorite(book.id, isFavorite); }, onEdit: () { - context.pushNamed('BOOK_EDIT', pathParameters: {'bookId': book.id}); + context.goNamed('BOOK_EDIT', pathParameters: {'bookId': book.id}); }, onMoveToShelf: () { _showMoveToShelfSheet(context, book); diff --git a/app/test/config/app_router_test.dart b/app/test/config/app_router_test.dart index cb04046..d2b6197 100644 --- a/app/test/config/app_router_test.dart +++ b/app/test/config/app_router_test.dart @@ -83,6 +83,14 @@ void main() { expect(appRouter.redirectForPath('/library/books'), isNull); }); + + test('book edit has a stable reloadable URL', () async { + final prefs = await SharedPreferences.getInstance(); + final provider = AuthProvider(prefs, repository: FakeAuthRepository(), bootstrapOnCreate: false); + final appRouter = AppRouter(authProvider: provider); + + expect(appRouter.router.namedLocation('BOOK_EDIT', pathParameters: {'bookId': 'book-1'}), '/library/edit/book-1'); + }); } AuthTokens _tokens() { diff --git a/app/test/providers/book_edit_provider_test.dart b/app/test/providers/book_edit_provider_test.dart index ec349e1..2c4e4b3 100644 --- a/app/test/providers/book_edit_provider_test.dart +++ b/app/test/providers/book_edit_provider_test.dart @@ -62,6 +62,69 @@ void main() { expect(provider.coverImageBytes, isNull); }); + test('saving cleared optional form fields removes their previous values', () async { + final dataStore = DataStore(); + final original = Book( + id: 'book-1', + title: 'Book', + subtitle: 'Subtitle', + author: 'Author', + coAuthors: const ['Co-author'], + isbn: 'ISBN', + isbn13: 'ISBN-13', + publicationDate: DateTime.utc(2020), + publisher: 'Publisher', + language: 'en', + pageCount: 100, + description: 'Description', + isPhysical: true, + physicalLocation: 'Shelf', + lentTo: 'Reader', + lentAt: DateTime.utc(2025), + rating: 4, + seriesName: 'Series', + seriesNumber: 2, + addedAt: DateTime.utc(2026), + ); + dataStore.addBook(original); + + final provider = BookEditProvider()..setDataStore(dataStore); + await provider.loadBook(original.id); + provider.updateSubtitle(''); + provider.updateCoAuthors(const []); + provider.updateIsbn(''); + provider.updateIsbn13(''); + provider.updatePublicationDate(null); + provider.updatePublisher(''); + provider.updateLanguage(''); + provider.updatePageCount(null); + provider.updateDescription(''); + provider.updatePhysicalLocation(''); + provider.updateLentTo(''); + provider.updateLentAt(null); + provider.updateRating(null); + provider.updateSeriesName(''); + provider.updateSeriesNumber(null); + + expect(await provider.save(), isTrue); + final saved = dataStore.getBook(original.id)!; + expect(saved.subtitle, isNull); + expect(saved.coAuthors, isEmpty); + expect(saved.isbn, isNull); + expect(saved.isbn13, isNull); + expect(saved.publicationDate, isNull); + expect(saved.publisher, isNull); + expect(saved.language, isNull); + expect(saved.pageCount, isNull); + expect(saved.description, isNull); + expect(saved.physicalLocation, isNull); + expect(saved.lentTo, isNull); + expect(saved.lentAt, isNull); + expect(saved.rating, isNull); + expect(saved.seriesName, isNull); + expect(saved.seriesNumber, isNull); + }); + test('save waits for synced metadata persistence before completing', () async { final repository = _GatedBookRepository(); final dataStore = DataStore(bookRepository: repository);