diff --git a/README.md b/README.md index 58e3c6c0..61cfeea3 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Many reading applications offer partial solutions but fall short on essential fe | Category | Features | |----------|----------| -| **Reading** | EPUB/PDF reader with pagination/scrolling, appearance settings and saved positions | +| **Reading** | EPUB, PDF, MOBI, AZW3, TXT, CBZ and CBR reader with pagination/scrolling and saved positions | | **Organization** | Shelves, tags/topics and library filters | | **Annotations** | Book-level bookmarks, notes and annotations; reader text selection/export is future work | | **Progress** | Saved progress, reading sessions and statistics | @@ -46,10 +46,15 @@ Many reading applications offer partial solutions but fall short on essential fe | **Catalogs** | OPDS 1.2/2.0 browsing, search and imports through the backend relay | | **Accessibility** | E-ink, light/dark themes and reader typography controls | -The built-in reader opens EPUB and PDF. Native metadata import also accepts MOBI, -AZW3, TXT, CBR and CBZ; these formats do not have reading engines yet. The web -file picker accepts EPUB and PDF. Additional cloud storage providers and -selection-based reader annotations remain future work. +The built-in reader and native/web file pickers accept EPUB, PDF, MOBI, AZW3, +TXT, CBR and CBZ. Text and Kindle books support reflow and typography controls; +comic archives support page navigation, zoom and scrolling. Files must be +DRM-free; encrypted archives and fixed-layout EPUBs are unsupported. Web imports +of TXT, Kindle and comic files initially use the filename as the library title. + +Book parsing runs in native isolates or browser workers. Integrity checks use a +native isolate or Web Crypto, and local book reads are asynchronous. Original +book files remain unchanged; the client persists the reader's saved position. ## Supported platforms diff --git a/app/lib/media/book_file_hash.dart b/app/lib/media/book_file_hash.dart new file mode 100644 index 00000000..9d036a5c --- /dev/null +++ b/app/lib/media/book_file_hash.dart @@ -0,0 +1 @@ +export 'book_file_hash_native.dart' if (dart.library.js_interop) 'book_file_hash_web.dart'; diff --git a/app/lib/media/book_file_hash_native.dart b/app/lib/media/book_file_hash_native.dart new file mode 100644 index 00000000..3562f91a --- /dev/null +++ b/app/lib/media/book_file_hash_native.dart @@ -0,0 +1,6 @@ +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; + +Future hashBookFile(Uint8List bytes) => compute(_hash, bytes, debugLabel: 'book-file-integrity'); + +String _hash(Uint8List bytes) => sha256.convert(bytes).toString(); diff --git a/app/lib/media/book_file_hash_web.dart b/app/lib/media/book_file_hash_web.dart new file mode 100644 index 00000000..05cfcf8d --- /dev/null +++ b/app/lib/media/book_file_hash_web.dart @@ -0,0 +1,9 @@ +import 'dart:js_interop'; +import 'dart:typed_data'; + +import 'package:web/web.dart' as web; + +Future hashBookFile(Uint8List bytes) async { + final digest = await web.window.crypto.subtle.digest('SHA-256'.toJS, bytes.toJS).toDart; + return (digest as JSArrayBuffer).toDart.asUint8List().map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); +} diff --git a/app/lib/media/media_cache_service.dart b/app/lib/media/media_cache_service.dart index 8b950648..736e5460 100644 --- a/app/lib/media/media_cache_service.dart +++ b/app/lib/media/media_cache_service.dart @@ -4,6 +4,8 @@ import 'package:crypto/crypto.dart'; import 'package:papyrus/media/media_storage_scope.dart'; import 'package:papyrus/models/book.dart'; +import 'book_file_hash.dart'; + typedef LocalBookFileReader = Future Function(String bookId); typedef LocalBookFileWriter = Future Function(String bookId, String extension, Uint8List bytes); typedef MediaDownloader = Future Function(String assetId); @@ -12,6 +14,9 @@ typedef LocalCoverWriter = Future Function(MediaStorageScope scope, String /// Coordinates lazy download and platform-local caching for private media. class MediaCacheService { + MediaCacheService({Future Function(Uint8List) hashFile = hashBookFile}) : _hashFile = hashFile; + + final Future Function(Uint8List) _hashFile; final Map> _coverDownloads = {}; /// Returns a cached book file when present and, if the book has a stored @@ -23,7 +28,7 @@ class MediaCacheService { return null; } - return _matchesExpectedHash(cached, book.fileHash) ? cached : null; + return await _matchesExpectedHash(cached, book.fileHash) ? cached : null; } /// Returns local book bytes, downloading and caching private server media @@ -48,7 +53,7 @@ class MediaCacheService { final downloaded = await downloadMedia(mediaId); - if (!_matchesExpectedHash(downloaded, book.fileHash)) { + if (!await _matchesExpectedHash(downloaded, book.fileHash)) { throw StateError('Downloaded book file did not match the expected hash.'); } @@ -115,12 +120,12 @@ class MediaCacheService { String sha256Hex(Uint8List bytes) => sha256.convert(bytes).toString(); - bool _matchesExpectedHash(Uint8List bytes, String? expectedHash) { + Future _matchesExpectedHash(Uint8List bytes, String? expectedHash) async { if (expectedHash == null || expectedHash.isEmpty) { return true; } - return sha256Hex(bytes) == expectedHash; + return await _hashFile(bytes) == expectedHash; } String _extensionFor(Book book) { diff --git a/app/lib/pages/reader_page.dart b/app/lib/pages/reader_page.dart index 47b9d10e..0d7851ed 100644 --- a/app/lib/pages/reader_page.dart +++ b/app/lib/pages/reader_page.dart @@ -252,7 +252,7 @@ class _ReaderPageState extends State { if (book == null || document == null || preferences == null) { return Scaffold( backgroundColor: Theme.of(context).colorScheme.surface, - body: const SafeArea(child: _ReaderLoading()), + body: SafeArea(child: _ReaderLoading(onBack: _close)), ); } @@ -398,10 +398,24 @@ class _ReaderPageState extends State { } class _ReaderLoading extends StatelessWidget { - const _ReaderLoading(); + const _ReaderLoading({this.onBack}); + + final VoidCallback? onBack; @override Widget build(BuildContext context) { - return const Center(child: AppCircularProgressIndicator(semanticsLabel: 'Opening book')); + const indicator = Center(child: AppCircularProgressIndicator(semanticsLabel: 'Opening book')); + + if (onBack == null) { + return indicator; + } + + return Stack( + fit: StackFit.expand, + children: [ + indicator, + PositionedDirectional(top: 0, start: 0, child: BackButton(onPressed: onBack)), + ], + ); } } diff --git a/app/lib/reader/reader_book_adapter.dart b/app/lib/reader/reader_book_adapter.dart index 4a74a4b6..c19ebd7b 100644 --- a/app/lib/reader/reader_book_adapter.dart +++ b/app/lib/reader/reader_book_adapter.dart @@ -14,6 +14,11 @@ final class ReaderBookAdapter { return switch (format) { BookFormat.epub => ReaderFormat.epub, BookFormat.pdf => ReaderFormat.pdf, + BookFormat.mobi => ReaderFormat.mobi, + BookFormat.azw3 => ReaderFormat.azw3, + BookFormat.txt => ReaderFormat.txt, + BookFormat.cbr => ReaderFormat.cbr, + BookFormat.cbz => ReaderFormat.cbz, _ => null, }; } @@ -29,11 +34,11 @@ final class ReaderBookAdapter { final locator = ReaderLocator.fromJson(stored.map((key, value) => MapEntry(key.toString(), value))); final format = formatFor(book.fileFormat); - if (format == ReaderFormat.epub && locator is! EpubReaderLocator) { + if (format?.isReflowable == true && locator is! EpubReaderLocator) { return null; } - if (format == ReaderFormat.pdf && locator is! PdfReaderLocator) { + if (format?.isReflowable == false && locator is! PdfReaderLocator) { return null; } @@ -47,7 +52,7 @@ final class ReaderBookAdapter { final format = formatFor(book.fileFormat); if (anchor == null) { - if (format == ReaderFormat.pdf && pageNumber != null && pageNumber > 0) { + if (format?.isReflowable == false && pageNumber != null && pageNumber > 0) { return PdfReaderLocator( pageIndex: pageNumber - 1, pageOffset: 0, @@ -62,8 +67,8 @@ final class ReaderBookAdapter { final locator = ReaderLocator.fromJson(anchor); final matchesFormat = switch (locator) { - EpubReaderLocator() => format == ReaderFormat.epub, - PdfReaderLocator() => format == ReaderFormat.pdf, + EpubReaderLocator() => format?.isReflowable == true, + PdfReaderLocator() => format?.isReflowable == false, }; if (!matchesFormat) { diff --git a/app/lib/services/book_import_service.dart b/app/lib/services/book_import_service.dart index e4c0b786..66edfcb0 100644 --- a/app/lib/services/book_import_service.dart +++ b/app/lib/services/book_import_service.dart @@ -144,7 +144,7 @@ class BookImportService { /// Processes a book file and stores it in OPFS via the web worker. /// - /// Supports EPUB and PDF files. + /// Supports all library book formats. /// Throws [UnsupportedError] when called on non-web platforms. /// Throws [ArgumentError] for unsupported file formats. Future importBook(Uint8List bytes, String filename) => @@ -162,12 +162,18 @@ class BookImportService { final ext = filename.toLowerCase().split('.').last; - if (ext != 'epub' && ext != 'pdf') { - throw ArgumentError('Unsupported format: $ext. Only EPUB and PDF are supported.'); + if (!const ['epub', 'pdf', 'mobi', 'azw3', 'txt', 'cbr', 'cbz'].contains(ext)) { + throw ArgumentError('Unsupported format: $ext.'); } JSObject? metadata; + if (ext != 'epub' && ext != 'pdf') { + metadata = JSObject(); + metadata['title'] = p.basenameWithoutExtension(filename).toJS; + metadata['author'] = 'Unknown Author'.toJS; + } + if (ext == 'pdf') { try { final extracted = extractPdfMetadata(bytes); diff --git a/app/lib/services/book_import_service_stub.dart b/app/lib/services/book_import_service_stub.dart index b2b48754..f1e45ed7 100644 --- a/app/lib/services/book_import_service_stub.dart +++ b/app/lib/services/book_import_service_stub.dart @@ -100,9 +100,7 @@ class BookImportService { /// Returns null if the file does not exist. Future getBookFile(String bookId) async { final booksDir = await _getBooksDirectory(); - final files = booksDir.listSync(); - - for (final entity in files) { + await for (final entity in booksDir.list()) { if (entity is File && p.basenameWithoutExtension(entity.path) == bookId) { return entity.readAsBytes(); } diff --git a/app/lib/widgets/add_book/book_import_controller.dart b/app/lib/widgets/add_book/book_import_controller.dart index 5d36b1ee..7b6cd91e 100644 --- a/app/lib/widgets/add_book/book_import_controller.dart +++ b/app/lib/widgets/add_book/book_import_controller.dart @@ -10,7 +10,7 @@ typedef BookImportProcessor = Future Function(Uint8List bytes, typedef ImportedBookFileDeleter = Future Function(String bookId); typedef ImportedBookCommitter = Future Function(BookImportResult result, String sourceFilename); -const bookImportWebExtensions = ['epub', 'pdf']; +const bookImportWebExtensions = bookImportNativeExtensions; const bookImportNativeExtensions = ['epub', 'pdf', 'mobi', 'azw3', 'txt', 'cbr', 'cbz']; enum BookImportPhase { selecting, processing, summary } diff --git a/app/pubspec.lock b/app/pubspec.lock index a123a69c..356fbf96 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -13,10 +13,10 @@ packages: dependency: "direct main" description: name: archive - sha256: de5ac590b8155e4e676088d714bd41ea620449a6adc0f7950c5073874b287ddb + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff url: "https://pub.dev" source: hosted - version: "4.6.0" + version: "4.0.9" args: dependency: transitive description: @@ -33,6 +33,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + barcode: + dependency: transitive + description: + name: barcode + sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4" + url: "https://pub.dev" + source: hosted + version: "2.2.9" + bidi: + dependency: transitive + description: + name: bidi + sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d" + url: "https://pub.dev" + source: hosted + version: "2.0.13" boolean_selector: dependency: transitive description: @@ -475,10 +491,10 @@ packages: dependency: "direct main" description: name: image - sha256: "89ed75b2f8231fe676808c5564f9590d728024ded7ac782a7ed8474f1c95b72a" + sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e" url: "https://pub.dev" source: hosted - version: "4.12.0" + version: "4.9.2" integration_test: dependency: "direct dev" description: flutter @@ -524,6 +540,39 @@ packages: url: "https://pub.dev" source: hosted version: "4.12.0" + kindle_unpack: + dependency: transitive + description: + path: "third_party/kindle_unpack" + ref: b4aafafcb4d80afe5f140960ac91dcc251dac70e + resolved-ref: b4aafafcb4d80afe5f140960ac91dcc251dac70e + url: "https://github.com/PapyrusReader/reader.git" + source: git + version: "0.2.0" + koni_archive_core: + dependency: transitive + description: + name: koni_archive_core + sha256: f323296866d6224725839e2b8ef25dfeba5e0c2476877fa7726f4b966a4c7a84 + url: "https://pub.dev" + source: hosted + version: "0.9.0" + koni_codecs: + dependency: transitive + description: + name: koni_codecs + sha256: "0a0ffa23f62f627bc50db6de79e88a6a41e019d3a21c6a455ac5ae7c1677a8bd" + url: "https://pub.dev" + source: hosted + version: "0.9.0" + koni_rar: + dependency: transitive + description: + name: koni_rar + sha256: c8f1a5b2f1b9d8f6cfd981c67e8c34298f722806f7288f06faabb40004362f56 + url: "https://pub.dev" + source: hosted + version: "0.9.0" leak_tracker: dependency: transitive description: @@ -672,8 +721,8 @@ packages: dependency: "direct main" description: path: "." - ref: a52d0e4f3b78f37234b917cec8ef11cec71a914b - resolved-ref: a52d0e4f3b78f37234b917cec8ef11cec71a914b + ref: b4aafafcb4d80afe5f140960ac91dcc251dac70e + resolved-ref: b4aafafcb4d80afe5f140960ac91dcc251dac70e url: "https://github.com/PapyrusReader/reader.git" source: git version: "0.0.1" @@ -741,6 +790,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + pdf: + dependency: transitive + description: + name: pdf + sha256: e47a275b267873d5944ad5f5ff0dcc7ac2e36c02b3046a0ffac9b72fd362c44b + url: "https://pub.dev" + source: hosted + version: "3.12.0" pdfium_dart: dependency: transitive description: @@ -853,6 +910,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.7.0" + qr: + dependency: transitive + description: + name: qr + sha256: "2c0189dca64a3ab08dda7acd7911c89af2374f08eb4eaded3e8ef4609f3b9c8b" + url: "https://pub.dev" + source: hosted + version: "3.0.3" record_use: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index eb3d4aeb..ae7a7d3c 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -27,7 +27,7 @@ dependencies: papyrus_reader: git: url: https://github.com/PapyrusReader/reader.git - ref: a52d0e4f3b78f37234b917cec8ef11cec71a914b + ref: b4aafafcb4d80afe5f140960ac91dcc251dac70e epub_pro: ^5.6.0 syncfusion_flutter_pdf: ^33.2.13 dart_mobi: ^1.0.2 diff --git a/app/test/media/media_cache_service_test.dart b/app/test/media/media_cache_service_test.dart index eb2345df..5ec5128d 100644 --- a/app/test/media/media_cache_service_test.dart +++ b/app/test/media/media_cache_service_test.dart @@ -2,11 +2,28 @@ import 'dart:async'; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:papyrus/media/media_cache_service.dart'; import 'package:papyrus/media/media_storage_scope.dart'; import 'package:papyrus/models/book.dart'; void main() { + test('validating a large cached book leaves the host event loop running', () async { + final service = MediaCacheService(); + final bytes = Uint8List(4 * 1024 * 1024); + final book = _book(fileHash: service.sha256Hex(bytes)); + var ticks = 0; + final timer = Timer.periodic(Duration.zero, (_) => ticks++); + addTearDown(timer.cancel); + final cached = await service.getValidCachedBookFile(book, readLocalBookFile: (_) async => bytes); + expect(cached, same(bytes)); + // Background browser test tabs can throttle timers; their checksum path + // still runs here, while foreground browser checks measure actual frames. + if (!kIsWeb) { + expect(ticks, greaterThan(1)); + } + }); + late MediaCacheService service; setUp(() { diff --git a/app/test/pages/book_details_delete_test.dart b/app/test/pages/book_details_delete_test.dart index 24437fda..8ee99c3d 100644 --- a/app/test/pages/book_details_delete_test.dart +++ b/app/test/pages/book_details_delete_test.dart @@ -224,7 +224,14 @@ void main() { ChangeNotifierProvider.value(value: dataStore), ChangeNotifierProvider.value(value: mediaUploadQueue), Provider.value(value: importService), - Provider.value(value: MediaCacheService()), + Provider.value( + value: MediaCacheService( + hashFile: (candidate) async { + expect(candidate, bytes); + return book.fileHash!; + }, + ), + ), Provider.value(value: downloadService), ], child: MaterialApp.router(routerConfig: router), diff --git a/app/test/pages/book_details_reader_test.dart b/app/test/pages/book_details_reader_test.dart index deb740cd..6f3f64eb 100644 --- a/app/test/pages/book_details_reader_test.dart +++ b/app/test/pages/book_details_reader_test.dart @@ -20,16 +20,18 @@ import '../helpers/test_helpers.dart'; void main() { for (final width in [400.0, 1280.0]) { - for (final format in [BookFormat.epub, BookFormat.pdf]) { + for (final format in BookFormat.values) { testWidgets('saved items open their exact $format location at width $width', (tester) async { tester.view.physicalSize = Size(width, 900); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); final book = buildTestBook(id: 'saved-location-book', fileFormat: format); - final ReaderLocator bookmarkLocator = format == BookFormat.epub + final ReaderLocator bookmarkLocator = + const [BookFormat.epub, BookFormat.mobi, BookFormat.azw3, BookFormat.txt].contains(format) ? EpubReaderLocator(spineIndex: 3, textOffset: 456, localProgression: .25, totalProgression: .4) : PdfReaderLocator(pageIndex: 7, pageOffset: .35, totalProgression: .4); - final ReaderLocator annotationLocator = format == BookFormat.epub + final ReaderLocator annotationLocator = + const [BookFormat.epub, BookFormat.mobi, BookFormat.azw3, BookFormat.txt].contains(format) ? EpubReaderLocator(spineIndex: 4, textOffset: 789, localProgression: .5, totalProgression: .6) : PdfReaderLocator(pageIndex: 12, pageOffset: .65, totalProgression: .6); final store = DataStore() @@ -485,7 +487,7 @@ void main() { } testWidgets('Start reading explains unsupported formats', (tester) async { - final book = buildTestBook(id: 'mobi-book', fileFormat: BookFormat.mobi); + final book = buildTestBook(id: 'unknown-format-book'); final dataStore = DataStore()..loadData(books: [book]); addTearDown(dataStore.dispose); diff --git a/app/test/pages/reader_page_test.dart b/app/test/pages/reader_page_test.dart index a7e74aa3..36d62847 100644 --- a/app/test/pages/reader_page_test.dart +++ b/app/test/pages/reader_page_test.dart @@ -3,6 +3,7 @@ import 'dart:async'; 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/data/repositories/library_repository.dart'; @@ -19,14 +20,16 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../helpers/test_helpers.dart'; void main() { - for (final format in [BookFormat.epub, BookFormat.pdf]) { + for (final format in BookFormat.values) { testWidgets('explicit $format location takes precedence over resume position', (tester) async { SharedPreferences.setMockInitialValues({}); final preferences = PreferencesProvider(await SharedPreferences.getInstance()); - final ReaderLocator target = format == BookFormat.epub + final ReaderLocator target = + const [BookFormat.epub, BookFormat.mobi, BookFormat.azw3, BookFormat.txt].contains(format) ? EpubReaderLocator(spineIndex: 3, textOffset: 456, localProgression: .25, totalProgression: .4) : PdfReaderLocator(pageIndex: 7, pageOffset: .35, totalProgression: .4); - final ReaderLocator resume = format == BookFormat.epub + final ReaderLocator resume = + const [BookFormat.epub, BookFormat.mobi, BookFormat.azw3, BookFormat.txt].contains(format) ? EpubReaderLocator(spineIndex: 1, textOffset: 0, localProgression: 0, totalProgression: .1) : PdfReaderLocator(pageIndex: 1, pageOffset: 0, totalProgression: .1); final book = buildTestBook( @@ -162,7 +165,7 @@ void main() { ), ); - expect(find.byTooltip('Back'), findsNothing); + expect(find.byTooltip('Back'), findsOneWidget); expect(find.byType(AppBar), findsNothing); final loaderCenter = tester.getCenter(find.byType(CircularProgressIndicator)); expect(loaderCenter, const Offset(400, 300)); @@ -174,7 +177,7 @@ void main() { expect(cache.loads, 1, reason: tester.widgetList(find.byType(Text)).map((text) => text.data).join(' / ')); expect(find.byType(PapyrusReader), findsOneWidget); - expect(find.byTooltip('Back'), findsNothing); + expect(find.byTooltip('Back'), findsOneWidget); expect(tester.getCenter(find.byType(CircularProgressIndicator)), loaderCenter); final before = tester.widget(find.byType(PapyrusReader)).document; updateTheme(() => dark = true); @@ -185,6 +188,59 @@ void main() { await tester.pumpWidget(const SizedBox.shrink()); }); + testWidgets('Back leaves a pending media load and ignores its late completion', (tester) async { + SharedPreferences.setMockInitialValues({}); + final preferences = PreferencesProvider(await SharedPreferences.getInstance()); + final store = DataStore() + ..loadData( + books: [buildTestBook(id: 'large-book', fileFormat: BookFormat.epub)], + ); + final gate = Completer(); + final cache = _ReaderMediaCache(gate: gate); + final router = GoRouter( + initialLocation: '/library', + routes: [ + GoRoute( + path: '/library', + builder: (context, state) => Scaffold( + body: TextButton(onPressed: () => context.push('/reader'), child: const Text('Open book')), + ), + ), + GoRoute( + path: '/reader', + builder: (_, _) => const ReaderPage(bookId: 'large-book'), + ), + ], + ); + addTearDown(store.dispose); + addTearDown(router.dispose); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: store), + ChangeNotifierProvider.value(value: preferences), + Provider.value(value: cache), + Provider(create: (_) => BookImportService()), + ChangeNotifierProvider(create: (_) => _ReaderAuth()), + ], + child: MaterialApp.router(routerConfig: router), + ), + ); + + await tester.tap(find.text('Open book')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(cache.loads, 1); + await tester.tap(find.byTooltip('Back')); + await tester.pumpAndSettle(); + expect(find.text('Open book'), findsOneWidget); + gate.complete(); + await tester.pumpAndSettle(); + expect(find.byType(PapyrusReader), findsNothing); + expect(tester.takeException(), isNull); + }); + testWidgets('profile switch during media loading ends the spinner without opening the old book', (tester) async { SharedPreferences.setMockInitialValues({}); final preferences = PreferencesProvider(await SharedPreferences.getInstance()); @@ -243,7 +299,7 @@ void main() { final dataStore = DataStore() ..loadData( - books: [buildTestBook(id: 'mobi-book', fileFormat: BookFormat.mobi)], + books: [buildTestBook(id: 'mobi-book', fileFormat: null)], ); addTearDown(dataStore.dispose); diff --git a/app/test/reader/reader_book_adapter_test.dart b/app/test/reader/reader_book_adapter_test.dart index 80b55da4..06789823 100644 --- a/app/test/reader/reader_book_adapter_test.dart +++ b/app/test/reader/reader_book_adapter_test.dart @@ -26,10 +26,27 @@ void main() { expect(page.pageOffset, 0); }); - test('maps only EPUB and PDF formats', () { + test('maps all importable formats', () { expect(ReaderBookAdapter.formatFor(BookFormat.epub), ReaderFormat.epub); expect(ReaderBookAdapter.formatFor(BookFormat.pdf), ReaderFormat.pdf); - expect(ReaderBookAdapter.formatFor(BookFormat.mobi), isNull); + for (final format in BookFormat.values) { + expect(ReaderBookAdapter.formatFor(format)?.name, format.name); + final book = buildTestBook(fileFormat: format, pageCount: 10); + final locator = ReaderBookAdapter.formatFor(format)!.isReflowable + ? EpubReaderLocator(spineIndex: 0, textOffset: 20, localProgression: .2, totalProgression: .2) + : PdfReaderLocator(pageIndex: 2, pageOffset: .1, totalProgression: .2); + expect(ReaderBookAdapter.decodeLocation(book, jsonEncode(locator.toJson())), locator); + expect( + ReaderBookAdapter.restoreLocator( + book.copyWith( + customMetadata: { + ReaderBookAdapter.locatorMetadataKey: locator.toJson(), + }, + ), + ), + locator, + ); + } expect(ReaderBookAdapter.formatFor(null), isNull); }); diff --git a/app/test/web/book_worker_test.cjs b/app/test/web/book_worker_test.cjs index 7c6eb3ad..0a6e9b7a 100644 --- a/app/test/web/book_worker_test.cjs +++ b/app/test/web/book_worker_test.cjs @@ -77,3 +77,24 @@ test('cover worker dispatches scoped storage, promotion, deletion and rejects un assert.equal((await send('getCover', {scopeKey: '../bob'})).type, 'error'); assert.ok(locks.length > 0); }); + +test('remaining book formats retain their bytes, extension and checksum in OPFS', async () => { + const root = directory(); + const responses = []; + const context = vm.createContext({ + self: {}, Uint8Array, crypto: require('node:crypto').webcrypto, + postMessage: message => responses.push(message), + navigator: {storage: {getDirectory: async () => root}}, + }); + vm.runInContext(readFileSync(resolve(__dirname, '../../web/book_worker.js'), 'utf8'), context); + for (const format of ['mobi', 'azw3', 'txt', 'cbz', 'cbr']) { + const bytes = new Uint8Array([1, 2, 3, 4]); + await context.self.onmessage({data: {type: 'process', bookId: format, format, fileData: bytes.buffer, metadata: {title: 'Book'}}}); + const result = responses.at(-1); + assert.equal(result.type, 'success'); + assert.equal(result.fileHash, require('node:crypto').createHash('sha256').update(bytes).digest('hex')); + const books = await root.getDirectoryHandle('books', {create: false}); + const stored = await books.getFileHandle(`${format}.${format}`, {create: false}); + assert.deepEqual(new Uint8Array(await (await stored.getFile()).arrayBuffer()), bytes); + } +}); diff --git a/app/web/book_worker.js b/app/web/book_worker.js index fe540888..dd565597 100644 --- a/app/web/book_worker.js +++ b/app/web/book_worker.js @@ -93,6 +93,13 @@ async function handleProcess(msg) { case 'pdf': await processPdf(bookId, fileData, msg.metadata); break; + case 'mobi': + case 'azw3': + case 'txt': + case 'cbr': + case 'cbz': + await processStoredBook(bookId, fileData, format, msg.metadata); + break; default: postMessage({ type: 'error', @@ -225,6 +232,23 @@ async function processPdf(bookId, fileData, metadata) { }); } +async function processStoredBook(bookId, fileData, format, metadata) { + const bytes = new Uint8Array(fileData); + if (bytes.byteLength === 0) throw new Error('The book file is empty'); + const fileHash = bufferToHex(await crypto.subtle.digest('SHA-256', fileData)); + await opfsWrite(bookId, format, bytes); + postMessage({ + type: 'success', + action: 'process', + bookId, + metadata, + coverData: null, + coverMimeType: null, + fileSize: bytes.byteLength, + fileHash, + }); +} + async function processEpub(bookId, fileData) { const bytes = new Uint8Array(fileData);