From 6cb5d00c8dacbd1f32cf9de081127294b3cf7821 Mon Sep 17 00:00:00 2001 From: VincenzoImp Date: Sat, 29 Aug 2026 15:22:15 +0200 Subject: [PATCH 1/3] feat: restore unfinished document drafts --- README.md | 16 +- SECURITY.md | 10 +- android/app/src/main/AndroidManifest.xml | 3 +- lib/main.dart | 9 +- lib/screens/corner_adjust_screen.dart | 208 ++++++--- lib/screens/scanner_home_page.dart | 291 +++++++++++-- lib/services/draft_store.dart | 4 + lib/services/draft_store_base.dart | 23 + lib/services/draft_store_native.dart | 344 +++++++++++++++ lib/services/draft_store_web.dart | 18 + pubspec.lock | 2 +- pubspec.yaml | 1 + test/platform_configuration_test.dart | 169 ++++---- .../corner_adjust_navigation_test.dart | 160 +++++++ test/screens/scanner_home_draft_test.dart | 401 ++++++++++++++++++ test/services/draft_store_native_test.dart | 231 ++++++++++ 16 files changed, 1711 insertions(+), 179 deletions(-) create mode 100644 lib/services/draft_store.dart create mode 100644 lib/services/draft_store_base.dart create mode 100644 lib/services/draft_store_native.dart create mode 100644 lib/services/draft_store_web.dart create mode 100644 test/screens/corner_adjust_navigation_test.dart create mode 100644 test/screens/scanner_home_draft_test.dart create mode 100644 test/services/draft_store_native_test.dart diff --git a/README.md b/README.md index ad69b70..7e009fb 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ and this page list.* - Re-edit any page after the fact (corners, filter, rotation, brightness, and contrast) without re-scanning - Capture multiple pages in sequence and reorder them with drag-and-drop +- Automatically restore an unfinished draft after a native app restart - Import existing photos from your gallery instead of (or alongside) capturing new ones - Combine captured pages into a single PDF @@ -62,8 +63,19 @@ used as-is there. - All image processing and PDF generation happens on-device. - Imported gallery originals are never modified or deleted. App-owned camera temp files are removed after the app attempts to copy their bytes into - memory (including failed reads), and in-progress pages remain only in memory - until the app is closed. + memory (including failed reads). +- On native platforms, unfinished drafts are saved automatically in the app's + private, OS-managed application cache. They survive normal process death and + app restarts, but caches are transient and can be purged by the OS under + storage pressure; they are not durable storage or included in Android OS + backups. A draft includes both original and processed page images plus + crop/filter/edit settings. Atomic staging plus one backup generation can + retain the current and immediately previous draft contents. Draft data is + also removed when you confirm **Clear all**, choose **Clear draft** after + sharing, clear the app's storage, or uninstall the app. Sharing does not + delete a draft unless you explicitly choose that option. +- The web build does not persist drafts; its in-progress pages remain only in + memory and disappear when the page is closed or reloaded. - PDF sharing starts from in-memory bytes. Depending on the platform, `share_plus` may materialize a copy in the app/OS cache for the receiving app; that cache is OS-managed and is not guaranteed to disappear immediately diff --git a/SECURITY.md b/SECURITY.md index 7728a7c..e0b0888 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -18,13 +18,17 @@ a private discussion with maintainers before anything becomes public. Given the app's design — all image processing and PDF generation happens on-device, and it makes no network requests of its own (see the README's -"Privacy" section) — the most relevant classes of report are: +"Privacy" section) — the most relevant classes of report are below. Automatic +native drafts use the app-private, OS-managed application cache: they survive +normal process death and restarts, but can be purged under storage pressure and +are excluded from Android OS backup. - Anything that would let a malicious document/image trigger memory corruption or a crash via the native OpenCV pipeline (`document_processor_native.dart`) -- Anything that would cause photos or generated PDFs to be persisted or - leaked when they shouldn't be (see the README's privacy guarantees) +- Anything that would expose app-private automatic draft files, retain them + after an explicit clear, or cause photos/generated PDFs to be persisted or + leaked outside the retention behavior documented in the README Dependency vulnerabilities are also welcome as reports, though Dependabot already opens PRs for those automatically where a fix is available. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c70bb41..b7954f3 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -4,7 +4,8 @@ + android:icon="@mipmap/ic_launcher" + android:allowBackup="false"> decodeSize(Uint8List imageBytes); + + Future> buildPreviews( + Uint8List imageBytes, + List corners, + ); + + Future processForExport( + Uint8List imageBytes, + List corners, { + required PageFilter filter, + required int rotationQuarterTurns, + required double brightness, + required double contrast, + }); +} + +class DefaultCornerAdjustOperations implements CornerAdjustOperations { + const DefaultCornerAdjustOperations(); + + @override + Future decodeSize(Uint8List imageBytes) async { + final codec = await ui.instantiateImageCodec(imageBytes); + final frame = await codec.getNextFrame(); + final size = Size( + frame.image.width.toDouble(), + frame.image.height.toDouble(), + ); + frame.image.dispose(); + codec.dispose(); + return size; + } + + @override + Future> buildPreviews( + Uint8List imageBytes, + List corners, + ) async { + final warped = warpDocument( + imageBytes, + corners, + maxPixels: maxPreviewWarpPixels, + maxEdge: maxPreviewWarpEdge, + ); + return { + for (final filter in PageFilter.values) + filter: applyFilter(warped, filter), + }; + } + + @override + Future processForExport( + Uint8List imageBytes, + List corners, { + required PageFilter filter, + required int rotationQuarterTurns, + required double brightness, + required double contrast, + }) { + Uint8List process() => processDocument( + imageBytes, + corners, + filter: filter, + rotationQuarterTurns: rotationQuarterTurns, + brightness: brightness, + contrast: contrast, + ); + return kIsWeb ? Future.value(process()) : Isolate.run(process); + } +} + const _filterLabels = { PageFilter.original: 'Original', PageFilter.autoEnhance: 'Enhance', @@ -43,6 +115,7 @@ class CornerAdjustScreen extends StatefulWidget { this.initialRotationQuarterTurns = 0, this.initialBrightness = 0.0, this.initialContrast = 1.0, + this.operations = const DefaultCornerAdjustOperations(), }); final Uint8List originalBytes; @@ -51,6 +124,7 @@ class CornerAdjustScreen extends StatefulWidget { final int initialRotationQuarterTurns; final double initialBrightness; final double initialContrast; + final CornerAdjustOperations operations; @override State createState() => _CornerAdjustScreenState(); @@ -63,6 +137,7 @@ class _CornerAdjustScreenState extends State { String? _error; _Step _step = _Step.corners; + LocalHistoryEntry? _filterHistoryEntry; late PageFilter _selectedFilter = widget.initialFilter; late int _rotationQuarterTurns = widget.initialRotationQuarterTurns; late double _brightness = widget.initialBrightness; @@ -86,14 +161,7 @@ class _CornerAdjustScreenState extends State { Future _initialize() async { try { - final codec = await ui.instantiateImageCodec(widget.originalBytes); - final frame = await codec.getNextFrame(); - final size = Size( - frame.image.width.toDouble(), - frame.image.height.toDouble(), - ); - frame.image.dispose(); - codec.dispose(); + final size = await widget.operations.decodeSize(widget.originalBytes); final candidateCorners = widget.initialCorners ?? detectCorners(widget.originalBytes); @@ -161,15 +229,10 @@ class _CornerAdjustScreenState extends State { } try { - final warped = warpDocument( + final previews = await widget.operations.buildPreviews( widget.originalBytes, corners, - maxPixels: maxPreviewWarpPixels, - maxEdge: maxPreviewWarpEdge, ); - final previews = { - for (final f in PageFilter.values) f: applyFilter(warped, f), - }; if (!mounted) return; setState(() { _filterPreviews = previews; @@ -206,34 +269,35 @@ class _CornerAdjustScreenState extends State { } } - Future _processForExport( - List corners, { - required PageFilter filter, - required int rotationQuarterTurns, - required double brightness, - required double contrast, - }) { - final imageBytes = widget.originalBytes; - Uint8List process() => processDocument( - imageBytes, - corners, - filter: filter, - rotationQuarterTurns: rotationQuarterTurns, - brightness: brightness, - contrast: contrast, - ); - - // Native OpenCV work is CPU/FFI-heavy; yielding it to another isolate lets - // the progress indicator paint and keeps pointer/system events responsive. - return kIsWeb ? Future.value(process()) : Isolate.run(process); - } - Future _goToFilterStep() async { if (_filterPreviews == null) { await _updatePreviews(); if (!mounted || _filterPreviews == null) return; } setState(() => _step = _Step.filter); + if (_filterHistoryEntry != null) return; + late final LocalHistoryEntry entry; + entry = LocalHistoryEntry( + onRemove: () { + if (identical(_filterHistoryEntry, entry)) { + _filterHistoryEntry = null; + } + if (mounted && _step == _Step.filter) { + setState(() => _step = _Step.corners); + } + }, + ); + _filterHistoryEntry = entry; + ModalRoute.of(context)?.addLocalHistoryEntry(entry); + } + + void _leaveFilterStep() { + final entry = _filterHistoryEntry; + if (entry != null) { + entry.remove(); + } else if (_step == _Step.filter) { + setState(() => _step = _Step.corners); + } } Future _confirm() async { @@ -245,7 +309,8 @@ class _CornerAdjustScreenState extends State { final contrast = _contrast; setState(() => _isProcessing = true); try { - final processed = await _processForExport( + final processed = await widget.operations.processForExport( + widget.originalBytes, corners, filter: filter, rotationQuarterTurns: rotationQuarterTurns, @@ -253,17 +318,19 @@ class _CornerAdjustScreenState extends State { contrast: contrast, ); if (!mounted) return; - Navigator.of(context).pop( - ScannedPage( - originalBytes: widget.originalBytes, - corners: corners, - filter: filter, - rotationQuarterTurns: rotationQuarterTurns, - brightness: brightness, - contrast: contrast, - processedBytes: processed, - ), + final page = ScannedPage( + originalBytes: widget.originalBytes, + corners: corners, + filter: filter, + rotationQuarterTurns: rotationQuarterTurns, + brightness: brightness, + contrast: contrast, + processedBytes: processed, ); + // Otherwise Navigator.pop would consume the local filter history entry + // instead of completing this route with the scanned page. + _filterHistoryEntry?.remove(); + if (mounted) Navigator.of(context).pop(page); } catch (_) { if (!mounted) return; setState(() => _isProcessing = false); @@ -284,27 +351,30 @@ class _CornerAdjustScreenState extends State { final corners = _corners; final ready = imageSize != null && corners != null; - return Scaffold( - appBar: AppBar( - title: Text(_appBarTitle), - actions: [ - if (_step == _Step.filter) - IconButton( - icon: const Icon(Icons.rotate_90_degrees_cw_outlined), - tooltip: 'Rotate', - onPressed: _isProcessing ? null : _rotate, - ), - ], + return PopScope( + canPop: !_isProcessing, + child: Scaffold( + appBar: AppBar( + title: Text(_appBarTitle), + actions: [ + if (_step == _Step.filter) + IconButton( + icon: const Icon(Icons.rotate_90_degrees_cw_outlined), + tooltip: 'Rotate', + onPressed: _isProcessing ? null : _rotate, + ), + ], + ), + body: !ready + ? Center( + child: _error != null + ? _InitErrorView(message: _error!) + : const CircularProgressIndicator(), + ) + : _step == _Step.corners + ? _buildCornersStep(context, imageSize, corners) + : _buildFilterStep(context), ), - body: !ready - ? Center( - child: _error != null - ? _InitErrorView(message: _error!) - : const CircularProgressIndicator(), - ) - : _step == _Step.corners - ? _buildCornersStep(context, imageSize, corners) - : _buildFilterStep(context), ); } @@ -459,9 +529,7 @@ class _CornerAdjustScreenState extends State { children: [ Expanded( child: OutlinedButton( - onPressed: _isProcessing - ? null - : () => setState(() => _step = _Step.corners), + onPressed: _isProcessing ? null : _leaveFilterStep, child: const Text('Back'), ), ), diff --git a/lib/screens/scanner_home_page.dart b/lib/screens/scanner_home_page.dart index dbaebde..bc83bc6 100644 --- a/lib/screens/scanner_home_page.dart +++ b/lib/screens/scanner_home_page.dart @@ -13,6 +13,7 @@ import 'package:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher.dart'; import '../models/scanned_page.dart'; +import '../services/draft_store.dart'; import '../services/image_metadata.dart'; import '../widgets/transient_message.dart'; import 'barcode_scan_screen.dart'; @@ -32,10 +33,12 @@ class ScannerHomePage extends StatefulWidget { super.key, this.initialPages = const [], this.sharePlus, + this.draftStore = const NoOpDraftStore(), }); final List initialPages; final SharePlus? sharePlus; + final DraftStore draftStore; @override State createState() => _ScannerHomePageState(); @@ -51,9 +54,14 @@ class _ScannerHomePageState extends State { late final List _pages; late final SharePlus _sharePlus; final ImagePicker _picker = ImagePicker(); + Future _draftWriteTail = Future.value(); + var _draftRevision = 0; + var _documentGeneration = 0; + var _undoGeneration = 0; final GlobalKey _shareButtonKey = GlobalKey(); bool _isGeneratingPdf = false; bool _isPickingImages = false; + bool _isClearingDraft = false; late bool _cameraSupported; @override @@ -62,6 +70,7 @@ class _ScannerHomePageState extends State { _pages = [...widget.initialPages]; _sharePlus = widget.sharePlus ?? SharePlus.instance; _cameraSupported = _picker.supportsImageSource(ImageSource.camera); + if (_pages.isEmpty) _draftWriteTail = _restoreDraft(); // Android can destroy MainActivity while the system picker/camera is in // front. image_picker stores that pending result for the restarted app, // but it is lost permanently unless retrieveLostData is called at startup. @@ -70,6 +79,57 @@ class _ScannerHomePageState extends State { } } + Future _restoreDraft() async { + final revision = _draftRevision; + try { + final restored = await widget.draftStore.load(); + if (!mounted || revision != _draftRevision || _pages.isNotEmpty) return; + var retainedBytes = 0; + for (var index = 0; index < restored.length; index++) { + final pageBytes = _pageMemoryBytes(restored[index]); + if (!canRetainDocument( + currentBytes: retainedBytes, + currentPages: index, + incomingBytes: pageBytes, + )) { + throw const _DocumentCapacityException(); + } + retainedBytes += pageBytes; + } + if (restored.isNotEmpty) setState(() => _pages.addAll(restored)); + } catch (_) { + if (mounted) _showMessage('Could not restore the saved draft.'); + } + } + + void _queueDraftSave() { + if (_isClearingDraft) return; + final snapshot = List.of(_pages); + _draftRevision++; + _draftWriteTail = _draftWriteTail.then((_) async { + try { + await widget.draftStore.save(snapshot); + } catch (_) { + if (mounted) _showMessage('Could not save the draft.'); + } + }); + } + + Future _queueDraftClear() { + _draftRevision++; + final clearOperation = _draftWriteTail.then((_) async { + try { + await widget.draftStore.clear(); + return true; + } catch (_) { + if (mounted) _showMessage('Could not clear the saved draft.'); + return false; + } + }); + _draftWriteTail = clearOperation.then((_) {}); + return clearOperation; + } + int _pageMemoryBytes(ScannedPage page) => page.originalBytes.length + (identical(page.originalBytes, page.processedBytes) @@ -91,7 +151,12 @@ class _ScannerHomePageState extends State { ); } - bool _tryAddPage(ScannedPage page) { + bool _tryAddPage(ScannedPage page, {required int documentGeneration}) { + if (_isClearingDraft || + !mounted || + documentGeneration != _documentGeneration) { + return false; + } if (!canRetainDocument( currentBytes: _retainedDocumentBytes, currentPages: _pages.length, @@ -101,6 +166,7 @@ class _ScannerHomePageState extends State { return false; } setState(() => _pages.add(page)); + _queueDraftSave(); return true; } @@ -160,7 +226,11 @@ class _ScannerHomePageState extends State { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.code, size: 18, color: theme.colorScheme.primary), + Icon( + Icons.code, + size: 18, + color: theme.colorScheme.primary, + ), const SizedBox(width: 8), Text( 'View source code', @@ -188,6 +258,7 @@ class _ScannerHomePageState extends State { } Future _recoverLostImages() async { + final documentGeneration = _documentGeneration; // Block a second picker request until startup recovery has completed; two // simultaneous results could otherwise push overlapping adjustment routes. _isPickingImages = true; @@ -212,6 +283,7 @@ class _ScannerHomePageState extends State { final result = await _addCapturedPhoto( files[index], deleteAfterRead: true, + documentGeneration: documentGeneration, ); if (result == _PhotoIntakeResult.capacityReached) { for (final remaining in files.skip(index + 1)) { @@ -227,9 +299,8 @@ class _ScannerHomePageState extends State { } } - // Best-effort cleanup: FOSScanner doesn't persist scanned pages, so the - // temp file image_picker writes on capture is deleted the moment we've - // read its bytes into memory. + // Best-effort cleanup: the picker temp file is no longer needed once its + // bytes are in app-managed memory (and, on native, queued for draft save). Future _deleteFileQuietly(String path) async { try { await File(path).delete(); @@ -244,11 +315,12 @@ class _ScannerHomePageState extends State { const {'camera-unavailable', 'no_available_camera'}.contains(error.code); Future _captureImage() async { - if (_isPickingImages) return; + if (_isPickingImages || _isClearingDraft) return; if (!_canStartImagePick) { _showDocumentLimit(); return; } + final documentGeneration = _documentGeneration; setState(() => _isPickingImages = true); try { final XFile? photo = await _picker.pickImage( @@ -258,7 +330,11 @@ class _ScannerHomePageState extends State { ); if (photo == null) return; // Camera capture writes a fresh file that's genuinely ours to delete. - await _addCapturedPhoto(photo, deleteAfterRead: true); + await _addCapturedPhoto( + photo, + deleteAfterRead: true, + documentGeneration: documentGeneration, + ); } catch (error) { if (_isDefinitiveCameraUnavailable(error) && mounted) { setState(() => _cameraSupported = false); @@ -270,11 +346,12 @@ class _ScannerHomePageState extends State { } Future _importFromGallery() async { - if (_isPickingImages) return; + if (_isPickingImages || _isClearingDraft) return; if (!_canStartImagePick) { _showDocumentLimit(); return; } + final documentGeneration = _documentGeneration; setState(() => _isPickingImages = true); try { final List photos = await _picker.pickMultiImage( @@ -283,6 +360,7 @@ class _ScannerHomePageState extends State { limit: maxDocumentPages - _pages.length, ); for (final photo in photos) { + if (_isClearingDraft) break; if (!_canStartImagePick) { _showDocumentLimit(); break; @@ -290,7 +368,11 @@ class _ScannerHomePageState extends State { // Unlike camera capture, a gallery pick's path isn't reliably an // app-owned temp copy across platforms — don't risk deleting a file // that might actually be the user's original photo. - final result = await _addCapturedPhoto(photo, deleteAfterRead: false); + final result = await _addCapturedPhoto( + photo, + deleteAfterRead: false, + documentGeneration: documentGeneration, + ); if (result == _PhotoIntakeResult.capacityReached) break; } } catch (_) { @@ -306,7 +388,13 @@ class _ScannerHomePageState extends State { Future<_PhotoIntakeResult> _addCapturedPhoto( XFile photo, { required bool deleteAfterRead, + required int documentGeneration, }) async { + if (_isClearingDraft || documentGeneration != _documentGeneration) { + if (deleteAfterRead) await _deleteFileQuietly(photo.path); + return _PhotoIntakeResult.skipped; + } + late final Uint8List bytes; try { try { @@ -353,6 +441,9 @@ class _ScannerHomePageState extends State { return _PhotoIntakeResult.skipped; } + if (_isClearingDraft || documentGeneration != _documentGeneration) { + return _PhotoIntakeResult.skipped; + } if (!canRetainDocument( currentBytes: _retainedDocumentBytes, currentPages: _pages.length, @@ -377,7 +468,11 @@ class _ScannerHomePageState extends State { _showMessage('Could not read this photo.'); return _PhotoIntakeResult.skipped; } - if (!mounted) return _PhotoIntakeResult.skipped; + if (!mounted || + _isClearingDraft || + documentGeneration != _documentGeneration) { + return _PhotoIntakeResult.skipped; + } if (kIsWeb) { // opencv_dart doesn't support web; use the photo as-is rather than @@ -388,6 +483,7 @@ class _ScannerHomePageState extends State { corners: const [], processedBytes: bytes, ), + documentGeneration: documentGeneration, ); return added ? _PhotoIntakeResult.added @@ -399,17 +495,23 @@ class _ScannerHomePageState extends State { builder: (_) => CornerAdjustScreen(originalBytes: bytes), ), ); - if (result == null || !mounted) return _PhotoIntakeResult.skipped; - return _tryAddPage(result) + if (result == null || + !mounted || + _isClearingDraft || + documentGeneration != _documentGeneration) { + return _PhotoIntakeResult.skipped; + } + return _tryAddPage(result, documentGeneration: documentGeneration) ? _PhotoIntakeResult.added : _PhotoIntakeResult.capacityReached; } Future _editPage(int index) async { // No detect/adjust flow on web (see _addCapturedPhoto) — nothing to edit. - if (kIsWeb) return; + if (kIsWeb || _isClearingDraft || index >= _pages.length) return; final page = _pages[index]; + final documentGeneration = _documentGeneration; final result = await Navigator.of(context).push( MaterialPageRoute( builder: (_) => CornerAdjustScreen( @@ -424,6 +526,8 @@ class _ScannerHomePageState extends State { ); if (result != null && mounted && + !_isClearingDraft && + documentGeneration == _documentGeneration && index < _pages.length && identical(_pages[index], page)) { if (!canReplaceDocumentPage( @@ -436,32 +540,141 @@ class _ScannerHomePageState extends State { return; } setState(() => _pages[index] = result); + _queueDraftSave(); } } void _removePage(int index) { + if (_isClearingDraft || index < 0 || index >= _pages.length) return; + final removed = _pages[index]; + final undoGeneration = ++_undoGeneration; + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); setState(() => _pages.removeAt(index)); + _queueDraftSave(); + messenger.showSnackBar( + SnackBar( + content: const Text('Page deleted.'), + action: SnackBarAction( + label: 'Undo', + onPressed: () { + if (!mounted || + _isClearingDraft || + undoGeneration != _undoGeneration) { + return; + } + if (!canRetainDocument( + currentBytes: _retainedDocumentBytes, + currentPages: _pages.length, + incomingBytes: _pageMemoryBytes(removed), + )) { + _showDocumentLimit(); + return; + } + final restoredIndex = index > _pages.length ? _pages.length : index; + setState(() => _pages.insert(restoredIndex, removed)); + _queueDraftSave(); + }, + ), + ), + ); } void _reorderPage(int fromIndex, int toIndex) { - if (fromIndex == toIndex) return; + if (_isClearingDraft || + fromIndex < 0 || + fromIndex >= _pages.length || + toIndex < 0 || + toIndex >= _pages.length || + fromIndex == toIndex) { + return; + } setState(() { final page = _pages.removeAt(fromIndex); _pages.insert(toIndex, page); }); + _queueDraftSave(); + } + + Future _clearPages() async { + if (_isClearingDraft) return; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Clear all pages?'), + content: const Text('This removes every page from the current draft.'), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('Clear all'), + ), + ], + ), + ); + if (confirmed != true || !mounted || _isClearingDraft) return; + await _clearCurrentDraft(); + } + + Future _clearCurrentDraft() async { + if (_isClearingDraft) return; + _documentGeneration++; + setState(() => _isClearingDraft = true); + + var cleared = false; + try { + cleared = await _queueDraftClear(); + } catch (_) { + if (mounted) _showMessage('Could not clear the saved draft.'); + } + if (!mounted) return; + + if (cleared) { + _undoGeneration++; + ScaffoldMessenger.of(context).clearSnackBars(); + } + setState(() { + if (cleared) _pages.clear(); + _isClearingDraft = false; + }); } - void _clearPages() { - setState(() => _pages.clear()); + Future _askWhetherToKeepDraft() async { + if (_isClearingDraft || _pages.isEmpty) return; + final clear = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Keep this draft?'), + content: const Text( + 'The PDF was shared. You can keep these pages for later or clear the draft now.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Keep draft'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('Clear draft'), + ), + ], + ), + ); + if (clear != true || !mounted || _isClearingDraft) return; + await _clearCurrentDraft(); } Future _generateAndSharePdf() async { - if (_pages.isEmpty) return; + if (_pages.isEmpty || _isClearingDraft) return; setState(() { _isGeneratingPdf = true; }); + var shared = false; try { // Capture the iPad popover anchor before PDF encoding yields; the page // list can change while encoding, which may remove the share button. @@ -499,7 +712,7 @@ class _ScannerHomePageState extends State { final fileName = 'FOSScanner_${DateTime.now().millisecondsSinceEpoch}.pdf'; - await _sharePlus.share( + final shareResult = await _sharePlus.share( ShareParams( files: [ XFile.fromData( @@ -519,6 +732,10 @@ class _ScannerHomePageState extends State { downloadFallbackEnabled: true, ), ); + // Some platforms cannot report a result and return `unavailable` even + // after presenting the share UI. Only an explicit dismissal means the + // user definitely did not share or download the PDF. + shared = shareResult.status != ShareResultStatus.dismissed; } catch (e) { if (mounted) { ScaffoldMessenger.of( @@ -532,6 +749,9 @@ class _ScannerHomePageState extends State { }); } } + if (shared && mounted && !_isClearingDraft && _pages.isNotEmpty) { + await _askWhetherToKeepDraft(); + } } @override @@ -554,15 +774,21 @@ class _ScannerHomePageState extends State { ), IconButton( icon: const Icon(Icons.photo_library_outlined), - onPressed: _isPickingImages || !_canStartImagePick + onPressed: + _isPickingImages || _isClearingDraft || !_canStartImagePick ? null : _importFromGallery, tooltip: 'Import from gallery', ), if (_pages.isNotEmpty) IconButton( - icon: const Icon(Icons.clear_all), - onPressed: _clearPages, + icon: _isClearingDraft + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.clear_all), + onPressed: _isClearingDraft ? null : _clearPages, tooltip: 'Clear all', ), ], @@ -615,7 +841,7 @@ class _ScannerHomePageState extends State { final card = Card( clipBehavior: Clip.antiAlias, child: InkWell( - onTap: () => _editPage(index), + onTap: _isClearingDraft ? null : () => _editPage(index), child: Stack( fit: StackFit.expand, children: [ @@ -659,7 +885,9 @@ class _ScannerHomePageState extends State { color: Colors.white, ), tooltip: 'Delete page ${index + 1}', - onPressed: () => _removePage(index), + onPressed: _isClearingDraft + ? null + : () => _removePage(index), ), ), ), @@ -671,13 +899,15 @@ class _ScannerHomePageState extends State { // Drag-to-reorder: long-press a page to pick it up, drop it // on another page's slot to swap it into that position. return DragTarget( - onWillAcceptWithDetails: (details) => details.data != index, + onWillAcceptWithDetails: (details) => + !_isClearingDraft && details.data != index, onAcceptWithDetails: (details) => _reorderPage(details.data, index), builder: (context, candidateData, rejectedData) { final isDropTarget = candidateData.isNotEmpty; return LongPressDraggable( data: index, + maxSimultaneousDrags: _isClearingDraft ? 0 : 1, feedback: SizedBox( width: 140, height: 200, @@ -706,7 +936,8 @@ class _ScannerHomePageState extends State { ), floatingActionButton: _cameraSupported ? FloatingActionButton( - onPressed: _isPickingImages || !_canStartImagePick + onPressed: + _isPickingImages || _isClearingDraft || !_canStartImagePick ? null : _captureImage, tooltip: 'Capture Image', @@ -725,8 +956,10 @@ class _ScannerHomePageState extends State { style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16), ), - onPressed: _isGeneratingPdf ? null : _generateAndSharePdf, - icon: _isGeneratingPdf + onPressed: _isGeneratingPdf || _isClearingDraft + ? null + : _generateAndSharePdf, + icon: _isGeneratingPdf || _isClearingDraft ? const SizedBox( width: 24, height: 24, @@ -734,7 +967,9 @@ class _ScannerHomePageState extends State { ) : const Icon(Icons.picture_as_pdf), label: Text( - _isGeneratingPdf + _isClearingDraft + ? 'Clearing draft...' + : _isGeneratingPdf ? 'Generating PDF...' : 'Save as PDF (${_pages.length} pages)', style: const TextStyle(fontSize: 16), diff --git a/lib/services/draft_store.dart b/lib/services/draft_store.dart new file mode 100644 index 0000000..3890508 --- /dev/null +++ b/lib/services/draft_store.dart @@ -0,0 +1,4 @@ +export 'draft_store_base.dart'; +export 'draft_store_native.dart' + if (dart.library.js_interop) 'draft_store_web.dart' + show createDraftStore; diff --git a/lib/services/draft_store_base.dart b/lib/services/draft_store_base.dart new file mode 100644 index 0000000..609acd8 --- /dev/null +++ b/lib/services/draft_store_base.dart @@ -0,0 +1,23 @@ +import '../models/scanned_page.dart'; + +/// Persistence boundary for an in-progress scan. +abstract interface class DraftStore { + Future> load(); + + Future save(List pages); + + Future clear(); +} + +class NoOpDraftStore implements DraftStore { + const NoOpDraftStore(); + + @override + Future> load() async => const []; + + @override + Future save(List pages) async {} + + @override + Future clear() async {} +} diff --git a/lib/services/draft_store_native.dart b/lib/services/draft_store_native.dart new file mode 100644 index 0000000..233a0dc --- /dev/null +++ b/lib/services/draft_store_native.dart @@ -0,0 +1,344 @@ +import 'dart:async'; +import 'dart:convert' hide Codec; +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:path_provider/path_provider.dart'; + +import '../models/scanned_page.dart'; +import 'draft_store_base.dart'; +import 'image_metadata.dart'; + +const _schemaVersion = 1; +const _maxManifestBytes = 1024 * 1024; +const _manifestName = 'manifest.json'; + +enum DraftSaveStage { afterStaging, afterCurrentMoved } + +typedef DraftSaveHook = FutureOr Function(DraftSaveStage stage); + +class _DraftPageFiles { + const _DraftPageFiles({ + required this.original, + required this.processed, + required this.corners, + required this.filter, + required this.rotation, + required this.brightness, + required this.contrast, + }); + + final File original; + final File processed; + final List corners; + final PageFilter filter; + final int rotation; + final double brightness; + final double contrast; +} + +DraftStore createDraftStore() => FileDraftStore(); + +/// Native, app-private cache for the current in-progress document. +/// +/// [directory] is the draft root itself when supplied. It exists so tests can +/// exercise the real filesystem protocol without invoking path_provider. +class FileDraftStore implements DraftStore { + FileDraftStore({this.directory, this.beforeCommit}); + + final Directory? directory; + final DraftSaveHook? beforeCommit; + + Future _root() async { + final supplied = directory; + if (supplied != null) return supplied; + final cache = await getApplicationCacheDirectory(); + return Directory('${cache.path}${Platform.pathSeparator}draft'); + } + + @override + Future> load() async { + final root = await _root(); + final current = Directory('${root.path}${Platform.pathSeparator}current'); + final backup = Directory('${root.path}${Platform.pathSeparator}backup'); + try { + final pages = await _loadDirectory(current); + if (pages != null) return pages; + } catch (_) { + // A partial write, unsupported schema, or corrupt image is not fatal. + } + + try { + final pages = await _loadDirectory(backup); + if (pages == null) return const []; + // Keep the recovered generation in the current slot. A subsequent save + // can then move it to backup before committing its replacement. + if (await current.exists()) await current.delete(recursive: true); + await backup.rename(current.path); + return pages; + } catch (_) { + // Leave any valid backup in place if promotion fails so a later restart + // can retry recovery rather than accepting a partial generation. + return const []; + } + } + + Future?> _loadDirectory(Directory directory) async { + if (!await directory.exists()) return null; + final manifestFile = File( + '${directory.path}${Platform.pathSeparator}$_manifestName', + ); + if (!await manifestFile.exists() || + await manifestFile.length() > _maxManifestBytes) { + return null; + } + + final decoded = jsonDecode(await manifestFile.readAsString()); + if (decoded is! Map || + decoded['schemaVersion'] != _schemaVersion || + decoded['pages'] is! List) { + return null; + } + + final entries = decoded['pages']! as List; + if (entries.length > maxDocumentPages) return null; + final pageFiles = <_DraftPageFiles>[]; + var retainedBytes = 0; + for (var index = 0; index < entries.length; index++) { + final entry = entries[index]; + if (entry is! Map) return null; + final corners = _decodeCorners(entry['corners']); + final filter = _decodeFilter(entry['filter']); + final rotation = entry['rotationQuarterTurns']; + final brightness = _finiteDouble(entry['brightness']); + final contrast = _finiteDouble(entry['contrast']); + if (corners == null || + filter == null || + rotation is! int || + brightness == null || + contrast == null || + !_validPageMetadata( + corners, + rotation: rotation, + brightness: brightness, + contrast: contrast, + )) { + return null; + } + + final original = File( + '${directory.path}${Platform.pathSeparator}page_${index}_original.bin', + ); + final processed = File( + '${directory.path}${Platform.pathSeparator}page_${index}_processed.bin', + ); + if (!await original.exists() || !await processed.exists()) return null; + final originalLength = await original.length(); + final processedLength = await processed.length(); + final pageBytes = originalLength + processedLength; + if (!canRetainDocument( + currentBytes: retainedBytes, + currentPages: index, + incomingBytes: pageBytes, + )) { + return null; + } + retainedBytes += pageBytes; + if (originalLength == 0 || processedLength == 0) return null; + pageFiles.add( + _DraftPageFiles( + original: original, + processed: processed, + corners: corners, + filter: filter, + rotation: rotation, + brightness: brightness, + contrast: contrast, + ), + ); + } + + final pages = []; + for (final files in pageFiles) { + final originalBytes = await files.original.readAsBytes(); + final processedBytes = await files.processed.readAsBytes(); + validateSourceImageSize(await readEncodedImageSize(originalBytes)); + validateSourceImageSize(await readEncodedImageSize(processedBytes)); + await _decodeFirstFrame(originalBytes); + await _decodeFirstFrame(processedBytes); + pages.add( + ScannedPage( + originalBytes: originalBytes, + processedBytes: processedBytes, + corners: files.corners, + filter: files.filter, + rotationQuarterTurns: files.rotation, + brightness: files.brightness, + contrast: files.contrast, + ), + ); + } + return pages; + } + + Future _decodeFirstFrame(Uint8List bytes) async { + Codec? codec; + FrameInfo? frame; + try { + codec = await instantiateImageCodec( + bytes, + targetWidth: 1, + targetHeight: 1, + ); + final decodedFrame = await codec.getNextFrame(); + frame = decodedFrame; + if (decodedFrame.image.width <= 0 || decodedFrame.image.height <= 0) { + throw const FormatException('Decoded image frame is empty'); + } + } finally { + frame?.image.dispose(); + codec?.dispose(); + } + } + + List? _decodeCorners(Object? value) { + if (value is! List) return null; + final corners = []; + for (final encoded in value) { + if (encoded is! List || encoded.length != 2) return null; + final x = _finiteDouble(encoded[0]); + final y = _finiteDouble(encoded[1]); + if (x == null || y == null) return null; + corners.add(Offset(x, y)); + } + return corners; + } + + bool _validPageMetadata( + List corners, { + required int rotation, + required double brightness, + required double contrast, + }) => + corners.length == 4 && + corners.toSet().length == 4 && + corners.every((corner) => corner.dx.isFinite && corner.dy.isFinite) && + rotation >= 0 && + rotation <= 3 && + brightness >= -100 && + brightness <= 100 && + contrast >= 0.5 && + contrast <= 2; + + PageFilter? _decodeFilter(Object? value) { + if (value is! String) return null; + for (final filter in PageFilter.values) { + if (filter.name == value) return filter; + } + return null; + } + + double? _finiteDouble(Object? value) { + if (value is! num) return null; + final result = value.toDouble(); + return result.isFinite ? result : null; + } + + @override + Future save(List pages) async { + if (pages.length > maxDocumentPages) { + throw const FormatException('Draft has too many pages'); + } + var retainedBytes = 0; + for (var index = 0; index < pages.length; index++) { + final page = pages[index]; + final pageBytes = page.originalBytes.length + page.processedBytes.length; + if (page.originalBytes.isEmpty || + page.processedBytes.isEmpty || + !_validPageMetadata( + page.corners, + rotation: page.rotationQuarterTurns, + brightness: page.brightness, + contrast: page.contrast, + ) || + !canRetainDocument( + currentBytes: retainedBytes, + currentPages: index, + incomingBytes: pageBytes, + )) { + throw const FormatException('Draft page is invalid'); + } + retainedBytes += pageBytes; + } + + final root = await _root(); + await root.create(recursive: true); + final staging = Directory('${root.path}${Platform.pathSeparator}staging'); + final current = Directory('${root.path}${Platform.pathSeparator}current'); + final backup = Directory('${root.path}${Platform.pathSeparator}backup'); + + if (await staging.exists()) await staging.delete(recursive: true); + await staging.create(); + var currentMoved = false; + try { + final manifestPages = >[]; + for (var index = 0; index < pages.length; index++) { + final page = pages[index]; + await File( + '${staging.path}${Platform.pathSeparator}page_${index}_original.bin', + ).writeAsBytes(page.originalBytes, flush: true); + await File( + '${staging.path}${Platform.pathSeparator}page_${index}_processed.bin', + ).writeAsBytes(page.processedBytes, flush: true); + manifestPages.add({ + 'corners': [ + for (final corner in page.corners) [corner.dx, corner.dy], + ], + 'filter': page.filter.name, + 'rotationQuarterTurns': page.rotationQuarterTurns, + 'brightness': page.brightness, + 'contrast': page.contrast, + }); + } + await File( + '${staging.path}${Platform.pathSeparator}$_manifestName', + ).writeAsString( + jsonEncode({'schemaVersion': _schemaVersion, 'pages': manifestPages}), + flush: true, + ); + await beforeCommit?.call(DraftSaveStage.afterStaging); + + if (await backup.exists()) await backup.delete(recursive: true); + if (await current.exists()) { + await current.rename(backup.path); + currentMoved = true; + } + await beforeCommit?.call(DraftSaveStage.afterCurrentMoved); + await staging.rename(current.path); + } catch (_) { + if (currentMoved && !await current.exists() && await backup.exists()) { + await backup.rename(current.path); + } + rethrow; + } finally { + if (await staging.exists()) { + try { + await staging.delete(recursive: true); + } catch (_) { + // A stale staging directory is ignored and replaced on the next save. + } + } + } + } + + @override + Future clear() async { + final root = await _root(); + if (!await root.exists()) return; + for (final name in const ['staging', 'current', 'backup']) { + final directory = Directory('${root.path}${Platform.pathSeparator}$name'); + if (await directory.exists()) await directory.delete(recursive: true); + } + } +} diff --git a/lib/services/draft_store_web.dart b/lib/services/draft_store_web.dart new file mode 100644 index 0000000..d4887b8 --- /dev/null +++ b/lib/services/draft_store_web.dart @@ -0,0 +1,18 @@ +import '../models/scanned_page.dart'; +import 'draft_store_base.dart'; + +/// Web intentionally keeps drafts in memory only. +DraftStore createDraftStore() => const WebNoOpDraftStore(); + +class WebNoOpDraftStore implements DraftStore { + const WebNoOpDraftStore(); + + @override + Future> load() async => const []; + + @override + Future save(List pages) async {} + + @override + Future clear() async {} +} diff --git a/pubspec.lock b/pubspec.lock index bdd378c..707edaf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -561,7 +561,7 @@ packages: source: hosted version: "1.1.0" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 diff --git a/pubspec.yaml b/pubspec.yaml index e10e36c..be32e78 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,6 +15,7 @@ dependencies: share_plus: ^13.3.0 opencv_dart: ^2.2.1 package_info_plus: ^10.2.1 + path_provider: ^2.1.6 url_launcher: ^6.3.2 flutter_zxing: ^2.3.0 diff --git a/test/platform_configuration_test.dart b/test/platform_configuration_test.dart index 646aa94..5837618 100644 --- a/test/platform_configuration_test.dart +++ b/test/platform_configuration_test.dart @@ -206,6 +206,31 @@ String _runner(_WorkflowJob job) => RegExp( ).firstMatch(job.source)!.group(1)!; void main() { + test('Android excludes app-private drafts from OS backup', () { + final manifest = File( + 'android/app/src/main/AndroidManifest.xml', + ).readAsStringSync(); + + expect( + manifest, + matches( + RegExp( + r' line.trim().replaceAll(RegExp(r'^/|/$'), '')) - .toSet(); + final gitIgnored = _activeLines( + File('.gitignore').readAsStringSync(), + ).map((line) => line.trim().replaceAll(RegExp(r'^/|/$'), '')).toSet(); expect(gitIgnored, containsAll({'docker-output', '.clean-check-tmp'})); final compose = File('docker-compose.yml').readAsStringSync(); @@ -543,74 +568,77 @@ void main() { ); }); - test('release preflight validates the tag before the release environment', () { - final release = File('.github/workflows/release.yml').readAsStringSync(); - expect( - _yamlListValues(release, 'tags'), - equals(['v[0-9]*.[0-9]*.[0-9]*']), - ); + test( + 'release preflight validates the tag before the release environment', + () { + final release = File('.github/workflows/release.yml').readAsStringSync(); + expect( + _yamlListValues(release, 'tags'), + equals(['v[0-9]*.[0-9]*.[0-9]*']), + ); - final jobs = _workflowJobs(release); - expect(jobs.keys, equals(['preflight', 'build-and-release'])); - final preflight = jobs['preflight']!; - final build = jobs['build-and-release']!; - expect(preflight.source, isNot(contains('environment:'))); - expect(preflight.source, isNot(contains(r'${{ secrets.'))); - expect( - build.source, - matches(RegExp(r'^\s+needs:\s*preflight\s*$', multiLine: true)), - ); - expect( - build.source, - matches(RegExp(r'^\s+environment:\s*release\s*$', multiLine: true)), - ); + final jobs = _workflowJobs(release); + expect(jobs.keys, equals(['preflight', 'build-and-release'])); + final preflight = jobs['preflight']!; + final build = jobs['build-and-release']!; + expect(preflight.source, isNot(contains('environment:'))); + expect(preflight.source, isNot(contains(r'${{ secrets.'))); + expect( + build.source, + matches(RegExp(r'^\s+needs:\s*preflight\s*$', multiLine: true)), + ); + expect( + build.source, + matches(RegExp(r'^\s+environment:\s*release\s*$', multiLine: true)), + ); - final preflightCommands = _workflowRunCommands( - preflight.source, - ).join('\n'); - const exactSemverPattern = - r'^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'; - expect(preflightCommands, contains(exactSemverPattern)); - final exactSemver = RegExp(exactSemverPattern); - for (final tag in ['v0.0.0', 'v1.2.2', 'v10.20.30']) { - expect(exactSemver.hasMatch(tag), isTrue, reason: tag); - } - for (final tag in ['v01.2.2', 'v1.2', 'v1.2.2-rc.1', 'v1.2.2+5']) { - expect(exactSemver.hasMatch(tag), isFalse, reason: tag); - } - expect( - preflightCommands, - contains(r'git merge-base --is-ancestor "$GITHUB_SHA" "origin/main"'), - ); - expect(preflightCommands, contains('pubspec.yaml')); - expect(preflightCommands, contains(r'${pubspec_version%%+*}')); - expect( - preflightCommands, - contains(r'"$GITHUB_REF_NAME" != "v$release_version"'), - ); - for (final command in [ - 'flutter pub get', - 'flutter analyze', - 'flutter test', - ]) { - expect(preflightCommands, contains(command), reason: command); - } + final preflightCommands = _workflowRunCommands( + preflight.source, + ).join('\n'); + const exactSemverPattern = + r'^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'; + expect(preflightCommands, contains(exactSemverPattern)); + final exactSemver = RegExp(exactSemverPattern); + for (final tag in ['v0.0.0', 'v1.2.2', 'v10.20.30']) { + expect(exactSemver.hasMatch(tag), isTrue, reason: tag); + } + for (final tag in ['v01.2.2', 'v1.2', 'v1.2.2-rc.1', 'v1.2.2+5']) { + expect(exactSemver.hasMatch(tag), isFalse, reason: tag); + } + expect( + preflightCommands, + contains(r'git merge-base --is-ancestor "$GITHUB_SHA" "origin/main"'), + ); + expect(preflightCommands, contains('pubspec.yaml')); + expect(preflightCommands, contains(r'${pubspec_version%%+*}')); + expect( + preflightCommands, + contains(r'"$GITHUB_REF_NAME" != "v$release_version"'), + ); + for (final command in [ + 'flutter pub get', + 'flutter analyze', + 'flutter test', + ]) { + expect(preflightCommands, contains(command), reason: command); + } - final preflightCheckout = _workflowSteps( - preflight.source, - ).singleWhere((step) => step.action == 'actions/checkout'); - expect( - preflightCheckout.source, - matches(RegExp(r'^\s+fetch-depth:\s*0\s*$', multiLine: true)), - ); - final buildCheckout = _workflowSteps( - build.source, - ).singleWhere((step) => step.action == 'actions/checkout'); - expect( - buildCheckout.source, - contains(r'ref: ${{ needs.preflight.outputs.validated-sha }}'), - ); - }); + final preflightCheckout = _workflowSteps( + preflight.source, + ).singleWhere((step) => step.action == 'actions/checkout'); + expect( + preflightCheckout.source, + matches(RegExp(r'^\s+fetch-depth:\s*0\s*$', multiLine: true)), + ); + final buildCheckout = _workflowSteps( + build.source, + ).singleWhere((step) => step.action == 'actions/checkout'); + expect( + buildCheckout.source, + contains(r'ref: ${{ needs.preflight.outputs.validated-sha }}'), + ); + }, + ); test('release attests every APK and fails if publishing matches nothing', () { final release = File('.github/workflows/release.yml').readAsStringSync(); @@ -675,9 +703,8 @@ void main() { final jobs = _workflowJobs(source); _WorkflowJob jobWith(String command) => jobs.values.singleWhere( - (job) => _workflowRunCommands( - job.source, - ).any((run) => run.contains(command)), + (job) => + _workflowRunCommands(job.source).any((run) => run.contains(command)), orElse: () => throw TestFailure('No CI job runs `$command`'), ); diff --git a/test/screens/corner_adjust_navigation_test.dart b/test/screens/corner_adjust_navigation_test.dart new file mode 100644 index 0000000..04105ea --- /dev/null +++ b/test/screens/corner_adjust_navigation_test.dart @@ -0,0 +1,160 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fosscanner/models/scanned_page.dart'; +import 'package:fosscanner/screens/corner_adjust_screen.dart'; + +class _Operations implements CornerAdjustOperations { + _Operations(this.bytes, {this.exportCompleter}); + + final Uint8List bytes; + final Completer? exportCompleter; + + @override + Future decodeSize(Uint8List imageBytes) async => const Size(100, 100); + + @override + Future> buildPreviews( + Uint8List imageBytes, + List corners, + ) async => {for (final filter in PageFilter.values) filter: bytes}; + + @override + Future processForExport( + Uint8List imageBytes, + List corners, { + required PageFilter filter, + required int rotationQuarterTurns, + required double brightness, + required double contrast, + }) => exportCompleter?.future ?? Future.value(bytes); +} + +void main() { + late Uint8List imageBytes; + + setUp(() async { + final data = await rootBundle.load('assets/icon/icon.png'); + imageBytes = data.buffer.asUint8List( + data.offsetInBytes, + data.lengthInBytes, + ); + }); + + const corners = [Offset(5, 5), Offset(95, 5), Offset(95, 95), Offset(5, 95)]; + + Future openAdjuster( + WidgetTester tester, + CornerAdjustOperations operations, + Uint8List originalBytes, { + ValueChanged? onResult, + }) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: FilledButton( + onPressed: () async { + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => CornerAdjustScreen( + originalBytes: originalBytes, + initialCorners: corners, + operations: operations, + ), + ), + ); + onResult?.call(result); + }, + child: const Text('Open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + expect(find.text('Edit page'), findsOneWidget); + } + + testWidgets('filter navigation uses removable local history', (tester) async { + await openAdjuster(tester, _Operations(imageBytes), imageBytes); + + await tester.tap(find.text('Next')); + await tester.pump(); + expect(find.text('Preview'), findsOneWidget); + expect( + ModalRoute.of( + tester.element(find.text('Preview')), + )!.willHandlePopInternally, + isTrue, + ); + + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(find.text('Edit page'), findsOneWidget); + expect(find.text('Open'), findsNothing); + expect( + ModalRoute.of( + tester.element(find.text('Edit page')), + )!.willHandlePopInternally, + isFalse, + ); + + await tester.tap(find.text('Next')); + await tester.pump(); + await tester.tap(find.widgetWithText(OutlinedButton, 'Back')); + await tester.pump(); + expect(find.text('Edit page'), findsOneWidget); + expect( + ModalRoute.of( + tester.element(find.text('Edit page')), + )!.willHandlePopInternally, + isFalse, + ); + + await tester.tap(find.text('Next')); + await tester.pump(); + await tester.tap(find.byTooltip('Back')); + await tester.pump(); + expect(find.text('Edit page'), findsOneWidget); + + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(find.text('Edit page'), findsNothing); + expect(find.text('Open'), findsOneWidget); + }); + + testWidgets('route pop is blocked while final processing is active', ( + tester, + ) async { + final completer = Completer(); + ScannedPage? result; + await openAdjuster( + tester, + _Operations(imageBytes, exportCompleter: completer), + imageBytes, + onResult: (value) => result = value, + ); + await tester.tap(find.text('Next')); + await tester.pump(); + + await tester.tap(find.text('Confirm')); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsWidgets); + + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(find.text('Preview'), findsOneWidget); + expect(find.text('Open'), findsNothing); + + completer.complete(imageBytes); + await tester.pumpAndSettle(); + expect(find.text('Open'), findsOneWidget); + expect(find.text('Preview'), findsNothing); + expect(result, isNotNull); + expect(result!.processedBytes, orderedEquals(imageBytes)); + }); +} diff --git a/test/screens/scanner_home_draft_test.dart b/test/screens/scanner_home_draft_test.dart new file mode 100644 index 0000000..9384ff9 --- /dev/null +++ b/test/screens/scanner_home_draft_test.dart @@ -0,0 +1,401 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image_picker_platform_interface/image_picker_platform_interface.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:share_plus_platform_interface/share_plus_platform_interface.dart'; + +import 'package:fosscanner/models/scanned_page.dart'; +import 'package:fosscanner/screens/scanner_home_page.dart'; +import 'package:fosscanner/services/draft_store.dart'; + +class _DraftStore implements DraftStore { + _DraftStore({ + Future>? loaded, + this.clearCompleter, + this.clearError, + }) : loaded = loaded ?? Future.value(const []); + + final Future> loaded; + final Completer? clearCompleter; + final Object? clearError; + final saves = >[]; + var clearCalls = 0; + + @override + Future> load() => loaded; + + @override + Future save(List pages) async { + saves.add(List.of(pages)); + } + + @override + Future clear() async { + clearCalls++; + if (clearError case final error?) throw error; + final completer = clearCompleter; + if (completer != null) await completer.future; + } +} + +class _SerialStore extends _DraftStore { + _SerialStore({super.clearCompleter}); + + final firstSave = Completer(); + + @override + Future save(List pages) async { + saves.add(List.of(pages)); + if (saves.length == 1) await firstSave.future; + } +} + +class _ImagePickerPlatform extends ImagePickerPlatform { + _ImagePickerPlatform({this.multiImageCompleter}); + + final Completer>? multiImageCompleter; + + @override + bool supportsImageSource(ImageSource source) => false; + + @override + Future getLostData() async => LostDataResponse.empty(); + + @override + Future> getMultiImageWithOptions({ + MultiImagePickerOptions options = const MultiImagePickerOptions(), + }) => multiImageCompleter?.future ?? Future.value(const []); +} + +class _SharePlatform implements SharePlatform { + var calls = 0; + + @override + Future share(ShareParams params) async { + calls++; + return const ShareResult('shared', ShareResultStatus.success); + } +} + +void main() { + late ImagePickerPlatform originalImagePicker; + late Uint8List imageBytes; + + setUp(() async { + originalImagePicker = ImagePickerPlatform.instance; + ImagePickerPlatform.instance = _ImagePickerPlatform(); + final data = await rootBundle.load('assets/icon/icon.png'); + imageBytes = data.buffer.asUint8List( + data.offsetInBytes, + data.lengthInBytes, + ); + }); + + tearDown(() { + ImagePickerPlatform.instance = originalImagePicker; + }); + + ScannedPage page(int marker) => ScannedPage( + originalBytes: Uint8List.fromList(imageBytes), + processedBytes: Uint8List.fromList(imageBytes), + corners: [ + Offset(marker.toDouble(), 0), + const Offset(10, 0), + const Offset(10, 10), + const Offset(0, 10), + ], + ); + + Future pumpHome( + WidgetTester tester, + DraftStore store, { + List pages = const [], + SharePlus? sharePlus, + }) => tester.pumpWidget( + MaterialApp( + home: ScannerHomePage( + initialPages: pages, + draftStore: store, + sharePlus: sharePlus, + ), + ), + ); + + IconButton iconButton(WidgetTester tester, String tooltip) => + tester.widget( + find.ancestor( + of: find.byTooltip(tooltip), + matching: find.byType(IconButton), + ), + ); + + Future tapDelete(WidgetTester tester, int pageNumber) async { + final delete = find.byTooltip('Delete page $pageNumber'); + await tester.ensureVisible(delete); + await tester.pump(); + await tester.tap(delete); + await tester.pump(); + } + + testWidgets('restores a saved draft asynchronously at startup', ( + tester, + ) async { + final completer = Completer>(); + final store = _DraftStore(loaded: completer.future); + final restored = [page(1), page(2)]; + + await pumpHome(tester, store); + expect(find.text('Ready to Scan'), findsOneWidget); + + completer.complete(restored); + await tester.pump(); + + expect(find.text('Save as PDF (2 pages)'), findsOneWidget); + expect(find.byTooltip('Delete page 1'), findsOneWidget); + expect(find.byTooltip('Delete page 2'), findsOneWidget); + expect(store.saves, isEmpty); + }); + + testWidgets('delete Undo restores the page at its exact index', ( + tester, + ) async { + final store = _DraftStore(); + final first = page(1); + final second = page(2); + final third = page(3); + await pumpHome(tester, store, pages: [first, second, third]); + + await tapDelete(tester, 2); + expect(store.saves.single, orderedEquals([first, third])); + expect(find.text('Page deleted.'), findsOneWidget); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(SnackBarAction, 'Undo')); + await tester.pump(); + + expect(store.saves.last, orderedEquals([first, second, third])); + expect(find.byTooltip('Delete page 2'), findsOneWidget); + }); + + testWidgets('two rapid deletes expose only the newest Undo', (tester) async { + final store = _DraftStore(); + final first = page(1); + final second = page(2); + final third = page(3); + await pumpHome(tester, store, pages: [first, second, third]); + + await tapDelete(tester, 1); + await tapDelete(tester, 1); + + expect(find.text('Page deleted.'), findsOneWidget); + expect(find.widgetWithText(SnackBarAction, 'Undo'), findsOneWidget); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(SnackBarAction, 'Undo')); + await tester.pump(); + + expect(store.saves.last, orderedEquals([second, third])); + }); + + testWidgets('snapshot writes are serialized in mutation order', ( + tester, + ) async { + final store = _SerialStore(); + final first = page(1); + final second = page(2); + final third = page(3); + await pumpHome(tester, store, pages: [first, second, third]); + + await tapDelete(tester, 1); + expect(store.saves, hasLength(1)); + expect(store.saves.single, orderedEquals([second, third])); + + await tapDelete(tester, 1); + expect(store.saves, hasLength(1)); + + store.firstSave.complete(); + await tester.pump(); + await tester.pump(); + expect(store.saves, hasLength(2)); + expect(store.saves.last, orderedEquals([third])); + }); + + testWidgets('Clear all requires confirmation and clears persisted data', ( + tester, + ) async { + final store = _DraftStore(); + await pumpHome(tester, store, pages: [page(1), page(2)]); + + await tester.tap(find.byTooltip('Clear all')); + await tester.pumpAndSettle(); + expect(find.text('Clear all pages?'), findsOneWidget); + expect(store.clearCalls, 0); + expect(find.text('Save as PDF (2 pages)'), findsOneWidget); + + await tester.tap(find.widgetWithText(TextButton, 'Cancel')); + await tester.pumpAndSettle(); + expect(store.clearCalls, 0); + + await tester.tap(find.byTooltip('Clear all')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Clear all')); + await tester.pump(); + await tester.pump(); + + expect(find.text('Ready to Scan'), findsOneWidget); + expect(store.clearCalls, 1); + }); + + testWidgets( + 'Clear all blocks delete, reorder, and add while queued save and clear wait', + (tester) async { + final clearCompleter = Completer(); + final store = _SerialStore(clearCompleter: clearCompleter); + await pumpHome(tester, store, pages: [page(1), page(2), page(3)]); + + await tapDelete(tester, 1); + expect(store.saves, hasLength(1)); + final staleDelete = iconButton(tester, 'Delete page 1').onPressed!; + final staleAdd = iconButton(tester, 'Import from gallery').onPressed!; + final staleReorder = tester + .widget>(find.byType(DragTarget).last) + .onAcceptWithDetails!; + await tester.tap(find.byTooltip('Clear all')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Clear all')); + await tester.pump(); + + expect(store.clearCalls, 0); + expect(find.text('Clearing draft...'), findsOneWidget); + expect(iconButton(tester, 'Import from gallery').onPressed, isNull); + expect(iconButton(tester, 'Delete page 1').onPressed, isNull); + expect( + tester + .widget>( + find.byType(LongPressDraggable).first, + ) + .maxSimultaneousDrags, + 0, + ); + + // Invoke callbacks captured before the lock as well as tapping the now + // disabled controls. The guards must reject stale in-flight gestures. + staleDelete(); + staleAdd(); + staleReorder(DragTargetDetails(data: 0, offset: Offset.zero)); + await tester.pump(); + + expect(store.saves, hasLength(1)); + expect(find.text('Clearing draft...'), findsOneWidget); + + store.firstSave.complete(); + await tester.pump(); + await tester.pump(); + expect(store.clearCalls, 1); + expect(store.saves, hasLength(1)); + expect(find.text('Clearing draft...'), findsOneWidget); + + clearCompleter.complete(); + await tester.pump(); + await tester.pump(); + expect(find.text('Ready to Scan'), findsOneWidget); + expect(store.saves, hasLength(1)); + expect(iconButton(tester, 'Import from gallery').onPressed, isNotNull); + }, + ); + + testWidgets('picker result cannot repopulate a successfully cleared draft', ( + tester, + ) async { + final pickerCompleter = Completer>(); + ImagePickerPlatform.instance = _ImagePickerPlatform( + multiImageCompleter: pickerCompleter, + ); + final clearCompleter = Completer(); + final store = _DraftStore(clearCompleter: clearCompleter); + await pumpHome(tester, store, pages: [page(1)]); + + await tester.tap(find.byTooltip('Import from gallery')); + await tester.pump(); + await tester.tap(find.byTooltip('Clear all')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Clear all')); + await tester.pump(); + expect(find.text('Clearing draft...'), findsOneWidget); + + clearCompleter.complete(); + await tester.pump(); + await tester.pump(); + expect(find.text('Ready to Scan'), findsOneWidget); + + pickerCompleter.complete([ + XFile.fromData(imageBytes, name: 'late-picker-result.png'), + ]); + await tester.pump(); + await tester.pump(); + + expect(find.text('Ready to Scan'), findsOneWidget); + expect(store.saves, isEmpty); + expect(store.clearCalls, 1); + }); + + testWidgets('clear failure keeps in-memory pages and shows an error', ( + tester, + ) async { + final store = _DraftStore(clearError: StateError('injected clear failure')); + await pumpHome(tester, store, pages: [page(1), page(2)]); + + await tester.tap(find.byTooltip('Clear all')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Clear all')); + await tester.pump(); + await tester.pump(); + + expect(store.clearCalls, 1); + expect(find.text('Save as PDF (2 pages)'), findsOneWidget); + expect(find.text('Could not clear the saved draft.'), findsOneWidget); + expect(find.text('Clearing draft...'), findsNothing); + expect(iconButton(tester, 'Delete page 1').onPressed, isNotNull); + expect(iconButton(tester, 'Clear all').onPressed, isNotNull); + }); + + testWidgets('successful share keeps the draft unless clear is chosen', ( + tester, + ) async { + final keepStore = _DraftStore(); + final sharePlatform = _SharePlatform(); + await pumpHome( + tester, + keepStore, + pages: [page(1)], + sharePlus: SharePlus.custom(sharePlatform), + ); + + await tester.tap(find.text('Save as PDF (1 pages)')); + await tester.pumpAndSettle(); + expect(find.text('Keep this draft?'), findsOneWidget); + await tester.tap(find.widgetWithText(TextButton, 'Keep draft')); + await tester.pumpAndSettle(); + expect(keepStore.clearCalls, 0); + expect(find.text('Save as PDF (1 pages)'), findsOneWidget); + + await tester.pumpWidget(const SizedBox()); + final clearStore = _DraftStore(); + await pumpHome( + tester, + clearStore, + pages: [page(2)], + sharePlus: SharePlus.custom(sharePlatform), + ); + await tester.tap(find.text('Save as PDF (1 pages)')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Clear draft')); + await tester.pump(); + await tester.pump(); + + expect(clearStore.clearCalls, 1); + expect(find.text('Ready to Scan'), findsOneWidget); + expect(sharePlatform.calls, 2); + }); +} diff --git a/test/services/draft_store_native_test.dart b/test/services/draft_store_native_test.dart new file mode 100644 index 0000000..6532e26 --- /dev/null +++ b/test/services/draft_store_native_test.dart @@ -0,0 +1,231 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fosscanner/models/scanned_page.dart'; +import 'package:fosscanner/services/draft_store_native.dart'; + +void main() { + late Directory directory; + final tinyPng = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk' + '+A8AAQUBAScY42YAAAAASUVORK5CYII=', + ); + + setUp(() async { + directory = Directory('.draft-store-test-tmp'); + if (await directory.exists()) await directory.delete(recursive: true); + await directory.create(recursive: true); + }); + + tearDown(() async { + if (await directory.exists()) await directory.delete(recursive: true); + }); + + ScannedPage page(int seed, PageFilter filter) => ScannedPage( + originalBytes: Uint8List.fromList(tinyPng), + processedBytes: Uint8List.fromList(tinyPng), + corners: [ + Offset(seed.toDouble(), seed + 0.5), + Offset(seed + 10.0, seed + 1.5), + Offset(seed + 11.0, seed + 12.5), + Offset(seed + 1.0, seed + 13.5), + ], + filter: filter, + rotationQuarterTurns: seed % 4, + brightness: seed + 0.25, + contrast: 1 + seed / 10, + ); + + void expectPage(ScannedPage actual, ScannedPage expected) { + expect(actual.originalBytes, orderedEquals(expected.originalBytes)); + expect(actual.processedBytes, orderedEquals(expected.processedBytes)); + expect(actual.corners, orderedEquals(expected.corners)); + expect(actual.filter, expected.filter); + expect(actual.rotationQuarterTurns, expected.rotationQuarterTurns); + expect(actual.brightness, expected.brightness); + expect(actual.contrast, expected.contrast); + } + + test('round-trips every ScannedPage field in page order', () async { + final store = FileDraftStore(directory: directory); + final pages = [ + page(1, PageFilter.autoEnhance), + page(7, PageFilter.blackAndWhite), + page(3, PageFilter.grayscale), + ]; + + await store.save(pages); + final restored = await store.load(); + + expect(restored, hasLength(3)); + for (var index = 0; index < pages.length; index++) { + expectPage(restored[index], pages[index]); + } + final manifestText = await File( + '${directory.path}/current/manifest.json', + ).readAsString(); + final manifest = jsonDecode(manifestText) as Map; + expect(manifest.keys, unorderedEquals(['schemaVersion', 'pages'])); + expect(manifest['schemaVersion'], 1); + expect( + manifestText, + isNot(contains(base64Encode(tinyPng))), + reason: 'image bytes belong in separate binary files', + ); + }); + + test('a corrupt or incomplete manifest loads as no draft', () async { + final current = Directory('${directory.path}/current'); + await current.create(); + await File('${current.path}/manifest.json').writeAsString( + jsonEncode({ + 'schemaVersion': 1, + 'pages': [ + { + 'corners': [], + 'filter': 'original', + 'rotationQuarterTurns': 0, + 'brightness': 0, + 'contrast': 1, + }, + ], + }), + ); + + final restored = await FileDraftStore(directory: directory).load(); + + expect(restored, isEmpty); + }); + + test('a failed replacement preserves the previous valid draft', () async { + final original = page(2, PageFilter.original); + await FileDraftStore(directory: directory).save([original]); + final failingStore = FileDraftStore( + directory: directory, + beforeCommit: (stage) { + if (stage == DraftSaveStage.afterCurrentMoved) { + throw const FileSystemException('injected replacement failure'); + } + }, + ); + + await expectLater( + failingStore.save([page(9, PageFilter.grayscale)]), + throwsA(isA()), + ); + + final restored = await FileDraftStore(directory: directory).load(); + expect(restored, hasLength(1)); + expectPage(restored.single, original); + }); + + test('falls back to the backup when current is corrupt', () async { + final first = page(4, PageFilter.original); + await FileDraftStore(directory: directory).save([first]); + await FileDraftStore( + directory: directory, + ).save([page(5, PageFilter.grayscale)]); + await File( + '${directory.path}/current/manifest.json', + ).writeAsString('{not-json'); + + final restored = await FileDraftStore(directory: directory).load(); + + expect(restored, hasLength(1)); + expectPage(restored.single, first); + expect(await Directory('${directory.path}/current').exists(), isTrue); + expect(await Directory('${directory.path}/backup').exists(), isFalse); + }); + + test('junk encoded image bytes fall back to the valid backup', () async { + final first = page(6, PageFilter.autoEnhance); + await FileDraftStore(directory: directory).save([first]); + await FileDraftStore( + directory: directory, + ).save([page(7, PageFilter.blackAndWhite)]); + await File( + '${directory.path}/current/page_0_original.bin', + ).writeAsBytes([1]); + + final restored = await FileDraftStore(directory: directory).load(); + + expect(restored, hasLength(1)); + expectPage(restored.single, first); + }); + + test( + 'corrupt PNG payload with intact metadata restores and promotes backup', + () async { + final backupPage = page(6, PageFilter.autoEnhance); + await FileDraftStore(directory: directory).save([backupPage]); + await FileDraftStore( + directory: directory, + ).save([page(7, PageFilter.blackAndWhite)]); + + final corrupted = Uint8List.fromList(tinyPng); + var idat = -1; + for (var index = 0; index <= corrupted.length - 4; index++) { + if (String.fromCharCodes(corrupted.sublist(index, index + 4)) == + 'IDAT') { + idat = index; + break; + } + } + expect(idat, greaterThanOrEqualTo(0)); + // Keep the PNG signature and IHDR dimensions intact, but invalidate the + // zlib stream in IDAT. Descriptor-only validation still accepts it. + corrupted[idat + 4] = 0; + await File( + '${directory.path}/current/page_0_processed.bin', + ).writeAsBytes(corrupted); + + final restored = await FileDraftStore(directory: directory).load(); + + expect(restored, hasLength(1)); + expectPage(restored.single, backupPage); + expect(await Directory('${directory.path}/current').exists(), isTrue); + expect(await Directory('${directory.path}/backup').exists(), isFalse); + }, + ); + + test('failed save after fallback preserves the recovered draft', () async { + final recovered = page(8, PageFilter.original); + await FileDraftStore(directory: directory).save([recovered]); + await FileDraftStore( + directory: directory, + ).save([page(9, PageFilter.grayscale)]); + await File( + '${directory.path}/current/page_0_processed.bin', + ).writeAsBytes([1]); + final restored = await FileDraftStore(directory: directory).load(); + expectPage(restored.single, recovered); + + final failingStore = FileDraftStore( + directory: directory, + beforeCommit: (stage) { + if (stage == DraftSaveStage.afterCurrentMoved) { + throw const FileSystemException('injected replacement failure'); + } + }, + ); + await expectLater( + failingStore.save([page(10, PageFilter.blackAndWhite)]), + throwsA(isA()), + ); + + final afterFailure = await FileDraftStore(directory: directory).load(); + expect(afterFailure, hasLength(1)); + expectPage(afterFailure.single, recovered); + }); + + test('clear removes current, backup, and staging data', () async { + final store = FileDraftStore(directory: directory); + await store.save([page(1, PageFilter.original)]); + + await store.clear(); + + expect(await store.load(), isEmpty); + expect(await directory.list().toList(), isEmpty); + }); +} From f9620b7442eed6d5066b0c6d35060efddfcd4485 Mon Sep 17 00:00:00 2001 From: VincenzoImp Date: Sat, 29 Aug 2026 19:52:25 +0200 Subject: [PATCH 2/3] docs: disclose native draft persistence --- .github/ISSUE_TEMPLATE/feature_request.yml | 9 +++-- .../android/en-US/full_description.txt | 5 ++- test/platform_configuration_test.dart | 37 +++++++++++++++++-- 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 340ba81..ae0ce05 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -23,9 +23,10 @@ body: attributes: label: Privacy description: >- - FOSScanner makes no network calls of its own and keeps in-progress - pages in memory (the OS may cache a shared PDF; see the README). - Please confirm your suggestion doesn't require breaking that. + FOSScanner makes no network calls of its own. On native platforms it + keeps an unfinished draft in the app's private, OS-managed cache; web + drafts are memory-only (see the README). Please confirm your suggestion + preserves these privacy boundaries. options: - - label: This doesn't require adding network calls or persistent storage of scanned content + - label: This doesn't require network calls or storage outside the app-private cache required: true diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 115837b..ade9c77 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -21,6 +21,7 @@ Privacy: * All image processing and PDF generation happens on-device * Imported gallery originals are never modified or deleted. App-owned camera temp files are removed after the app attempts to copy them into memory, including failed reads -* In-progress pages stay in memory. Sharing a PDF may create a copy in an OS-managed cache - for the receiving app, and the OS decides when that cache is removed +* An unfinished draft is saved automatically in the app's private, OS-managed cache so it + can be restored after an app restart. It is removed after you clear or share the document, + and the OS may evict cached data. Sharing may create a separate cached PDF copy * The app makes no network requests of its own diff --git a/test/platform_configuration_test.dart b/test/platform_configuration_test.dart index 5837618..f888986 100644 --- a/test/platform_configuration_test.dart +++ b/test/platform_configuration_test.dart @@ -766,10 +766,39 @@ void main() { final fastlane = File( 'fastlane/metadata/android/en-US/full_description.txt', ).readAsStringSync(); - expect( - fastlane, - matches(RegExp(r'OS-managed\s+cache', caseSensitive: false)), - ); + final featureRequest = File( + '.github/ISSUE_TEMPLATE/feature_request.yml', + ).readAsStringSync(); + for (final (name, disclosure) in [ + ('Fastlane description', fastlane), + ('feature request template', featureRequest), + ]) { + expect( + disclosure, + matches( + RegExp(r'private,?\s+OS-managed\s+cache', caseSensitive: false), + ), + reason: '$name must disclose automatic native draft persistence', + ); + expect( + disclosure, + matches(RegExp(r'unfinished\s+draft', caseSensitive: false)), + reason: '$name must explain what the cache contains', + ); + expect( + disclosure, + isNot( + matches( + RegExp( + r'(in-progress\s+pages\s+stay|keeps?\s+in-progress\s+pages)' + r'\s+in\s+memory', + caseSensitive: false, + ), + ), + ), + reason: '$name must not claim native pages are memory-only', + ); + } expect( fastlane, matches( From 92d803f996e798d982728ba1f9dc82e4b7a3de4d Mon Sep 17 00:00:00 2001 From: VincenzoImp Date: Sat, 29 Aug 2026 20:05:17 +0200 Subject: [PATCH 3/3] docs: clarify post-share draft retention --- .../android/en-US/full_description.txt | 5 +++-- test/platform_configuration_test.dart | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index ade9c77..47c5cd5 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -22,6 +22,7 @@ Privacy: * Imported gallery originals are never modified or deleted. App-owned camera temp files are removed after the app attempts to copy them into memory, including failed reads * An unfinished draft is saved automatically in the app's private, OS-managed cache so it - can be restored after an app restart. It is removed after you clear or share the document, - and the OS may evict cached data. Sharing may create a separate cached PDF copy + can be restored after an app restart. Clearing the document removes it; after sharing, you + choose whether to clear or keep the draft. The OS may evict cached data, and sharing may + create a separate cached PDF copy * The app makes no network requests of its own diff --git a/test/platform_configuration_test.dart b/test/platform_configuration_test.dart index f888986..fc96610 100644 --- a/test/platform_configuration_test.dart +++ b/test/platform_configuration_test.dart @@ -799,6 +799,25 @@ void main() { reason: '$name must not claim native pages are memory-only', ); } + expect( + fastlane, + matches( + RegExp( + r'after\s+sharing[^.]*choose[^.]*clear[^.]*keep[^.]*draft', + caseSensitive: false, + ), + ), + reason: 'Fastlane must disclose the post-share retention choice', + ); + expect( + fastlane, + isNot( + matches( + RegExp(r'(removed|deleted)[^.]*after[^.]*shar', caseSensitive: false), + ), + ), + reason: 'Sharing does not always remove the draft', + ); expect( fastlane, matches(