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..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. @@ -74,6 +97,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( @@ -92,22 +118,54 @@ 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) { throw StateError('Book repository is not initialized'); } + _books[book.id] = book; + notifyListeners(); unawaited(repository.upsert(book)); } + Future addBookAndWait(Book book) async { + 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) { final repository = _bookRepository; if (repository == null) { throw StateError('Book repository is not initialized'); } + _books[book.id] = book; + notifyListeners(); 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) { @@ -116,11 +174,35 @@ class DataStore extends ChangeNotifier { unawaited(repository.delete(id)); } + Future deleteBookAndWait(String id) async { + 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) { - 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)); @@ -185,7 +267,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(bookId: b.id, url: b.coverUrl, mediaId: b.coverMediaId, title: b.title)) + .toList(); } // ============================================================ diff --git a/app/lib/main.dart b/app/lib/main.dart index 6cad691..40016cb 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -7,13 +7,23 @@ 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'; +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'; import 'package:papyrus/powersync/sync_state.dart'; 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'; import 'package:papyrus/themes/app_theme.dart'; import 'package:provider/provider.dart'; @@ -22,6 +32,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'config/app_router.dart'; Future main() async { + await runPreviousHotRestartCleanup(); WidgetsFlutterBinding.ensureInitialized(); usePathUrlStrategy(); @@ -42,12 +53,17 @@ 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; late String _activeProfileKey; + late final SyncProfileSwitchQueue _profileSwitchQueue; late final AppRouter _appRouter; bool _switchingSyncProfile = false; + Future? _authStateOperation; + bool _authStateUpdateQueued = false; @override void initState() { @@ -57,13 +73,27 @@ 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); + _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, + ), ); + registerHotRestartCleanup(_disposeDataServices); unawaited(_dataStore.attachBookRepository(_powerSyncService)); _appRouter = AppRouter(authProvider: _authProvider); _authProvider.addListener(_syncPowerSyncAuthState); @@ -76,6 +106,7 @@ class _PapyrusState extends State { _authProvider.removeListener(_syncPowerSyncAuthState); _syncSettingsProvider.removeListener(_handleSyncSettingsChanged); unawaited(_disposeDataServices()); + _bookImportService.dispose(); _authProvider.dispose(); _syncSettingsProvider.dispose(); super.dispose(); @@ -95,53 +126,150 @@ 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)); + 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 _handleSyncSettingsChanged() { - final nextProfileKey = _syncSettingsProvider.activeProfileKey; - if (nextProfileKey == _activeProfileKey) { - return; + void _clearAuthStateOperation(Future operation) { + if (identical(_authStateOperation, operation)) { + _authStateOperation = null; } + if (_authStateUpdateQueued) { + _syncPowerSyncAuthState(); + } + } - _activeProfileKey = nextProfileKey; - unawaited(_switchActiveSyncProfile()); + void _handleSyncSettingsChanged() { + final nextProfileKey = _syncSettingsProvider.activeProfileKey; + 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 { _switchingSyncProfile = false; _syncPowerSyncAuthState(); } } + Future _refreshMediaUsage() async { + if (!_authProvider.isSignedIn || _authProvider.isOfflineMode) return; + final repository = _authRepository; + try { + await _mediaUploadQueue.refreshUsage(repository.fetchMediaUsage); + } catch (_) { + // Usage is informational; failed refresh must not block data sync. + } + } + + Future _processMediaUploads() async { + if (_switchingSyncProfile || !_authProvider.isSignedIn || _authProvider.isOfflineMode) return; + final user = _authProvider.user; + if (user == null) return; + final profileKey = _activeProfileKey; + final repository = _authRepository; + final scope = MediaStorageScope(profileKey: profileKey, userId: user.userId); + if (_mediaUploadQueue.activeScope != scope) { + await _mediaUploadQueue.activateScope(scope); + } + if (_switchingSyncProfile || profileKey != _activeProfileKey || !identical(repository, _authRepository)) { + return; + } + await _mediaUploadQueue.processPending( + dataStore: _dataStore, + readBookFile: _bookImportService.getBookFile, + readPendingCover: _bookImportService.getPendingCoverFile, + 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; + } + }, + 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(); + } + } + @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: _createBookDownloadService), + Provider(create: _createMediaCacheService), StreamProvider.value(value: _powerSyncService.syncStates, initialData: _powerSyncService.syncState), // Auth and UI state providers ChangeNotifierProvider.value(value: _authProvider), @@ -165,3 +293,7 @@ class _PapyrusState extends State { ); } } + +BookDownloadService _createBookDownloadService(BuildContext _) => const BookDownloadService(); + +MediaCacheService _createMediaCacheService(BuildContext _) => MediaCacheService(); 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/cover_upload_persistence.dart b/app/lib/media/cover_upload_persistence.dart new file mode 100644 index 0000000..22979bb --- /dev/null +++ b/app/lib/media/cover_upload_persistence.dart @@ -0,0 +1,35 @@ +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) { + try { + onPromotionError(error, stackTrace); + } catch (_) { + // Reporting must not turn an accepted server upload into a retry. + } + } + return asset; +} 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/lib/media/media_cache_service.dart b/app/lib/media/media_cache_service.dart new file mode 100644 index 0000000..45d1774 --- /dev/null +++ b/app/lib/media/media_cache_service.dart @@ -0,0 +1,108 @@ +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 { + final Map> _coverDownloads = {}; + + /// 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; + } + + /// 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) { + 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_storage_scope.dart b/app/lib/media/media_storage_scope.dart new file mode 100644 index 0000000..7068eb2 --- /dev/null +++ b/app/lib/media/media_storage_scope.dart @@ -0,0 +1,27 @@ +/// 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_.-]+$'); + + static const localGuest = MediaStorageScope._('local', 'guest'); + + const MediaStorageScope._(this.profileKey, this.userId); + + 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/lib/media/media_upload_queue.dart b/app/lib/media/media_upload_queue.dart new file mode 100644 index 0000000..d7af9ba --- /dev/null +++ b/app/lib/media/media_upload_queue.dart @@ -0,0 +1,472 @@ +import 'dart:async'; +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'; + +typedef BookFileReader = Future Function(String bookId); +typedef PendingCoverReader = Future Function(MediaStorageScope scope, String bookId); +typedef MediaUploader = Future Function(MediaUploadPayload payload); +typedef MediaWorkAvailableCallback = Future Function(); + +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, + if (coverBase64 != null) '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, {this.onWorkAvailable}); + + static const _storageKeyPrefix = 'media_upload_queue:'; + + final SharedPreferences _prefs; + final MediaWorkAvailableCallback? onWorkAvailable; + List _tasks = []; + MediaStorageScope? _activeScope; + MediaStorageUsage? _storageUsage; + Future? _processing; + bool _processAgain = false; + Future _mutationTail = Future.value(); + Map _taskVersions = {}; + + 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(); + await _waitForMutations(); + _activeScope = scope; + _tasks = scope == null ? [] : _loadTasks(scope); + _taskVersions = {for (final task in _tasks) task.id: 0}; + _storageUsage = null; + notifyListeners(); + } + + Future waitUntilIdle() => _processing ?? Future.value(); + + 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}) { + return _enqueue( + MediaUploadTask( + id: '${book.id}:cover_image', + bookId: book.id, + kind: MediaKind.coverImage, + filename: filename, + contentType: contentType, + status: MediaUploadTaskStatus.pending, + ), + ); + } + + 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 _notifyWorkAvailableBestEffort(); + } + + Future retryFailed({String? bookId}) async { + final scope = _requireActiveScope(); + var retriedAny = false; + 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(); + } + } + + Future removeTasksForBook(String bookId) async { + final scope = _activeScope; + if (scope == null) return; + 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({ + required DataStore dataStore, + required BookFileReader readBookFile, + required PendingCoverReader readPendingCover, + required MediaUploader uploadMedia, + }) { + final inFlight = _processing; + if (inFlight != null) { + _processAgain = true; + return inFlight; + } + final scope = _activeScope; + if (scope == null) return Future.value(); + + final completer = Completer(); + final operation = completer.future; + _processing = operation; + unawaited(() async { + try { + 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) { + _clearProcessing(operation); + completer.completeError(error, stackTrace); + } + }()); + return operation; + } + + Future _processPending({ + required MediaStorageScope scope, + required DataStore dataStore, + required BookFileReader readBookFile, + required PendingCoverReader readPendingCover, + required MediaUploader uploadMedia, + }) async { + 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'); + + 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; + } + + await uploadMedia( + MediaUploadPayload( + bookId: task.bookId, + kind: task.kind, + filename: task.filename, + contentType: task.contentType, + bytes: bytes, + ), + ); + 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()), + ); + } + } + } + } + + Future _enqueue(MediaUploadTask task) async { + final scope = _requireActiveScope(); + await _withMutation(() async { + _advanceTaskVersion(task.id); + _tasks = [..._tasks.where((existing) => existing.id != task.id), task]; + await _save(scope); + notifyListeners(); + }); + 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); + 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, + MediaStorageScope scope, + BookFileReader readBookFile, + PendingCoverReader readPendingCover, + ) async { + if (task.kind == MediaKind.coverImage) { + final coverBase64 = task.coverBase64; + if (coverBase64 != null) return base64Decode(coverBase64); + return readPendingCover(scope, task.bookId); + } + return readBookFile(task.bookId); + } + + List _loadTasks(MediaStorageScope scope) { + final raw = _prefs.getString(_storageKey(scope)); + if (raw == null || raw.isEmpty) return []; + 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) => _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}'; + + 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/lib/models/book.dart b/app/lib/models/book.dart index 8da3fa8..ea83902 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, @@ -187,40 +191,58 @@ 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, + String? coverMediaId, String? filePath, BookFormat? fileFormat, int? fileSize, 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, @@ -229,35 +251,37 @@ 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, filePath: filePath ?? this.filePath, fileFormat: fileFormat ?? this.fileFormat, 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, @@ -281,6 +305,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 +348,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/models/shelf.dart b/app/lib/models/shelf.dart index c706fb7..8211176 100644 --- a/app/lib/models/shelf.dart +++ b/app/lib/models/shelf.dart @@ -5,10 +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, 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 f145cd4..51ab2ee 100644 --- a/app/lib/pages/annotations_page.dart +++ b/app/lib/pages/annotations_page.dart @@ -276,8 +276,10 @@ 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), count: entry.value.length, itemLabel: 'annotation', isCollapsed: isCollapsed, diff --git a/app/lib/pages/book_details_page.dart b/app/lib/pages/book_details_page.dart index 0437135..844e395 100644 --- a/app/lib/pages/book_details_page.dart +++ b/app/lib/pages/book_details_page.dart @@ -1,10 +1,20 @@ +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'; import 'package:papyrus/widgets/book/book_annotations.dart'; import 'package:papyrus/widgets/book/book_bookmarks.dart'; @@ -216,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')), + ], ), ], ), @@ -345,17 +358,86 @@ 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...'))); } void _onEdit() { if (_provider.book != null) { - context.pushNamed('BOOK_EDIT', pathParameters: {'bookId': _provider.book!.id}); + context.goNamed('BOOK_EDIT', pathParameters: {'bookId': _provider.book!.id}); } } + 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; @@ -501,11 +583,61 @@ 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 importService = context.read(); + final mediaScope = mediaUploadQueue.activeScope; + final messenger = ScaffoldMessenger.of(context); + + await deleteBookWithMediaCleanup( + dataStore: dataStore, + mediaUploadQueue: mediaUploadQueue, + 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), + ); + + 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/book_edit_page.dart b/app/lib/pages/book_edit_page.dart index 61eba4a..1354cf1 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'; @@ -169,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'), + ), ), ], ), @@ -190,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) { @@ -210,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), ), ), ], @@ -505,9 +541,12 @@ 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, + coverWidth: isDesktop ? 240 : null, onUrlChanged: (url) => _provider.updateCoverUrl(url), onFileChanged: (bytes) => _provider.updateCoverFromFile(bytes), ); @@ -760,22 +799,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/pages/bookmarks_page.dart b/app/lib/pages/bookmarks_page.dart index 04f790d..4d99e63 100644 --- a/app/lib/pages/bookmarks_page.dart +++ b/app/lib/pages/bookmarks_page.dart @@ -288,8 +288,10 @@ 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), count: entry.value.length, itemLabel: 'bookmark', isCollapsed: isCollapsed, 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/pages/notes_page.dart b/app/lib/pages/notes_page.dart index 095bbe6..715c063 100644 --- a/app/lib/pages/notes_page.dart +++ b/app/lib/pages/notes_page.dart @@ -267,8 +267,10 @@ 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), count: entry.value.length, itemLabel: 'note', isCollapsed: isCollapsed, diff --git a/app/lib/pages/profile_page.dart b/app/lib/pages/profile_page.dart index df0a6a2..a8ca4f8 100644 --- a/app/lib/pages/profile_page.dart +++ b/app/lib/pages/profile_page.dart @@ -4,12 +4,15 @@ 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'; 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'; @@ -239,6 +242,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) @@ -929,6 +938,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), @@ -951,6 +962,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), @@ -1017,6 +1034,8 @@ class _ProfilePageState extends State { syncSettings: context.watch(), syncState: context.watch(), fileStorageUsedBytes: _fileStorageUsedBytes(context.watch()), + mediaStorageUsage: context.watch().storageUsage, + failedMediaUploadCount: _failedMediaUploadCount(context.watch()), ); } @@ -1024,6 +1043,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; @@ -1352,6 +1375,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, @@ -1418,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/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/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/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/powersync/storage_sync_controller.dart b/app/lib/powersync/storage_sync_controller.dart index aa35b62..0ed4237 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,8 @@ class StorageSyncController { required this.syncSettings, required this.syncState, required this.fileStorageUsedBytes, + this.failedMediaUploadCount = 0, + this.mediaStorageUsage, }); final AuthProvider authProvider; @@ -17,6 +20,8 @@ class StorageSyncController { final SyncSettingsProvider syncSettings; final SyncState syncState; final int fileStorageUsedBytes; + final int failedMediaUploadCount; + final MediaStorageUsage? mediaStorageUsage; LibraryDatabaseMode? get databaseMode => powerSyncService.mode; @@ -54,7 +59,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'; @@ -80,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/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/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/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/book_details_provider.dart b/app/lib/providers/book_details_provider.dart index 982eb8f..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); + 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 060b63b..936ba21 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 } @@ -82,7 +81,13 @@ class BookEditProvider extends ChangeNotifier { notifyListeners(); try { - final book = _dataStore!.getBook(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 { @@ -106,17 +111,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; @@ -148,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(); } @@ -166,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(); } @@ -223,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(); } @@ -253,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/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/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_delete_cleanup_service.dart b/app/lib/services/book_delete_cleanup_service.dart new file mode 100644 index 0000000..3f35f71 --- /dev/null +++ b/app/lib/services/book_delete_cleanup_service.dart @@ -0,0 +1,38 @@ +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({ + required DataStore dataStore, + required MediaUploadQueue mediaUploadQueue, + required String bookId, + required DeleteBookFile deleteBookFile, + String? coverMediaId, + DeleteBookCover? deletePendingCover, + DeleteBookCover? deleteGuestCover, + DeleteCoverFile? deleteCoverFile, +}) async { + await mediaUploadQueue.removeTasksForBook(bookId); + await _bestEffort(() => deleteBookFile(bookId)); + if (deletePendingCover != null) { + await _bestEffort(() => deletePendingCover(bookId)); + } + if (deleteGuestCover != null) { + await _bestEffort(() => deleteGuestCover(bookId)); + } + if (coverMediaId != null && deleteCoverFile != null) { + 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/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/services/book_import_commit_service.dart b/app/lib/services/book_import_commit_service.dart new file mode 100644 index 0000000..0fe0095 --- /dev/null +++ b/app/lib/services/book_import_commit_service.dart @@ -0,0 +1,170 @@ +import 'dart:async'; +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 PendingCoverDelete = FutureOr Function(MediaStorageScope scope, String bookId); +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, + 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 BookDelete deleteBook, + required ImportedBookMediaEnqueuer enqueueImportedBookMedia, + LibraryContextValidator isLibraryContextCurrent = _alwaysCurrent, + }) : _storePendingCover = storePendingCover, + _storeGuestCover = storeGuestCover, + _deletePendingCover = deletePendingCover, + _deleteGuestCover = deleteGuestCover, + _addBook = addBook, + _deleteBook = deleteBook, + _enqueueImportedBookMedia = enqueueImportedBookMedia, + _isLibraryContextCurrent = isLibraryContextCurrent; + + final PendingCoverStore _storePendingCover; + final GuestCoverStore _storeGuestCover; + final PendingCoverDelete _deletePendingCover; + final GuestCoverDelete _deleteGuestCover; + final BookAdder _addBook; + final BookDelete _deleteBook; + final ImportedBookMediaEnqueuer _enqueueImportedBookMedia; + final LibraryContextValidator _isLibraryContextCurrent; + + 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; + 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; + } + + _ensureLibraryContextCurrent(); + + await _addBook(book); + metadataAdded = true; + _ensureLibraryContextCurrent(); + + if (accountScope != null) { + await _enqueueImportedBookMedia( + scope: accountScope, + book: book, + 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); + } + } + + void _ensureLibraryContextCurrent() { + if (!_isLibraryContextCurrent()) { + throw StateError('Library context changed during book import'); + } + } +} + +bool _alwaysCurrent() => true; + +Future _bestEffort(FutureOr Function() compensation) async { + try { + await compensation(); + } catch (_) { + // Preserve the import failure; compensation is intentionally best effort. + } +} + +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/services/book_import_service.dart b/app/lib/services/book_import_service.dart index 3dd9d87..244ca78 100644 --- a/app/lib/services/book_import_service.dart +++ b/app/lib/services/book_import_service.dart @@ -3,6 +3,9 @@ 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/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'; import 'package:web/web.dart' as web; @@ -65,7 +68,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 +94,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); @@ -212,6 +224,117 @@ 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); + }, + ); + } + + Future getCoverFile(MediaStorageScope scope, String mediaId) async { + 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 { + await _sendCoverRequest( + type: 'promoteCover', + scope: scope, + bucket: CoverStorageBucket.pending, + mediaId: bookId, + targetMediaId: mediaId, + ); + } + + 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; + } + return (fileDataJs as JSArrayBuffer).toDart.asUint8List(); + } + + 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, 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 { + await _sendCoverRequest(type: 'clearCovers', scope: scope, bucket: CoverStorageBucket.cached); + } + /// Terminates the Web Worker and releases resources. void dispose() { _worker?.terminate(); @@ -227,6 +350,47 @@ class BookImportService { // Private helpers // --------------------------------------------------------------------------- + Future _sendCoverRequest({ + required String type, + required MediaStorageScope scope, + required CoverStorageBucket bucket, + String? mediaId, + String? targetMediaId, + 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; + 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); + } else { + // 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); + } + + 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 27beb47..c697562 100644 --- a/app/lib/services/book_import_service_stub.dart +++ b/app/lib/services/book_import_service_stub.dart @@ -1,7 +1,11 @@ +import 'dart:async'; import 'dart:io'; 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'; import 'package:path/path.dart' as p; @@ -15,7 +19,10 @@ 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(); + static final Map> _coverOperations = {}; /// Imports a book file: extracts metadata and stores the file locally. /// @@ -86,6 +93,134 @@ 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); + } + + /// Returns a persistently cached private cover for [scope], when present. + Future getCoverFile(MediaStorageScope scope, String mediaId) async { + 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 _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 _withCoverLock( + scope, + CoverStorageBucket.cached, + mediaId, + () => _removeCoverFile(scope, CoverStorageBucket.cached, mediaId), + ); + } + + Future getPendingCoverFile(MediaStorageScope scope, String bookId) async { + return _withCoverLock( + scope, + CoverStorageBucket.pending, + bookId, + () => _readCoverFile(scope, CoverStorageBucket.pending, bookId), + ); + } + + Future storePendingCoverFile(MediaStorageScope scope, String bookId, Uint8List bytes) async { + await _withCoverLock( + scope, + CoverStorageBucket.pending, + bookId, + () => _writeCoverFile(scope, CoverStorageBucket.pending, bookId, bytes), + ); + } + + Future deletePendingCoverFile(MediaStorageScope scope, String bookId) async { + await _withCoverLock( + scope, + CoverStorageBucket.pending, + bookId, + () => _removeCoverFile(scope, CoverStorageBucket.pending, bookId), + ); + } + + Future getGuestCoverFile(String bookId) async { + return _withCoverLock( + MediaStorageScope.localGuest, + CoverStorageBucket.guestBooks, + bookId, + () => _readCoverFile(MediaStorageScope.localGuest, CoverStorageBucket.guestBooks, bookId), + ); + } + + Future storeGuestCoverFile(String bookId, Uint8List bytes) async { + await _withCoverLock( + MediaStorageScope.localGuest, + CoverStorageBucket.guestBooks, + bookId, + () => _writeCoverFile(MediaStorageScope.localGuest, CoverStorageBucket.guestBooks, bookId, bytes), + ); + } + + Future deleteGuestCoverFile(String bookId) async { + await _withCoverLock( + MediaStorageScope.localGuest, + CoverStorageBucket.guestBooks, + bookId, + () => _removeCoverFile(MediaStorageScope.localGuest, CoverStorageBucket.guestBooks, bookId), + ); + } + + Future promotePendingCoverFile( + MediaStorageScope scope, { + required String bookId, + required String mediaId, + }) async { + 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), + ); + final pendingFile = await _coverFile(scope, CoverStorageBucket.pending, bookId); + if (await pendingFile.exists()) { + await pendingFile.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() {} @@ -98,4 +233,98 @@ class BookImportService { } return booksDir; } + + 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 _writeCoverFile(MediaStorageScope scope, CoverStorageBucket bucket, String id, Uint8List bytes) async { + final file = await _coverFile(scope, bucket, id); + final operationId = const Uuid().v4(); + final tempFile = File('${file.path}.$operationId.tmp'); + try { + await tempFile.writeAsBytes(bytes, flush: true); + await tempFile.rename(file.path); + } finally { + if (await tempFile.exists()) { + await tempFile.delete(); + } + } + } + + Future _removeCoverFile(MediaStorageScope scope, CoverStorageBucket bucket, String id) async { + final file = await _coverFile(scope, bucket, id); + 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 { + _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()) { + await directory.create(recursive: true); + } + return directory; + } } diff --git a/app/lib/utils/book_actions.dart b/app/lib/utils/book_actions.dart index ac048a0..3c43d76 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'; @@ -29,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); @@ -43,12 +52,63 @@ 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 + final mediaUploadQueue = context.read(); + final importService = context.read(); + final mediaScope = mediaUploadQueue.activeScope; + unawaited( + deleteBookWithMediaCleanup( + dataStore: context.read(), + mediaUploadQueue: mediaUploadQueue, + bookId: book.id, + coverMediaId: book.coverMediaId, + deleteBookFile: importService.deleteBookFile, + deleteCoverFile: mediaScope == null ? null : (mediaId) => importService.deleteCoverFile(mediaScope, mediaId), + ), + ); }, ); } +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..4a6d5d4 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,25 @@ 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(); + final mediaUploadQueue = context.read(); + final importService = context.read(); + final mediaScope = mediaUploadQueue.activeScope; Navigator.pop(context); - bulkDelete(dataStore, libraryProvider.selectedBookIds); + for (final bookId in selectedBookIds) { + final book = dataStore.getBook(bookId); + await deleteBookWithMediaCleanup( + dataStore: dataStore, + mediaUploadQueue: mediaUploadQueue, + bookId: bookId, + coverMediaId: book?.coverMediaId, + deleteBookFile: importService.deleteBookFile, + deleteCoverFile: mediaScope == null + ? null + : (mediaId) => importService.deleteCoverFile(mediaScope, mediaId), + ); + } libraryProvider.exitSelectionMode(); }, style: FilledButton.styleFrom(backgroundColor: Theme.of(context).colorScheme.error), 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/add_book/import_book_sheet.dart b/app/lib/widgets/add_book/import_book_sheet.dart index 7693e5b..7974787 100644 --- a/app/lib/widgets/add_book/import_book_sheet.dart +++ b/app/lib/widgets/add_book/import_book_sheet.dart @@ -4,11 +4,15 @@ 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_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'; @@ -102,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 { @@ -153,47 +158,69 @@ class _ImportContentState extends State<_ImportContent> { } } - void _addToLibrary() { + Future _addToLibrary() async { + if (_committing) return; final result = _result; if (result == null) return; + setState(() => _committing = true); - final dataStore = context.read(); - final now = DateTime.now(); + Book? committedBook; + try { + final dataStore = context.read(); + final bookRepository = dataStore.requireBookRepository(); + final queue = context.read(); + final importService = context.read(); + 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'); + } - 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 commitService = BookImportCommitService( + storePendingCover: importService.storePendingCoverFile, + storeGuestCover: importService.storeGuestCoverFile, + deletePendingCover: importService.deletePendingCoverFile, + deleteGuestCover: importService.deleteGuestCoverFile, + 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, + sourceFilename: _filename ?? '${result.bookId}.$ext', + addedAt: DateTime.now(), + localFilePath: filePath, + accountScope: accountScope, + ); + } catch (error) { + if (!mounted) return; + setState(() { + _state = _ImportState.error; + _errorMessage = error.toString(); + }); + } finally { + if (mounted) { + setState(() => _committing = false); + } } - 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); - + if (!mounted || committedBook == null) return; final messenger = ScaffoldMessenger.of(context); Navigator.of(context).pop(); - messenger.showSnackBar(SnackBar(content: Text('Added "${book.title}" to library'))); + messenger.showSnackBar(SnackBar(content: Text('Added "${committedBook.title}" to library'))); } @override @@ -249,7 +276,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'), ), @@ -334,19 +361,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')), ), ], ), @@ -375,7 +404,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/lib/widgets/book/private_book_cover.dart b/app/lib/widgets/book/private_book_cover.dart new file mode 100644 index 0000000..d15f210 --- /dev/null +++ b/app/lib/widgets/book/private_book_cover.dart @@ -0,0 +1,350 @@ +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'; +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); +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 CoverImage extends StatefulWidget { + const CoverImage({ + super.key, + this.bookId, + this.imageUrl, + this.mediaId, + this.fit = BoxFit.cover, + required this.placeholder, + this.loadPrivateCover, + this.loadLocalBookCover, + this.loadPendingBookCover, + this.loadGuestBookCover, + }); + + final String? bookId; + final String? imageUrl; + final String? mediaId; + final BoxFit fit; + final Widget placeholder; + final PrivateCoverLoader? loadPrivateCover; + final LocalBookCoverLoader? loadLocalBookCover; + final PendingBookCoverLoader? loadPendingBookCover; + final GuestBookCoverLoader? loadGuestBookCover; + + @override + State createState() => _PrivateBookCoverState(); +} + +class _PrivateBookCoverState extends State { + Future? _coverFuture; + LocalCoverImageProvider? _localCoverProvider; + String? _loadKey; + + @override + void initState() { + super.initState(); + _configureInjectedLoader(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_hasInjectedLoaderForCurrentSource) { + _configureProviderLoader(subscribe: true); + } + } + + @override + void didUpdateWidget(covariant CoverImage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.imageUrl != widget.imageUrl || + oldWidget.mediaId != widget.mediaId || + oldWidget.bookId != widget.bookId || + !identical(oldWidget.loadPrivateCover, widget.loadPrivateCover) || + !identical(oldWidget.loadLocalBookCover, widget.loadLocalBookCover) || + !identical(oldWidget.loadPendingBookCover, widget.loadPendingBookCover) || + !identical(oldWidget.loadGuestBookCover, widget.loadGuestBookCover)) { + _coverFuture = null; + _localCoverProvider = null; + _loadKey = null; + _configureInjectedLoader(); + if (!_hasInjectedLoaderForCurrentSource) { + _configureProviderLoader(); + } + } + } + + void _configureInjectedLoader() { + 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(bookId); + } + + void _configureProviderLoader({bool subscribe = false}) { + try { + 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(AuthProvider authProvider, SyncSettingsProvider? syncSettings) { + if (_hasPublicUrl) return; + + final mediaId = _usableMediaId; + final user = authProvider.user; + if (mediaId != null) { + if (!authProvider.isSignedIn || authProvider.isOfflineMode || user == null) { + _clearProviderLoader(); + return; + } + + if (syncSettings == null) { + _clearProviderLoader(); + 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; + + final importService = context.read(); + final cacheService = context.read(); + _loadKey = key; + _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; + } + + final bookId = _usableBookId; + if (bookId == null) return; + + if (!authProvider.isOfflineMode && user != null) { + if (syncSettings == null) { + _clearProviderLoader(); + return; + } + final scope = MediaStorageScope(profileKey: syncSettings.activeProfileKey, userId: user.userId); + final key = '${scope.persistenceKey}:${CoverStorageBucket.pending.name}:$bookId'; + if (_loadKey == key) return; + _loadKey = key; + final loader = widget.loadPendingBookCover ?? context.read().getPendingCoverFile; + _coverFuture = null; + _localCoverProvider = LocalCoverImageProvider( + scopeKey: scope.persistenceKey, + bucket: CoverStorageBucket.pending, + fileId: bookId, + loadBytes: () => loader(scope, bookId), + ); + return; + } + + 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 = 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; + 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 (!_hasInjectedLoaderForCurrentSource) { + _configureProviderLoader(subscribe: true); + } + + 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!, + fit: widget.fit, + placeholder: (_, _) => _buildLoadingPlaceholder(context), + errorWidget: (_, _, _) => widget.placeholder, + ); + } + + 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 _buildLoadingPlaceholder(context); + }, + errorBuilder: (_, _, _) => widget.placeholder, + ); + } + + final coverFuture = _coverFuture; + if (coverFuture == null) return widget.placeholder; + + 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); + } + if (snapshot.hasError) return widget.placeholder; + return widget.placeholder; + }, + ); + } + + 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/lib/widgets/book_details/book_cover_image.dart b/app/lib/widgets/book_details/book_cover_image.dart index 54aa71f..2811296 100644 --- a/app/lib/widgets/book_details/book_cover_image.dart +++ b/app/lib/widgets/book_details/book_cover_image.dart @@ -1,6 +1,6 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/book/private_book_cover.dart'; /// Cover image size variants. enum BookCoverSize { @@ -18,12 +18,21 @@ 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({super.key, this.imageUrl, this.bookTitle, this.size = BookCoverSize.medium}); + const CoverImagePreview({ + super.key, + this.bookId, + this.imageUrl, + this.mediaId, + this.bookTitle, + this.size = BookCoverSize.medium, + }); @override Widget build(BuildContext context) { @@ -44,15 +53,12 @@ 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), - ); - } - return _buildPlaceholder(context, colorScheme); + return CoverImage( + bookId: bookId, + imageUrl: imageUrl, + mediaId: mediaId, + placeholder: _buildPlaceholder(context, colorScheme), + ); } Widget _buildPlaceholder(BuildContext context, ColorScheme colorScheme) { @@ -89,13 +95,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)), - ); - } - _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..0aa2103 100644 --- a/app/lib/widgets/book_details/book_header.dart +++ b/app/lib/widgets/book_details/book_header.dart @@ -40,7 +40,13 @@ class BookHeader extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ // Cover image - BookCoverImage(imageUrl: book.coverURL, bookTitle: book.title, size: BookCoverSize.large), + CoverImagePreview( + bookId: book.id, + imageUrl: book.coverURL, + mediaId: book.coverMediaId, + bookTitle: book.title, + size: BookCoverSize.large, + ), const SizedBox(width: Spacing.xl), // Book info @@ -111,7 +117,13 @@ class BookHeader extends StatelessWidget { const SizedBox(height: Spacing.lg), // Cover image (centered) - BookCoverImage(imageUrl: book.coverURL, bookTitle: book.title, size: BookCoverSize.medium), + CoverImagePreview( + bookId: book.id, + imageUrl: book.coverURL, + mediaId: book.coverMediaId, + bookTitle: book.title, + size: BookCoverSize.medium, + ), const SizedBox(height: Spacing.md), // Title (centered) diff --git a/app/lib/widgets/book_edit/cover_image_picker.dart b/app/lib/widgets/book_edit/cover_image_picker.dart index a818651..c6944a6 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 CoverImage( + bookId: widget.bookId, + mediaId: widget.mediaId, + fit: BoxFit.cover, + placeholder: _buildPlaceholder(context), + ); + } + return _buildPlaceholder(context); } diff --git a/app/lib/widgets/context_menu/book_context_menu.dart b/app/lib/widgets/context_menu/book_context_menu.dart index 7f903f5..5a56295 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. @@ -17,6 +18,7 @@ class BookContextMenu { VoidCallback? onMoveToShelf, VoidCallback? onManageTopics, Function(ReadingStatus)? onStatusChange, + VoidCallback? onDownload, VoidCallback? onDelete, }) { final screenWidth = MediaQuery.of(context).size.width; @@ -34,6 +36,7 @@ class BookContextMenu { onMoveToShelf: onMoveToShelf, onManageTopics: onManageTopics, onStatusChange: onStatusChange, + onDownload: onDownload, onDelete: onDelete, ); } else { @@ -47,6 +50,7 @@ class BookContextMenu { onMoveToShelf: onMoveToShelf, onManageTopics: onManageTopics, onStatusChange: onStatusChange, + onDownload: onDownload, onDelete: onDelete, ); } @@ -63,6 +67,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 +122,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 +153,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 +171,7 @@ class BookContextMenu { VoidCallback? onMoveToShelf, VoidCallback? onManageTopics, Function(ReadingStatus)? onStatusChange, + VoidCallback? onDownload, VoidCallback? onDelete, }) { showModalBottomSheet( @@ -175,6 +189,7 @@ class BookContextMenu { onMoveToShelf: onMoveToShelf, onManageTopics: onManageTopics, onStatusChange: onStatusChange, + onDownload: onDownload, onDelete: onDelete, ), ); @@ -250,6 +265,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 +277,7 @@ class _BookContextBottomSheet extends StatelessWidget { this.onMoveToShelf, this.onManageTopics, this.onStatusChange, + this.onDownload, this.onDelete, }); @@ -402,9 +419,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); @@ -425,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 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 83005b1..6908308 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,12 @@ 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: CoverImage( + 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 4dc4e58..17f6876 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,12 @@ 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: CoverImage( + 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 1d7166e..9106550 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,12 @@ 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 CoverImage( + bookId: widget.book.id, + 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..b85c7d8 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,12 @@ 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 CoverImage( + bookId: widget.book.id, + 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..9d6335b 100644 --- a/app/lib/widgets/shared/book_group_header.dart +++ b/app/lib/widgets/shared/book_group_header.dart @@ -1,17 +1,24 @@ 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. /// /// 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; /// 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; @@ -26,8 +33,10 @@ class BookGroupHeader extends StatelessWidget { const BookGroupHeader({ super.key, + required this.bookId, required this.bookTitle, this.coverUrl, + this.coverMediaId, required this.count, required this.itemLabel, required this.isCollapsed, @@ -54,19 +63,15 @@ 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: CoverImage( + bookId: bookId, + 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..6f946e9 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 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 3c495ab..21bdc8b 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,12 @@ 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 CoverImage( + bookId: cover.bookId, + 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..69e592d 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 CoverImage(bookId: book.id, imageUrl: book.coverURL, mediaId: book.coverMediaId, placeholder: placeholder); } Widget _buildEmptyState(BuildContext context) { 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/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/data/book_repository_data_store_test.dart b/app/test/data/book_repository_data_store_test.dart index 2b9ead1..7707354 100644 --- a/app/test/data/book_repository_data_store_test.dart +++ b/app/test/data/book_repository_data_store_test.dart @@ -1,17 +1,29 @@ 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:papyrus/models/shelf.dart'; +import 'package:shared_preferences/shared_preferences.dart'; 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 +34,8 @@ class FakeBookRepository implements BookRepository { @override Future upsert(Book book) async { + await upsertGate?.future; + if (upsertError != null) throw upsertError!; upserts.add(book); } @@ -34,6 +48,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(); @@ -52,6 +70,58 @@ 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('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); + 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(); @@ -68,4 +138,162 @@ void main() { await store.disposeBookRepository(); 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() + ..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(); + }); + + 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('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 does not rewrite a repository-bound add', () 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(queue.pendingTasks, isEmpty); + expect(store.getBook(book.id)?.fileMediaId, isNull); + expect(store.getBook(book.id)?.fileHash, 'hash'); + await pumpEventQueue(); + 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/cover_upload_persistence_test.dart b/app/test/media/cover_upload_persistence_test.dart new file mode 100644 index 0000000..0b1fc18 --- /dev/null +++ b/app/test/media/cover_upload_persistence_test.dart @@ -0,0 +1,123 @@ +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('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; + + 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/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(); +} 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..9d23c9e --- /dev/null +++ b/app/test/media/media_cache_service_test.dart @@ -0,0 +1,183 @@ +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 = 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, + ); + }); + + 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}) { + 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_profile_switch_contract_test.dart b/app/test/media/media_profile_switch_contract_test.dart new file mode 100644 index 0000000..51e7878 --- /dev/null +++ b/app/test/media/media_profile_switch_contract_test.dart @@ -0,0 +1,81 @@ +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)')); + }); + + 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( + 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 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('deletePendingCover: importService.deletePendingCoverFile')); + expect(commit, contains('deleteGuestCover: importService.deleteGuestCoverFile')); + 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'))); + }); + + 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_storage_scope_test.dart b/app/test/media/media_storage_scope_test.dart new file mode 100644 index 0000000..7bb5f4b --- /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('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'); + + 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); + }); +} 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..2a2990d --- /dev/null +++ b/app/test/media/media_upload_queue_test.dart @@ -0,0 +1,625 @@ +import 'dart:async'; +import 'dart:convert'; + +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'; +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'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('processPending does not rewrite book metadata after server upload', () 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 uploadStarted = Completer(); + final uploadResult = Completer(); + + final processing = 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); + expect(payload.bytes, Uint8List.fromList('epub bytes'.codeUnits)); + 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); + 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 { + 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'); + + await queue.processPending( + dataStore: dataStore, + readBookFile: (bookId) async => Uint8List.fromList('epub bytes'.codeUnits), + readPendingCover: (_, _) async => null, + 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'); + }); + + 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 = await _activeQueue(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), + readPendingCover: (_, _) async => null, + 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 = 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'); + await queue.enqueueCover(book: book, filename: 'cover.jpg', contentType: 'image/jpeg'); + + await queue.removeTasksForBook(book.id); + + expect(queue.pendingTasks, isEmpty); + 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 drains the 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 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); + }); + + 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('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(); + 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); + }); + + 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]), + readPendingCover: (_, _) async => null, + 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); + }); + + 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); + }); + + 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]), + readPendingCover: (_, _) async => null, + 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]), + readPendingCover: (_, _) async => null, + 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'); + 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('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('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; + 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]), + readPendingCover: (_, _) async => null, + uploadMedia: (_) async => throw const MediaUploadException.storageFull(), + ); + + await queue.retryFailed(); + await queue.retryFailed(); + + expect(callbacks, 1); + }); +} + +Book _book({String id = '11111111-1111-1111-1111-111111111111', String? filePath, int? fileSize, String? fileHash}) { + return Book( + id: id, + 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', + ); +} + +Future _activeQueue(SharedPreferences prefs) async { + final queue = MediaUploadQueue(prefs); + await queue.activateScope(MediaStorageScope(profileKey: 'official', userId: 'user-1')); + return queue; +} 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..79a32c9 --- /dev/null +++ b/app/test/pages/book_details_delete_test.dart @@ -0,0 +1,297 @@ +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/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'; +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); + await mediaUploadQueue.activateScope(MediaStorageScope(profileKey: 'official', userId: 'user-1')); + final importService = _RecordingBookImportService(); + 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(); + 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(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(); + 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: 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: 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 List deletedPendingCovers = []; + final List deletedGuestCovers = []; + final List deletedCachedCovers = []; + final Map bookFiles = {}; + + @override + Future deleteBookFile(String bookId) async { + 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]; + } + + @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/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/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/pages/profile_storage_sync_test.dart b/app/test/pages/profile_storage_sync_test.dart index 251334e..880538d 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'; @@ -121,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( @@ -131,6 +133,7 @@ void main() { return MultiProvider( providers: [ ChangeNotifierProvider.value(value: dataStore ?? DataStore()), + ChangeNotifierProvider.value(value: mediaUploadQueue ?? MediaUploadQueue(prefs)), ChangeNotifierProvider.value( value: syncSettingsProvider ?? SyncSettingsProvider(prefs, officialConfig: config), ), @@ -187,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/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/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/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/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/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/providers/book_details_provider_test.dart b/app/test/providers/book_details_provider_test.dart index df1b793..50cc721 100644 --- a/app/test/providers/book_details_provider_test.dart +++ b/app/test/providers/book_details_provider_test.dart @@ -1,5 +1,8 @@ +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'; import 'package:papyrus/models/annotation.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/providers/book_details_provider.dart'; @@ -75,6 +78,41 @@ 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('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'); @@ -525,3 +563,37 @@ 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 _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 new file mode 100644 index 0000000..2c4e4b3 --- /dev/null +++ b/app/test/providers/book_edit_provider_test.dart @@ -0,0 +1,203 @@ +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('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('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( + 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('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); + 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 _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 _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(); + + @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/services/book_cover_storage_test.dart b/app/test/services/book_cover_storage_test.dart new file mode 100644 index 0000000..473b302 --- /dev/null +++ b/app/test/services/book_cover_storage_test.dart @@ -0,0 +1,562 @@ +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'; +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 { + _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); + } + }); + + 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('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'); + + 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('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('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])); + + 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])); + + await service.deleteCoverFile(scope, 'asset-1'); + await service.deleteCoverFile(scope, 'asset-1'); + + 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.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); + }); + } + + testWidgets('promotion preserves decoded pending and cached covers', (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 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; + }); + await tester.pumpWidget(const SizedBox()); + await tester.pump(); + await _pumpCover(tester, scope, CoverStorageBucket.cached, 'asset-1', () async { + cachedLoads++; + return _pngBytes; + }); + + expect(pendingLoads, 1); + 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'); + + 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])); + }); + + 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); + expect(() => service.storePendingCoverFile(scope, r'book\1', Uint8List.fromList([1])), throwsArgumentError); + expect(() => service.storeGuestCoverFile('../book', Uint8List.fromList([1])), throwsArgumentError); + }); + + 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', '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('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 { + 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('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( + source.indexOf('Future _sendCoverRequest'), + source.indexOf('BookImportResult _parseImportResult'), + ); + + 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'))); + }); + + 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, contains("await _sendCoverRequest(\n type: 'promoteCover'")); + expect(promotion, isNot(contains('LocalCoverImageProvider.evictKey('))); + 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) { + 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/test/services/book_delete_cleanup_service_test.dart b/app/test/services/book_delete_cleanup_service_test.dart new file mode 100644 index 0000000..3dc9485 --- /dev/null +++ b/app/test/services/book_delete_cleanup_service_test.dart @@ -0,0 +1,53 @@ +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'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('deleteBookWithMediaCleanup removes every local cover representation before deleting book', () async { + final prefs = await SharedPreferences.getInstance(); + final repository = InMemoryBookRepository(); + final dataStore = DataStore(bookRepository: repository); + final queue = MediaUploadQueue(prefs); + 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(); + await queue.enqueueBookFile(book: book, filename: 'book.epub', contentType: 'application/epub+zip'); + + await deleteBookWithMediaCleanup( + dataStore: dataStore, + mediaUploadQueue: queue, + bookId: book.id, + coverMediaId: book.coverMediaId, + deleteBookFile: (bookId) async => deletedLocalFiles.add(bookId), + 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, ['pending:${book.id}', 'guest:${book.id}', 'cached:cover-1']); + expect(dataStore.getBook(book.id), isNull); + expect(await repository.getById(book.id), isNull); + }); +} 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..13bf522 --- /dev/null +++ b/app/test/services/book_import_commit_service_test.dart @@ -0,0 +1,634 @@ +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'; +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 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, 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'); + }, + 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( + result: _result(coverImage: Uint8List.fromList([1, 2, 3]), coverMimeType: 'image/png'), + sourceFilename: 'original.epub', + addedAt: addedAt, + localFilePath: 'opfs://books/book-1.epub', + accountScope: accountScope, + ); + + 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'))); + 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'); + }, + deletePendingCover: (_, _) async => fail('compensation must not run'), + deleteGuestCover: (_) async => fail('compensation must not run'), + addBook: (book) => calls.add('add book'), + 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( + 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'), + deletePendingCover: (_, _) async => fail('compensation must not run'), + deleteGuestCover: (_) async => fail('compensation must not run'), + addBook: (_) => calls.add('add book'), + 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( + result: _result(), + sourceFilename: 'original.epub', + addedAt: addedAt, + localFilePath: 'book-1', + accountScope: accountScope, + ); + + expect(calls, ['add book', 'enqueue imported media']); + }); + + 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, + deletePendingCover: (_, _) async => fail('unstored cover must not be deleted'), + deleteGuestCover: (_) async => fail('unstored cover must not be deleted'), + addBook: (_) => added = 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( + service.commit( + result: _result(coverImage: Uint8List.fromList([9])), + sourceFilename: 'original.epub', + addedAt: addedAt, + localFilePath: 'book-1', + 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); + }); + + 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(); + }); + + 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 { + _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}) { + 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', + ); +} 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/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 new file mode 100644 index 0000000..b827172 --- /dev/null +++ b/app/test/widgets/book/private_book_cover_test.dart @@ -0,0 +1,816 @@ +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/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'; + +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({}); + final cache = PaintingBinding.instance.imageCache; + cache.clear(); + cache.clearLiveImages(); + }); + + tearDown(() { + final cache = PaintingBinding.instance.imageCache; + cache.clear(); + 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); + + CoverImage cover() { + return const CoverImage( + bookId: 'book-1', + mediaId: 'asset-1', + placeholder: SizedBox(key: Key('placeholder')), + ); + } + + await tester.pumpWidget(harness.wrap(Align(child: cover()))); + await _waitForDecodedCover(tester); + 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('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; + Future loadPending(MediaStorageScope scope, String bookId) async { + pendingReads++; + return Uint8List.fromList(pngBytes); + } + + CoverImage cover() { + return CoverImage( + 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); + } + + CoverImage cover() { + return CoverImage( + 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( + MaterialApp( + home: CoverImage( + 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 renders a local cover loaded by book id', (tester) async { + final loadedBookIds = []; + + await tester.pumpWidget( + MaterialApp( + home: CoverImage( + 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: CoverImage( + 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: CoverImage( + 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: CoverImage( + 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 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: CoverImage( + 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: CoverImage( + 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( + CoverImage( + 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( + CoverImage( + 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); + _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); + _expectLocalCoverKey( + tester, + scopeKey: '${SyncSettingsProvider.officialServerId}--user-1', + bucket: CoverStorageBucket.pending, + fileId: 'book-1', + ); + + harness.authProvider.setOfflineMode(true); + await tester.pumpAndSettle(); + expect(guestLoads, 1); + expect(pendingLoads, 1); + _expectLocalCoverKey( + tester, + scopeKey: MediaStorageScope.localGuest.persistenceKey, + bucket: CoverStorageBucket.guestBooks, + fileId: 'book-1', + ); + }); + + 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( + CoverImage( + 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-')]); + 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 { + 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( + CoverImage( + 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]); + _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-')]); + _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 { + await tester.pumpWidget( + MaterialApp( + home: CoverImage( + 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: CoverImage( + 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: CoverImage( + 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: CoverImage( + 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 { + loads++; + return Uint8List.fromList(pngBytes); + } + + Widget build() { + return MaterialApp( + home: CoverImage( + 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: CoverImage( + 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: CoverImage( + 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); + }); +} + +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, + 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, + 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, + BookImportService? importService, + MediaCacheService? cacheService, +}) 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, + importService: importService ?? BookImportService(), + cacheService: cacheService ?? MediaCacheService(), + ); +} + +class _RecordingBookImportService extends BookImportService { + _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; + } +} 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..2bc700a --- /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: CoverImagePreview(bookId: 'book-1', imageUrl: 'https://example.test/cover.jpg', bookTitle: 'Book'), + ), + ); + + 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 new file mode 100644 index 0000000..6f080dc --- /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(CoverImage)); + expect(cover.bookId, 'book-1'); + expect(cover.mediaId, 'cover-1'); + }); +} 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..9feb872 --- /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')).dy; + final deleteTop = tester.getTopLeft(find.text('Delete')).dy; + expect(downloadTop, lessThan(deleteTop)); + + await tester.tap(find.text('Download')); + await tester.pumpAndSettle(); + + expect(downloaded, isTrue); + }); +} + +Book _book() { + return Book(id: 'book-1', title: 'Book', author: 'Author', fileFormat: BookFormat.epub, addedAt: DateTime.utc(2026)); +} diff --git a/app/test/widgets/library/book_card_test.dart b/app/test/widgets/library/book_card_test.dart index a9d4729..2c5f76a 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(CoverImage)).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..e54aeb4 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(CoverImage)).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..9c9b21b --- /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(CoverImage)).bookId, 'book-1'); + }); +} diff --git a/app/web/book_worker.js b/app/web/book_worker.js index f5f9985..31c70cc 100644 --- a/app/web/book_worker.js +++ b/app/web/book_worker.js @@ -8,11 +8,19 @@ * { type: 'process', format: 'epub', bookId, fileData: ArrayBuffer } * { type: 'delete', bookId } * { type: 'getFile', bookId } + * { type: 'storeFile', format, bookId, fileData: ArrayBuffer } + * { 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: * { 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: 'success', action: 'promoteCover', requestId, promoted } * { type: 'error', message } */ @@ -35,6 +43,24 @@ self.onmessage = async (event) => { case 'getFile': await handleGetFile(msg); break; + 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 'promoteCover': + await handlePromoteCover(msg); + break; + case 'clearCovers': + await handleClearCovers(msg); + break; default: postMessage({ type: 'error', message: `Unknown message type: ${msg.type}` }); } @@ -43,6 +69,7 @@ self.onmessage = async (event) => { type: 'error', action: msg.type, bookId: msg.bookId, + requestId: msg.requestId, message: err.message || String(err), }); } @@ -92,6 +119,72 @@ 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 }); +} + +async function handleGetCover(msg) { + 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 }, + fileData ? [fileData] : [], + ); +} + +async function handleStoreCover(msg) { + const { requestId, scopeKey, bucket, mediaId, fileData } = msg; + await withCoverLocks([[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 withCoverLocks([[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'); + const promoted = await withCoverLocks( + [ + [scopeKey, bucket, mediaId], + [scopeKey, 'cached', targetMediaId], + ], + async () => { + const bytes = await opfsReadCover(scopeKey, bucket, mediaId); + if (!bytes) return false; + await opfsWriteCover(scopeKey, 'cached', targetMediaId, bytes); + await opfsDeleteCover(scopeKey, bucket, mediaId); + return true; + }, + ); + postMessage({ type: 'success', action: 'promoteCover', requestId, promoted }); +} + +async function handleClearCovers(msg) { + const { requestId, scopeKey, bucket } = msg; + validateCoverBucket(bucket); + await opfsClearCovers(scopeKey); + postMessage({ type: 'success', action: 'clearCovers', requestId }); +} + // --------------------------------------------------------------------------- // EPUB processing // --------------------------------------------------------------------------- @@ -559,6 +652,130 @@ 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`); + } +} + +const COVER_BUCKETS = new Set(['cached', 'pending', 'books']); +const inWorkerCoverOperations = new Map(); + +function validateCoverBucket(bucket) { + if (!COVER_BUCKETS.has(bucket)) { + throw new Error('bucket is not supported'); + } +} + +function coverLockName(scopeKey, bucket, mediaId) { + validateFilePart(scopeKey, 'scopeKey'); + validateCoverBucket(bucket); + validateFilePart(mediaId, 'mediaId'); + 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 (inWorkerCoverOperations.get(lockName) === current) { + inWorkerCoverOperations.delete(lockName); + } + } +} + +function isNotFoundError(error) { + return error != null && error.name === 'NotFoundError'; +} + +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 }); + const scopeDirectory = await coversRoot.getDirectoryHandle(scopeKey, { create }); + return await scopeDirectory.getDirectoryHandle(bucket, { create }); + } catch (error) { + if (!create && isNotFoundError(error)) return null; + throw error; + } +} + +async function opfsWriteCover(scopeKey, bucket, mediaId, uint8Array) { + validateFilePart(mediaId, 'mediaId'); + 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, bucket, mediaId) { + validateFilePart(mediaId, 'mediaId'); + const directory = await opfsCoverDirectory(scopeKey, bucket, 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 (error) { + if (isNotFoundError(error)) return null; + throw error; + } +} + +async function opfsDeleteCover(scopeKey, bucket, mediaId) { + validateFilePart(mediaId, 'mediaId'); + const directory = await opfsCoverDirectory(scopeKey, bucket, false); + if (!directory) return; + try { + await directory.removeEntry(`${mediaId}.bin`); + } catch (error) { + if (!isNotFoundError(error)) throw error; + } +} + +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 (error) { + if (isNotFoundError(error)) return; + throw error; + } + try { + await coversRoot.removeEntry(scopeKey, { recursive: true }); + } catch (error) { + if (!isNotFoundError(error)) throw error; + } +} + // --------------------------------------------------------------------------- // Utility helpers // --------------------------------------------------------------------------- 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..a45a6c3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-local-first-cover-lifecycle.md @@ -0,0 +1,623 @@ +# 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 `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** + +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: CoverImage( + 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: CoverImage( + 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 `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) { + _coverFuture = isAccountLibrary + ? importService.getPendingCoverFile(accountScope, bookId) + : importService.getGuestCoverFile(bookId); +} +``` + +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** + +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" +``` 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..1f5bf8b --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-media-storage-hardening.md @@ -0,0 +1,782 @@ +# 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: CoverImage( + 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: CoverImage( + 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: `CoverImage` does not exist. + +- [ ] **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 `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** + +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 `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** + +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. 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..cf15507 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-stable-local-cover-image-cache.md @@ -0,0 +1,373 @@ +# 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. `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. + +--- + +## 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( + CoverImage( + 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`. 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. 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..c1c7703 --- /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 `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. + +## 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 + +`CoverImage` 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, `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 + +None for this iteration. Cover thumbnail generation and image-size optimization can be evaluated separately if profiling shows decode memory pressure. 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..4bcf2d2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-media-storage-hardening-design.md @@ -0,0 +1,163 @@ +# 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. 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. +- 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, 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. + +## 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///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. + +### Cache behavior + +A reusable cover loader follows this order: + +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 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. + +### UI integration + +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. + +## 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 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 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 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. + +## 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. +- 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. + +## 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; +- 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. + +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.