From 6ffe589ef438f213207c7de2ac6f5d666a227d75 Mon Sep 17 00:00:00 2001 From: Eoic Date: Sat, 27 Jun 2026 18:45:25 +0300 Subject: [PATCH] Add client media upload and cache pipeline --- app/lib/auth/auth_api_client.dart | 53 +++++ app/lib/auth/auth_repository.dart | 25 +++ app/lib/data/data_store.dart | 4 + app/lib/main.dart | 53 ++++- app/lib/media/media_cache_service.dart | 57 +++++ app/lib/media/media_models.dart | 98 +++++++++ app/lib/media/media_upload_queue.dart | 206 ++++++++++++++++++ app/lib/models/book.dart | 12 + app/lib/pages/book_details_page.dart | 29 ++- app/lib/pages/profile_page.dart | 2 + .../papyrus_powersync_connector.dart | 4 +- app/lib/powersync/papyrus_schema.dart | 2 + app/lib/powersync/powersync_book_mapper.dart | 6 + .../powersync/storage_sync_controller.dart | 11 +- app/lib/providers/auth_provider.dart | 4 + app/lib/providers/sync_settings_provider.dart | 4 +- app/lib/services/book_import_service.dart | 39 ++++ .../services/book_import_service_stub.dart | 15 ++ .../widgets/add_book/import_book_sheet.dart | 49 +++++ .../book_details/book_cover_image.dart | 34 ++- app/lib/widgets/book_details/book_header.dart | 14 +- app/pubspec.lock | 2 +- app/pubspec.yaml | 1 + app/test/auth/auth_api_client_test.dart | 102 +++++++++ app/test/media/media_cache_service_test.dart | 101 +++++++++ app/test/media/media_upload_queue_test.dart | 91 ++++++++ app/test/pages/profile_storage_sync_test.dart | 2 + .../powersync/powersync_book_mapper_test.dart | 8 + .../services/book_import_service_test.dart | 21 ++ app/web/book_worker.js | 16 ++ 30 files changed, 1054 insertions(+), 11 deletions(-) create mode 100644 app/lib/media/media_cache_service.dart create mode 100644 app/lib/media/media_models.dart create mode 100644 app/lib/media/media_upload_queue.dart create mode 100644 app/test/media/media_cache_service_test.dart create mode 100644 app/test/media/media_upload_queue_test.dart diff --git a/app/lib/auth/auth_api_client.dart b/app/lib/auth/auth_api_client.dart index 83b0fa4..a49777f 100644 --- a/app/lib/auth/auth_api_client.dart +++ b/app/lib/auth/auth_api_client.dart @@ -1,8 +1,11 @@ import 'dart:convert'; +import 'dart:typed_data'; import 'package:http/http.dart' as http; +import 'package:http_parser/http_parser.dart'; import 'package:papyrus/auth/auth_models.dart'; import 'package:papyrus/auth/papyrus_api_config.dart'; +import 'package:papyrus/media/media_models.dart'; class AuthApiException implements Exception { final int statusCode; @@ -151,6 +154,46 @@ class AuthApiClient { await _postJson(config.endpoint('/sync/powersync-upload'), accessToken: accessToken, body: {'batch': batch}); } + Future fetchMediaUsage(String accessToken) async { + final json = await _getJson(config.endpoint('/media/usage'), accessToken: accessToken); + return MediaStorageUsage.fromJson(json); + } + + Future uploadMedia(String accessToken, MediaUploadPayload payload) async { + final request = http.MultipartRequest('POST', config.endpoint('/media')) + ..headers.addAll(_authHeaders(accessToken)) + ..fields['book_id'] = payload.bookId + ..fields['kind'] = payload.kind.apiValue + ..files.add( + http.MultipartFile.fromBytes( + 'file', + payload.bytes, + filename: payload.filename, + contentType: _mediaType(payload.contentType), + ), + ); + + final response = await http.Response.fromStream(await _httpClient.send(request)); + return MediaAsset.fromJson(_decodeResponse(response)); + } + + Future downloadMedia(String accessToken, String assetId) async { + final response = await _httpClient.get(config.endpoint('/media/$assetId'), headers: _authHeaders(accessToken)); + if (response.statusCode >= 200 && response.statusCode < 300) { + return response.bodyBytes; + } + _decodeResponse(response); + throw const AuthApiException(statusCode: 0, message: 'Media download failed'); + } + + Future deleteMedia(String accessToken, String assetId) async { + final response = await _httpClient.delete(config.endpoint('/media/$assetId'), headers: _authHeaders(accessToken)); + if (response.statusCode >= 200 && response.statusCode < 300) { + return; + } + _decodeResponse(response); + } + Future> _getJson(Uri uri, {String? accessToken}) async { final response = await _httpClient.get(uri, headers: _headers(accessToken)); @@ -185,6 +228,16 @@ class AuthApiClient { }; } + Map _authHeaders(String accessToken) { + return {'Accept': 'application/json', 'Authorization': 'Bearer $accessToken'}; + } + + MediaType _mediaType(String contentType) { + final parts = contentType.split('/'); + if (parts.length != 2) return MediaType('application', 'octet-stream'); + return MediaType(parts[0], parts[1]); + } + Map _decodeResponse(http.Response response) { final decoded = response.body.isEmpty ? {} : jsonDecode(response.body) as Map; diff --git a/app/lib/auth/auth_repository.dart b/app/lib/auth/auth_repository.dart index aa67a36..ce990a9 100644 --- a/app/lib/auth/auth_repository.dart +++ b/app/lib/auth/auth_repository.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; import 'package:papyrus/auth/auth_api_client.dart'; import 'package:papyrus/auth/auth_models.dart'; +import 'package:papyrus/media/media_models.dart'; import 'package:papyrus/auth/token_store.dart'; import 'package:papyrus/platform/web_redirect.dart'; @@ -194,6 +195,30 @@ class AuthRepository { }); } + Future fetchMediaUsage() { + return _withFreshAccessToken((accessToken) { + return apiClient.fetchMediaUsage(accessToken); + }); + } + + Future uploadMedia(MediaUploadPayload payload) { + return _withFreshAccessToken((accessToken) { + return apiClient.uploadMedia(accessToken, payload); + }); + } + + Future downloadMedia(String assetId) { + return _withFreshAccessToken((accessToken) { + return apiClient.downloadMedia(accessToken, assetId); + }); + } + + Future deleteMedia(String assetId) { + return _withFreshAccessToken((accessToken) { + return apiClient.deleteMedia(accessToken, assetId); + }); + } + Future clearTokens() { return tokenStore.clear(); } diff --git a/app/lib/data/data_store.dart b/app/lib/data/data_store.dart index 4deb034..0a9a276 100644 --- a/app/lib/data/data_store.dart +++ b/app/lib/data/data_store.dart @@ -97,6 +97,8 @@ class DataStore extends ChangeNotifier { if (repository == null) { throw StateError('Book repository is not initialized'); } + _books[book.id] = book; + notifyListeners(); unawaited(repository.upsert(book)); } @@ -105,6 +107,8 @@ class DataStore extends ChangeNotifier { if (repository == null) { throw StateError('Book repository is not initialized'); } + _books[book.id] = book; + notifyListeners(); unawaited(repository.upsert(book)); } diff --git a/app/lib/main.dart b/app/lib/main.dart index 6cad691..0b6f945 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -7,6 +7,9 @@ import 'package:papyrus/auth/auth_repository.dart'; import 'package:papyrus/auth/papyrus_api_config.dart'; import 'package:papyrus/auth/token_store.dart'; import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/media/media_cache_service.dart'; +import 'package:papyrus/media/media_models.dart'; +import 'package:papyrus/media/media_upload_queue.dart'; import 'package:papyrus/powersync/powersync_service.dart'; import 'package:papyrus/powersync/papyrus_powersync_connector.dart'; import 'package:papyrus/powersync/sync_state.dart'; @@ -14,6 +17,8 @@ import 'package:papyrus/providers/auth_provider.dart'; import 'package:papyrus/providers/library_provider.dart'; import 'package:papyrus/providers/preferences_provider.dart'; import 'package:papyrus/providers/sync_settings_provider.dart'; +import 'package:papyrus/services/book_import_service_stub.dart' + if (dart.library.js_interop) 'package:papyrus/services/book_import_service.dart'; import 'package:papyrus/providers/sidebar_provider.dart'; import 'package:papyrus/themes/app_theme.dart'; import 'package:provider/provider.dart'; @@ -42,6 +47,8 @@ class _PapyrusState extends State { late final DataStore _dataStore; late final AuthProvider _authProvider; late final SyncSettingsProvider _syncSettingsProvider; + late final MediaUploadQueue _mediaUploadQueue; + late final BookImportService _bookImportService; late final PapyrusPowerSyncService _powerSyncService; late final PapyrusApiConfig _officialApiConfig; late AuthRepository _authRepository; @@ -59,10 +66,15 @@ class _PapyrusState extends State { _authRepository = _buildAuthRepository(_syncSettingsProvider.activeApiConfig, _activeProfileKey); _dataStore = DataStore(); + _mediaUploadQueue = MediaUploadQueue(widget.prefs); + _bookImportService = BookImportService(); _authProvider = AuthProvider(widget.prefs, repository: _authRepository); _powerSyncService = PapyrusPowerSyncService( - connectorFactory: () => - PapyrusPowerSyncConnector(authRepository: _authRepository, config: _syncSettingsProvider.activeApiConfig), + connectorFactory: () => PapyrusPowerSyncConnector( + authRepository: _authRepository, + config: _syncSettingsProvider.activeApiConfig, + onUploadComplete: _processMediaUploads, + ), ); unawaited(_dataStore.attachBookRepository(_powerSyncService)); _appRouter = AppRouter(authProvider: _authProvider); @@ -76,6 +88,7 @@ class _PapyrusState extends State { _authProvider.removeListener(_syncPowerSyncAuthState); _syncSettingsProvider.removeListener(_handleSyncSettingsChanged); unawaited(_disposeDataServices()); + _bookImportService.dispose(); _authProvider.dispose(); _syncSettingsProvider.dispose(); super.dispose(); @@ -99,6 +112,8 @@ class _PapyrusState extends State { if (user != null && !_authProvider.isOfflineMode) { final userId = user.userId; unawaited(_powerSyncService.activateAuthenticated(userId, profileKey: _activeProfileKey)); + unawaited(_refreshMediaUsage()); + unawaited(_processMediaUploads()); return; } @@ -128,20 +143,52 @@ class _PapyrusState extends State { await _powerSyncService.deactivate(clearAuthenticated: false); _authRepository = _buildAuthRepository(_syncSettingsProvider.activeApiConfig, _activeProfileKey); await _authProvider.replaceRepository(_authRepository, bootstrapNewRepository: !_authProvider.isOfflineMode); + unawaited(_refreshMediaUsage()); } finally { _switchingSyncProfile = false; _syncPowerSyncAuthState(); } } + Future _refreshMediaUsage() async { + if (!_authProvider.isSignedIn || _authProvider.isOfflineMode) return; + try { + await _mediaUploadQueue.refreshUsage(_authRepository.fetchMediaUsage); + } catch (_) { + // Usage is informational; failed refresh must not block data sync. + } + } + + Future _processMediaUploads() async { + if (!_authProvider.isSignedIn || _authProvider.isOfflineMode) return; + await _mediaUploadQueue.processPending( + dataStore: _dataStore, + readBookFile: _bookImportService.getBookFile, + uploadMedia: (payload) async { + try { + return await _authRepository.uploadMedia(payload); + } on AuthApiException catch (error) { + if (error.statusCode == 409) { + throw const MediaUploadException.storageFull(); + } + rethrow; + } + }, + ); + await _refreshMediaUsage(); + } + @override Widget build(BuildContext context) { return MultiProvider( providers: [ // Core data store - single source of truth ChangeNotifierProvider.value(value: _dataStore), + ChangeNotifierProvider.value(value: _mediaUploadQueue), ChangeNotifierProvider.value(value: _syncSettingsProvider), Provider.value(value: _powerSyncService), + Provider.value(value: _bookImportService), + Provider(create: _createMediaCacheService), StreamProvider.value(value: _powerSyncService.syncStates, initialData: _powerSyncService.syncState), // Auth and UI state providers ChangeNotifierProvider.value(value: _authProvider), @@ -165,3 +212,5 @@ class _PapyrusState extends State { ); } } + +MediaCacheService _createMediaCacheService(BuildContext _) => const MediaCacheService(); diff --git a/app/lib/media/media_cache_service.dart b/app/lib/media/media_cache_service.dart new file mode 100644 index 0000000..09640c1 --- /dev/null +++ b/app/lib/media/media_cache_service.dart @@ -0,0 +1,57 @@ +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:papyrus/models/book.dart'; + +typedef LocalBookFileReader = Future Function(String bookId); +typedef LocalBookFileWriter = Future Function(String bookId, String extension, Uint8List bytes); +typedef MediaDownloader = Future Function(String assetId); + +/// Coordinates lazy download and platform-local caching for private media. +class MediaCacheService { + const MediaCacheService(); + + /// Returns a cached book file when present and, if the book has a stored + /// hash, the bytes match the expected hash. + Future getValidCachedBookFile(Book book, {required LocalBookFileReader readLocalBookFile}) async { + final cached = await readLocalBookFile(book.id); + if (cached == null) return null; + return _matchesExpectedHash(cached, book.fileHash) ? cached : null; + } + + /// Returns local book bytes, downloading and caching private server media + /// when needed. + Future ensureBookFileCached( + Book book, { + required LocalBookFileReader readLocalBookFile, + required LocalBookFileWriter writeLocalBookFile, + required MediaDownloader downloadMedia, + }) async { + final cached = await getValidCachedBookFile(book, readLocalBookFile: readLocalBookFile); + if (cached != null) return cached; + + final mediaId = book.fileMediaId; + if (mediaId == null || mediaId.isEmpty) { + throw StateError('Book file is not available on this device or server.'); + } + + final downloaded = await downloadMedia(mediaId); + if (!_matchesExpectedHash(downloaded, book.fileHash)) { + throw StateError('Downloaded book file did not match the expected hash.'); + } + + await writeLocalBookFile(book.id, _extensionFor(book), downloaded); + return downloaded; + } + + String sha256Hex(Uint8List bytes) => sha256.convert(bytes).toString(); + + bool _matchesExpectedHash(Uint8List bytes, String? expectedHash) { + if (expectedHash == null || expectedHash.isEmpty) return true; + return sha256Hex(bytes) == expectedHash; + } + + String _extensionFor(Book book) { + return book.fileFormat?.name ?? 'bin'; + } +} diff --git a/app/lib/media/media_models.dart b/app/lib/media/media_models.dart new file mode 100644 index 0000000..2e249c3 --- /dev/null +++ b/app/lib/media/media_models.dart @@ -0,0 +1,98 @@ +import 'dart:typed_data'; + +enum MediaKind { + bookFile('book_file'), + coverImage('cover_image'); + + const MediaKind(this.apiValue); + + final String apiValue; + + static MediaKind fromApiValue(String value) { + return MediaKind.values.firstWhere((kind) => kind.apiValue == value); + } +} + +class MediaUploadPayload { + const MediaUploadPayload({ + required this.bookId, + required this.kind, + required this.filename, + required this.contentType, + required this.bytes, + }); + + final String bookId; + final MediaKind kind; + final String filename; + final String contentType; + final Uint8List bytes; +} + +class MediaAsset { + const MediaAsset({ + required this.assetId, + required this.ownerUserId, + required this.bookId, + required this.kind, + required this.originalFilename, + required this.contentType, + required this.extension, + required this.sizeBytes, + required this.sha256, + required this.storagePath, + }); + + final String assetId; + final String ownerUserId; + final String bookId; + final MediaKind kind; + final String originalFilename; + final String contentType; + final String extension; + final int sizeBytes; + final String sha256; + final String storagePath; + + factory MediaAsset.fromJson(Map json) { + return MediaAsset( + assetId: json['asset_id'] as String, + ownerUserId: json['owner_user_id'] as String, + bookId: json['book_id'] as String, + kind: MediaKind.fromApiValue(json['kind'] as String), + originalFilename: json['original_filename'] as String, + contentType: json['content_type'] as String, + extension: json['extension'] as String, + sizeBytes: json['size_bytes'] as int, + sha256: json['sha256'] as String, + storagePath: json['storage_path'] as String, + ); + } +} + +class MediaStorageUsage { + const MediaStorageUsage({required this.usedBytes, required this.quotaBytes, required this.availableBytes}); + + final int usedBytes; + final int quotaBytes; + final int availableBytes; + + factory MediaStorageUsage.fromJson(Map json) { + return MediaStorageUsage( + usedBytes: json['used_bytes'] as int, + quotaBytes: json['quota_bytes'] as int, + availableBytes: json['available_bytes'] as int, + ); + } +} + +class MediaUploadException implements Exception { + const MediaUploadException(this.message, {this.storageFull = false}); + const MediaUploadException.storageFull() : this('Storage full', storageFull: true); + + final String message; + final bool storageFull; + + @override + String toString() => message; +} diff --git a/app/lib/media/media_upload_queue.dart b/app/lib/media/media_upload_queue.dart new file mode 100644 index 0000000..87e44f0 --- /dev/null +++ b/app/lib/media/media_upload_queue.dart @@ -0,0 +1,206 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/media/media_models.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +typedef BookFileReader = Future Function(String bookId); +typedef MediaUploader = Future Function(MediaUploadPayload payload); + +enum MediaUploadTaskStatus { pending, failed } + +class MediaUploadTask { + const MediaUploadTask({ + required this.id, + required this.bookId, + required this.kind, + required this.filename, + required this.contentType, + required this.status, + this.coverBase64, + this.errorMessage, + }); + + final String id; + final String bookId; + final MediaKind kind; + final String filename; + final String contentType; + final MediaUploadTaskStatus status; + final String? coverBase64; + final String? errorMessage; + + MediaUploadTask copyWith({MediaUploadTaskStatus? status, String? errorMessage}) { + return MediaUploadTask( + id: id, + bookId: bookId, + kind: kind, + filename: filename, + contentType: contentType, + status: status ?? this.status, + coverBase64: coverBase64, + errorMessage: errorMessage, + ); + } + + Map toJson() { + return { + 'id': id, + 'book_id': bookId, + 'kind': kind.apiValue, + 'filename': filename, + 'content_type': contentType, + 'status': status.name, + 'cover_base64': coverBase64, + 'error_message': errorMessage, + }; + } + + factory MediaUploadTask.fromJson(Map json) { + return MediaUploadTask( + id: json['id'] as String, + bookId: json['book_id'] as String, + kind: MediaKind.fromApiValue(json['kind'] as String), + filename: json['filename'] as String, + contentType: json['content_type'] as String, + status: MediaUploadTaskStatus.values.byName(json['status'] as String? ?? MediaUploadTaskStatus.pending.name), + coverBase64: json['cover_base64'] as String?, + errorMessage: json['error_message'] as String?, + ); + } +} + +class MediaUploadQueue extends ChangeNotifier { + MediaUploadQueue(this._prefs) { + _tasks = _loadTasks(); + } + + static const _storageKey = 'media_upload_queue'; + + final SharedPreferences _prefs; + late List _tasks; + MediaStorageUsage? _storageUsage; + + List get pendingTasks => List.unmodifiable(_tasks); + MediaStorageUsage? get storageUsage => _storageUsage; + + Future refreshUsage(Future Function() fetchUsage) async { + _storageUsage = await fetchUsage(); + notifyListeners(); + } + + Future enqueueBookFile({required Book book, required String filename, required String contentType}) { + return _enqueue( + MediaUploadTask( + id: '${book.id}:book_file', + bookId: book.id, + kind: MediaKind.bookFile, + filename: filename, + contentType: contentType, + status: MediaUploadTaskStatus.pending, + ), + ); + } + + Future enqueueCover({ + required Book book, + required String filename, + required String contentType, + required Uint8List bytes, + }) { + return _enqueue( + MediaUploadTask( + id: '${book.id}:cover_image', + bookId: book.id, + kind: MediaKind.coverImage, + filename: filename, + contentType: contentType, + status: MediaUploadTaskStatus.pending, + coverBase64: base64Encode(bytes), + ), + ); + } + + Future processPending({ + required DataStore dataStore, + required BookFileReader readBookFile, + required MediaUploader uploadMedia, + }) async { + final nextTasks = []; + for (final task in _tasks) { + if (task.status == MediaUploadTaskStatus.failed) { + nextTasks.add(task); + continue; + } + + final bytes = await _bytesForTask(task, readBookFile); + if (bytes == null) { + nextTasks.add(task.copyWith(status: MediaUploadTaskStatus.pending, errorMessage: 'Local file not found')); + continue; + } + + try { + final asset = await uploadMedia( + MediaUploadPayload( + bookId: task.bookId, + kind: task.kind, + filename: task.filename, + contentType: task.contentType, + bytes: bytes, + ), + ); + _applyUploadedAsset(dataStore, asset); + } on MediaUploadException catch (error) { + nextTasks.add( + task.copyWith( + status: error.storageFull ? MediaUploadTaskStatus.failed : MediaUploadTaskStatus.pending, + errorMessage: error.message, + ), + ); + } catch (error) { + nextTasks.add(task.copyWith(status: MediaUploadTaskStatus.pending, errorMessage: error.toString())); + } + } + _tasks = nextTasks; + await _save(); + notifyListeners(); + } + + Future _enqueue(MediaUploadTask task) async { + _tasks = [..._tasks.where((existing) => existing.id != task.id), task]; + await _save(); + notifyListeners(); + } + + Future _bytesForTask(MediaUploadTask task, BookFileReader readBookFile) async { + if (task.kind == MediaKind.coverImage) { + final coverBase64 = task.coverBase64; + return coverBase64 == null ? null : base64Decode(coverBase64); + } + return readBookFile(task.bookId); + } + + void _applyUploadedAsset(DataStore dataStore, MediaAsset asset) { + final book = dataStore.getBook(asset.bookId); + if (book == null) return; + + if (asset.kind == MediaKind.bookFile) { + dataStore.updateBook(book.copyWith(fileMediaId: asset.assetId)); + return; + } + dataStore.updateBook(book.copyWith(coverMediaId: asset.assetId, clearCoverUrl: true)); + } + + List _loadTasks() { + final raw = _prefs.getString(_storageKey); + if (raw == null || raw.isEmpty) return []; + final decoded = jsonDecode(raw) as List; + return decoded.map((item) => MediaUploadTask.fromJson(item as Map)).toList(growable: false); + } + + Future _save() { + return _prefs.setString(_storageKey, jsonEncode(_tasks.map((task) => task.toJson()).toList())); + } +} diff --git a/app/lib/models/book.dart b/app/lib/models/book.dart index 8da3fa8..4211255 100644 --- a/app/lib/models/book.dart +++ b/app/lib/models/book.dart @@ -74,6 +74,8 @@ class Book { final int? pageCount; final String? description; final String? coverUrl; + final String? fileMediaId; + final String? coverMediaId; // Digital book fields final String? filePath; @@ -123,6 +125,8 @@ class Book { this.pageCount, this.description, this.coverUrl, + this.fileMediaId, + this.coverMediaId, this.filePath, this.fileFormat, this.fileSize, @@ -203,6 +207,8 @@ class Book { String? description, String? coverUrl, bool clearCoverUrl = false, + String? fileMediaId, + String? coverMediaId, String? filePath, BookFormat? fileFormat, int? fileSize, @@ -240,6 +246,8 @@ class Book { pageCount: pageCount ?? this.pageCount, description: description ?? this.description, coverUrl: clearCoverUrl ? null : (coverUrl ?? this.coverUrl), + fileMediaId: fileMediaId ?? this.fileMediaId, + coverMediaId: coverMediaId ?? this.coverMediaId, filePath: filePath ?? this.filePath, fileFormat: fileFormat ?? this.fileFormat, fileSize: fileSize ?? this.fileSize, @@ -281,6 +289,8 @@ class Book { 'page_count': pageCount, 'description': description, 'cover_image_url': coverUrl, + 'file_media_id': fileMediaId, + 'cover_media_id': coverMediaId, 'file_path': filePath, 'file_format': fileFormat?.name, 'file_size': fileSize, @@ -322,6 +332,8 @@ class Book { pageCount: json['page_count'] as int?, description: json['description'] as String?, coverUrl: json['cover_image_url'] as String?, + fileMediaId: json['file_media_id'] as String?, + coverMediaId: json['cover_media_id'] as String?, filePath: json['file_path'] as String?, fileFormat: json['file_format'] != null ? BookFormat.values.byName(json['file_format'] as String) : null, fileSize: json['file_size'] as int?, diff --git a/app/lib/pages/book_details_page.dart b/app/lib/pages/book_details_page.dart index 0437135..f6283de 100644 --- a/app/lib/pages/book_details_page.dart +++ b/app/lib/pages/book_details_page.dart @@ -1,10 +1,14 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/media/media_cache_service.dart'; import 'package:papyrus/models/annotation.dart'; import 'package:papyrus/models/bookmark.dart'; import 'package:papyrus/models/note.dart'; +import 'package:papyrus/providers/auth_provider.dart'; import 'package:papyrus/providers/book_details_provider.dart'; +import 'package:papyrus/services/book_import_service_stub.dart' + if (dart.library.js_interop) 'package:papyrus/services/book_import_service.dart'; import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/book/book_annotations.dart'; import 'package:papyrus/widgets/book/book_bookmarks.dart'; @@ -345,7 +349,30 @@ class _BookDetailsPageState extends State with SingleTickerProv ); } - void _onContinueReading() { + Future _onContinueReading() async { + final book = _provider.book; + if (book == null) return; + + if (book.fileMediaId != null) { + final messenger = ScaffoldMessenger.of(context); + messenger.showSnackBar(const SnackBar(content: Text('Preparing book file...'))); + + try { + final importService = context.read(); + await context.read().ensureBookFileCached( + book, + readLocalBookFile: importService.getBookFile, + writeLocalBookFile: importService.storeBookFile, + downloadMedia: context.read().downloadMedia, + ); + } catch (_) { + if (!mounted) return; + messenger.showSnackBar(const SnackBar(content: Text('Could not download this book file.'))); + return; + } + } + + if (!mounted) return; // TODO: Navigate to reader ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Opening book reader...'))); } diff --git a/app/lib/pages/profile_page.dart b/app/lib/pages/profile_page.dart index df0a6a2..01d4cf1 100644 --- a/app/lib/pages/profile_page.dart +++ b/app/lib/pages/profile_page.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/media/media_upload_queue.dart'; import 'package:papyrus/powersync/powersync_service.dart'; import 'package:papyrus/powersync/storage_sync_controller.dart'; import 'package:papyrus/providers/auth_provider.dart'; @@ -1017,6 +1018,7 @@ class _ProfilePageState extends State { syncSettings: context.watch(), syncState: context.watch(), fileStorageUsedBytes: _fileStorageUsedBytes(context.watch()), + mediaStorageUsage: context.watch().storageUsage, ); } diff --git a/app/lib/powersync/papyrus_powersync_connector.dart b/app/lib/powersync/papyrus_powersync_connector.dart index 43c0325..de0ddc5 100644 --- a/app/lib/powersync/papyrus_powersync_connector.dart +++ b/app/lib/powersync/papyrus_powersync_connector.dart @@ -7,8 +7,9 @@ import 'package:powersync/powersync.dart'; class PapyrusPowerSyncConnector extends PowerSyncBackendConnector { final AuthRepository authRepository; final PapyrusApiConfig config; + final Future Function()? onUploadComplete; - PapyrusPowerSyncConnector({required this.authRepository, required this.config}); + PapyrusPowerSyncConnector({required this.authRepository, required this.config, this.onUploadComplete}); @override Future fetchCredentials() async { @@ -47,6 +48,7 @@ class PapyrusPowerSyncConnector extends PowerSyncBackendConnector { await authRepository.uploadPowerSyncBatch(batch); await transaction.complete(); + await onUploadComplete?.call(); } } } diff --git a/app/lib/powersync/papyrus_schema.dart b/app/lib/powersync/papyrus_schema.dart index a289a00..84a77ee 100644 --- a/app/lib/powersync/papyrus_schema.dart +++ b/app/lib/powersync/papyrus_schema.dart @@ -13,6 +13,8 @@ const _bookColumns = [ Column.integer('page_count'), Column.text('description'), Column.text('cover_image_url'), + Column.text('file_media_id'), + Column.text('cover_media_id'), Column.text('reading_status'), Column.integer('current_page'), Column.real('current_position'), diff --git a/app/lib/powersync/powersync_book_mapper.dart b/app/lib/powersync/powersync_book_mapper.dart index ce079ba..3483506 100644 --- a/app/lib/powersync/powersync_book_mapper.dart +++ b/app/lib/powersync/powersync_book_mapper.dart @@ -14,6 +14,8 @@ const syncedBookColumns = [ 'page_count', 'description', 'cover_image_url', + 'file_media_id', + 'cover_media_id', 'reading_status', 'current_page', 'current_position', @@ -43,6 +45,8 @@ class PowerSyncBookMapper { pageCount: _toInt(row['page_count']), description: row['description'] as String?, coverUrl: row['cover_image_url'] as String?, + fileMediaId: row['file_media_id'] as String?, + coverMediaId: row['cover_media_id'] as String?, fileFormat: _bookFormat(metadata['file_format']), fileSize: _toInt(metadata['file_size']), fileHash: metadata['file_hash'] as String?, @@ -100,6 +104,8 @@ class PowerSyncBookMapper { 'page_count': book.pageCount, 'description': book.description, 'cover_image_url': _remoteCoverUrl(book.coverUrl), + 'file_media_id': book.fileMediaId, + 'cover_media_id': book.coverMediaId, 'reading_status': book.readingStatus.name, 'current_page': book.currentPage, 'current_position': book.currentPosition, diff --git a/app/lib/powersync/storage_sync_controller.dart b/app/lib/powersync/storage_sync_controller.dart index aa35b62..c273852 100644 --- a/app/lib/powersync/storage_sync_controller.dart +++ b/app/lib/powersync/storage_sync_controller.dart @@ -2,6 +2,7 @@ import 'package:papyrus/powersync/powersync_service.dart'; import 'package:papyrus/powersync/sync_state.dart'; import 'package:papyrus/providers/auth_provider.dart'; import 'package:papyrus/providers/sync_settings_provider.dart'; +import 'package:papyrus/media/media_models.dart'; class StorageSyncController { StorageSyncController({ @@ -10,6 +11,7 @@ class StorageSyncController { required this.syncSettings, required this.syncState, required this.fileStorageUsedBytes, + this.mediaStorageUsage, }); final AuthProvider authProvider; @@ -17,6 +19,7 @@ class StorageSyncController { final SyncSettingsProvider syncSettings; final SyncState syncState; final int fileStorageUsedBytes; + final MediaStorageUsage? mediaStorageUsage; LibraryDatabaseMode? get databaseMode => powerSyncService.mode; @@ -54,7 +57,13 @@ class StorageSyncController { bool get shouldShowServerSettings => !isGuest; - String get fileStorageLabel => syncSettings.fileStorageLabel(usedBytes: fileStorageUsedBytes); + String get fileStorageLabel { + final usage = mediaStorageUsage; + if (isAuthenticated && usage != null) { + return syncSettings.fileStorageLabel(usedBytes: usage.usedBytes, quotaBytesOverride: usage.quotaBytes); + } + return syncSettings.fileStorageLabel(usedBytes: fileStorageUsedBytes); + } String get statusLabel { if (isGuest) return 'Guest local'; diff --git a/app/lib/providers/auth_provider.dart b/app/lib/providers/auth_provider.dart index dfe7d47..a692c7d 100644 --- a/app/lib/providers/auth_provider.dart +++ b/app/lib/providers/auth_provider.dart @@ -181,6 +181,10 @@ class AuthProvider extends ChangeNotifier { return _runMessageAction(() => _repository.resendVerification(email)); } + Future downloadMedia(String assetId) { + return _repository.downloadMedia(assetId); + } + void setOfflineMode(bool value) { _isOfflineMode = value; _prefs.setBool(_keyOfflineMode, value); diff --git a/app/lib/providers/sync_settings_provider.dart b/app/lib/providers/sync_settings_provider.dart index c837d26..34a30dd 100644 --- a/app/lib/providers/sync_settings_provider.dart +++ b/app/lib/providers/sync_settings_provider.dart @@ -141,8 +141,8 @@ class SyncSettingsProvider extends ChangeNotifier { return activeCustomServer?.fileStorageQuotaBytes ?? officialFileStorageQuotaBytes; } - String fileStorageLabel({required int usedBytes}) { - final quotaBytes = fileStorageQuotaBytes; + String fileStorageLabel({required int usedBytes, int? quotaBytesOverride}) { + final quotaBytes = quotaBytesOverride ?? fileStorageQuotaBytes; if (quotaBytes == null) return '${_formatBytes(usedBytes)} used'; final availableBytes = quotaBytes > usedBytes ? quotaBytes - usedBytes : 0; diff --git a/app/lib/services/book_import_service.dart b/app/lib/services/book_import_service.dart index 3dd9d87..31d3bf8 100644 --- a/app/lib/services/book_import_service.dart +++ b/app/lib/services/book_import_service.dart @@ -212,6 +212,45 @@ class BookImportService { return (fileDataJs as JSArrayBuffer).toDart.asUint8List(); } + /// Stores raw book bytes in OPFS for [bookId]. + /// + /// Throws [UnsupportedError] when called on non-web platforms. + Future storeBookFile(String bookId, String extension, Uint8List bytes) async { + if (!kIsWeb) { + throw UnsupportedError('BookImportService is only supported on web.'); + } + + final normalizedExtension = extension.toLowerCase().replaceFirst('.', ''); + if (normalizedExtension.isEmpty) { + throw ArgumentError('Book file extension cannot be empty.'); + } + + final completer = Completer(); + final worker = _getWorker(); + + _pending['storeFile:$bookId'] = completer; + + final actualBytes = bytes.offsetInBytes == 0 && bytes.lengthInBytes == bytes.buffer.lengthInBytes + ? bytes + : Uint8List.fromList(bytes); + final jsBuffer = actualBytes.buffer.toJS; + final message = JSObject(); + message['type'] = 'storeFile'.toJS; + message['format'] = normalizedExtension.toJS; + message['bookId'] = bookId.toJS; + message['fileData'] = jsBuffer; + + worker.postMessage(message, [jsBuffer].toJS); + + await completer.future.timeout( + _timeout, + onTimeout: () { + _pending.remove('storeFile:$bookId'); + throw TimeoutException('Store file timed out after ${_timeout.inSeconds}s', _timeout); + }, + ); + } + /// Terminates the Web Worker and releases resources. void dispose() { _worker?.terminate(); diff --git a/app/lib/services/book_import_service_stub.dart b/app/lib/services/book_import_service_stub.dart index 27beb47..06f39da 100644 --- a/app/lib/services/book_import_service_stub.dart +++ b/app/lib/services/book_import_service_stub.dart @@ -86,6 +86,21 @@ class BookImportService { return null; } + /// Stores raw book bytes under the app-local book cache for [bookId]. + /// + /// Used when a signed-in device lazily downloads a book file from the + /// selected server. + Future storeBookFile(String bookId, String extension, Uint8List bytes) async { + final normalizedExtension = extension.toLowerCase().replaceFirst('.', ''); + if (normalizedExtension.isEmpty) { + throw ArgumentError('Book file extension cannot be empty.'); + } + await deleteBookFile(bookId); + final booksDir = await _getBooksDirectory(); + final file = File(p.join(booksDir.path, '$bookId.$normalizedExtension')); + await file.writeAsBytes(bytes); + } + /// No-op on native — no worker to terminate. void dispose() {} diff --git a/app/lib/widgets/add_book/import_book_sheet.dart b/app/lib/widgets/add_book/import_book_sheet.dart index 7693e5b..26f4770 100644 --- a/app/lib/widgets/add_book/import_book_sheet.dart +++ b/app/lib/widgets/add_book/import_book_sheet.dart @@ -4,7 +4,11 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/media/media_upload_queue.dart'; import 'package:papyrus/models/book.dart'; +import 'package:papyrus/powersync/powersync_service.dart'; +import 'package:papyrus/powersync/sync_state.dart'; +import 'package:papyrus/providers/auth_provider.dart'; import 'package:papyrus/services/book_import_service_stub.dart' if (dart.library.js_interop) 'package:papyrus/services/book_import_service.dart'; import 'package:papyrus/themes/design_tokens.dart'; @@ -190,12 +194,57 @@ class _ImportContentState extends State<_ImportContent> { ); dataStore.addBook(book); + unawaited(_enqueueOnlineMediaUploads(book, result)); final messenger = ScaffoldMessenger.of(context); Navigator.of(context).pop(); messenger.showSnackBar(SnackBar(content: Text('Added "${book.title}" to library'))); } + Future _enqueueOnlineMediaUploads(Book book, BookImportResult result) async { + final isOnlineAccount = + context.read().isSignedIn && + context.read().mode == LibraryDatabaseMode.authenticated; + if (!isOnlineAccount) return; + + final queue = context.read(); + await queue.enqueueBookFile( + book: book, + filename: _filename ?? '${book.id}.${result.fileExtension}', + contentType: _contentTypeForExtension(result.fileExtension), + ); + + final coverImage = result.coverImage; + if (coverImage != null) { + await queue.enqueueCover( + book: book, + filename: '${book.id}-cover.${_coverExtension(result.coverMimeType)}', + contentType: result.coverMimeType ?? 'image/jpeg', + bytes: coverImage, + ); + } + } + + String _contentTypeForExtension(String extension) { + return switch (extension) { + 'epub' => 'application/epub+zip', + 'pdf' => 'application/pdf', + 'txt' => 'text/plain', + 'cbz' => 'application/vnd.comicbook+zip', + 'cbr' => 'application/vnd.comicbook-rar', + _ => 'application/octet-stream', + }; + } + + String _coverExtension(String? contentType) { + return switch (contentType) { + 'image/png' => 'png', + 'image/webp' => 'webp', + 'image/gif' => 'gif', + _ => 'jpg', + }; + } + @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; diff --git a/app/lib/widgets/book_details/book_cover_image.dart b/app/lib/widgets/book_details/book_cover_image.dart index 54aa71f..9a77c59 100644 --- a/app/lib/widgets/book_details/book_cover_image.dart +++ b/app/lib/widgets/book_details/book_cover_image.dart @@ -1,6 +1,12 @@ +import 'dart:typed_data'; + import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:papyrus/providers/auth_provider.dart'; import 'package:papyrus/themes/design_tokens.dart'; +import 'package:provider/provider.dart'; + +final Map> _privateCoverDownloads = {}; /// Cover image size variants. enum BookCoverSize { @@ -20,10 +26,11 @@ enum BookCoverSize { /// Book cover image widget with size variants and placeholder. class BookCoverImage extends StatelessWidget { final String? imageUrl; + final String? mediaId; final String? bookTitle; final BookCoverSize size; - const BookCoverImage({super.key, this.imageUrl, this.bookTitle, this.size = BookCoverSize.medium}); + const BookCoverImage({super.key, this.imageUrl, this.mediaId, this.bookTitle, this.size = BookCoverSize.medium}); @override Widget build(BuildContext context) { @@ -52,6 +59,24 @@ class BookCoverImage extends StatelessWidget { progressIndicatorBuilder: (context, url, progress) => _buildLoadingIndicator(context, colorScheme, progress), ); } + if (mediaId != null && mediaId!.isNotEmpty) { + final future = _privateCoverDownloads.putIfAbsent( + mediaId!, + () => context.read().downloadMedia(mediaId!), + ); + return FutureBuilder( + future: future, + builder: (context, snapshot) { + if (snapshot.hasData) { + return Image.memory(snapshot.data!, fit: BoxFit.cover); + } + if (snapshot.hasError) { + return _buildPlaceholder(context, colorScheme); + } + return _buildIndeterminateLoadingIndicator(colorScheme); + }, + ); + } return _buildPlaceholder(context, colorScheme); } @@ -96,6 +121,13 @@ class BookCoverImage extends StatelessWidget { ); } + Widget _buildIndeterminateLoadingIndicator(ColorScheme colorScheme) { + return Container( + color: colorScheme.surfaceContainerHighest, + child: const Center(child: CircularProgressIndicator(strokeWidth: 2)), + ); + } + _CoverDimensions _getDimensions() { switch (size) { case BookCoverSize.large: diff --git a/app/lib/widgets/book_details/book_header.dart b/app/lib/widgets/book_details/book_header.dart index e94b8a3..dd68e95 100644 --- a/app/lib/widgets/book_details/book_header.dart +++ b/app/lib/widgets/book_details/book_header.dart @@ -40,7 +40,12 @@ class BookHeader extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ // Cover image - BookCoverImage(imageUrl: book.coverURL, bookTitle: book.title, size: BookCoverSize.large), + BookCoverImage( + imageUrl: book.coverURL, + mediaId: book.coverMediaId, + bookTitle: book.title, + size: BookCoverSize.large, + ), const SizedBox(width: Spacing.xl), // Book info @@ -111,7 +116,12 @@ class BookHeader extends StatelessWidget { const SizedBox(height: Spacing.lg), // Cover image (centered) - BookCoverImage(imageUrl: book.coverURL, bookTitle: book.title, size: BookCoverSize.medium), + BookCoverImage( + imageUrl: book.coverURL, + mediaId: book.coverMediaId, + bookTitle: book.title, + size: BookCoverSize.medium, + ), const SizedBox(height: Spacing.md), // Title (centered) diff --git a/app/pubspec.lock b/app/pubspec.lock index a69813f..eafdf66 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -371,7 +371,7 @@ packages: source: hosted version: "1.6.0" http_parser: - dependency: transitive + dependency: "direct main" description: name: http_parser sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index d691ec2..f811203 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: fl_chart: ^0.69.0 intl: ^0.19.0 http: ^1.2.0 + http_parser: ^4.0.2 file_picker: ^8.0.0+1 cached_network_image: ^3.3.1 google_fonts: ^6.2.1 diff --git a/app/test/auth/auth_api_client_test.dart b/app/test/auth/auth_api_client_test.dart index 5242866..9c44906 100644 --- a/app/test/auth/auth_api_client_test.dart +++ b/app/test/auth/auth_api_client_test.dart @@ -1,10 +1,12 @@ import 'dart:convert'; +import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:papyrus/auth/auth_api_client.dart'; import 'package:papyrus/auth/papyrus_api_config.dart'; +import 'package:papyrus/media/media_models.dart'; const _authResponse = { 'access_token': 'access-token', @@ -143,4 +145,104 @@ void main() { }, ]); }); + + test('fetchMediaUsage maps storage usage response', () async { + final client = AuthApiClient( + config: PapyrusApiConfig(serverBaseUri: Uri.parse('http://server.test')), + httpClient: MockClient((request) async { + expect(request.url.path, '/v1/media/usage'); + expect(request.headers['Authorization'], 'Bearer access-token'); + + return http.Response(jsonEncode({'used_bytes': 10, 'quota_bytes': 100, 'available_bytes': 90}), 200); + }), + ); + + final usage = await client.fetchMediaUsage('access-token'); + + expect(usage.usedBytes, 10); + expect(usage.quotaBytes, 100); + expect(usage.availableBytes, 90); + }); + + test('downloadMedia returns authenticated bytes', () async { + final client = AuthApiClient( + config: PapyrusApiConfig(serverBaseUri: Uri.parse('http://server.test')), + httpClient: MockClient((request) async { + expect(request.url.path, '/v1/media/asset-id'); + expect(request.headers['Authorization'], 'Bearer access-token'); + + return http.Response.bytes([1, 2, 3], 200, headers: {'content-type': 'application/epub+zip'}); + }), + ); + + final bytes = await client.downloadMedia('access-token', 'asset-id'); + + expect(bytes, Uint8List.fromList([1, 2, 3])); + }); + + test('uploadMedia sends authenticated multipart media request', () async { + final client = AuthApiClient( + config: PapyrusApiConfig(serverBaseUri: Uri.parse('http://server.test')), + httpClient: _CapturingMultipartClient((request, body) async { + expect(request.method, 'POST'); + expect(request.url.path, '/v1/media'); + expect(request.headers['Authorization'], 'Bearer access-token'); + expect(request.headers['content-type'], contains('multipart/form-data')); + expect(body, contains('name="book_id"')); + expect(body, contains('11111111-1111-1111-1111-111111111111')); + expect(body, contains('name="kind"')); + expect(body, contains('book_file')); + expect(body, contains('filename="book.epub"')); + expect(body, contains('epub bytes')); + + return http.Response( + jsonEncode({ + 'asset_id': '22222222-2222-2222-2222-222222222222', + 'owner_user_id': '33333333-3333-3333-3333-333333333333', + 'book_id': '11111111-1111-1111-1111-111111111111', + 'kind': 'book_file', + 'original_filename': 'book.epub', + 'content_type': 'application/epub+zip', + 'extension': 'epub', + 'size_bytes': 10, + 'sha256': 'hash', + 'storage_path': 'path', + }), + 201, + ); + }), + ); + + final asset = await client.uploadMedia( + 'access-token', + MediaUploadPayload( + bookId: '11111111-1111-1111-1111-111111111111', + kind: MediaKind.bookFile, + filename: 'book.epub', + contentType: 'application/epub+zip', + bytes: Uint8List.fromList('epub bytes'.codeUnits), + ), + ); + + expect(asset.assetId, '22222222-2222-2222-2222-222222222222'); + expect(asset.kind, MediaKind.bookFile); + }); +} + +class _CapturingMultipartClient extends http.BaseClient { + _CapturingMultipartClient(this.handler); + + final Future Function(http.BaseRequest request, String body) handler; + + @override + Future send(http.BaseRequest request) async { + final body = await request.finalize().bytesToString(); + final response = await handler(request, body); + return http.StreamedResponse( + Stream.value(response.bodyBytes), + response.statusCode, + headers: response.headers, + request: request, + ); + } } diff --git a/app/test/media/media_cache_service_test.dart b/app/test/media/media_cache_service_test.dart new file mode 100644 index 0000000..f54ebc0 --- /dev/null +++ b/app/test/media/media_cache_service_test.dart @@ -0,0 +1,101 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/media/media_cache_service.dart'; +import 'package:papyrus/models/book.dart'; + +void main() { + late MediaCacheService service; + + setUp(() { + service = const MediaCacheService(); + }); + + test('uses cached book file when its hash matches', () async { + var downloads = 0; + var writes = 0; + final bytes = Uint8List.fromList('cached'.codeUnits); + final book = _book(fileHash: service.sha256Hex(bytes), fileMediaId: 'asset-1'); + + final result = await service.ensureBookFileCached( + book, + readLocalBookFile: (_) async => bytes, + writeLocalBookFile: (_, _, _) async => writes++, + downloadMedia: (_) async { + downloads++; + return Uint8List.fromList('remote'.codeUnits); + }, + ); + + expect(result, bytes); + expect(downloads, 0); + expect(writes, 0); + }); + + test('downloads and stores book file when local cache is missing', () async { + Uint8List? written; + String? writtenExtension; + final remote = Uint8List.fromList('remote file'.codeUnits); + final book = _book(fileHash: service.sha256Hex(remote), fileMediaId: 'asset-1', fileFormat: BookFormat.pdf); + + final result = await service.ensureBookFileCached( + book, + readLocalBookFile: (_) async => null, + writeLocalBookFile: (_, extension, bytes) async { + writtenExtension = extension; + written = bytes; + }, + downloadMedia: (assetId) async { + expect(assetId, 'asset-1'); + return remote; + }, + ); + + expect(result, remote); + expect(writtenExtension, 'pdf'); + expect(written, remote); + }); + + test('redownloads when cached hash does not match', () async { + final stale = Uint8List.fromList('stale'.codeUnits); + final remote = Uint8List.fromList('remote file'.codeUnits); + final book = _book(fileHash: service.sha256Hex(remote), fileMediaId: 'asset-1'); + + final result = await service.ensureBookFileCached( + book, + readLocalBookFile: (_) async => stale, + writeLocalBookFile: (_, _, _) async {}, + downloadMedia: (_) async => remote, + ); + + expect(result, remote); + }); + + test('rejects downloaded bytes when expected hash does not match', () async { + final book = _book(fileHash: List.filled(64, '0').join(), fileMediaId: 'asset-1'); + + expect( + () => service.ensureBookFileCached( + book, + readLocalBookFile: (_) async => null, + writeLocalBookFile: (_, _, _) async {}, + downloadMedia: (_) async => Uint8List.fromList('wrong'.codeUnits), + ), + throwsStateError, + ); + }); +} + +Book _book({required String fileHash, String? fileMediaId, BookFormat? fileFormat}) { + return Book( + id: 'book-1', + title: 'Book', + author: 'Author', + filePath: 'book-1', + fileSize: 12, + fileHash: fileHash, + fileFormat: fileFormat ?? BookFormat.epub, + fileMediaId: fileMediaId, + addedAt: DateTime.utc(2026), + ); +} diff --git a/app/test/media/media_upload_queue_test.dart b/app/test/media/media_upload_queue_test.dart new file mode 100644 index 0000000..f894da0 --- /dev/null +++ b/app/test/media/media_upload_queue_test.dart @@ -0,0 +1,91 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/data/repositories/book_repository.dart'; +import 'package:papyrus/media/media_models.dart'; +import 'package:papyrus/media/media_upload_queue.dart'; +import 'package:papyrus/models/book.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('processPending uploads book file and stores returned media id on the book', () async { + final prefs = await SharedPreferences.getInstance(); + final repository = InMemoryBookRepository(); + final dataStore = DataStore(bookRepository: repository); + final book = _book(filePath: 'book-1', fileSize: 10, fileHash: 'hash'); + await repository.upsert(book); + await pumpEventQueue(); + final queue = MediaUploadQueue(prefs); + await queue.enqueueBookFile(book: book, filename: 'book.epub', contentType: 'application/epub+zip'); + + await queue.processPending( + dataStore: dataStore, + readBookFile: (bookId) async => Uint8List.fromList('epub bytes'.codeUnits), + uploadMedia: (payload) async { + expect(payload.bookId, book.id); + expect(payload.kind, MediaKind.bookFile); + expect(payload.bytes, Uint8List.fromList('epub bytes'.codeUnits)); + return _asset(assetId: 'file-asset', bookId: book.id, kind: MediaKind.bookFile); + }, + ); + + expect(queue.pendingTasks, isEmpty); + expect(dataStore.getBook(book.id)?.fileMediaId, 'file-asset'); + }); + + test('processPending keeps quota failures visible without dropping local media', () async { + final prefs = await SharedPreferences.getInstance(); + final repository = InMemoryBookRepository(); + final dataStore = DataStore(bookRepository: repository); + final book = _book(filePath: 'book-1', fileSize: 10, fileHash: 'hash'); + await repository.upsert(book); + await pumpEventQueue(); + final queue = MediaUploadQueue(prefs); + await queue.enqueueBookFile(book: book, filename: 'book.epub', contentType: 'application/epub+zip'); + + await queue.processPending( + dataStore: dataStore, + readBookFile: (bookId) async => Uint8List.fromList('epub bytes'.codeUnits), + uploadMedia: (payload) async => throw const MediaUploadException.storageFull(), + ); + + expect(queue.pendingTasks, hasLength(1)); + expect(queue.pendingTasks.single.status, MediaUploadTaskStatus.failed); + expect(queue.pendingTasks.single.errorMessage, 'Storage full'); + expect(dataStore.getBook(book.id)?.fileMediaId, isNull); + expect(dataStore.getBook(book.id)?.filePath, 'book-1'); + }); +} + +Book _book({String? filePath, int? fileSize, String? fileHash}) { + return Book( + id: '11111111-1111-1111-1111-111111111111', + title: 'Book', + author: 'Author', + filePath: filePath, + fileSize: fileSize, + fileHash: fileHash, + fileFormat: BookFormat.epub, + addedAt: DateTime.utc(2026, 6, 27), + ); +} + +MediaAsset _asset({required String assetId, required String bookId, required MediaKind kind}) { + return MediaAsset( + assetId: assetId, + ownerUserId: 'user-id', + bookId: bookId, + kind: kind, + originalFilename: 'book.epub', + contentType: 'application/epub+zip', + extension: 'epub', + sizeBytes: 10, + sha256: 'hash', + storagePath: 'path', + ); +} diff --git a/app/test/pages/profile_storage_sync_test.dart b/app/test/pages/profile_storage_sync_test.dart index 251334e..adbc8f4 100644 --- a/app/test/pages/profile_storage_sync_test.dart +++ b/app/test/pages/profile_storage_sync_test.dart @@ -7,6 +7,7 @@ import 'package:papyrus/auth/papyrus_api_config.dart'; import 'package:papyrus/auth/token_store.dart'; import 'package:papyrus/data/data_store.dart'; import 'package:papyrus/data/repositories/book_repository.dart'; +import 'package:papyrus/media/media_upload_queue.dart'; import 'package:papyrus/models/book.dart'; import 'package:papyrus/pages/profile_page.dart'; import 'package:papyrus/powersync/powersync_service.dart'; @@ -131,6 +132,7 @@ void main() { return MultiProvider( providers: [ ChangeNotifierProvider.value(value: dataStore ?? DataStore()), + ChangeNotifierProvider(create: (_) => MediaUploadQueue(prefs)), ChangeNotifierProvider.value( value: syncSettingsProvider ?? SyncSettingsProvider(prefs, officialConfig: config), ), diff --git a/app/test/powersync/powersync_book_mapper_test.dart b/app/test/powersync/powersync_book_mapper_test.dart index e8c90c1..153f077 100644 --- a/app/test/powersync/powersync_book_mapper_test.dart +++ b/app/test/powersync/powersync_book_mapper_test.dart @@ -13,6 +13,8 @@ void main() { coAuthors: const ['Co Author'], coverUrl: 'data:image/png;base64,abc', filePath: '/local/book.epub', + fileMediaId: '22222222-2222-2222-2222-222222222222', + coverMediaId: '33333333-3333-3333-3333-333333333333', fileFormat: BookFormat.epub, fileSize: 1024, fileHash: 'hash', @@ -28,6 +30,8 @@ void main() { final metadata = jsonDecode(row['custom_metadata']! as String) as Map; expect(row['cover_image_url'], isNull); + expect(row['file_media_id'], '22222222-2222-2222-2222-222222222222'); + expect(row['cover_media_id'], '33333333-3333-3333-3333-333333333333'); expect(row.containsKey('file_path'), isFalse); expect(row['co_authors'], jsonEncode(['Co Author'])); expect(row['reading_status'], 'inProgress'); @@ -48,6 +52,8 @@ void main() { 'reading_status': 'in_progress', 'current_position': 0.5, 'is_favorite': 1, + 'file_media_id': '22222222-2222-2222-2222-222222222222', + 'cover_media_id': '33333333-3333-3333-3333-333333333333', 'custom_metadata': jsonEncode({'file_format': 'epub', 'is_physical': false}), 'added_at': '2026-05-09T12:00:00Z', }); @@ -58,5 +64,7 @@ void main() { expect(book.currentPosition, 0.5); expect(book.isFavorite, isTrue); expect(book.fileFormat, BookFormat.epub); + expect(book.fileMediaId, '22222222-2222-2222-2222-222222222222'); + expect(book.coverMediaId, '33333333-3333-3333-3333-333333333333'); }); } diff --git a/app/test/services/book_import_service_test.dart b/app/test/services/book_import_service_test.dart index 1b85f3e..f2c8839 100644 --- a/app/test/services/book_import_service_test.dart +++ b/app/test/services/book_import_service_test.dart @@ -177,6 +177,27 @@ void main() { }); }); + group('storeBookFile', () { + test('stores downloaded book bytes with the provided extension', () async { + final bytes = Uint8List.fromList('downloaded epub bytes'.codeUnits); + + await service.storeBookFile('downloaded-book', 'epub', bytes); + + final retrieved = await service.getBookFile('downloaded-book'); + final storedFile = File(p.join(tempDir.path, 'books', 'downloaded-book.epub')); + expect(storedFile.existsSync(), isTrue); + expect(retrieved, bytes); + }); + + test('normalizes extension and replaces existing cached file', () async { + await service.storeBookFile('downloaded-book', '.epub', Uint8List.fromList([1, 2, 3])); + await service.storeBookFile('downloaded-book', 'epub', Uint8List.fromList([4, 5])); + + final retrieved = await service.getBookFile('downloaded-book'); + expect(retrieved, Uint8List.fromList([4, 5])); + }); + }); + group('deleteBookFile', () { test('removes stored file', () async { final bytes = loadTestFile('book1.epub'); diff --git a/app/web/book_worker.js b/app/web/book_worker.js index f5f9985..c51fc91 100644 --- a/app/web/book_worker.js +++ b/app/web/book_worker.js @@ -8,11 +8,13 @@ * { type: 'process', format: 'epub', bookId, fileData: ArrayBuffer } * { type: 'delete', bookId } * { type: 'getFile', bookId } + * { type: 'storeFile', format, bookId, fileData: ArrayBuffer } * * Outgoing: * { type: 'success', action: 'process', bookId, metadata, coverData, coverMimeType, fileSize, fileHash } * { type: 'success', action: 'delete', bookId } * { type: 'success', action: 'getFile', bookId, fileData } + * { type: 'success', action: 'storeFile', bookId } * { type: 'error', message } */ @@ -35,6 +37,9 @@ self.onmessage = async (event) => { case 'getFile': await handleGetFile(msg); break; + case 'storeFile': + await handleStoreFile(msg); + break; default: postMessage({ type: 'error', message: `Unknown message type: ${msg.type}` }); } @@ -92,6 +97,17 @@ async function handleGetFile(msg) { ); } +// --------------------------------------------------------------------------- +// StoreFile handler +// --------------------------------------------------------------------------- + +async function handleStoreFile(msg) { + const { bookId, format, fileData } = msg; + await opfsDelete(bookId); + await opfsWrite(bookId, format, new Uint8Array(fileData)); + postMessage({ type: 'success', action: 'storeFile', bookId }); +} + // --------------------------------------------------------------------------- // EPUB processing // ---------------------------------------------------------------------------