Compare commits

...
177 Commits
Author SHA1 Message Date
Pierre-Yves NicolasandGitHub 22973418c5 Prepare release: v2.2.0 (#226)
Android CI / build (push) Canceled after 0s
2026-07-19 17:29:03 +02:00
Pierre-Yves NicolasandGitHub cd78e9bff2 Reduce false positive quads on import (#225)
On import, users have no live feedback, so images are more likely to not contain quad edges.
- Remove quad fallbacks on import
- Adjust minSideLengthRatio for all modes
2026-07-19 16:13:50 +02:00
Pierre-Yves NicolasandGitHub f5de6bb5e1 Remove comments and non-translatable strings in Indonesian translation (#224) 2026-07-14 14:12:56 +02:00
IhfandiandGitHub c4705a24a2 New translation: Indonesian (#223) 2026-07-14 13:52:19 +02:00
Pierre-Yves NicolasandGitHub a6e4b349d7 Mention Weblate in README.md (#222) 2026-07-13 13:43:45 +02:00
Pierre-Yves NicolasandPierre-Yves Nicolas b7787b64f1 chore(l10n): update French translation
Translation: FairScan/Android app
Language: French
Progress: 100.0% (94 of 94 strings)
Translate-URL: https://hosted.weblate.org/projects/fairscan/android-app/fr/
2026-07-06 21:36:29 +02:00
ℂ𝕠𝕠𝕠𝕝 (𝕘𝕚𝕥𝕙𝕦𝕓.𝕔𝕠𝕞/ℂ𝕠𝕠𝕠𝕝)andPierre-Yves Nicolas e99d015675 Add Latvian translation
Translation: FairScan/Android app
Language: Latvian
Progress: 100.0% (94 of 94 strings)
Translate-URL: https://hosted.weblate.org/projects/fairscan/android-app/lv/
2026-07-06 21:23:29 +02:00
Pierre-Yves Nicolas 26428a6a7c Exclude quad when a corner is out of the frame (#216)
There was a bug in the previous validation logic: it checked the quad in the mask against the dimensions of the original image.
2026-07-04 17:37:44 +02:00
Priit JõerüütandPierre-Yves Nicolas 56f46a54cc Add Estonian translation
Translation: FairScan/Android app
Language: Estonian
Progress: 100.0% (94 of 94 strings)
Translate-URL: https://hosted.weblate.org/projects/fairscan/android-app/et/
2026-07-03 11:26:01 +02:00
Pierre-Yves Nicolas 7e7f14214c strings.xml: comments to help translations 2026-07-02 16:30:19 +02:00
Pierre-Yves Nicolas 7559055022 camera_permission_rationale: replace "current document" by "current scan" 2026-07-02 16:30:19 +02:00
Pierre-Yves Nicolas 7034551c57 Single string for "New scan" 2026-07-02 16:30:19 +02:00
Pierre-Yves NicolasandGitHub 306cde9537 strings.xml: provide context for some strings for translations (#211) 2026-07-01 21:05:12 +02:00
Pierre-Yves Nicolas ca8dde33ec Fix lint errors in Hungarian translation 2026-06-29 17:27:39 +02:00
István ElgesandPierre-Yves Nicolas 0b1a2be120 Add Hungarian translation 2026-06-29 17:15:15 +02:00
Pierre-Yves Nicolas 12e8f98252 Prepare release: v2.1.0 2026-06-26 14:49:10 +02:00
Pierre-Yves Nicolas 98380c098a ExportScreen: remove condition on resumed scan
With the new ResumeScanScreen, users don't reach the ExportScreen directly on startup. See #204.
2026-06-26 14:18:55 +02:00
Pierre-Yves NicolasandGitHub 28d1144da3 CropScreen: save quad state across device rotations (#208) 2026-06-26 13:34:55 +02:00
Pierre-Yves NicolasandGitHub 8bc930a235 GetMultipleContents: photo picker with a "Browse" option (#207) 2026-06-25 20:31:56 +02:00
Pierre-Yves NicolasandGitHub e74308d9a4 Trigger capture when a volume button is pressed (#205) 2026-06-25 14:22:13 +02:00
Pierre-Yves Nicolas 522dd5a508 AboutScreen: avoid crash when opening contact link 2026-06-25 06:31:40 +02:00
Pierre-Yves Nicolas 2ecf15bb4c openUri: avoid crash on call to newUri 2026-06-25 05:50:36 +02:00
Pierre-Yves Nicolas 7f5befc581 Adjust message when OCR download fails (#202) 2026-06-24 21:17:04 +02:00
Pierre-Yves NicolasandGitHub 28087c4449 Explicit choice on startup: resume current scan or start new (#204)
It seems that some app concepts are unclear to some users (current scan, scan ≠ page, see #201). This is probably linked to the removal of the Home screen (#170) which also removed the explicit choice on startup when a current scan exists: either resume the current scan or start a new one. The explicit choice at least clarified the concept of current scan. This PR creates a new screen that is displayed only on startup when a current scan exists. When the user wants to start a new scan (probably the most common case), it takes one tap, exactly the same as currently.
2026-06-24 20:46:21 +02:00
Pierre-Yves Nicolas bed27ca653 Prepare release: v2.0.0 2026-06-13 21:36:43 +02:00
Pierre-Yves Nicolas 161bd63297 Mention Tesseract in README.md 2026-06-13 21:36:43 +02:00
Pierre-Yves Nicolas c221b21199 Update screenshots 2026-06-13 21:36:43 +02:00
Pierre-Yves Nicolas d1284f3f77 Update descriptions about internet permission 2026-06-13 21:36:43 +02:00
Pierre-Yves Nicolas 25b73c21c9 Update Android Studio config files 2026-06-13 11:21:24 +02:00
Pierre-Yves Nicolas 5d86bdd7ab Improve handling of corrupted images 2026-06-13 07:47:20 +02:00
Pierre-Yves Nicolas 974ee5f913 openUri: avoid crash on unexpected exception 2026-06-13 07:46:59 +02:00
Pierre-Yves Nicolas 2ab5dcf7d9 Separate screen for OCR languages 2026-06-12 16:25:54 +02:00
Pierre-Yves Nicolas 991ddc8e1d Settings screen: more compact style for radio buttons 2026-06-12 16:25:54 +02:00
Pierre-Yves Nicolas 96c80fc977 Settings screen: adjust style for export folder 2026-06-12 16:25:54 +02:00
Pierre-Yves Nicolas ec4b5cf104 Export: cancel job when leaving the screen 2026-06-11 11:11:58 +02:00
Pierre-Yves Nicolas d3c39b03fa ExportScreen: adjust progress indicator for 1 page 2026-06-11 08:26:46 +02:00
Pierre-Yves Nicolas 0be14e5e8d Disable OCR on external call 2026-06-10 19:02:02 +02:00
Pierre-Yves Nicolas cf96095938 OCR: handle download failure 2026-06-10 19:02:02 +02:00
Pierre-Yves Nicolas f3d0e11651 OCR: GlyphLessFont->TesseractGlyphLessFont 2026-06-10 19:02:02 +02:00
Pierre-Yves Nicolas a319292045 OCR: avoid duplicate resources in PDF 2026-06-10 19:02:02 +02:00
Pierre-Yves Nicolas 028316147c OCR: fix perf issue with fonts and invalid text stream 2026-06-10 19:02:02 +02:00
Pierre-Yves Nicolas 6de0e2addf OCR: externalize strings 2026-06-10 19:02:02 +02:00
Pierre-Yves Nicolas e23ce044f9 OCR: fix PDF generation for non-latin characters 2026-06-10 19:02:02 +02:00
Pierre-Yves Nicolas 104c60e073 OCR: improve text positioning 2026-06-10 19:02:02 +02:00
Pierre-Yves Nicolas dfa35c24f2 ExportScreen: display OCR activation 2026-06-10 19:02:02 +02:00
Pierre-Yves Nicolas cdd44acd2b Restart export on OCR language change 2026-06-10 19:02:02 +02:00
Pierre-Yves Nicolas b5ed935ad2 ExportScreen: progress indicator 2026-06-10 19:02:02 +02:00
Pierre-Yves Nicolas 75bcda22cc OCR: progress indicator for download 2026-06-10 19:02:02 +02:00
Pierre-Yves Nicolas f005eefdeb OCR language management 2026-06-10 19:02:02 +02:00
Pierre-Yves Nicolas 8d11244374 Initialize segmentation and OCR asynchronously 2026-06-10 19:02:02 +02:00
Pierre-Yves Nicolas 90413cd97f OCR: basic integration of Tesseract 2026-06-10 19:02:02 +02:00
Philipp HasperandPierre-Yves Nicolas 6ad21eeb59 Fix system gesture triggering when editing quad corners.
On devices without a back button (e.g. Pixel10), dragging corners when
close to the screen borders triggered the system back gesture.

Adding exclusion areas for each of the corner handles fixes this.
It was not possible to exclude the whole image area, as Android seems to
silently limit the area of allowed exclusion.

The mandatory system gestures like the home or quick-switch gesture cannot
be excluded via areas. The only solution here is to introduce a bottom
padding which moves the image view upwards, out of the gesture area.

Both changes are no-ops for devices without system gestures, so no change
there.

Closes #193
2026-06-07 10:54:24 +02:00
Pierre-Yves NicolasandGitHub dd8002009d Update the "Issues" section in CONTRIBUTING.md 2026-06-01 07:58:55 +02:00
Pierre-Yves NicolasandGitHub 8ba049eed5 Update the "Issues" section in CONTRIBUTING.md 2026-05-31 22:17:09 +02:00
Pierre-Yves Nicolas afb7e388a6 Prepare release: v1.23.0 2026-05-31 06:56:07 +02:00
Pierre-Yves NicolasandGitHub e4cf65aaf9 Human-friendly file name when called through an intent (#188) 2026-05-31 06:18:54 +02:00
Pierre-Yves Nicolas b9a9255a5c More detailed exception when resizeToThumbnail fails (#185) 2026-05-30 21:52:33 +02:00
Pierre-Yves Nicolas eaaf5b01a2 openUri: avoid crash on SecurityException 2026-05-30 19:12:21 +02:00
Pierre-Yves NicolasandGitHub a38909fb52 Make FairScan callable through the GET_CONTENT intent (#187) 2026-05-30 15:52:13 +02:00
Pierre-Yves Nicolas d3a28d0eaf Prepare release: v1.22.1 2026-05-29 12:33:05 +02:00
Pierre-Yves Nicolas e3494e8c90 Remove ACCESS_NETWORK_STATE permission (coming from CameraX) 2026-05-29 12:27:05 +02:00
en2svandPierre-Yves Nicolas 3e09f78c03 Add Swedish translation 2026-05-26 15:51:24 +02:00
Pierre-Yves Nicolas 8ecebdf9ed Prepare release: v1.22.0 2026-05-24 17:03:55 +02:00
Pierre-Yves Nicolas 66f71927df Fix page dimensions when a manual rotation is applied 2026-05-24 16:55:17 +02:00
Pierre-Yves Nicolas f18b65579e openUri: adjust code to avoid SecurityException in rare cases 2026-05-24 15:34:09 +02:00
Pierre-Yves Nicolas 0a448c24c9 Standard formats: Letter dimensions should be respected 2026-05-24 11:27:23 +02:00
Pierre-Yves Nicolas c3bd144681 Snap to standard formats: A4, Letter... 2026-05-23 20:41:06 +02:00
Pierre-Yves Nicolas 9394b19731 Use focus distance to estimate physical size 2026-05-23 20:41:06 +02:00
Pierre-Yves Nicolas b89aecf369 Remove unused strings 2026-05-21 07:41:28 +02:00
Pierre-Yves NicolasandGitHub 27ad5efeff Improve perspective correction with camera intrinsics (#182) 2026-05-20 20:33:03 +02:00
Pierre-Yves NicolasandGitHub d9844be4de Avoid displaying the back button when back should be handled by the system (#181)
After removing the home screen, displaying the back button on the camera screen didn't make sense anymore.
See #179.
2026-05-18 14:00:08 +02:00
monta-ghandGitHub 6a3ccb854e Add Japanese (ja) localization (#178) 2026-05-16 08:00:57 +02:00
Pierre-Yves Nicolas c8233c7388 Prepare release: v1.21.0 2026-05-14 22:01:30 +02:00
Pierre-Yves Nicolas 0f0045fade Capture/import image even when no document is detected 2026-05-13 18:03:57 +02:00
Pierre-Yves Nicolas 17aa9e1a4c CropScreen: change name in header to "The FairScan authors" 2026-05-13 18:03:57 +02:00
Pierre-Yves Nicolas 9164c17763 Document screen: externalize string for "Crop" 2026-05-13 18:03:57 +02:00
Pierre-Yves Nicolas baa8318de6 CropScreen: remove stripes in loupe 2026-05-13 18:03:57 +02:00
Pierre-Yves Nicolas b089baec97 CropScreen: rename and translate button "confirm" -> "apply" 2026-05-13 18:03:57 +02:00
Pierre-Yves Nicolas f234d50a2a Rename EditPageScreen to CropScreen 2026-05-13 18:03:57 +02:00
Pierre-Yves Nicolas c43e4e73ad EditPageScreen: fix visual glitch when loading the same page twice 2026-05-13 18:03:57 +02:00
Pierre-Yves Nicolas fed95c99d4 EditPageScreen: apply output quad 2026-05-13 18:03:57 +02:00
Pierre-Yves Nicolas 0d83265f6b Change Scaffold for EditPageScreen 2026-05-13 18:03:57 +02:00
Pierre-Yves Nicolas d03d411706 Plug input data to EditPageScreen 2026-05-13 18:03:57 +02:00
Philipp HasperandPierre-Yves Nicolas 2b63273168 EditPage: New screen for editing an individual page 2026-05-13 18:03:57 +02:00
Pierre-Yves Nicolas dcc797785b Change name in LICENSE_HEADER to "The FairScan authors" 2026-05-12 13:49:03 +02:00
Pierre-Yves Nicolas 69f5ca0636 ExportScreen: "End scan" -> "New scan" + change behavior 2026-05-12 12:10:47 +02:00
Pierre-Yves Nicolas e43da6b9aa Remove Home screen 2026-05-12 12:10:47 +02:00
Pierre-Yves Nicolas 9dedaaec9d Prepare release: v1.20.0 2026-05-03 18:49:25 +02:00
Pierre-Yves NicolasandGitHub c9da4f692f Improve perspective correction using projective geometry (#173) 2026-05-03 17:46:35 +02:00
Pierre-Yves Nicolas 01402c54ce Log SecurityException on openUri 2026-04-25 10:07:21 +02:00
Pierre-Yves Nicolas 32c24c3eae Avoid crashing when importing an invalid image (#171) 2026-04-24 17:34:06 +02:00
Pierre-Yves Nicolas 1169bed9a5 Grayscale: avoid darkening pages with little content (#158)
Adjust black point: 0.01 -> 0.004
2026-04-21 15:39:43 +02:00
Philipp HasperandGitHub 8b80ca12b2 Fix reporting email for images scanned via Intent (#163)
When starting the app via "org.fairscan.app.action.SCAN_TO_PDF" and then
reporting the last image via createEmailWithImageIntent() the URI creation
failed with

> java.lang.IllegalArgumentException: Failed to find configured root that
> contains /data/data/org.fairscan.app/cache/sessions/097d29ab-f706-4ba2-848a-fea16fb5f41f/sources/1776590719661.jpg

The issue was that when launched via SCAN_TO_PDF, images are stored under
the cache directory (cache/sessions/<uuid>/sources/), but the FileProvider
configuration in file_paths.xml only had a <cache-path> for pdfs/ and
<files-path> for sources/. There was no cache path covering sessions/.
2026-04-21 09:27:23 +02:00
Pierre-Yves NicolasandGitHub e70f4e4d1e Contributing guidelines (#166) 2026-04-20 18:54:45 +02:00
Pierre-Yves Nicolas 8fae0badb9 Prepare release: v1.19.0 2026-04-11 11:34:28 +02:00
Pierre-Yves Nicolas eeb0d8e8b3 PDF pages physical dimensions should be close to US Letter or A4 (#153) 2026-04-10 19:00:56 +02:00
Pierre-Yves Nicolas 3e085eca5c DocumentScreen: reset zoom after rotation so that all areas are reachable (#152) 2026-04-10 16:20:45 +02:00
Pierre-Yves Nicolas 479680a83d Throw explicit exceptions when decoding images to import 2026-04-10 15:49:51 +02:00
Pierre-Yves Nicolas 31155275e8 CameraScreen: cancel import when leaving the screen 2026-04-10 15:49:51 +02:00
Pierre-Yves Nicolas 6290717635 CameraScreen: improve UI for import 2026-04-10 15:49:51 +02:00
Pierre-Yves Nicolas b946913594 New feature: import photos 2026-04-10 15:49:51 +02:00
Pierre-Yves Nicolas 2f4b330b8d Move camera permission rationale to CameraScreen 2026-04-10 15:49:51 +02:00
Pierre-Yves Nicolas 336609d32d Drop unused integration test 2026-04-04 18:48:36 +02:00
Pierre-Yves Nicolas 53b226a465 Centralize image processing for 'app' module 2026-04-04 18:48:04 +02:00
Pierre-Yves Nicolas 953d0e4a42 Prepare release: v1.18.0 2026-04-03 18:45:05 +02:00
Pierre-Yves Nicolas 47109d6ab4 Revert AGP upgrade to fix broken APK
Building with AGP 8.13.2 leads to a (minified) release APK that crashes with LiteRT.
2026-04-03 18:44:37 +02:00
Pierre-Yves Nicolas 7f51e462c1 Fix visual glitch when rotating a page with RTL language (#144) 2026-04-03 10:51:30 +02:00
Pierre-Yves Nicolas 115472aeb0 ImageRepository: add unit tests for setColorMode 2026-04-02 19:34:29 +02:00
Pierre-Yves Nicolas 1e99520473 Thumbnails: avoid loading many Bitmaps, increase cache size 2026-04-02 17:50:23 +02:00
Pierre-Yves Nicolas 7a9e4203ed SettingsScreen: adjust style 2026-04-02 13:16:22 +02:00
Pierre-Yves Nicolas 4aae5accae New setting for default color mode ("default filter") 2026-04-02 13:16:22 +02:00
Pierre-Yves Nicolas 139922c19e DocumentScreen: add a spinner when changing color mode 2026-04-01 18:47:27 +02:00
Pierre-Yves Nicolas 18923d5934 DocumentScreen: dropdown menu for color mode 2026-04-01 12:08:07 +02:00
Pierre-Yves Nicolas 215f57bb74 Avoid resolution of current page in DocumentScreen 2026-04-01 12:08:07 +02:00
Pierre-Yves Nicolas b258082ce1 New feature: ability to force color mode for a page 2026-04-01 12:08:07 +02:00
Pierre-Yves Nicolas 7d01493477 Introduce ColorMode enum 2026-04-01 12:08:07 +02:00
Pierre-Yves Nicolas 3681d5771d Introduce Jpeg class 2026-04-01 12:08:07 +02:00
Pierre-Yves Nicolas 7cd3dfd990 ImageRepository: make jpegBytes and getThumbnail suspend 2026-04-01 12:08:07 +02:00
Pierre-Yves Nicolas 05a8af577c Refactor UI to remove blocking image loading and support suspend APIs 2026-04-01 12:08:07 +02:00
Pierre-Yves Nicolas 446b915d59 ImageRepository: rotations and thumbnails are not stored on disk anymore 2026-04-01 12:08:07 +02:00
Pierre-Yves NicolasandGitHub 69690d5c2a Splash screen should be dark for dark theme (#148) 2026-03-26 11:26:15 +01:00
Pierre-Yves Nicolas f130d33eba Avoid swallowing errors during export preparation 2026-03-25 18:47:45 +01:00
Pierre-Yves NicolasandGitHub 92914c1730 Make ImageRepository thread-safe (#147) 2026-03-25 14:35:17 +01:00
Pierre-Yves Nicolas 516dd75e9c Fix grayscale PDF export: partial revert of 40314de 2026-03-25 09:06:04 +01:00
Pierre-Yves Nicolas 90287b3389 Compress source JPEG during capture preview rather than later 2026-03-24 20:34:24 +01:00
Pierre-Yves Nicolas fa619da867 MainViewModel: avoid running IO operations on the main thread 2026-03-24 20:34:24 +01:00
Pierre-Yves Nicolas 40314def4e Capture: reduce conversions between Mat/Bitmap/ByteArray 2026-03-24 20:34:24 +01:00
Pierre-Yves Nicolas ceceaf8792 Centralize JPEG compression (also in MainViewModel) 2026-03-22 18:28:17 +01:00
Pierre-Yves Nicolas a9edfa2b87 Centralize JPEG compression to a single function 2026-03-22 18:09:12 +01:00
Pierre-Yves Nicolas f336680a69 Upgrade AGP: 8.10.1 -> 8.13.2 2026-03-22 12:53:41 +01:00
Pierre-Yves Nicolas fe2f37b7e8 Upgrade most dependencies 2026-03-22 12:53:41 +01:00
Pierre-Yves Nicolas 2f3d42049e Prepare release: v1.17.0 2026-03-20 17:09:01 +01:00
Pierre-Yves Nicolas 1ad586ed8f Feed basic PDF metadata: date and creator 2026-03-20 16:40:39 +01:00
Pierre-Yves Nicolas f371be4e14 Layout of rotation buttons should be the same for RTL as for LTR (fixes #135) 2026-03-20 15:50:26 +01:00
Pierre-Yves NicolasandGitHub 58abc2f3fe Improve grayscale postprocessing (#138)
Apply exp() to the Retinex output before normalization, restoring a
linear tone distribution. This improves contrast overall: light gray
content is preserved, and dark content stays dark even when the image
contains deep blacks. The previous normalization compressed all tones
toward the same gray level. Trade-off: shadows and unwanted background
variations are also more visible in some images.

Other:
- stretch toward white using histogram mode
- fall back to simple normalization on documents with large dark areas
- add bilateral denoising
2026-03-20 11:40:58 +01:00
Pierre-Yves Nicolas 0e8037fa8d Export: fix SecurityException when tapping "open" (#133) 2026-03-14 14:52:24 +01:00
Pierre-Yves Nicolas cd4206b413 Prepare release: v1.16.0 2026-03-10 11:42:21 +01:00
Pierre-Yves Nicolas dea8bcc389 Export: avoid silencing issues in applyRenaming 2026-03-10 09:38:13 +01:00
Pierre-Yves Nicolas 8666fce250 Right-to-left: fix target position of post-capture animation (#122) 2026-03-09 15:02:41 +01:00
Pierre-Yves Nicolas 8784c0b923 Extract strings rotate_left and rotate_right (used for icon descriptions) 2026-03-09 10:55:31 +01:00
Pierre-Yves Nicolas 3d70abbac2 Right-to-left: fix rotation buttons (#121) 2026-03-09 10:28:38 +01:00
Pierre-Yves Nicolas 68531bf4e3 Fix quad stabilization (fix #131) 2026-03-09 09:26:13 +01:00
Pierre-Yves Nicolas c62fba87d8 Simplify code for live analysis 2026-03-08 14:53:03 +01:00
Pierre-Yves Nicolas 4e3cc95979 Run segmentation and quad detection in sequence to avoid CPU contention 2026-03-08 14:22:59 +01:00
Pierre-Yves NicolasandGitHub 343495dafe New quad algorithm: identify edges from contour orientation (#130)
Goal: improve precision of automatic document cropping by switching:
- from Douglas-Peucker algorithm (OpenCV's approxPolyDP) + a heuristic for documents missing a corner
- to an algorithm that looks for edges

* New quad algorithm: identify edges from contour orientation
* Performance optimization: reduce number of calls to trigonometric functions
* Performance: use a single threshold for live analysis
* Fix orientation of debug mask and compute it only if required
* Exclude quads that go out of the frame
2026-03-07 12:09:41 +01:00
Pierre-Yves Nicolas cf196576fe Arabic: fix MissingQuantity for page_count and files_saved_to 2026-03-03 11:47:27 +01:00
jermanutsandPierre-Yves Nicolas ca7cdce0e1 Add Arabic translation 2026-03-03 11:47:27 +01:00
Pierre-Yves NicolasandGitHub d26f4048d2 GitHub action: upload APKs for PRs (#123) 2026-03-02 21:06:44 +01:00
ninjumandGitHub ee7c301f08 Create galician translation (gl) (#119) 2026-03-01 12:22:44 +01:00
Pierre-Yves Nicolas f70ea33fcc Prepare release: v1.15.0 2026-02-25 17:25:24 +01:00
Pierre-Yves Nicolas e52942e618 Upgrade fairscan-segmentation-model to v1.2.0 2026-02-25 16:42:34 +01:00
Pierre-Yves Nicolas 40f17eb571 evaluation: add tools for segmentation and quads 2026-02-25 16:26:58 +01:00
Pierre-Yves Nicolas 75b677ebbc evaluation: resize segmentation input with OpenCV to reduce difference with Android results 2026-02-16 20:50:23 +01:00
Pierre-Yves Nicolas a2ae0440b6 Prepare release: v1.14.0 2026-02-11 09:17:58 +01:00
Pierre-Yves Nicolas 0456a5329f Improve processing of color documents: retinex + contrast 2026-02-11 07:29:58 +01:00
Pierre-Yves Nicolas 933f00dba6 Add missing calls to Mat.release() 2026-02-11 07:29:58 +01:00
Pierre-Yves Nicolas c04f5a6fd2 Update README.md and screenshots 2026-02-04 17:35:30 +01:00
Pierre-Yves Nicolas c16b0d64bd Prepare release: v1.13.0 2026-02-03 18:42:45 +01:00
Pierre-Yves Nicolas 2c23978002 Update description and screenshots for F-Droid 2026-02-03 18:19:47 +01:00
Pierre-Yves Nicolas 60f6e404a8 CameraScreen: fix torch state persistence (broken by previous commit) 2026-02-03 16:54:46 +01:00
Pierre-Yves Nicolas b73dc20bd5 CameraScreen: avoid ghost quads when live analysis starts 2026-02-03 07:40:40 +01:00
Pierre-Yves Nicolas 9cdff7e74b Preview CameraScreen with different devices 2026-02-02 17:26:30 +01:00
Pierre-Yves Nicolas c91237cd2f Export: avoid running preparation when it's useless 2026-02-02 16:43:41 +01:00
Pierre-Yves Nicolas b135e8108a Export screen: persist (in-memory) filename during scan 2026-02-02 13:58:34 +01:00
Pierre-Yves Nicolas 974666f071 Export screen: style improvements 2026-02-01 20:56:02 +01:00
Pierre-Yves Nicolas 0c8974af9a Export screen: move filename field close to buttons 2026-02-01 18:10:36 +01:00
Pierre-Yves Nicolas a4822d3c9e Export screen: add thumbnail 2026-02-01 10:48:26 +01:00
Pierre-Yves Nicolas 88e5a701fa Document screen: make "Add page" button more explicit 2026-01-31 19:12:01 +01:00
Pierre-Yves Nicolas ec14abd696 Adjust style of thumbnails to show they can be tapped 2026-01-31 19:12:01 +01:00
Pierre-Yves Nicolas 37d4ded7f8 Move Document screen out of default scan process 2026-01-31 19:12:01 +01:00
Pierre-Yves Nicolas 6fa3fe68fb Settings: handle lost access to export folder 2026-01-31 06:50:13 +01:00
Pierre-Yves Nicolas 429d4e7a37 Export: detailed error reporting 2026-01-30 19:18:21 +01:00
Pierre-Yves Nicolas a5b20c22c0 Export screen: stop using toasts for save errors 2026-01-30 09:22:21 +01:00
Pierre-Yves Nicolas f32b4f8799 Export screen: add a spinner during the save operation 2026-01-29 21:07:18 +01:00
Pierre-Yves Nicolas 85c966a7aa Use the real name of the selected export folder rather than manipulating the URI 2026-01-29 17:06:31 +01:00
Pierre-Yves Nicolas 435de6c2e8 Fix export failures with some cloud storage providers (#106) 2026-01-29 11:43:39 +01:00
Pierre-Yves Nicolas 07753427e4 Specific message when no permission for export dir (#105) 2026-01-28 20:36:41 +01:00
191 changed files with 9578 additions and 2615 deletions
+9
View File
@@ -32,3 +32,12 @@ jobs:
- name: Run Gradle
run: ./gradlew clean license check assembleRelease --no-daemon
- name: Upload APKs
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@v4
with:
name: FairScan-APKs-PR-${{ github.event.pull_request.number }}
path: |
app/build/outputs/apk/release/*.apk
retention-days: 30
+2 -1
View File
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="2.2.21" />
<option name="externalSystemId" value="Gradle" />
<option name="version" value="2.3.10" />
</component>
</project>
+1
View File
@@ -1,3 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
+35
View File
@@ -0,0 +1,35 @@
## Contributing
Thanks for your interest in contributing to FairScan.
Before contributing, please take a moment to understand the scope of the project.
FairScan is an app that focuses on simplicity. The goal is to provide a fast, reliable scanning experience without requiring manual adjustments.
- The goal is not to re-implement all features found in similar apps
- The goal is not to give users control over every aspect of the generated PDFs
Recommended reading: https://fairscan.org/blog/fairscan-purpose/
### Issues
You're very welcome to describe the problems you face when using FairScan.
When the automatic processing gives a bad result, please share the original image whenever possible. You can get it by tapping "Report a problem with last captured image" in the "About" screen. You can either attach the image to the GitHub issue or send it privately to contact@fairscan.org. Screenshots are usually not helpful to understand image-processing issues.
The most useful issues focus on the problem rather than on a proposed solution. Understanding the underlying problem is essential to finding the right solution. A detailed description of the problem and its context is therefore usually much more valuable than discussing a specific change to the app.
- ❌ "Please add a button or an option to..."
- ✅ "Here is the problem I face, the context in which it occurs, and why it is problematic..."
### Pull Requests
- Please avoid starting any significant work without discussing it first.
- Small, focused PRs are much easier to review and integrate.
- FairScan is primarily developed by its maintainer, and core parts of the app are implemented directly to ensure consistency with the overall vision. Large or architectural changes are unlikely to be accepted.
### Project direction
FairScan is currently maintained by a single person, and product and technical decisions are centralized.
This helps keep the project consistent and aligned with its original vision, and may evolve in the future.
Thanks for your understanding.
+1 -1
View File
@@ -1,4 +1,4 @@
Copyright 2025-2026 Pierre-Yves Nicolas
Copyright 2025-2026 The FairScan authors
This program is free software: you can redistribute it and/or modify it
under the terms of the GNU General Public License as published by the Free
+29 -2
View File
@@ -32,6 +32,23 @@ And **respectful**: open source, minimal permissions, no tracking, no ads.
---
## Contributing
Contributions are welcome, but please read the guidelines first: [CONTRIBUTING.md](CONTRIBUTING.md)
---
## Translations
Translations are managed on Weblate, and everyone is welcome to contribute.
[![Translation status](https://hosted.weblate.org/widget/fairscan/android-app/language-badge.svg?threshold=95)](https://hosted.weblate.org/engage/fairscan/)
Start translating:
https://hosted.weblate.org/projects/fairscan/
---
## Screenshots
| Scan | Preview | Save & Share |
@@ -43,17 +60,26 @@ And **respectful**: open source, minimal permissions, no tracking, no ads.
## Features
- **Clear, distraction-free interface**
- **Easy scanning process**: scan → preview share
- **Easy flow**: scan, review if needed, save or share
- **Automatic document detection** using a custom segmentation model
- **Automatic perspective correction**
- **Automatic image enhancement**
- **Fast PDF generation** with no manual adjustments
- **Fully offline** the app has *no* internet permission
- **All document processing happens locally on your device**, no cloud processing
- **Minimal permissions**
- **Open source**, GPLv3
---
## What FairScan is not
FairScan is **not** intended to:
- provide fine-grained manual control over document processing
- replicate all features found in other scanning apps
- optimize for highly specific use cases at the expense of simplicity
---
## Compatibility
FairScan works on any device that:
@@ -95,6 +121,7 @@ FairScan uses:
- [CameraX](https://developer.android.com/media/camera/camerax) for image capture
- [LiteRT](https://ai.google.dev/edge/litert) to run the custom segmentation model for automatic document detection
- [OpenCV](https://opencv.org/) for perspective correction and image enhancement
- [Tesseract](https://github.com/tesseract-ocr/tesseract) for text recognition (OCR)
- [PDFBox-Android](https://github.com/TomRoush/PdfBox-Android) for PDF generation
---
+5 -21
View File
@@ -3,7 +3,6 @@ plugins {
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.aboutLibrariesAndroid)
alias(libs.plugins.protobuf)
alias(libs.plugins.kotlin.serialization)
}
@@ -26,8 +25,8 @@ android {
// https://ai.google.dev/edge/litert/android/index
minSdk = 26
targetSdk = 36
versionCode = 48 // increment by 3 because of ABI-specific APKs
versionName = "1.12.1"
versionCode = 93 // increment by 3 because of ABI-specific APKs
versionName = "2.2.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -100,7 +99,7 @@ android {
}
}
apply(from = "download-tflite.gradle.kts")
apply(from = file("download-tflite.gradle.kts"))
dependencies {
@@ -122,10 +121,8 @@ dependencies {
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view)
implementation(libs.androidx.datastore)
implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.documentfile)
implementation(libs.protobuf.javalite)
implementation(libs.litert)
implementation(libs.litert.support)
implementation(libs.litert.metadata)
@@ -139,9 +136,11 @@ dependencies {
implementation(libs.reorderable)
implementation(libs.aboutlibraries.compose.m3)
implementation(libs.kotlinx.serialization.json)
implementation(libs.tesseract4android)
testImplementation(libs.junit)
testImplementation(libs.assertj)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
@@ -159,21 +158,6 @@ aboutLibraries {
}
}
protobuf {
protoc {
artifact = "com.google.protobuf:protoc:4.32.0"
}
generateProtoTasks {
all().forEach { task ->
task.builtins {
create("java") {
option("lite")
}
}
}
}
}
// See https://developer.android.com/build/configure-apk-splits
androidComponents {
onVariants { variant ->
+1 -1
View File
@@ -1,7 +1,7 @@
import org.gradle.api.tasks.Copy
import java.net.URL
val modelVersion = "v1.1.0"
val modelVersion = "v1.2.0"
val modelFileName = "fairscan-segmentation-model.tflite"
val modelUrl = "https://github.com/pynicolas/fairscan-segmentation-model/releases/download/$modelVersion/$modelFileName"
@@ -1,75 +0,0 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.domain
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Log
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.runBlocking
import org.fairscan.app.ui.screens.camera.extractDocumentFromBitmap
import org.fairscan.imageprocessing.detectDocumentQuad
import org.fairscan.imageprocessing.scaledTo
import org.junit.Assert.assertEquals
import org.junit.Assert.fail
import org.junit.Test
import org.junit.runner.RunWith
import org.opencv.android.OpenCVLoader
import java.io.File
import java.io.FileOutputStream
@RunWith(AndroidJUnit4::class)
class DocumentDetectionTest {
@Test
fun extractDocumentFromImage() {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("org.fairscan.app", appContext.packageName)
val context = ApplicationProvider.getApplicationContext<Context>()
val segmentationService = ImageSegmentationService(context) { _, _, _ -> }
segmentationService.initialize()
OpenCVLoader.initLocal()
listOf("img01.jpg", "img02.jpg", "img03.jpg").forEach { imageFileName ->
val inputStream = context.assets.open("uncropped/$imageFileName")
val bitmap = BitmapFactory.decodeStream(inputStream)
var outputBitmap: Bitmap? = null
val segmentationResult = runBlocking {
segmentationService.runSegmentationAndReturn(bitmap, 0)
}
if (segmentationResult != null) {
val mask = segmentationResult.segmentation
val quad = detectDocumentQuad(mask, false)
if (quad != null) {
val resizedQuad =
quad.scaledTo(mask.width, mask.height, bitmap.width, bitmap.height)
outputBitmap = extractDocumentFromBitmap(bitmap, resizedQuad, 0, mask).page
val file = File(context.getExternalFilesDir(null), imageFileName)
FileOutputStream(file).use {
outputBitmap.compress(Bitmap.CompressFormat.JPEG, 95, it)
}
Log.i("DocumentDetectionTest", "Image saved to ${file.absolutePath}")
}
}
if (outputBitmap == null) {
fail("Failed to extract document from image $imageFileName")
}
}
}
}
+11
View File
@@ -8,6 +8,11 @@
<uses-permission android:name="android.permission.CAMERA" />
<!-- Required (on Android 9 and lower) to save files -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
<!-- To download Tesseract language data -->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- REMOVE ACCESS_NETWORK_STATE -->
<!-- cameraX 1.6.1 depends on androidx.media3:media3-common which adds ACCESS_NETWORK_STATE -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" tools:node="remove" />
<application
android:allowBackup="true"
@@ -33,6 +38,12 @@
<action android:name="org.fairscan.app.action.SCAN_TO_PDF" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.GET_CONTENT"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.OPENABLE"/>
<data android:mimeType="application/pdf"/>
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
Binary file not shown.
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -17,18 +17,23 @@ package org.fairscan.app
import android.app.Application
import android.content.Context
import android.os.Environment
import androidx.datastore.preferences.preferencesDataStore
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.CreationExtras
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.fairscan.app.data.FileLogger
import org.fairscan.app.data.FileManager
import org.fairscan.app.data.LogRepository
import org.fairscan.app.data.recentDocumentsDataStore
import org.fairscan.app.data.OcrLanguageRepository
import org.fairscan.app.domain.ImageSegmentationService
import org.fairscan.app.domain.OcrService
import org.fairscan.app.platform.AndroidImageLoader
import org.fairscan.app.platform.AndroidPdfWriter
import org.fairscan.app.ui.screens.about.AboutViewModel
import org.fairscan.app.ui.screens.camera.CameraViewModel
import org.fairscan.app.ui.screens.home.HomeViewModel
import org.fairscan.app.ui.screens.settings.SettingsRepository
import org.fairscan.app.ui.screens.settings.SettingsViewModel
import java.io.File
@@ -45,19 +50,31 @@ class FairScanApp : Application() {
const val THUMBNAIL_SIZE_DP = 120
private val Context.dataStore by preferencesDataStore(name = "fairscan_settings")
class AppContainer(context: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val cacheDir = context.cacheDir
private val dataStore = context.dataStore
val preparationDir = File(context.cacheDir, "pdfs")
val ocrLanguageRepository =
OcrLanguageRepository(dataStore, File(context.filesDir, "tesseract/tessdata"))
val ocrService = OcrService(ocrLanguageRepository, scope)
val fileManager = FileManager(
preparationDir,
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
AndroidPdfWriter()
AndroidPdfWriter(ocrService, context.assets)
)
val logRepository = LogRepository(File(context.filesDir, "logs.txt"))
val logger = FileLogger(logRepository)
val imageSegmentationService = ImageSegmentationService(context, logger)
val recentDocumentsDataStore = context.recentDocumentsDataStore
val settingsRepository = SettingsRepository(context)
val imageLoader = AndroidImageLoader(context.contentResolver)
val settingsRepository = SettingsRepository(context, dataStore)
init {
scope.launch { imageSegmentationService.initialize() }
scope.launch { ocrService.initialize() }
}
@Suppress("UNCHECKED_CAST")
inline fun <reified VM : ViewModel> viewModelFactory(
@@ -68,7 +85,6 @@ class AppContainer(context: Context) {
}
}
val homeViewModelFactory = viewModelFactory { HomeViewModel(it, context) }
val cameraViewModelFactory = viewModelFactory { CameraViewModel(it) }
val settingsViewModelFactory = viewModelFactory { SettingsViewModel(it) }
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -27,6 +27,7 @@ import android.os.Build.VERSION.SDK_INT
import android.os.Build.VERSION_CODES.Q
import android.os.Bundle
import android.util.Log
import android.view.KeyEvent
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.ManagedActivityResultLauncher
@@ -53,8 +54,8 @@ import org.fairscan.app.data.ImageRepository
import org.fairscan.app.ui.Navigation
import org.fairscan.app.ui.Screen
import org.fairscan.app.ui.components.rememberCameraPermissionState
import org.fairscan.app.ui.screens.DocumentScreen
import org.fairscan.app.ui.screens.LibrariesScreen
import org.fairscan.app.ui.screens.ResumeScanScreen
import org.fairscan.app.ui.screens.about.AboutEvent
import org.fairscan.app.ui.screens.about.AboutScreen
import org.fairscan.app.ui.screens.about.AboutViewModel
@@ -62,15 +63,16 @@ import org.fairscan.app.ui.screens.about.createEmailWithImageIntent
import org.fairscan.app.ui.screens.camera.CameraEvent
import org.fairscan.app.ui.screens.camera.CameraScreen
import org.fairscan.app.ui.screens.camera.CameraViewModel
import org.fairscan.app.ui.screens.crop.CropScreen
import org.fairscan.app.ui.screens.document.DocumentScreen
import org.fairscan.app.ui.screens.export.ExportActions
import org.fairscan.app.ui.screens.export.ExportEvent
import org.fairscan.app.ui.screens.export.ExportResult
import org.fairscan.app.ui.screens.export.ExportScreenWrapper
import org.fairscan.app.ui.screens.export.ExportViewModel
import org.fairscan.app.ui.screens.home.HomeScreen
import org.fairscan.app.ui.screens.home.HomeViewModel
import org.fairscan.app.ui.screens.settings.ExportFormat
import org.fairscan.app.ui.screens.settings.OcrLanguagesScreen
import org.fairscan.app.ui.screens.settings.SettingsScreen
import org.fairscan.app.ui.screens.settings.SettingsUiState
import org.fairscan.app.ui.screens.settings.SettingsViewModel
import org.fairscan.app.ui.theme.FairScanTheme
import org.opencv.android.OpenCVLoader
@@ -78,6 +80,9 @@ import java.io.File
class MainActivity : ComponentActivity() {
private lateinit var cameraViewModel: CameraViewModel
private lateinit var viewModel: MainViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
initLibraries()
@@ -94,11 +99,11 @@ class MainActivity : ComponentActivity() {
}
val imageRepository = sessionViewModel.imageRepository
val viewModel: MainViewModel by viewModels {
viewModel = viewModels<MainViewModel> {
appContainer.viewModelFactory {
MainViewModel(imageRepository, launchMode)
MainViewModel(imageRepository, appContainer.logger)
}
}
}.value
val exportViewModel: ExportViewModel by viewModels {
appContainer.viewModelFactory {
ExportViewModel(appContainer, imageRepository)
@@ -109,8 +114,7 @@ class MainActivity : ComponentActivity() {
AboutViewModel(appContainer, imageRepository)
}
}
val homeViewModel: HomeViewModel by viewModels { appContainer.homeViewModelFactory }
val cameraViewModel: CameraViewModel by viewModels { appContainer.cameraViewModelFactory }
cameraViewModel = viewModels<CameraViewModel> { appContainer.cameraViewModelFactory }.value
val settingsViewModel: SettingsViewModel
by viewModels { appContainer.settingsViewModelFactory }
@@ -119,11 +123,16 @@ class MainActivity : ComponentActivity() {
}
enableEdgeToEdge()
setContent {
val logger = appContainer.logger
val context = LocalContext.current
val currentScreen by viewModel.currentScreen.collectAsStateWithLifecycle()
val liveAnalysisState by cameraViewModel.liveAnalysisState.collectAsStateWithLifecycle()
val importState by cameraViewModel.importState.collectAsStateWithLifecycle()
val document by viewModel.documentUiModel.collectAsStateWithLifecycle()
val documentUiState by viewModel.documentUiState.collectAsStateWithLifecycle()
val cropInitialState by viewModel.cropInitState.collectAsStateWithLifecycle()
val exportUiState by exportViewModel.uiState.collectAsStateWithLifecycle()
val settingsUiState by settingsViewModel.uiState.collectAsStateWithLifecycle()
val cameraPermission = rememberCameraPermissionState()
CollectCameraEvents(cameraViewModel, viewModel)
CollectExportEvents(context, exportViewModel)
@@ -131,66 +140,99 @@ class MainActivity : ComponentActivity() {
FairScanTheme {
val navigation = navigation(viewModel, launchMode)
when (val screen = currentScreen) {
is Screen.Main.Home -> {
val recentDocs by homeViewModel.recentDocuments.collectAsStateWithLifecycle()
HomeScreen(
cameraPermission = cameraPermission,
currentDocument = document,
navigation = navigation,
onClearScan = { viewModel.startNewDocument() },
recentDocuments = recentDocs,
onOpenPdf = { fileUri -> openUri(fileUri, ExportFormat.PDF.mimeType) }
val onExportClick = if (launchMode == LaunchMode.EXTERNAL_SCAN_TO_PDF) {
{
lifecycleScope.launch {
try {
val result = exportViewModel.generatePdfForExternalCall()
sendActivityResult(result)
} catch (e: Exception) {
val message = "Export failed"
showToast(message)
appContainer.logger.e("MainActivity", message, e)
return@launch
}
viewModel.startNewDocument()
finish()
}
Unit
}
} else {
navigation.toExportScreen
}
when (currentScreen) {
null -> {
// waiting to load pages to get an initial screen
}
is Screen.Main.ResumeScan -> {
ResumeScanScreen(
currentDocument = documentUiState,
onResumeScan = navigation.toCameraScreen,
onStartNewScan = {
viewModel.startNewDocument()
navigation.toCameraScreen()
}
)
}
is Screen.Main.Camera -> {
val pickMultiple = rememberLauncherForActivityResult(
ActivityResultContracts.GetMultipleContents()) {
uris -> cameraViewModel.importPhotos(uris)
}
CameraScreen(
viewModel,
cameraViewModel,
navigation,
liveAnalysisState,
importState,
onImageAnalyzed = { image -> cameraViewModel.liveAnalysis(image) },
onFinalizePressed = navigation.toDocumentScreen,
cameraPermission = cameraPermission
onFinalizePressed = onExportClick,
cameraPermission = cameraPermission,
onImportClicked = {
cameraViewModel.onImportClicked()
pickMultiple.launch("image/*")
}
)
}
is Screen.Main.EditImage -> {
CropScreen(
pageId = documentUiState.currentPage?.key?.pageId ?: "",
initState = cropInitialState,
navigation = navigation,
onUpdatePageQuad = { quad -> viewModel.setCurrentPageUserQuad(quad) },
)
}
is Screen.Main.Document -> {
DocumentScreen (
document = document,
initialPage = screen.initialPage,
uiState = documentUiState,
navigation = navigation,
onExportClick = if (launchMode == LaunchMode.EXTERNAL_SCAN_TO_PDF) {
{
lifecycleScope.launch {
val result = exportViewModel.generatePdfForExternalCall()
sendActivityResult(result)
viewModel.startNewDocument()
finish()
}
Unit
}
} else {
navigation.toExportScreen
},
onDeleteImage = { id -> viewModel.deletePage(id) },
onRotateImage = { id, clockwise -> viewModel.rotateImage(id, clockwise) },
onExportClick = onExportClick,
onDeleteImage = { viewModel.deleteCurrentPage() },
onRotateImage = { clockwise -> viewModel.rotateCurrentPage(clockwise) },
onToggleColorMode = { viewModel.toggleCurrentPageColorMode() },
onCropClick = { viewModel.onClickOnCropButton() },
onPageReorder = { id, newIndex -> viewModel.movePage(id, newIndex) },
onPageSelected = viewModel::onPageSelected
)
}
is Screen.Main.Export -> {
ExportScreenWrapper(
navigation = navigation,
uiState = exportUiState,
pdfActions = ExportActions(
initializeExportScreen = exportViewModel::initializeExportScreen,
currentDocument = document,
exportActions = ExportActions(
prepareExportIfNeeded = exportViewModel::prepareExportIfNeeded,
setFilename = exportViewModel::setFilename,
share = { share(exportViewModel.applyRenaming(), exportViewModel) },
share = { exportViewModel.onShareClicked() },
save = { exportViewModel.onSaveClicked() },
open = { item -> openUri(item.uri, item.format.mimeType) },
open = { item -> openUri(item.uri, item.format.mimeType, logger) },
cancelPreparationJob = exportViewModel::cancelPreparationJob,
),
onCloseScan = {
exportViewModel.resetFilename()
viewModel.startNewDocument()
viewModel.navigateTo(Screen.Main.Home)
viewModel.navigateTo(Screen.Main.Camera)
}
)
}
@@ -205,13 +247,29 @@ class MainActivity : ComponentActivity() {
onCopyLogs = { aboutViewModel.onCopyLogsClicked() },
onContactWithLastImageClicked =
{ aboutViewModel.onContactWithLastImageClicked() },
onStartActivity = { intent ->
try {
startActivity(intent)
} catch (_: ActivityNotFoundException) {
showToast(getString(R.string.error_occurred))
}
},
onViewLibraries = navigation.toLibrariesScreen)
}
is Screen.Overlay.Libraries -> {
LibrariesScreen(onBack = navigation.back)
}
is Screen.Overlay.Settings -> {
SettingsScreenWrapper(settingsViewModel, navigation, appContainer.logger)
SettingsScreenWrapper(settingsUiState, settingsViewModel, navigation, logger)
}
is Screen.Overlay.OcrLanguages -> {
OcrLanguagesScreen(
uiState = settingsUiState,
onBack = navigation.back,
onLanguageClick = settingsViewModel::onLanguageClick,
onRemoveLanguage = settingsViewModel::onRemoveLanguage,
onCancelOcrDownload = settingsViewModel::cancelOcrDownload,
)
}
}
}
@@ -221,12 +279,18 @@ class MainActivity : ComponentActivity() {
private fun resolveLaunchMode(intent: Intent?): LaunchMode {
return when (intent?.action) {
"org.fairscan.app.action.SCAN_TO_PDF" -> LaunchMode.EXTERNAL_SCAN_TO_PDF
"android.intent.action.GET_CONTENT" -> LaunchMode.EXTERNAL_SCAN_TO_PDF
else -> LaunchMode.NORMAL
}
}
private fun showToast(text: String) {
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
}
@Composable
private fun SettingsScreenWrapper(
settingsUiState: SettingsUiState,
settingsViewModel: SettingsViewModel,
nav: Navigation,
logger: FileLogger,
@@ -242,27 +306,29 @@ class MainActivity : ComponentActivity() {
settingsViewModel.setExportDirUri(uri.toString())
} catch (e: Exception) {
logger.e("Settings", "Failed to set export dir to $uri", e)
val text = getString(R.string.error_file_picker_result)
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
showToast(this.getString(R.string.error_file_picker_result))
}
}
}
val settingsUiState by settingsViewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
settingsViewModel.refreshExportDirName()
}
SettingsScreen(
settingsUiState,
onDefaultColorModeChanged = { mode -> settingsViewModel.setDefaultColorMode(mode) },
onChooseDirectoryClick = {
try {
launcher.launch(null)
} catch (e: Exception) {
val message = getString(R.string.error_file_picker_launch)
logger.e("Settings", message, e)
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
showToast(message)
}
},
onResetExportDirClick = { settingsViewModel.setExportDirUri(null) },
onExportFormatChanged = { format -> settingsViewModel.setExportFormat(format) },
onExportQualityChanged = { quality -> settingsViewModel.setExportQuality(quality) },
onBack = nav.back,
navigation = nav,
)
}
@@ -281,7 +347,7 @@ class MainActivity : ComponentActivity() {
clipboard.setClipEntry(
ClipData.newPlainText("FairScan logs", event.logs).toClipEntry()
)
Toast.makeText(context, msgCopiedLogs, Toast.LENGTH_SHORT).show()
showToast(msgCopiedLogs)
}
is AboutEvent.PrepareEmailWithLastImage -> {
val file = imageRepository.lastAddedSourceFile()
@@ -305,8 +371,7 @@ class MainActivity : ComponentActivity() {
if (isGranted) {
exportViewModel.onSaveClicked()
} else {
val message = getString(R.string.storage_permission_denied)
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
showToast(this.getString(R.string.storage_permission_denied))
}
}
LaunchedEffect(Unit) {
@@ -317,10 +382,8 @@ class MainActivity : ComponentActivity() {
exportViewModel.onRequestSave(context)
}
}
is ExportEvent.SaveError -> {
val text = getString(R.string.error_save)
Toast.makeText(context, text, Toast.LENGTH_SHORT).show()
is ExportEvent.Share -> {
share(event.result)
}
}
}
@@ -341,10 +404,8 @@ class MainActivity : ComponentActivity() {
}
}
private fun share(result: ExportResult?, viewModel: ExportViewModel) {
if (result == null || result.files.isEmpty()) return
viewModel.setAsShared()
private fun share(result: ExportResult) {
if (result.files.isEmpty()) return
val uris = result.files.map(::uriForFile)
val intent = Intent().apply {
@@ -401,7 +462,7 @@ class MainActivity : ComponentActivity() {
}
}
private fun openUri(fileUri: Uri?, mimeType: String) {
private fun openUri(fileUri: Uri?, mimeType: String, logger: FileLogger) {
if (fileUri == null) return
val uriToOpen: Uri =
if (fileUri.scheme == ContentResolver.SCHEME_CONTENT) {
@@ -409,14 +470,23 @@ class MainActivity : ComponentActivity() {
} else {
uriForFile(fileUri.toFile())
}
val openIntent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uriToOpen, mimeType)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
try {
startActivity(Intent.createChooser(openIntent, getString(R.string.open_file)))
val openIntent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uriToOpen, mimeType)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
clipData = ClipData.newUri(contentResolver, "file", uriToOpen)
}
val chooser = Intent.createChooser(openIntent, getString(R.string.open_file)).apply {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(chooser)
} catch (_: ActivityNotFoundException) {
Toast.makeText(this, getString(R.string.error_no_app), Toast.LENGTH_SHORT).show()
showToast(getString(R.string.error_no_app))
} catch (e: Exception) {
val errorMessage =
"Failed to open URI, scheme=${uriToOpen.scheme}, authority=${uriToOpen.authority}"
logger.e("OpenUri", errorMessage, e)
showToast(getString(R.string.error_occurred))
}
}
@@ -431,8 +501,8 @@ class MainActivity : ComponentActivity() {
}
private fun navigation(viewModel: MainViewModel, launchMode: LaunchMode): Navigation = Navigation(
toHomeScreen = { viewModel.navigateTo(Screen.Main.Home) },
toCameraScreen = { viewModel.navigateTo(Screen.Main.Camera) },
toEditImageScreen = { viewModel.navigateTo(Screen.Main.EditImage) },
toDocumentScreen = { viewModel.navigateTo(Screen.Main.Document()) },
toExportScreen = { viewModel.navigateTo(Screen.Main.Export) },
toAboutScreen = { viewModel.navigateTo(Screen.Overlay.About) },
@@ -442,6 +512,7 @@ class MainActivity : ComponentActivity() {
viewModel.navigateTo(Screen.Overlay.Settings)
}
},
toOcrLanguagesScreen = { viewModel.navigateTo(Screen.Overlay.OcrLanguages) },
back = {
val origin = viewModel.currentScreen.value
viewModel.navigateBack()
@@ -450,7 +521,21 @@ class MainActivity : ComponentActivity() {
setResult(RESULT_CANCELED)
finish()
}
},
shouldDisplayBackButton = {
viewModel.currentScreen.value !is Screen.Main.Camera
|| launchMode == LaunchMode.EXTERNAL_SCAN_TO_PDF
}
)
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (keyCode == KeyEvent.KEYCODE_VOLUME_UP ||
keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) {
if (viewModel.currentScreen.value is Screen.Main.Camera) {
cameraViewModel.onVolumeKeyPressed()
return true
}
}
return super.onKeyDown(keyCode, event)
}
}
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -16,107 +16,274 @@ package org.fairscan.app
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.fairscan.app.data.ImageRepository
import org.fairscan.app.data.Logger
import org.fairscan.app.domain.CapturedPage
import org.fairscan.app.domain.PageViewKey
import org.fairscan.app.domain.Rotation
import org.fairscan.app.domain.ScanPage
import org.fairscan.app.ui.NavigationState
import org.fairscan.app.ui.Screen
import org.fairscan.app.ui.screens.crop.CropInitState
import org.fairscan.app.ui.screens.document.CurrentPageUiState
import org.fairscan.app.ui.screens.document.DocumentUiState
import org.fairscan.app.ui.state.DocumentUiModel
import java.io.ByteArrayOutputStream
import org.fairscan.app.ui.state.PageThumbnail
import org.fairscan.imageprocessing.ColorMode
import org.fairscan.imageprocessing.ImageSize
import org.fairscan.imageprocessing.Quad
import kotlin.math.min
class MainViewModel(val imageRepository: ImageRepository, launchMode: LaunchMode): ViewModel() {
@OptIn(ExperimentalCoroutinesApi::class)
class MainViewModel(val imageRepository: ImageRepository, logger: Logger): ViewModel() {
private val _navigationState = MutableStateFlow(NavigationState.initial(launchMode))
val currentScreen: StateFlow<Screen> = _navigationState.map { it.current }
.stateIn(viewModelScope, SharingStarted.Eagerly, _navigationState.value.current)
private val _navigationState = MutableStateFlow<NavigationState?>(null)
val currentScreen: StateFlow<Screen?> = _navigationState.map { it?.current }
.stateIn(viewModelScope, SharingStarted.Eagerly, null)
private val _pages = MutableStateFlow<List<ScanPage>>(emptyList())
init {
viewModelScope.launch {
val pages = imageRepository.pages()
_pages.value = pages
_navigationState.value =
if (pages.isEmpty()) {
NavigationState.initial()
} else {
NavigationState.initial().navigateTo(Screen.Main.ResumeScan)
}
}
}
private val _pages = MutableStateFlow(imageRepository.pages())
val documentUiModel: StateFlow<DocumentUiModel> =
_pages.map { pages ->
DocumentUiModel(
pageKeys = pages.map { p ->
PageViewKey(p.id, p.manualRotation)
}.toImmutableList(),
imageLoader = ::getBitmap,
thumbnailLoader = ::getThumbnail,
)
}.stateIn(
pages.map {
val jpeg = imageRepository.getThumbnail(it.key())
PageThumbnail(it.key(), jpeg)
}.toImmutableList()
}
.flowOn(Dispatchers.IO)
.map { DocumentUiModel(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = DocumentUiModel(persistentListOf(), ::getBitmap, ::getThumbnail)
initialValue = DocumentUiModel()
)
private val _currentPageIndex = MutableStateFlow(0)
private val _loadingPageId = MutableStateFlow<String?>(null)
private val currentPageUiState: Flow<CurrentPageUiState?> =
combine(_currentPageIndex, _pages, _loadingPageId) { index, pages, loadingId ->
val page = pages.getOrNull(index)
Pair(page, loadingId)
}
.mapLatest { (page,loadingId) ->
page?.let {
val isLoading = (it.id == loadingId)
val canBeCropped = page.metadata != null
val bitmap = try {
imageRepository.jpegBytes(it.key())?.toBitmap()
} catch (e: Exception) {
logger.e("MainViewModel", "Failed to load image for ${it.id}", e)
null
}
CurrentPageUiState(it.key(), bitmap, it.colorMode, canBeCropped, isLoading)
}
}
.flowOn(Dispatchers.IO)
val documentUiState: StateFlow<DocumentUiState> =
combine(_currentPageIndex, currentPageUiState, documentUiModel) { index, page, document ->
DocumentUiState(index, page, document)
}
.stateIn(
viewModelScope, SharingStarted.Eagerly,
DocumentUiState(0, null, DocumentUiModel())
)
fun onPageSelected(index: Int) {
_currentPageIndex.value = index
}
fun navigateTo(destination: Screen) {
_navigationState.update { it.navigateTo(destination) }
if (destination is Screen.Main.Document) {
require(_pages.value.isNotEmpty()) {
"Cannot navigate to DocumentScreen with zero pages"
}
_currentPageIndex.value = min(_pages.value.size - 1, destination.initialPage)
}
_navigationState.update { it?.navigateTo(destination) }
}
fun navigateBack() {
_navigationState.update { stack -> stack.navigateBack() }
_navigationState.update { stack -> stack?.navigateBack() }
}
fun rotateImage(id: String, clockwise: Boolean) {
fun rotateCurrentPage(clockwise: Boolean) {
viewModelScope.launch {
imageRepository.rotate(id, clockwise)
_pages.value = imageRepository.pages()
val pages = withContext(Dispatchers.IO) {
imageRepository.rotate(currentPage().id, clockwise)
imageRepository.pages()
}
_pages.value = pages
}
}
fun movePage(id: String, newIndex: Int) {
viewModelScope.launch {
imageRepository.movePage(id, newIndex)
_pages.value = imageRepository.pages()
val pages = withContext(Dispatchers.IO) {
imageRepository.movePage(id, newIndex)
imageRepository.pages()
}
_pages.value = pages
}
}
fun deletePage(id: String) {
fun deleteCurrentPage() {
viewModelScope.launch {
imageRepository.delete(id)
_pages.value = imageRepository.pages()
val pages = withContext(Dispatchers.IO) {
imageRepository.delete(currentPage().id)
imageRepository.pages()
}
if (pages.isEmpty()) {
navigateTo(Screen.Main.Camera)
_currentPageIndex.value = 0
} else if (_currentPageIndex.value >= pages.size) {
_currentPageIndex.value = pages.size - 1
}
_pages.value = pages
}
}
fun toggleCurrentPageColorMode() {
viewModelScope.launch {
val currentPage = currentPage()
currentPage.colorMode?.let {
_loadingPageId.value = currentPage.id
val newColorMode =
if (it == ColorMode.COLOR) ColorMode.GRAYSCALE else ColorMode.COLOR
val pages = withContext(Dispatchers.IO) {
imageRepository.setColorMode(currentPage.id, newColorMode)
imageRepository.pages()
}
_pages.value = pages
_loadingPageId.value = null
}
}
}
fun setCurrentPageUserQuad(userQuad: Quad) {
viewModelScope.launch {
val currentPage = currentPage()
val totalRotation = currentPage.totalRotation()
val rotateIterations = (4 - totalRotation.degrees / 90) % 4
val newQuad = userQuad.rotate90(rotateIterations, ImageSize(1, 1))
_loadingPageId.value = currentPage.id
val pages = withContext(Dispatchers.IO) {
imageRepository.setUserQuad(currentPage.id, newQuad)
imageRepository.pages()
}
_pages.value = pages
_loadingPageId.value = null
}
}
private fun currentPage(): ScanPage {
val index = _currentPageIndex.value
val pages = _pages.value
return pages.getOrNull(index) ?: throw IllegalStateException(
"No current page for index $index (${pages.size} pages)")
}
fun startNewDocument() {
_pages.value = persistentListOf()
viewModelScope.launch {
imageRepository.clear()
withContext(Dispatchers.IO) {
imageRepository.clear()
}
}
}
fun getBitmap(key: PageViewKey): Bitmap? {
val bytes = imageRepository.jpegBytes(key)
return bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
}
fun getThumbnail(key: PageViewKey): Bitmap? {
val bytes = imageRepository.getThumbnail(key)
return bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
}
fun handleImageCaptured(capturedPage: CapturedPage) {
viewModelScope.launch {
imageRepository.add(
compressJpeg(capturedPage.page, 75),
compressJpeg(capturedPage.source, 90),
capturedPage.metadata,
)
_pages.value = imageRepository.pages()
val pages = withContext(Dispatchers.IO) {
val sourceJpeg = capturedPage.sourceJpeg.await()
imageRepository.add(
capturedPage.pageJpeg,
sourceJpeg,
capturedPage.metadata,
capturedPage.colorMode,
)
imageRepository.pages()
}
_pages.value = pages
}
}
private fun compressJpeg(bitmap: Bitmap, quality: Int): ByteArray {
val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream)
return outputStream.toByteArray()
private val _cropInitState = MutableStateFlow<CropInitState>(CropInitState.Loading)
val cropInitState: StateFlow<CropInitState> = _cropInitState
private var cropInitialStateJob: Job? = null
fun onClickOnCropButton() {
cropInitialStateJob?.cancel()
cropInitialStateJob = viewModelScope.launch {
_cropInitState.value = CropInitState.Loading
val page = currentPage()
val metadata = page.metadata
val rotation = page.totalRotation()
val bitmap = withContext(Dispatchers.IO) {
val source = imageRepository.source(page.id)
val bytes = source?.bytes ?: return@withContext null
val original = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
if (original != null && rotation != Rotation.R0) {
val matrix = Matrix().apply { postRotate(rotation.degrees.toFloat()) }
Bitmap.createBitmap(
original, 0, 0, original.width, original.height, matrix, true
)
} else {
original
}
}
val quad = metadata?.normalizedQuad?.rotate90(
rotation.degrees / 90,
ImageSize(1, 1)
)
_cropInitState.value = if (bitmap == null || quad == null)
CropInitState.Error
else
CropInitState.Ready(page.id, bitmap, quad)
navigateTo(Screen.Main.EditImage)
}
}
}
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -17,8 +17,11 @@ package org.fairscan.app
import android.app.Application
import android.content.Context
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CoroutineScope
import org.fairscan.app.data.ImageRepository
import org.fairscan.app.platform.OpenCvTransformations
import org.fairscan.app.data.Logger
import org.fairscan.app.platform.ImageProcessor
import java.io.File
import java.util.UUID
@@ -39,7 +42,9 @@ class SessionViewModel(
private val sessionContainer = ScanSessionContainer(
context = app,
scanRootDir = sessionDir
scanRootDir = sessionDir,
scope = viewModelScope,
logger = appContainer.logger,
)
val imageRepository: ImageRepository = sessionContainer.imageRepository
@@ -55,14 +60,17 @@ class SessionViewModel(
class ScanSessionContainer(
context: Context,
scanRootDir: File
scanRootDir: File,
scope: CoroutineScope,
logger: Logger,
) {
private val density = context.resources.displayMetrics.density
private val thumbnailSizePx = (THUMBNAIL_SIZE_DP * density).toInt()
val imageRepository = ImageRepository(
scanRootDir,
OpenCvTransformations(),
thumbnailSizePx
ImageProcessor(thumbnailSizePx),
scope,
logger,
)
}
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -15,6 +15,7 @@
package org.fairscan.app.data
import kotlinx.serialization.Serializable
import org.fairscan.imageprocessing.ColorMode
@Serializable
data class DocumentMetadataV1(
@@ -39,7 +40,15 @@ data class PageV2(
val baseRotationDegrees: Int = 0,
val manualRotationDegrees: Int = 0,
val quad: NormalizedQuad? = null,
val isColored: Boolean? = null
val quadVersion: Int = 0,
val userQuad: NormalizedQuad? = null,
val isColored: Boolean? = null,
val colorMode: ColorMode? = null,
val focalLength: Float? = null,
val sensorWidth: Float? = null,
val subjectDistance: Float? = null,
val sourceWidth: Int? = null,
val sourceHeight: Int? = null,
)
@Serializable
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -14,6 +14,7 @@
*/
package org.fairscan.app.data
import org.fairscan.app.domain.PageToExport
import java.io.File
import java.io.FileOutputStream
import java.io.OutputStream
@@ -25,7 +26,12 @@ data class GeneratedPdf(
)
fun interface PdfWriter {
fun writePdfFromJpegs(jpegs: Sequence<ByteArray>, outputStream: OutputStream): Int
suspend fun writePdfFromJpegs(
pages: List<PageToExport>,
outputStream: OutputStream,
disableOcr: Boolean,
onProgress: (Int) -> Unit,
)
}
class FileManager(
@@ -42,15 +48,19 @@ class FileManager(
}
}
fun generatePdf(jpegs: Sequence<ByteArray>): GeneratedPdf {
suspend fun generatePdf(
pages: List<PageToExport>,
disableOcr: Boolean,
onProgress: (Int) -> Unit
): GeneratedPdf {
pdfDir.mkdirs()
require(pdfDir.exists() && pdfDir.isDirectory) { "Invalid pdfDir: $pdfDir" }
val file = File(pdfDir, "${System.currentTimeMillis()}.pdf")
val pageCount = FileOutputStream(file).use {
pdfWriter.writePdfFromJpegs(jpegs, it)
FileOutputStream(file).use {
pdfWriter.writePdfFromJpegs(pages, it, disableOcr, onProgress)
}
val sizeBytes = file.length()
return GeneratedPdf(file, sizeBytes, pageCount)
return GeneratedPdf(file, sizeBytes, pages.size)
}
fun copyToExternalDir(original: File): File {
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -14,22 +14,35 @@
*/
package org.fairscan.app.data
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.fairscan.app.domain.ExportQuality
import org.fairscan.app.domain.Jpeg
import org.fairscan.app.domain.PageMetadata
import org.fairscan.app.domain.PageViewKey
import org.fairscan.app.domain.Rotation
import org.fairscan.app.domain.ScanPage
import org.fairscan.imageprocessing.ColorMode
import org.fairscan.imageprocessing.ImageSize
import org.fairscan.imageprocessing.OpticalMeasures
import org.fairscan.imageprocessing.Point
import org.fairscan.imageprocessing.Quad
import org.fairscan.imageprocessing.cameraIntrinsics
import java.io.File
import java.util.Collections.synchronizedMap
const val SOURCE_DIR_NAME = "sources"
const val SCAN_DIR_NAME = "scanned_pages"
const val PROCESSED_DIR_NAME = "scanned_pages"
const val THUMBNAIL_DIR_NAME = "thumbnails"
/**
@@ -41,33 +54,34 @@ const val THUMBNAIL_DIR_NAME = "thumbnails"
class ImageRepository(
scanRootDir: File,
val transformations: ImageTransformations,
private val thumbnailSizePx: Int,
private val scope: CoroutineScope,
private val logger: Logger,
) {
private val sourceDir = File(scanRootDir, SOURCE_DIR_NAME).apply { mkdirs() }
private val processedDir = File(scanRootDir, PROCESSED_DIR_NAME).apply { mkdirs() }
private val thumbnailDir = File(scanRootDir, THUMBNAIL_DIR_NAME)
private val sourceDir: File = File(scanRootDir, SOURCE_DIR_NAME).apply {
if (!exists()) mkdirs()
}
private val scanDir: File = File(scanRootDir, SCAN_DIR_NAME).apply {
if (!exists()) mkdirs()
}
private val thumbnailDir: File = File(scanRootDir, THUMBNAIL_DIR_NAME).apply {
if (!exists()) mkdirs()
}
private val metadataFile = File(scanDir, "document.json")
private val json = Json {
prettyPrint = false
encodeDefaults = true
}
private val mutex = Mutex()
private val metadataFile = File(processedDir, "document.json")
private val json = Json { prettyPrint = false; encodeDefaults = true }
private var pages: PageStore = PageStore(loadPages())
private val processingJobs = synchronizedMap(mutableMapOf<PageViewKey, Deferred<Unit>>())
private val imageCache = createLruCache<PageViewKey, Deferred<Jpeg?>>(maxEntries = 50)
private val thumbnailCache = createLruCache<PageViewKey, Deferred<Jpeg?>>(maxEntries = 1000)
private fun <K, V> createLruCache(maxEntries: Int): MutableMap<K, V> =
synchronizedMap(object : LinkedHashMap<K, V>(16, 0.75f, true) {
override fun removeEldestEntry(eldest: Map.Entry<K, V>) = size > maxEntries
})
// --- Metadata ---
private fun loadPages(): MutableList<PageV2> {
thumbnailDir.deleteRecursively() // clean up dir that was used in older versions
normalizeLegacyFiles()
val filesOnDisk = scanDir.listFiles()
val filesOnDisk = processedDir.listFiles()
?.filter { it.extension == "jpg" }
?.map { it.name }
?.toSet()
@@ -80,7 +94,7 @@ class ImageRepository(
return when {
metadataPages != null ->
metadataPages
.filter { it.workFileName() in filesOnDisk }
.filter { processedImageFileName(it.id, it.colorMode, it.quadVersion) in filesOnDisk }
.toMutableList()
else ->
filesOnDisk
@@ -103,11 +117,8 @@ class ImageRepository(
}
}
private fun migrateFromV1(meta: DocumentMetadataV1): MutableList<PageV2> {
return meta.pages.map { old ->
pageFromLegacyFileName(old.file)
}.toMutableList()
}
private fun migrateFromV1(meta: DocumentMetadataV1): MutableList<PageV2> =
meta.pages.map { pageFromLegacyFileName(it.file) }.toMutableList()
private fun pageFromLegacyFileName(fileName: String): PageV2 {
val name = fileName.removeSuffix(".jpg")
@@ -121,150 +132,228 @@ class ImageRepository(
metadataFile.writeText(json.encodeToString(metadata))
}
fun pages(): List<ScanPage> =
// --- Main API ---
suspend fun pages(): List<ScanPage> = mutex.withLock {
pages.pages().mapNotNull {
runCatching {
val manualRotation = Rotation.fromDegrees(it.manualRotationDegrees)
ScanPage(it.id, manualRotation, it.toMetadata())
ScanPage(it.id, manualRotation, it.colorMode, it.quadVersion, it.toMetadata())
}.getOrNull()
}
private fun page(id: String): PageV2? = pages.get(id)
fun add(pageBytes: ByteArray, sourceBytes: ByteArray, metadata: PageMetadata) {
val id = "${System.currentTimeMillis()}"
val fileName = "$id.jpg"
val file = File(scanDir, fileName)
file.writeBytes(pageBytes)
writeThumbnail(file)
File(sourceDir, fileName).writeBytes(sourceBytes)
pages.addOrReplace(
PageV2(
id = id,
quad = metadata.normalizedQuad.toSerializable(),
baseRotationDegrees = metadata.baseRotation.degrees,
manualRotationDegrees = Rotation.R0.degrees,
isColored = metadata.isColored
)
)
saveMetadata()
}
private fun workFileName(key: PageViewKey): String =
workFileName(key.pageId, key.rotation)
private fun workFileName(pageId: String, manualRotation: Rotation): String =
workFileName(pageId, manualRotation.degrees)
private fun workFileName(pageId: String, manualRotationDegrees: Int): String =
when (manualRotationDegrees) {
0 -> "$pageId.jpg"
else -> "$pageId-${manualRotationDegrees}.jpg"
suspend fun add(processed: Jpeg, source: Jpeg, metadata: PageMetadata, colorMode: ColorMode) =
mutex.withLock {
val id = "${System.currentTimeMillis()}"
val key = PageViewKey(id, Rotation.R0, colorMode, 0)
processedImageFile(key).writeBytes(processed.bytes)
sourceFile(id).writeBytes(source.bytes)
pages.addOrReplace(
PageV2(
id = id,
quad = metadata.normalizedQuad.toSerializable(),
baseRotationDegrees = metadata.baseRotation.degrees,
manualRotationDegrees = Rotation.R0.degrees,
isColored = metadata.autoColorMode == ColorMode.COLOR,
colorMode = colorMode,
focalLength = metadata.opticalMeasures?.cameraIntrinsics?.focalLength,
sensorWidth = metadata.opticalMeasures?.cameraIntrinsics?.sensorWidth,
subjectDistance = metadata.opticalMeasures?.subjectDistance,
sourceWidth = metadata.sourceSize?.width?.toInt(),
sourceHeight = metadata.sourceSize?.height?.toInt(),
)
)
saveMetadata()
// Pre-populate cache for R0
imageCache.put(key, CompletableDeferred(processed))
}
fun PageV2.workFileName() = workFileName(id, manualRotationDegrees)
fun rotate(id: String, clockwise: Boolean) {
val page = pages.get(id) ?: return
val delta = if (clockwise) Rotation.R90 else Rotation.R270
val newRotation = Rotation.fromDegrees(page.manualRotationDegrees).add(delta)
val inputFile = File(scanDir, "$id.jpg")
val outputFile = File(scanDir, workFileName(id, newRotation.degrees))
if (inputFile.exists() && !outputFile.exists()) {
transformations.rotate(
inputFile,
outputFile,
newRotation.degrees,
ExportQuality.BALANCED.jpegQuality
suspend fun setColorMode(id: String, colorMode: ColorMode) {
updatePage(id) { page, metadata ->
PageUpdate(
updatedPage = page.copy(colorMode = colorMode),
normalizedQuad = metadata.normalizedQuad,
colorMode = colorMode,
)
}
}
suspend fun setUserQuad(id: String, newQuad: Quad) {
updatePage(id) { page, metadata ->
PageUpdate(
updatedPage = page.copy(
quadVersion = page.quadVersion + 1,
userQuad = newQuad.toSerializable(),
),
normalizedQuad = newQuad,
colorMode = page.colorMode ?: metadata.autoColorMode,
)
}
}
private data class PageUpdate(
val updatedPage: PageV2,
val normalizedQuad: Quad,
val colorMode: ColorMode,
)
private suspend fun updatePage(
id: String,
buildUpdate: (PageV2, PageMetadata) -> PageUpdate
) {
val page = mutex.withLock { pages.get(id) }
val metadata = page?.toMetadata() ?: return
val sourceFile = sourceFile(id)
if (!sourceFile.exists())
return
val update = buildUpdate(page, metadata)
val key = PageViewKey(
pageId = id,
rotation = Rotation.R0,
colorMode = update.colorMode,
quadVersion = update.updatedPage.quadVersion
)
val processedFile = processedImageFile(key)
val job = processingJobs.computeIfAbsent(key) {
scope.async(Dispatchers.IO) {
if (!processedFile.exists()) {
val sourceJpeg = Jpeg(sourceFile.readBytes())
val processedJpeg =
transformations.process(
sourceJpeg,
metadata = metadata.copy(normalizedQuad = update.normalizedQuad),
colorMode = update.colorMode
)
processedFile.writeBytes(processedJpeg.bytes)
}
}
}
try {
job.await()
} finally {
processingJobs.remove(key, job)
}
mutex.withLock {
pages.update(id) { update.updatedPage }
saveMetadata()
}
}
suspend fun rotate(id: String, clockwise: Boolean) = mutex.withLock {
val page = pages.get(id) ?: return@withLock
val delta = if (clockwise) Rotation.R90 else Rotation.R270
val newRotation = Rotation.fromDegrees(page.manualRotationDegrees).add(delta)
pages.update(id) {
it.copy(manualRotationDegrees = newRotation.degrees)
}
saveMetadata()
}
fun jpegBytes(key: PageViewKey): ByteArray? {
val file = File(scanDir, workFileName(key))
return (if (file.exists()) file else null)?.readBytes()
}
suspend fun jpegBytes(key: PageViewKey): Jpeg? =
getOrCompute(imageCache, key, ::computeProcessedImage)
fun jpegBytes(id: String): ByteArray? {
val page = page(id)
if (page == null) return null
val file = File(scanDir, page.workFileName())
return (if (file.exists()) file else null)?.readBytes()
}
fun sourceJpegBytes(id: String): ByteArray? {
val file = getSourceFile(id)
return if (file.exists()) file.readBytes() else null
}
suspend fun getThumbnail(key: PageViewKey): Jpeg? =
getOrCompute(thumbnailCache, key, ::computeThumbnail)
private fun getSourceFile(id: String): File {
return File(sourceDir, "$id.jpg")
}
// --- Cache compute functions ---
fun getThumbnail(key: PageViewKey): ByteArray? {
val thumbFile = getThumbnailFile(key)
if (thumbFile == null) {
return null
private suspend fun getOrCompute(
cache: MutableMap<PageViewKey, Deferred<Jpeg?>>,
key: PageViewKey,
compute: suspend (PageViewKey) -> Jpeg?
): Jpeg? {
val deferred = cache.computeIfAbsent(key) { k ->
scope.async(Dispatchers.IO) { compute(k) }
}
if (!thumbFile.exists()) {
val workFile = File(scanDir, workFileName(key))
if (!workFile.exists()) return null
writeThumbnail(workFile)
try {
return deferred.await()
} catch (e: Exception) {
cache.remove(key, deferred)
throw e
}
return if (thumbFile.exists()) thumbFile.readBytes() else null
}
private fun writeThumbnail(originalFile: File) {
val thumbFile = File(thumbnailDir, originalFile.name)
transformations.resize(originalFile, thumbFile, thumbnailSizePx)
private suspend fun computeProcessedImage(key: PageViewKey): Jpeg? =
withContext(Dispatchers.IO) {
val baseFile = processedImageFile(key)
if (!baseFile.exists()) return@withContext null
val baseJpeg = Jpeg(baseFile.readBytes())
if (key.rotation == Rotation.R0) {
baseJpeg
} else {
transformations.rotate(
baseJpeg,
key.rotation.degrees)
}
}
private suspend fun computeThumbnail(key: PageViewKey): Jpeg? =
withContext(Dispatchers.IO) {
val processed = getOrCompute(imageCache, key, ::computeProcessedImage)
?: return@withContext null
try {
transformations.resizeToThumbnail(processed)
} catch (e: Exception) {
val message = "Failed to compute thumbnail for ${key.pageId}"
logger.e("ImageRepository", message, e)
null
}
}
// --- Other operations ---
private fun processedImageFileName(id: String, colorMode: ColorMode?, quadVersion: Int) : String {
val sb = StringBuilder(id)
if (colorMode != null)
sb.append(".").append(colorMode.name.lowercase())
if (quadVersion > 0)
sb.append(".q").append(quadVersion)
sb.append(".jpg")
return sb.toString()
}
private fun getThumbnailFile(key: PageViewKey): File? {
return File(thumbnailDir, workFileName(key))
private fun processedImageFile(key: PageViewKey) : File =
File(processedDir, processedImageFileName(key.pageId, key.colorMode, key.quadVersion))
private fun sourceFile(id: String): File =
File(sourceDir, "$id.jpg")
fun source(id: String): Jpeg? {
val file = sourceFile(id)
return if (file.exists()) Jpeg(file.readBytes()) else null
}
fun movePage(id: String, newIndex: Int) {
suspend fun movePage(id: String, newIndex: Int) = mutex.withLock {
pages.move(id, newIndex)
saveMetadata()
}
fun delete(id: String) {
suspend fun delete(id: String) = mutex.withLock {
pages.delete(id)
saveMetadata()
getSourceFile(id).delete()
scanDir.listFiles()
?.filter { it.name.startsWith("${id}.") || it.name.startsWith("$id-") }
?.forEach { it.delete() }
thumbnailDir.listFiles()
?.filter { it.name.startsWith("${id}.") || it.name.startsWith("$id-") }
sourceFile(id).delete()
processedDir.listFiles()
?.filter { it.name.startsWith("$id.") || it.name.startsWith("$id-") }
?.forEach { it.delete() }
// No need to clean caches: stale entries will be evicted by LRU
}
fun clear() {
suspend fun clear() = mutex.withLock {
pages.clear()
saveMetadata() // "empty" json file
thumbnailDir.listFiles()?.forEach {
file -> file.delete()
}
scanDir.listFiles()?.forEach {
file -> file.delete()
}
sourceDir.listFiles()?.forEach {
file -> file.delete()
}
saveMetadata()
sourceDir.listFiles()?.forEach { it.delete() }
processedDir.listFiles()?.forEach { it.delete() }
synchronized(imageCache) { imageCache.clear() }
synchronized(thumbnailCache) { thumbnailCache.clear() }
}
// --- Legacy migration ---
data class DiskPageFiles(
val base: File?,
val rotated: List<File>
@@ -275,7 +364,7 @@ class ImageRepository(
// and discard the others. We intentionally sacrifice exact rotation
// fidelity to restore a coherent model.
private fun normalizeLegacyFiles() {
val jpgs = scanDir.listFiles()?.filter { it.extension == "jpg" }.orEmpty()
val jpgs = processedDir.listFiles()?.filter { it.extension == "jpg" }.orEmpty()
val byId = jpgs.groupBy { file ->
val name = file.name.removeSuffix(".jpg")
val dash = name.lastIndexOf('-')
@@ -290,7 +379,7 @@ class ImageRepository(
if (files.base == null && files.rotated.isNotEmpty()) {
val sortedRotatedFiles = files.rotated.sortedBy { it.name }
val legacyFile = sortedRotatedFiles.first()
val target = File(scanDir, "$id.jpg")
val target = File(processedDir, "$id.jpg")
if (legacyFile.renameTo(target)) {
sortedRotatedFiles.drop(1).forEach { it.delete() }
}
@@ -325,9 +414,17 @@ fun NormalizedQuad.toQuad(): Quad =
fun PageV2.toMetadata(): PageMetadata? {
if (quad == null || isColored == null) return null
val cameraIntrinsics = cameraIntrinsics(focalLength, sensorWidth)
val sourceSize =
if (sourceWidth != null && sourceHeight != null)
ImageSize(sourceWidth, sourceHeight)
else
null
return PageMetadata(
quad.toQuad(),
(userQuad ?: quad).toQuad(),
Rotation.fromDegrees(baseRotationDegrees),
isColored
if (isColored) ColorMode.COLOR else ColorMode.GRAYSCALE,
sourceSize,
cameraIntrinsics?.let { OpticalMeasures(it, subjectDistance) },
)
}
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -14,12 +14,20 @@
*/
package org.fairscan.app.data
import java.io.File
import org.fairscan.app.domain.Jpeg
import org.fairscan.app.domain.PageMetadata
import org.fairscan.imageprocessing.ColorMode
interface ImageTransformations {
fun rotate(inputFile: File, outputFile: File, rotationDegrees: Int, jpegQuality: Int)
fun rotate(input: Jpeg, rotationDegrees: Int): Jpeg
fun resize(inputFile: File, outputFile: File, maxSize: Int)
fun resizeToThumbnail(input: Jpeg): Jpeg
fun process(
source: Jpeg,
metadata: PageMetadata,
colorMode: ColorMode
): Jpeg
}
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -0,0 +1,89 @@
/*
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.data
import java.util.Locale
data class OcrLanguage(
val code: String,
) {
val locale: Locale by lazy {
resolveLocale(code)
}
fun displayName(displayLocale: Locale): String =
locale.getDisplayName(displayLocale)
companion object {
private fun verticalVariant(locale: Locale) =
Locale.Builder().setLocale(locale).setVariant("vertical").build()
private fun resolveLocale(code: String): Locale {
val firstPart = code.substringBefore("_")
return LOCALE_OVERRIDES[code] ?: Locale.Builder().setLanguage(firstPart).build()
}
val AVAILABLE_LANGUAGE_CODES = listOf(
"afr", "amh", "ara", "asm", "aze_cyrl", "aze", "bel", "ben", "bod", "bos",
"bre", "bul", "cat", "ceb", "ces", "chi_sim", "chi_sim_vert", "chi_tra",
"chi_tra_vert", "chr", "cos", "cym", "dan", "deu", "div", "dzo",
"ell", "eng", "enm", "epo", "est", "eus", "fao", "fas", "fil", "fin", "fra",
"frm", "fry", "gla", "gle", "glg", "grc", "guj", "hat", "heb", "hin", "hrv",
"hun", "hye", "iku", "ind", "isl", "ita", "jav", "jpn", "jpn_vert",
"kan", "kat_old", "kat", "kaz", "khm", "kir", "kmr", "kor", "kor_vert", "lao",
"lat", "lav", "lit", "ltz", "mal", "mar", "mkd", "mlt", "mon", "mri", "msa",
"mya", "nep", "nld", "nor", "oci", "ori", "pan", "pol", "por", "pus", "que",
"ron", "rus", "san", "sin", "slk", "slv", "snd", "spa", "sqi",
"srp_latn", "srp", "sun", "swa", "swe", "syr", "tam", "tat", "tel", "tgk",
"tha", "tir", "ton", "tur", "uig", "ukr", "urd", "uzb_cyrl", "uzb", "vie",
"yid", "yor",
)
private val LOCALE_OVERRIDES = mapOf(
// Codes not recognized by Locale.Builder
"bod" to Locale.forLanguageTag("bo"), // Tibetan
"ces" to Locale.forLanguageTag("cs"), // Czech
"cym" to Locale.forLanguageTag("cy"), // Welsh
"deu" to Locale.GERMAN,
"ell" to Locale.forLanguageTag("el"), // Greek
"eus" to Locale.forLanguageTag("eu"), // Basque
"fas" to Locale.forLanguageTag("fa"), // Persian
"fra" to Locale.FRENCH,
"hye" to Locale.forLanguageTag("hy"), // Armenian
"isl" to Locale.forLanguageTag("is"), // Icelandic
"kat" to Locale.forLanguageTag("ka"), // Georgian
"kmr" to Locale.forLanguageTag("ku"), // Kurdish
"mkd" to Locale.forLanguageTag("mk"), // Macedonian
"mri" to Locale.forLanguageTag("mi"), // Maori
"msa" to Locale.forLanguageTag("ms"), // Malay
"mya" to Locale.forLanguageTag("my"), // Burmese
"nld" to Locale.forLanguageTag("nl"), // Dutch
"ron" to Locale.forLanguageTag("ro"), // Romanian
"slk" to Locale.forLanguageTag("sk"), // Slovak
"sqi" to Locale.forLanguageTag("sq"), // Albanian
// Variants pointing to same Locale
"chi_sim" to Locale.SIMPLIFIED_CHINESE,
"chi_tra" to Locale.TRADITIONAL_CHINESE,
"chi_sim_vert" to verticalVariant(Locale.SIMPLIFIED_CHINESE),
"chi_tra_vert" to verticalVariant(Locale.TRADITIONAL_CHINESE),
"jpn_vert" to verticalVariant(Locale.JAPANESE),
"kor_vert" to verticalVariant(Locale.forLanguageTag("ko")),
"aze_cyrl" to Locale.forLanguageTag("az-Cyrl"),
"uzb_cyrl" to Locale.forLanguageTag("uz-Cyrl"),
"srp_latn" to Locale.forLanguageTag("sr-Latn"),
"deu_latf" to Locale.forLanguageTag("de-1901"),
)
}
}
@@ -0,0 +1,155 @@
/*
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.data
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.fairscan.app.data.OcrLanguage.Companion.AVAILABLE_LANGUAGE_CODES
import java.io.File
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
class OcrLanguageRepository(
private val dataStore: DataStore<Preferences>,
val tessdataDir: File,
) {
companion object {
private val ENABLED_LANGUAGES = stringSetPreferencesKey("enabled_ocr_languages")
}
val enabledLanguages: Flow<Set<String>> =
dataStore.data.map { prefs ->
prefs[ENABLED_LANGUAGES] ?: emptySet()
}
suspend fun getInstalledLanguages(): Set<String> =
withContext(Dispatchers.IO) {
if (!tessdataDir.exists()) {
return@withContext emptySet()
}
return@withContext tessdataDir
.listFiles { _, name -> name.endsWith(".traineddata") }
?.map { f -> f.nameWithoutExtension }
?.filter { it in AVAILABLE_LANGUAGE_CODES }
?.toSet()
?: emptySet()
}
suspend fun getActiveLanguages(): Set<String> {
val enabled = enabledLanguagesValue()
val installed = getInstalledLanguages()
return enabled.intersect(installed)
}
suspend fun setLanguageEnabled(
code: String,
enabled: Boolean,
) {
require(code in AVAILABLE_LANGUAGE_CODES)
if (enabled && code !in getInstalledLanguages()) {
return
}
dataStore.edit { prefs ->
val current = prefs[ENABLED_LANGUAGES]?.toMutableSet() ?: mutableSetOf()
if (enabled) {
current.add(code)
} else {
current.remove(code)
}
prefs[ENABLED_LANGUAGES] = current
}
}
data class DownloadProgress(
val downloadedBytes: Long,
val totalBytes: Long?,
)
suspend fun downloadLanguage(code: String, onProgress: (DownloadProgress) -> Unit) =
withContext(Dispatchers.IO) {
require(code in AVAILABLE_LANGUAGE_CODES)
tessdataDir.mkdirs()
val url = URL("https://raw.githubusercontent.com/tesseract-ocr/tessdata_fast/main/$code.traineddata")
val targetFile = File(tessdataDir, "$code.traineddata")
val tempFile = File(tessdataDir, "$code.download")
val connection = (url.openConnection() as HttpURLConnection)
try {
connection.requestMethod = "GET"
connection.connectTimeout = 15_000
connection.readTimeout = 30_000
connection.instanceFollowRedirects = true
connection.connect()
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
throw IOException("HTTP ${connection.responseCode}")
}
connection.inputStream.use { input ->
tempFile.outputStream().use { output ->
val totalBytes = connection.contentLengthLong.takeIf { it > 0 }
val buffer = ByteArray(8192)
var downloadedBytes = 0L
input.use { input ->
tempFile.outputStream().use { output ->
while (true) {
coroutineContext.ensureActive()
val read = input.read(buffer)
if (read < 0) break
output.write(buffer, 0, read)
downloadedBytes += read
onProgress(DownloadProgress(downloadedBytes, totalBytes))
}
}
}
}
}
if (tempFile.length() < 300_000) {
throw IOException("Downloaded file too small")
}
if (!tempFile.renameTo(targetFile)) {
throw IOException("Failed to move downloaded file")
}
} finally {
connection.disconnect()
tempFile.delete()
}
}
suspend fun deleteLanguage(code: String) {
val file = File(tessdataDir, "$code.traineddata")
file.delete()
setLanguageEnabled(code, false)
}
suspend fun buildTesseractLanguageString(): String {
return getActiveLanguages()
.sorted()
.joinToString("+")
}
private suspend fun enabledLanguagesValue(): Set<String> {
return enabledLanguages.map { it }.first()
}
}
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -1,47 +0,0 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.data
import android.content.Context
import androidx.datastore.core.CorruptionException
import androidx.datastore.core.DataStore
import androidx.datastore.core.Serializer
import androidx.datastore.dataStore
import com.google.protobuf.InvalidProtocolBufferException
import org.fairscan.app.RecentDocuments
import java.io.InputStream
import java.io.OutputStream
object RecentDocumentsSerializer : Serializer<RecentDocuments> {
override val defaultValue: RecentDocuments = RecentDocuments.getDefaultInstance()
override suspend fun readFrom(input: InputStream): RecentDocuments {
return try {
RecentDocuments.parseFrom(input)
} catch (e: InvalidProtocolBufferException) {
throw CorruptionException("Cannot read proto.", e)
}
}
override suspend fun writeTo(
t: RecentDocuments,
output: OutputStream
) = t.writeTo(output)
}
val Context.recentDocumentsDataStore: DataStore<RecentDocuments> by dataStore(
fileName = "recent_documents.pb",
serializer = RecentDocumentsSerializer
)
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -12,13 +12,14 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.domain
import android.graphics.Bitmap
import kotlinx.coroutines.Deferred
import org.fairscan.imageprocessing.ColorMode
data class CapturedPage(
val page: Bitmap,
val source: Bitmap,
val pageJpeg: Jpeg,
val sourceJpeg: Deferred<Jpeg>,
val metadata: PageMetadata,
val colorMode: ColorMode,
)
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -15,110 +15,100 @@
package org.fairscan.app.domain
import org.fairscan.app.data.ImageRepository
import org.fairscan.imageprocessing.extractDocument
import org.fairscan.app.platform.processedImage
import org.fairscan.imageprocessing.EstimatedDimensions
import org.fairscan.imageprocessing.estimateRealDimensions
import org.fairscan.imageprocessing.resizeForMaxPixels
import org.fairscan.imageprocessing.scaledTo
import org.opencv.core.Mat
import org.opencv.core.MatOfByte
import org.opencv.core.MatOfInt
import org.opencv.imgcodecs.Imgcodecs
fun jpegsForExport(
fun interface JpegProvider {
suspend fun get(): Jpeg
}
data class PageToExport(
val page: ScanPage,
val jpeg: JpegProvider,
) {
fun estimatedDimensions(): EstimatedDimensions? {
val metadata = page.metadata
if (metadata == null)
return null
val size = metadata.sourceSize
if (size == null)
return null
val quad = metadata.normalizedQuad.scaledTo(1.0, 1.0, size.width, size.height)
val realDimensions = estimateRealDimensions(
quad, size.width.toInt(), size.height.toInt(), metadata.opticalMeasures
).snapToStandardFormat()
return realDimensions.applyRotation(page.totalRotation())
}
}
private fun EstimatedDimensions.applyRotation(rotation: Rotation): EstimatedDimensions {
if ((rotation == Rotation.R90 || rotation == Rotation.R270)
&& this is EstimatedDimensions.Physical) {
return EstimatedDimensions.Physical(heightMm, widthMm)
}
return this
}
suspend fun pagesToExport(
imageRepository: ImageRepository,
exportQuality: ExportQuality
): Sequence<ByteArray> {
): List<PageToExport> {
val pages = imageRepository.pages().asSequence()
val pages = imageRepository.pages()
return when (exportQuality) {
ExportQuality.BALANCED -> pages.mapNotNull { imageRepository.jpegBytes(it.id) }
ExportQuality.BALANCED -> pages.map {
PageToExport(it) { jpeg(it, imageRepository) }
}
ExportQuality.LOW -> pages.mapNotNull { page ->
imageRepository.jpegBytes(page.id)?.let { jpeg ->
ExportQuality.LOW -> pages.map { page ->
PageToExport(page) {
resizeJpegBytesForMaxPixels(
jpegBytes = jpeg,
jpeg = jpeg(page, imageRepository),
maxPixels = exportQuality.maxPixels.toDouble(),
jpegQuality = exportQuality.jpegQuality
)
}
}
ExportQuality.HIGH -> pages.mapNotNull { page ->
val sourceJpegBytes = imageRepository.sourceJpegBytes(page.id)
val pageMetadata = page.metadata
val manualRotation = page.manualRotation
if (sourceJpegBytes != null && pageMetadata != null)
prepareJpegForHigh(sourceJpegBytes, pageMetadata, manualRotation, exportQuality)
else
imageRepository.jpegBytes(page.id)
ExportQuality.HIGH -> pages.map { page ->
PageToExport(page) {
val source = imageRepository.source(page.id)
val metadata = page.metadata
val colorMode = page.colorMode
if (source != null && metadata != null && colorMode != null) {
val rotation = page.totalRotation()
processedImage(source, metadata, rotation, colorMode, exportQuality)
}
else
jpeg(page, imageRepository)
}
}
}
}
fun resizeJpegBytesForMaxPixels(
jpegBytes: ByteArray,
private suspend fun jpeg(page: ScanPage, imageRepository: ImageRepository): Jpeg {
val key = page.key()
return imageRepository.jpegBytes(key)
?: throw IllegalArgumentException("JPEG not found for $key")
}
private fun resizeJpegBytesForMaxPixels(
jpeg: Jpeg,
maxPixels: Double,
jpegQuality: Int
): ByteArray? {
val decoded = decodeJpeg(jpegBytes)
if (decoded == null)
return null
val resized = resizeForMaxPixels(decoded, maxPixels)
val outJpegBytes = encodeJpeg(resized, jpegQuality)
decoded.release()
resized.release()
return outJpegBytes
}
fun prepareJpegForHigh(
sourceJpegBytes: ByteArray,
pageMetadata: PageMetadata,
manualRotation: Rotation,
exportQuality: ExportQuality,
): ByteArray? {
val decoded = decodeJpeg(sourceJpegBytes)
if (decoded == null)
return null
val quad = pageMetadata.normalizedQuad.scaledTo(1,1,decoded.width(), decoded.height())
val page = extractDocument(
decoded,
quad,
pageMetadata.baseRotation.add(manualRotation).degrees,
pageMetadata.isColored,
exportQuality.maxPixels)
val outJpegBytes = encodeJpeg(page, exportQuality.jpegQuality)
decoded.release()
page.release()
return outJpegBytes
}
fun decodeJpeg(jpegBytes: ByteArray): Mat? {
val src = MatOfByte(*jpegBytes)
val decoded = Imgcodecs.imdecode(src, Imgcodecs.IMREAD_COLOR)
src.release()
if (decoded.empty()) {
decoded.release()
return null
): Jpeg {
var decoded: Mat? = null
var resized: Mat? = null
try {
decoded = jpeg.toMat()
resized = resizeForMaxPixels(decoded, maxPixels)
return Jpeg.fromMat(resized, jpegQuality)
} finally {
decoded?.release()
resized?.release()
}
return decoded
}
fun encodeJpeg(mat: Mat, jpegQuality: Int): ByteArray? {
val params = MatOfInt(Imgcodecs.IMWRITE_JPEG_QUALITY, jpegQuality.coerceIn(0, 100))
val encoded = MatOfByte()
val ok = Imgcodecs.imencode(".jpg", mat, encoded, params)
params.release()
if (!ok) {
encoded.release()
return null
}
val result = encoded.toArray()
encoded.release()
return result
}
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -0,0 +1,34 @@
/*
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.domain
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import org.fairscan.imageprocessing.decodeJpeg
import org.fairscan.imageprocessing.encodeJpeg
import org.opencv.core.Mat
class Jpeg(val bytes: ByteArray) {
companion object {
fun fromMat(mat: Mat, jpegQuality: Int): Jpeg = Jpeg(encodeJpeg(mat, jpegQuality))
}
fun toBitmap() : Bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
fun toMat() : Mat = decodeJpeg(bytes)
}
interface ImageLoader {
suspend fun load(uri: Uri): Bitmap
}
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -20,15 +20,10 @@ import android.graphics.Bitmap.createBitmap
import android.graphics.Color
import android.os.SystemClock
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.fairscan.app.data.Logger
import org.fairscan.imageprocessing.ImageSize
import org.fairscan.imageprocessing.Mask
import org.opencv.core.CvType
import org.opencv.core.Mat
@@ -39,7 +34,6 @@ import org.tensorflow.lite.support.common.ops.NormalizeOp
import org.tensorflow.lite.support.image.ImageProcessor
import org.tensorflow.lite.support.image.TensorImage
import org.tensorflow.lite.support.image.ops.ResizeOp
import org.tensorflow.lite.support.image.ops.Rot90Op
import java.nio.ByteBuffer
import java.nio.ByteOrder
@@ -49,9 +43,6 @@ class ImageSegmentationService(private val context: Context, private val logger:
private const val TAG = "ImageSegmentation"
}
private val _segmentation = MutableStateFlow<SegmentationResult?>(null)
val segmentation: StateFlow<SegmentationResult?> = _segmentation.asStateFlow()
private var interpreter: Interpreter? = null
private val inferenceLock = Mutex()
@@ -70,16 +61,14 @@ class ImageSegmentationService(private val context: Context, private val logger:
}
}
private fun runSegmentation(interpreter: Interpreter, bitmap: Bitmap, rotationDegrees: Int): SegmentationResult {
private fun runSegmentation(interpreter: Interpreter, bitmap: Bitmap): SegmentationResult {
val startTime = SystemClock.uptimeMillis()
val rotation = -rotationDegrees / 90
val (_, h, w, _) = interpreter.getOutputTensor(0).shape()
val imageProcessor =
ImageProcessor
.Builder()
.add(ResizeOp(h, w, ResizeOp.ResizeMethod.BILINEAR))
.add(Rot90Op(rotation))
.add(NormalizeOp(127.5f, 127.5f)) // TODO check if it's correct
.build()
val tensorImage = TensorImage(DataType.FLOAT32)
@@ -91,25 +80,12 @@ class ImageSegmentationService(private val context: Context, private val logger:
return SegmentationResult(segmentResult, inferenceTime)
}
suspend fun runSegmentationAndReturn(bitmap: Bitmap, rotationDegrees: Int): SegmentationResult? {
suspend fun runSegmentationAndReturn(bitmap: Bitmap): SegmentationResult? {
if (interpreter == null) {
return null
}
return inferenceLock.withLock {
runSegmentation(interpreter!!, bitmap, rotationDegrees)
}
}
suspend fun runSegmentationAndEmit(bitmap: Bitmap, rotationDegrees: Int) {
try {
withContext(Dispatchers.IO) {
val segmentationResult = runSegmentationAndReturn(bitmap, rotationDegrees)
if (isActive) {
_segmentation.value = segmentationResult
}
}
} catch (e: Exception) {
logger.e(TAG, "Error occurred in image segmentation", e)
runSegmentation(interpreter!!, bitmap)
}
}
@@ -163,6 +139,8 @@ class ImageSegmentationService(private val context: Context, private val logger:
mask.put(0, 0, data)
return mask
}
fun maskSize() = ImageSize(width, height)
}
data class SegmentationResult(
@@ -0,0 +1,88 @@
/*
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.domain
import android.graphics.Bitmap
import android.graphics.Rect
import com.googlecode.tesseract.android.TessBaseAPI
import com.googlecode.tesseract.android.TessBaseAPI.PageIteratorLevel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.fairscan.app.data.OcrLanguageRepository
import org.fairscan.imageprocessing.ImageRect
import org.fairscan.imageprocessing.OcrTextBox
class OcrService(
private val ocrLanguageRepository: OcrLanguageRepository,
private val scope: CoroutineScope,
) {
private var tess: TessBaseAPI? = null
private val mutex = Mutex()
private var languageString = ""
fun languageString() = languageString
fun initialize() {
scope.launch {
ocrLanguageRepository.enabledLanguages.collect { _ -> reinitialize() }
}
}
private suspend fun reinitialize() {
mutex.withLock {
tess?.recycle()
tess = null
languageString = ocrLanguageRepository.buildTesseractLanguageString()
if (languageString.isEmpty()) return
val dataPath = ocrLanguageRepository.tessdataDir.parent!!
val newTess = TessBaseAPI()
if (!newTess.init(dataPath, languageString)) {
newTess.recycle()
return
}
tess = newTess
}
}
suspend fun runOcr(bitmap: Bitmap): List<OcrTextBox> {
mutex.withLock {
val tess = this.tess ?: return listOf()
val textBoxes = mutableListOf<OcrTextBox>()
tess.setImage(bitmap)
tess.getUTF8Text() // Trigger text recognition
val iterator = tess.resultIterator
iterator.begin()
do {
val word = iterator.getUTF8Text(PageIteratorLevel.RIL_WORD) ?: continue
val wordBox = iterator.getBoundingRect(PageIteratorLevel.RIL_WORD)
val lineBox = iterator.getBoundingRect(PageIteratorLevel.RIL_TEXTLINE)
val confidence = iterator.confidence(PageIteratorLevel.RIL_WORD)
if (confidence > 50) {
val rect = wordBox.toImageRect()
textBoxes.add(OcrTextBox(word, rect, lineBox.height(), lineBox.bottom))
}
} while (iterator.next(PageIteratorLevel.RIL_WORD))
iterator.delete()
return textBoxes
}
}
private fun Rect.toImageRect(): ImageRect = ImageRect(left, top, right, bottom)
}
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -14,27 +14,36 @@
*/
package org.fairscan.app.domain
import org.fairscan.imageprocessing.ColorMode
import org.fairscan.imageprocessing.ImageSize
import org.fairscan.imageprocessing.OpticalMeasures
import org.fairscan.imageprocessing.Quad
data class PageMetadata(
val normalizedQuad: Quad,
val baseRotation: Rotation,
val isColored: Boolean,
val autoColorMode: ColorMode,
val sourceSize: ImageSize?,
val opticalMeasures: OpticalMeasures?,
)
data class ScanPage(
val id: String,
val manualRotation: Rotation,
val colorMode: ColorMode?,
val quadVersion: Int,
val metadata: PageMetadata?,
)
) {
fun key() = PageViewKey(id, manualRotation, colorMode, quadVersion)
fun totalRotation() = manualRotation.add(metadata?.baseRotation ?: Rotation.R0)
}
data class PageViewKey(
val pageId: String,
val rotation: Rotation,
) {
val saveKey: String get() = "$pageId-${rotation.degrees}"
}
val colorMode: ColorMode?,
val quadVersion: Int,
)
enum class Rotation(val degrees: Int) {
R0(0),
R90(90),
@@ -0,0 +1,92 @@
/*
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.platform
import android.content.ContentResolver
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.core.util.component1
import androidx.core.util.component2
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.fairscan.app.domain.ImageLoader
import java.io.IOException
class AndroidImageLoader(
private val contentResolver: ContentResolver
) : ImageLoader {
override suspend fun load(uri: Uri): Bitmap {
val bitmap = loadBitmapFromUri(contentResolver, uri)
return ensureArgb8888(bitmap)
}
}
suspend fun loadBitmapFromUri(
contentResolver: ContentResolver,
uri: Uri,
maxPixels: Int = 12_000_000,
): Bitmap = withContext(Dispatchers.IO) {
if (Build.VERSION.SDK_INT >= 28) {
decodeWithImageDecoder(contentResolver, uri, maxPixels)
} else {
decodeWithBitmapFactory(contentResolver, uri)
}
}
@RequiresApi(28)
private fun decodeWithImageDecoder(
contentResolver: ContentResolver,
uri: Uri,
maxPixels: Int
): Bitmap {
val source = ImageDecoder.createSource(contentResolver, uri)
return ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
val (width, height) = info.size
val scale = computeScale(width, height, maxPixels)
decoder.setTargetSize((width * scale).toInt(), (height * scale).toInt())
}
}
private fun decodeWithBitmapFactory(contentResolver: ContentResolver, uri: Uri, ): Bitmap {
val options = BitmapFactory.Options()
val inputStream = contentResolver.openInputStream(uri)
?: throw IOException("Cannot open input stream for uri: $uri")
return inputStream.use {
BitmapFactory.decodeStream(it, null, options)
?: throw IOException("Failed to decode bitmap for uri: $uri")
}
}
private fun computeScale(width: Int, height: Int, maxPixels: Int): Float {
val pixels = width * height
return if (pixels > maxPixels) {
maxPixels.toFloat() / pixels
} else {
1f
}
}
private fun ensureArgb8888(bitmap: Bitmap): Bitmap {
return if (bitmap.config != Bitmap.Config.ARGB_8888) {
bitmap.copy(Bitmap.Config.ARGB_8888, true)
} else {
bitmap
}
}
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -14,30 +14,283 @@
*/
package org.fairscan.app.platform
import android.content.res.AssetManager
import android.util.Log
import com.tom_roush.pdfbox.cos.COSArray
import com.tom_roush.pdfbox.cos.COSDictionary
import com.tom_roush.pdfbox.cos.COSName
import com.tom_roush.pdfbox.pdmodel.PDDocument
import com.tom_roush.pdfbox.pdmodel.PDPage
import com.tom_roush.pdfbox.pdmodel.PDPageContentStream
import com.tom_roush.pdfbox.pdmodel.PDPageContentStream.AppendMode
import com.tom_roush.pdfbox.pdmodel.PDResources
import com.tom_roush.pdfbox.pdmodel.common.PDRectangle
import com.tom_roush.pdfbox.pdmodel.common.PDStream
import com.tom_roush.pdfbox.pdmodel.font.PDFontDescriptor
import com.tom_roush.pdfbox.pdmodel.graphics.image.JPEGFactory
import org.fairscan.app.BuildConfig
import org.fairscan.app.data.PdfWriter
import org.fairscan.app.domain.OcrService
import org.fairscan.app.domain.PageToExport
import org.fairscan.imageprocessing.EstimatedDimensions
import org.fairscan.imageprocessing.OcrTextBox
import org.fairscan.imageprocessing.PaperFormats
import java.io.OutputStream
import java.util.Calendar
import java.util.Locale
class AndroidPdfWriter : PdfWriter {
override fun writePdfFromJpegs(jpegs: Sequence<ByteArray>, outputStream: OutputStream): Int {
class AndroidPdfWriter(val ocrService: OcrService, val assets: AssetManager) : PdfWriter {
override suspend fun writePdfFromJpegs(
pages: List<PageToExport>,
outputStream: OutputStream,
disableOcr: Boolean,
onProgress: (Int) -> Unit,
) {
val doc = PDDocument()
doc.documentInformation.creationDate = Calendar.getInstance()
doc.documentInformation.creator = "FairScan ${BuildConfig.VERSION_NAME}"
doc.use { document ->
for (jpegBytes in jpegs) {
val image = JPEGFactory.createFromByteArray(document, jpegBytes)
val page = PDPage(PDRectangle(image.width.toFloat(), image.height.toFloat()))
val ocrDocument = OcrDocument(document, assets)
for ((index, page) in pages.withIndex()) {
val jpeg = page.jpeg.get()
val image = JPEGFactory.createFromByteArray(document, jpeg.bytes)
// PDF has 72 points (units) per inch, 1 inch = 25.4 mm
val pointsPerMm = 72f / 25.4f
val widthPx = image.width.toFloat()
val heightPx = image.height.toFloat()
val dimensions = page.estimatedDimensions()
val (widthMm, heightMm) = when (dimensions) {
is EstimatedDimensions.Physical ->
constrainToMaxFormat(dimensions.widthMm, dimensions.heightMm)
else -> {
// No physical dimensions available
val maxDimMm = PaperFormats.A4.heightMm
val scalePxToMm = maxDimMm / maxOf(widthPx, heightPx)
constrainToMaxFormat(widthPx * scalePxToMm, heightPx * scalePxToMm)
}
}
val widthPoints = widthMm.toFloat() * pointsPerMm
val heightPoints = heightMm.toFloat() * pointsPerMm
val page = PDPage(PDRectangle(widthPoints, heightPoints))
document.addPage(page)
val contentStream = PDPageContentStream(document, page, AppendMode.OVERWRITE, false)
contentStream.drawImage(image, 0f, 0f)
contentStream.drawImage(image, 0f, 0f, widthPoints, heightPoints)
if (!disableOcr) {
try {
val bitmap = jpeg.toBitmap()
val ocrTextBoxes = ocrService.runOcr(bitmap)
val pdfPageDimensions = PageDimensions(
bitmap.width,
bitmap.height,
widthPoints,
heightPoints
)
ocrDocument.addPage(page, ocrTextBoxes, pdfPageDimensions)
} catch (e: Exception) {
Log.e("AndroidPdfWriter", "Failed to run OCR on page $index", e)
}
}
contentStream.close()
onProgress(index + 1)
}
// TODO So the whole document is in memory before this line...
document.save(outputStream)
}
return doc.numberOfPages
}
}
fun constrainToMaxFormat(widthMm: Double, heightMm: Double): Pair<Double, Double> {
val maxDim = 297.0 // A4 height
val minDim = 215.9 // Letter width
val scale = minOf(
maxDim / maxOf(widthMm, heightMm),
minDim / minOf(widthMm, heightMm),
1.0
)
return widthMm * scale to heightMm * scale
}
data class PageDimensions(
val imageWidth: Int,
val imageHeight: Int,
val pageWidth: Float,
val pageHeight: Float,
)
// Adapted from https://github.com/tesseract-ocr/tesseract/blob/main/src/api/pdfrenderer.cpp
class OcrDocument(
private val document: PDDocument,
private val assets: AssetManager,
) {
private val fontBytes: ByteArray by lazy {
assets.open("fonts/TesseractGlyphLessFont.ttf").readBytes()
}
private val cidToGidMap: ByteArray by lazy {
ByteArray(65536 * 2) { i -> if (i % 2 == 0) 0x00.toByte() else 0x01.toByte() }
}
private var fontDict: COSDictionary? = null
fun ensureResourcesAreCreated() {
if (fontDict != null)
return
// -- Object 1 : embedded TTF --
val fontFileStream = PDStream(document)
fontFileStream.createOutputStream().use { it.write(fontBytes) }
fontFileStream.cosObject.setInt(COSName.LENGTH1, fontBytes.size)
// -- Object 2 : CIDToGIDMap stream --
val cidToGidStream = PDStream(document)
cidToGidStream.createOutputStream(COSName.FLATE_DECODE).use {
it.write(cidToGidMap)
}
// -- Object 3 : FontDescriptor --
val fontDescriptor = PDFontDescriptor(COSDictionary())
fontDescriptor.fontName = "GlyphLessFont"
fontDescriptor.flags = 5
fontDescriptor.fontBoundingBox = PDRectangle(0f, 0f, 500f, 750f)
fontDescriptor.italicAngle = 0f
fontDescriptor.ascent = 750f
fontDescriptor.descent = 0f
fontDescriptor.capHeight = 750f
fontDescriptor.stemV = 80f
fontDescriptor.cosObject.setItem(COSName.FONT_FILE2, fontFileStream)
// -- Object 4 : CIDFont descendant --
val cidFont = COSDictionary()
cidFont.setName(COSName.TYPE, "Font")
cidFont.setName(COSName.SUBTYPE, "CIDFontType2")
cidFont.setName(COSName.BASE_FONT, "GlyphLessFont")
val cidSystemInfo = COSDictionary()
cidSystemInfo.setString(COSName.getPDFName("Registry"), "Adobe")
cidSystemInfo.setString(COSName.getPDFName("Ordering"), "Identity")
cidSystemInfo.setInt(COSName.getPDFName("Supplement"), 0)
cidFont.setItem(COSName.getPDFName("CIDSystemInfo"), cidSystemInfo)
cidFont.setItem(COSName.FONT_DESC, fontDescriptor)
cidFont.setInt(COSName.getPDFName("DW"), 500)
cidFont.setItem(COSName.getPDFName("CIDToGIDMap"), cidToGidStream)
// -- Object 5 : ToUnicode CMap --
val toUnicode = buildToUnicodeCMap()
val toUnicodeStream = PDStream(document)
toUnicodeStream.createOutputStream().use {
it.write(toUnicode.toByteArray(Charsets.US_ASCII))
}
// -- Object 6 : Font Type0 --
val fontDict = COSDictionary()
fontDict.setName(COSName.TYPE, "Font")
fontDict.setName(COSName.SUBTYPE, "Type0")
fontDict.setName(COSName.BASE_FONT, "GlyphLessFont")
fontDict.setName(COSName.ENCODING, "Identity-H")
val descendants = COSArray()
descendants.add(cidFont)
fontDict.setItem(COSName.DESCENDANT_FONTS, descendants)
fontDict.setItem(COSName.TO_UNICODE, toUnicodeStream)
this.fontDict = fontDict
}
private fun buildToUnicodeCMap(): String = buildString {
append("/CIDInit /ProcSet findresource begin\n")
append("12 dict begin\n")
append("begincmap\n")
append("/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n")
append("/CMapName /Adobe-Identify-UCS def\n")
append("/CMapType 2 def\n")
append("1 begincodespacerange\n")
append("<0000> <FFFF>\n")
append("endcodespacerange\n")
append("1 beginbfrange\n")
append("<0000> <FFFF> <0000>\n")
append("endbfrange\n")
append("endcmap\n")
append("CMapName currentdict /CMap defineresource pop\n")
append("end\nend\n")
}
fun addPage(
page: PDPage,
ocrTextBoxes: List<OcrTextBox>,
dimensions: PageDimensions,
) {
if (ocrTextBoxes.isEmpty()) return
ensureResourcesAreCreated()
val resources = page.resources ?: PDResources().also { page.resources = it }
val fontResources = resources.cosObject
.getDictionaryObject(COSName.FONT) as? COSDictionary
?: COSDictionary().also {
resources.cosObject.setItem(COSName.FONT, it)
}
fontResources.setItem(COSName.getPDFName("F1"), fontDict)
val textStream = buildTextStream(ocrTextBoxes, dimensions)
val pdTextStream = PDStream(document)
pdTextStream.createOutputStream(COSName.FLATE_DECODE).use {
it.write(textStream.toByteArray(Charsets.US_ASCII))
}
val contentsArray = COSArray()
val iter = page.contentStreams
while (iter.hasNext()) contentsArray.add(iter.next().cosObject)
contentsArray.add(pdTextStream.cosObject)
page.cosObject.setItem(COSName.CONTENTS, contentsArray)
}
private fun buildTextStream(
ocrTextBoxes: List<OcrTextBox>,
dimensions: PageDimensions,
): String {
val scaleX = dimensions.pageWidth / dimensions.imageWidth
val scaleY = dimensions.pageHeight / dimensions.imageHeight
val sb = StringBuilder()
for (textBox in ocrTextBoxes) {
val x = textBox.box.left * scaleX
val wordWidth = textBox.box.width * scaleX
val fontSize = textBox.lineHeight * scaleY * 0.8f
// nominal width: fontSize / kCharWidth (Tesseract convention)
val nominalWidth = textBox.text.length * fontSize / 2f
val hScale = if (nominalWidth > 0f) (wordWidth / nominalWidth) * 100f else 100f
val baselineY = dimensions.pageHeight - (textBox.lineBottom * scaleY) + fontSize * 0.2f
val utf16hex = buildString {
textBox.text.codePoints().forEach { cp ->
append(codepointToUtf16beHex(cp))
}
}
sb.append("BT\n")
sb.append(String.format(Locale.US, "/F1 %.3f Tf\n", fontSize))
sb.append(String.format(Locale.US,"%.3f Tz\n", hScale))
sb.append("3 Tr\n")
sb.append(String.format(Locale.US,"%.3f %.3f Td\n", x, baselineY))
sb.append("<${utf16hex}0020> Tj\n")
sb.append("ET\n")
}
return sb.toString()
}
private fun codepointToUtf16beHex(cp: Int): String {
return if (cp < 0x10000) {
"%04X".format(cp)
} else {
val a = cp - 0x10000
val high = (a shr 10) + 0xD800
val low = (a and 0x3FF) + 0xDC00
"%04X%04X".format(high, low)
}
}
}
@@ -0,0 +1,182 @@
/*
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.platform
import android.graphics.Bitmap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import org.fairscan.app.data.ImageTransformations
import org.fairscan.app.domain.CapturedPage
import org.fairscan.app.domain.ExportQuality
import org.fairscan.app.domain.Jpeg
import org.fairscan.app.domain.PageMetadata
import org.fairscan.app.domain.Rotation
import org.fairscan.app.ui.screens.settings.DefaultColorMode
import org.fairscan.imageprocessing.ColorMode
import org.fairscan.imageprocessing.ImageSize
import org.fairscan.imageprocessing.Mask
import org.fairscan.imageprocessing.OpticalMeasures
import org.fairscan.imageprocessing.Point
import org.fairscan.imageprocessing.Quad
import org.fairscan.imageprocessing.autoColorMode
import org.fairscan.imageprocessing.createQuad
import org.fairscan.imageprocessing.extractDocument
import org.fairscan.imageprocessing.resizeForMaxPixels
import org.fairscan.imageprocessing.rotate
import org.fairscan.imageprocessing.scaledTo
import org.opencv.android.Utils
import org.opencv.core.CvException
import org.opencv.core.Mat
import org.opencv.core.Size
import org.opencv.imgproc.Imgproc
import kotlin.math.min
class ImageProcessor(private val thumbnailSizePx: Int) : ImageTransformations {
override fun rotate(input: Jpeg, rotationDegrees: Int): Jpeg {
return transform(input, ExportQuality.BALANCED.jpegQuality) {
rotate(it, rotationDegrees)
}
}
override fun resizeToThumbnail(input: Jpeg): Jpeg {
val maxSize = thumbnailSizePx.toFloat()
return transform(input, 85) { src ->
val ratio = min(maxSize / src.width(), maxSize / src.height())
val newW = (src.width() * ratio).toDouble()
val newH = (src.height() * ratio).toDouble()
val scaled = Mat()
try {
Imgproc.resize(src, scaled, Size(newW, newH))
} catch (e: CvException) {
val msg = "Resize failed. src=${src.width()}x${src.height()} dst=${newW}x${newH}"
throw IllegalStateException(msg, e)
}
scaled
}
}
private fun transform(
inJpeg: Jpeg,
jpegQuality: Int,
transform: (Mat) -> Mat,
): Jpeg {
val input = inJpeg.toMat()
var output: Mat? = null
try {
output = transform.invoke(input)
return Jpeg.fromMat(output, jpegQuality)
} finally {
input.release()
output?.release()
}
}
override fun process(
source: Jpeg,
metadata: PageMetadata,
colorMode: ColorMode
): Jpeg {
val baseRotation = metadata.baseRotation
return processedImage(source, metadata, baseRotation, colorMode, ExportQuality.BALANCED)
}
}
fun processedImage(
source: Jpeg,
metadata: PageMetadata,
rotation: Rotation,
colorMode: ColorMode,
exportQuality: ExportQuality,
): Jpeg {
val rotationDegrees = rotation.degrees
var sourceMat: Mat? = null
var page: Mat? = null
try {
sourceMat = source.toMat()
val quad = metadata.normalizedQuad.scaledTo(1, 1, sourceMat.width(), sourceMat.height())
page = extractDocument(sourceMat, quad, rotationDegrees, colorMode, exportQuality.maxPixels,
metadata.opticalMeasures)
return Jpeg.fromMat(page, exportQuality.jpegQuality)
} finally {
sourceMat?.release()
page?.release()
}
}
fun extractDocumentFromBitmap(
source: Bitmap,
quadInMask: Quad?,
rotationDegrees: Int,
mask: Mask?,
viewModelScope: CoroutineScope,
defaultColorMode: DefaultColorMode = DefaultColorMode.AUTO,
opticalMeasures: OpticalMeasures?,
): CapturedPage {
val exportQuality = ExportQuality.BALANCED
var colorMode = ColorMode.COLOR
var autoColorMode = colorMode
var normalizedQuad = createQuad(listOf(
Point(0.0, 0.0), Point(0.0, 1.0), Point(1.0, 1.0), Point(1.0, 0.0))
)
var page: Mat
val rgba = Mat()
Utils.bitmapToMat(source, rgba)
val bgr = Mat()
Imgproc.cvtColor(rgba, bgr, Imgproc.COLOR_RGBA2BGR)
rgba.release()
if (mask == null || quadInMask == null) {
// No document detected
val resized = resizeForMaxPixels(bgr, exportQuality.maxPixels.toDouble())
page = rotate(resized, rotationDegrees)
resized.release()
} else {
val quad = quadInMask.scaledTo(mask.width, mask.height, source.width, source.height)
normalizedQuad = quad.scaledTo(source.width, source.height, 1, 1)
autoColorMode = autoColorMode(bgr, mask, quad)
colorMode = defaultColorMode.colorMode ?: autoColorMode
page = extractDocument(bgr, quad, rotationDegrees, colorMode, exportQuality.maxPixels,
opticalMeasures)
}
val pageJpeg = Jpeg.fromMat(page, exportQuality.jpegQuality)
bgr.release()
page.release()
val baseRotation = Rotation.fromDegrees(rotationDegrees)
val sourceSize = ImageSize(source.width, source.height)
val metadata =
PageMetadata(normalizedQuad, baseRotation, autoColorMode, sourceSize, opticalMeasures)
val sourceJpegDeferred = viewModelScope.async(Dispatchers.IO) {
compressSource(source)
}
return CapturedPage(pageJpeg, sourceJpegDeferred, metadata, colorMode)
}
private fun compressSource(source: Bitmap): Jpeg {
val rgba = Mat()
Utils.bitmapToMat(source, rgba)
val bgr = Mat()
Imgproc.cvtColor(rgba, bgr, Imgproc.COLOR_RGBA2BGR)
rgba.release()
return try {
Jpeg.fromMat(bgr, 90)
} finally {
bgr.release()
}
}
@@ -1,58 +0,0 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.platform
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.core.graphics.scale
import org.fairscan.app.data.ImageTransformations
import org.opencv.core.MatOfInt
import org.opencv.imgcodecs.Imgcodecs
import java.io.File
import kotlin.math.min
class OpenCvTransformations : ImageTransformations {
override fun rotate(
inputFile: File,
outputFile: File,
rotationDegrees: Int,
jpegQuality: Int
) {
val src = Imgcodecs.imread(inputFile.absolutePath)
require(!src.empty()) { "Could not load image from ${inputFile.absolutePath}" }
val dst = org.fairscan.imageprocessing.rotate(src, rotationDegrees)
val params = MatOfInt(Imgcodecs.IMWRITE_JPEG_QUALITY, jpegQuality)
if (!Imgcodecs.imwrite(outputFile.absolutePath, dst, params)) {
throw RuntimeException("Could not write image to ${outputFile.absolutePath}")
}
params.release()
src.release()
dst.release()
}
override fun resize(inputFile: File, outputFile: File, maxSize: Int) {
val bitmap = BitmapFactory.decodeFile(inputFile.absolutePath)
val ratio = min(maxSize.toFloat() / bitmap.width, maxSize.toFloat() / bitmap.height)
val newW = (bitmap.width * ratio).toInt()
val newH = (bitmap.height * ratio).toInt()
val scaled = bitmap.scale(newW, newH)
outputFile.outputStream().use {
scaled.compress(Bitmap.CompressFormat.JPEG, 85, it)
}
}
}
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -14,45 +14,41 @@
*/
package org.fairscan.app.ui
import org.fairscan.app.LaunchMode
sealed class Screen {
sealed class Main : Screen() {
object Home : Main()
object Camera : Main()
object EditImage : Main()
data class Document(val initialPage: Int = 0) : Main()
object Export : Main()
object ResumeScan : Main()
}
sealed class Overlay : Screen() {
object About : Overlay()
object Libraries : Overlay()
object Settings : Overlay()
object OcrLanguages : Overlay()
}
}
data class Navigation(
val toHomeScreen: () -> Unit,
val toCameraScreen: () -> Unit,
val toEditImageScreen: () -> Unit,
val toDocumentScreen: () -> Unit,
val toExportScreen: () -> Unit,
val toAboutScreen: () -> Unit,
val toLibrariesScreen: () -> Unit,
val toSettingsScreen: (() -> Unit)?,
val toOcrLanguagesScreen: () -> Unit,
val back: () -> Unit,
val shouldDisplayBackButton: () -> Boolean,
)
fun startScreenFor(mode: LaunchMode): Screen.Main =
when (mode) {
LaunchMode.NORMAL -> Screen.Main.Home
LaunchMode.EXTERNAL_SCAN_TO_PDF -> Screen.Main.Camera
}
@ConsistentCopyVisibility
data class NavigationState private constructor(val stack: List<Screen>, val root: Screen.Main) {
companion object {
fun initial(mode: LaunchMode): NavigationState {
val root = startScreenFor(mode)
fun initial(): NavigationState {
val root = Screen.Main.Camera
return NavigationState(listOf(root), root)
}
}
@@ -70,10 +66,11 @@ data class NavigationState private constructor(val stack: List<Screen>, val root
fun navigateBack(): NavigationState {
return when (current) {
root -> this // Back handled by system
is Screen.Main.Home -> this // Back handled by system
is Screen.Main.Camera -> copy(stack = listOf(Screen.Main.Home))
is Screen.Main.ResumeScan -> this // Back handled by system
is Screen.Main.Camera -> this // Back handled by system
is Screen.Main.Document -> copy(stack = listOf(Screen.Main.Camera))
is Screen.Main.Export -> copy(stack = listOf(Screen.Main.Document()))
is Screen.Main.EditImage -> copy(stack = listOf(Screen.Main.Document()))
is Screen.Main.Export -> copy(stack = listOf(Screen.Main.Camera))
is Screen.Overlay -> copy(stack = stack.dropLast(1))
}
}
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -15,28 +15,25 @@
package org.fairscan.app.ui
import android.content.Context
import android.graphics.BitmapFactory
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import org.fairscan.app.domain.Jpeg
import org.fairscan.app.domain.PageViewKey
import org.fairscan.app.domain.Rotation
import org.fairscan.app.ui.state.DocumentUiModel
import org.fairscan.app.ui.state.PageThumbnail
import org.fairscan.imageprocessing.ColorMode
fun dummyNavigation(): Navigation {
return Navigation({}, {}, {}, {}, {}, {}, {}, {})
}
fun fakeDocument(): DocumentUiModel {
return DocumentUiModel(persistentListOf(), { _ -> null }, { _ -> null })
return Navigation({}, {}, {}, {}, {}, {}, {}, {}, {}, { -> true})
}
fun fakeDocument(pageIds: ImmutableList<String>, context: Context): DocumentUiModel {
val loader = { key: PageViewKey ->
context.assets.open("${key.pageId}.jpg").use { input ->
BitmapFactory.decodeStream(input)
}
}
val pageKeys = pageIds.map { PageViewKey(it, Rotation.R0) }.toImmutableList()
return DocumentUiModel(pageKeys, loader, loader)
val pageKeys = pageIds.map {
PageThumbnail(PageViewKey(it, Rotation.R0, ColorMode.COLOR, 0), fakeImage(it, context))
}.toImmutableList()
return DocumentUiModel(pageKeys)
}
fun fakeImage(id: String, context: Context): Jpeg =
Jpeg(context.assets.open("${id}.jpg").readBytes())
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -14,6 +14,7 @@
*/
package org.fairscan.app.ui.components
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -28,6 +29,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -56,15 +58,18 @@ fun SecondaryActionButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
enabled: Boolean = true
) {
FilledIconButton (
onClick = onClick,
colors = IconButtonDefaults.outlinedIconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f),
contentColor = MaterialTheme.colorScheme.primary
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.primary,
disabledContainerColor = if (isSystemInDarkTheme()) Color.DarkGray else Color.LightGray
),
modifier = modifier.size(40.dp)
modifier = modifier.size(40.dp),
enabled = enabled
) {
Icon(
imageVector = icon,
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -16,12 +16,14 @@ package org.fairscan.app.ui.components
import android.content.res.Configuration
import android.graphics.Bitmap
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -33,6 +35,8 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -44,8 +48,10 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.fairscan.app.THUMBNAIL_SIZE_DP
@@ -60,6 +66,7 @@ data class CommonPageListState(
val onPageClick: (Int) -> Unit,
val onPageReorder: (String, Int) -> Unit,
val listState: LazyListState,
val showPageNumbers: Boolean,
val currentPageIndex: Int? = null,
val onLastItemPosition: ((Offset) -> Unit)? = null,
)
@@ -72,12 +79,12 @@ fun CommonPageList(
val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
val reorderableLazyListState = rememberReorderableLazyListState(state.listState) { from, to ->
val pageId = state.document.pageKeys[from.index].pageId
val pageId = state.document.pages[from.index].key.pageId
state.onPageReorder(pageId, to.index)
}
val content: LazyListScope.() -> Unit = {
itemsIndexed(state.document.pageKeys, key = { _, item -> item.saveKey}) { index, item ->
ReorderableItem(reorderableLazyListState, key = item.saveKey) { isDragging ->
itemsIndexed(state.document.pages.map { it.key }, key = { _, item -> item.pageId}) { index, item ->
ReorderableItem(reorderableLazyListState, key = item.pageId) { isDragging ->
val borderColor =
if (isDragging) MaterialTheme.colorScheme.primary else Color.Transparent
val modifier = Modifier
@@ -87,7 +94,7 @@ fun CommonPageList(
color = borderColor,
shape = RoundedCornerShape(6.dp)
)
val image = state.document.loadThumbnail(index)
val image = state.document.thumbnail(index)
if (image != null) {
PageThumbnail(image, index, state, modifier)
}
@@ -111,10 +118,12 @@ fun CommonPageList(
)
}
if (state.document.isEmpty()) {
val layoutDirection = LocalLayoutDirection.current
Box(
modifier = Modifier
.height(THUMBNAIL_SIZE_DP.dp)
.addPositionCallback(state.onLastItemPosition, LocalDensity.current, 0.5f)
.addPositionCallback(
state.onLastItemPosition, LocalDensity.current, 0.5f, layoutDirection)
) {}
}
}
@@ -129,7 +138,7 @@ private fun PageThumbnail(
val bitmap = image.asImageBitmap()
val isSelected = index == state.currentPageIndex
val borderColor =
if (isSelected) MaterialTheme.colorScheme.secondary else Color.Transparent
if (isSelected) MaterialTheme.colorScheme.primary else Color.LightGray
val maxImageSize = PAGE_LIST_ELEMENT_SIZE_DP.dp
var imageModifier =
if (bitmap.height > bitmap.width)
@@ -138,45 +147,69 @@ private fun PageThumbnail(
Modifier.width(maxImageSize)
if (index == state.document.lastIndex()) {
val density = LocalDensity.current
imageModifier = imageModifier.addPositionCallback(state.onLastItemPosition, density, 1.0f)
val layoutDirection = LocalLayoutDirection.current
imageModifier = imageModifier.addPositionCallback(
state.onLastItemPosition, density, 1.0f, layoutDirection)
}
Box (modifier = modifier.height(PAGE_LIST_ELEMENT_SIZE_DP.dp)) {
Image(
bitmap = bitmap,
contentDescription = null,
modifier = imageModifier
.align(Alignment.Center)
.padding(4.dp)
.border(2.dp, borderColor)
.clickable { state.onPageClick(index) }
)
Box(
modifier = Modifier
.padding(8.dp)
.align(Alignment.BottomCenter)
.background(Color.Black.copy(alpha = 0.5f), shape = RoundedCornerShape(4.dp))
.padding(vertical = 0.dp, horizontal = 8.dp)
Box(modifier = modifier.height(PAGE_LIST_ELEMENT_SIZE_DP.dp)) {
Card(
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
shape = RoundedCornerShape(6.dp),
border = BorderStroke(1.dp, borderColor),
modifier = Modifier.padding(4.dp).align(Alignment.Center)
) {
Text(
text = "${index + 1}",
color = Color.White,
fontSize = 10.sp,
textAlign = TextAlign.Center,
Image(
bitmap = bitmap,
contentDescription = "${index + 1}",
modifier = imageModifier.clickable { state.onPageClick(index) }
)
}
if (state.showPageNumbers) {
PageNumberBadge(index)
}
}
}
fun Modifier.addPositionCallback(callback: ((Offset) -> Unit)?, density: Density, xFactor: Float): Modifier {
@Composable
private fun BoxScope.PageNumberBadge(index: Int) {
Box(
modifier = Modifier
.padding(8.dp)
.align(Alignment.BottomCenter)
.background(Color.Black.copy(alpha = 0.5f), shape = RoundedCornerShape(4.dp))
.padding(vertical = 0.dp, horizontal = 8.dp)
) {
Text(
text = "${index + 1}",
color = Color.White,
fontSize = 10.sp,
textAlign = TextAlign.Center,
)
}
}
fun Modifier.addPositionCallback(
callback: ((Offset) -> Unit)?,
density: Density,
xFactor: Float,
layoutDirection: LayoutDirection
): Modifier {
if (callback == null) {
return this
}
return this.onGloballyPositioned { coordinates ->
with(density) {
callback(coordinates.localToWindow(
Offset(
x = PAGE_LIST_ELEMENT_SIZE_DP.dp.toPx() * xFactor,
y = PAGE_LIST_ELEMENT_SIZE_DP.dp.toPx() / 2)))
val width = PAGE_LIST_ELEMENT_SIZE_DP.dp.toPx()
val x = when (layoutDirection) {
LayoutDirection.Ltr -> width * xFactor
LayoutDirection.Rtl -> width * (0.5f - xFactor)
}
callback(
coordinates.localToWindow(Offset(x = x, y = width / 2))
)
}
}
}
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -54,16 +54,20 @@ import org.fairscan.app.ui.Navigation
@Composable
fun MyScaffold(
navigation: Navigation,
pageListState: CommonPageListState,
pageListButton: (@Composable () -> Unit)? = null,
pageListState: CommonPageListState? = null,
bottomBar: @Composable () -> Unit,
onBack: (() -> Unit)? = null,
content: @Composable (Modifier) -> Unit,
) {
Box {
if (!isLandscape(LocalConfiguration.current)) {
Scaffold(
bottomBar = { DocumentBar(pageListState, bottomBar, Modifier, pageListButton) }
bottomBar = {
if (pageListState == null) {
bottomBar
} else {
DocumentBar(pageListState, bottomBar, Modifier)
}
}
) { innerPadding ->
content(Modifier.padding(innerPadding).fillMaxSize())
}
@@ -73,13 +77,17 @@ fun MyScaffold(
modifier = Modifier.padding(innerPadding).fillMaxSize()
) {
content(Modifier.weight(2f))
DocumentBar(pageListState, bottomBar, Modifier.weight(1f), pageListButton)
if (pageListState == null) {
bottomBar
} else {
DocumentBar(pageListState, bottomBar, Modifier.weight(1f))
}
}
}
}
if (onBack != null) {
if (navigation.shouldDisplayBackButton()) {
BackButton(
onBack,
navigation.back,
modifier = Modifier
.align(Alignment.TopStart)
.windowInsetsPadding(WindowInsets.safeDrawing)
@@ -99,7 +107,6 @@ fun DocumentBar(
pageListState: CommonPageListState,
buttonBar: @Composable () -> Unit,
modifier: Modifier = Modifier,
pageListButton: (@Composable () -> Unit)? = null,
) {
val isLandscape = isLandscape(LocalConfiguration.current)
Column(
@@ -115,17 +122,6 @@ fun DocumentBar(
Modifier
) {
CommonPageList(pageListState, modifier = Modifier.fillMaxWidth())
if (pageListButton != null) {
val alignment = if (isLandscape) Alignment.BottomEnd else Alignment.CenterEnd
Box(
Modifier
.align(alignment)
.padding(horizontal = 8.dp, vertical = 16.dp)
) {
pageListButton()
}
}
}
val color = MaterialTheme.colorScheme.surfaceContainerHigh
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -25,9 +25,3 @@ import java.util.Date
fun pageCountText(quantity: Int): String {
return LocalResources.current.getQuantityString(R.plurals.page_count, quantity, quantity)
}
fun formatDate(timestamp: Long, context: Context): String {
val date = Date(timestamp)
return DateFormat.getMediumDateFormat(context).format(date) + " " +
DateFormat.getTimeFormat(context).format(date)
}
@@ -1,260 +0,0 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.ui.screens
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.RotateLeft
import androidx.compose.material.icons.automirrored.filled.RotateRight
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableIntState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.collections.immutable.toImmutableList
import net.engawapg.lib.zoomable.ZoomState
import net.engawapg.lib.zoomable.zoomable
import org.fairscan.app.R
import org.fairscan.app.ui.Navigation
import org.fairscan.app.ui.components.CommonPageListState
import org.fairscan.app.ui.components.ConfirmationDialog
import org.fairscan.app.ui.components.MainActionButton
import org.fairscan.app.ui.components.MyScaffold
import org.fairscan.app.ui.components.SecondaryActionButton
import org.fairscan.app.ui.dummyNavigation
import org.fairscan.app.ui.fakeDocument
import org.fairscan.app.ui.state.DocumentUiModel
import org.fairscan.app.ui.theme.FairScanTheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DocumentScreen(
document: DocumentUiModel,
initialPage: Int,
navigation: Navigation,
onExportClick: () -> Unit,
onDeleteImage: (String) -> Unit,
onRotateImage: (String, Boolean) -> Unit,
onPageReorder: (String, Int) -> Unit,
) {
// TODO Check how often images are loaded
val showDeletePageDialog = rememberSaveable { mutableStateOf(false) }
val currentPageIndex = rememberSaveable { mutableIntStateOf(initialPage) }
if (currentPageIndex.intValue >= document.pageCount()) {
currentPageIndex.intValue = document.pageCount() - 1
}
if (currentPageIndex.intValue < 0) {
navigation.toCameraScreen()
return
}
BackHandler { navigation.back() }
val listState = rememberLazyListState()
LaunchedEffect(initialPage) {
listState.scrollToItem(initialPage)
}
MyScaffold(
navigation = navigation,
pageListState = CommonPageListState(
document,
onPageClick = { index -> currentPageIndex.intValue = index },
onPageReorder = onPageReorder,
currentPageIndex = currentPageIndex.intValue,
listState = listState,
),
onBack = navigation.back,
bottomBar = {
BottomBar(onExportClick)
},
pageListButton = {
SecondaryActionButton(
icon = Icons.Default.Add,
onClick = navigation.toCameraScreen,
contentDescription = stringResource(R.string.add_page),
)
},
) { modifier ->
DocumentPreview(
document,
currentPageIndex,
{ showDeletePageDialog.value = true },
onRotateImage,
modifier
)
if (showDeletePageDialog.value) {
ConfirmationDialog(
title = stringResource(R.string.delete_page),
message = stringResource(R.string.delete_page_warning),
showDialog = showDeletePageDialog
) { onDeleteImage(document.pageId(currentPageIndex.intValue)) }
}
}
}
@Composable
private fun DocumentPreview(
document: DocumentUiModel,
currentPageIndex: MutableIntState,
onDeleteImage: (String) -> Unit,
onRotateImage: (String, Boolean) -> Unit,
modifier: Modifier,
) {
val imageId = document.pageId(currentPageIndex.intValue)
Column (
modifier = modifier
.background(MaterialTheme.colorScheme.surfaceContainerLow)
) {
Box (
modifier = Modifier.fillMaxSize()
) {
val bitmap = document.load(currentPageIndex.intValue)
if (bitmap != null) {
val imageBitmap = bitmap.asImageBitmap()
val zoomState = remember(imageId) {
ZoomState(
contentSize = Size(bitmap.width.toFloat(), bitmap.height.toFloat())
)
}
Box(modifier = Modifier
.fillMaxSize(0.92f)
.align(Alignment.Center)) {
Image(
bitmap = imageBitmap,
contentDescription = null,
modifier = Modifier
.padding(4.dp)
.align(Alignment.Center)
.zoomable(zoomState)
)
}
}
RotationButtons(imageId, onRotateImage, Modifier.align(Alignment.BottomCenter))
SecondaryActionButton(
Icons.Outlined.Delete,
contentDescription = stringResource(R.string.delete_page),
onClick = { onDeleteImage(imageId) },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(8.dp)
)
Text("${currentPageIndex.intValue + 1} / ${document.pageCount()}",
color = MaterialTheme.colorScheme.inverseOnSurface,
modifier = Modifier
.align(Alignment.BottomStart)
.padding(all = 16.dp)
.background(
color = MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.5f),
shape = RoundedCornerShape(4.dp)
)
.padding(horizontal = 8.dp, vertical = 2.dp)
)
}
}
}
@Composable
fun RotationButtons(
imageId: String,
onRotateImage: (String, Boolean) -> Unit,
modifier: Modifier = Modifier
) {
Row(modifier = modifier.padding(4.dp)) {
SecondaryActionButton(
icon = Icons.AutoMirrored.Default.RotateLeft,
contentDescription = "Rotate left",
onClick = { onRotateImage(imageId, false) }
)
Spacer(Modifier.width(8.dp))
SecondaryActionButton(
icon = Icons.AutoMirrored.Default.RotateRight,
contentDescription = "Rotate right",
onClick = { onRotateImage(imageId, true) }
)
}
}
@Composable
private fun BottomBar(
onExportClick: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
MainActionButton(
onClick = onExportClick,
icon = Icons.Default.Description,
text = stringResource(R.string.export),
)
}
}
@Composable
@Preview
fun DocumentScreenPreview() {
FairScanTheme {
DocumentScreen(
fakeDocument(
listOf(1, 2).map { "gallica.bnf.fr-bpt6k5530456s-$it.jpg" }.toImmutableList(),
LocalContext.current
),
initialPage = 1,
navigation = dummyNavigation(),
onExportClick = {},
onDeleteImage = { _ -> },
onRotateImage = { _,_ -> },
onPageReorder = { _,_ -> },
)
}
}
@Preview(showBackground = true, widthDp = 640, heightDp = 320)
@Composable
fun DocumentScreenPreviewInLandscapeMode() {
DocumentScreenPreview()
}
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -0,0 +1,206 @@
/*
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.ui.screens
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.collections.immutable.toImmutableList
import org.fairscan.app.R
import org.fairscan.app.domain.PageViewKey
import org.fairscan.app.domain.Rotation
import org.fairscan.app.ui.components.isLandscape
import org.fairscan.app.ui.components.pageCountText
import org.fairscan.app.ui.fakeDocument
import org.fairscan.app.ui.fakeImage
import org.fairscan.app.ui.screens.document.CurrentPageUiState
import org.fairscan.app.ui.screens.document.DocumentUiState
import org.fairscan.app.ui.theme.FairScanTheme
import org.fairscan.imageprocessing.ColorMode
@Composable
fun ResumeScanScreen(
currentDocument: DocumentUiState,
onResumeScan: () -> Unit,
onStartNewScan: () -> Unit,
) {
val pageCount = currentDocument.document.pageCount()
val firstPageThumbnail = currentDocument.currentPage?.bitmap
val resumeScanModifier = Modifier
.fillMaxWidth()
.clickable(onClick = onResumeScan)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
.padding(horizontal = 16.dp, vertical = 24.dp)
Scaffold { innerPadding ->
if (!isLandscape(LocalConfiguration.current)) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
Column(
modifier = resumeScanModifier.weight(2f),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
FirstPageThumbnail(firstPageThumbnail, Modifier.weight(1f))
Spacer(modifier = Modifier.height(20.dp))
ResumeScanActions(pageCount, onResumeScan)
}
HorizontalDivider()
NewScanArea(onStartNewScan, Modifier.weight(1f))
}
} else {
Row {
Row(
modifier = resumeScanModifier.weight(1.8f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
FirstPageThumbnail(firstPageThumbnail, Modifier.weight(1f))
Spacer(Modifier.width(8.dp))
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
) {
ResumeScanActions(pageCount, onResumeScan)
}
}
VerticalDivider()
NewScanArea(onStartNewScan, Modifier.weight(1f))
}
}
}
}
@Composable
private fun FirstPageThumbnail(firstPageThumbnail: Bitmap?, modifier: Modifier) {
firstPageThumbnail?.let { bmp ->
Box(
modifier = modifier,
contentAlignment = Alignment.Center,
) {
Image(
bitmap = bmp.asImageBitmap(),
contentDescription = null,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.border(1.dp, Color.Gray, RoundedCornerShape(8.dp)),
contentScale = ContentScale.Fit,
)
}
}
}
@Composable
private fun ResumeScanActions(pageCount: Int, onResumeScan: () -> Unit) {
FlowRow {
val style = MaterialTheme.typography.bodyMedium
Text(stringResource(R.string.scan_current), style = style)
Text("", style = style)
Text(pageCountText(pageCount), style = style)
}
BigButton(onClick = onResumeScan, text = stringResource(R.string.scan_resume))
}
@Composable
private fun NewScanArea(onStartNewScan: () -> Unit, modifier: Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.clickable(onClick = onStartNewScan)
.padding(horizontal = 16.dp, vertical = 32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(R.string.scan_discard_current),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
)
BigButton(onClick = onStartNewScan, text = stringResource(R.string.scan_new))
}
}
@Composable
fun BigButton(onClick: () -> Unit, text: String) {
Button(onClick = onClick, modifier = Modifier.padding(vertical = 16.dp)) {
Text(
text,
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(vertical = 4.dp),
)
}
}
@Preview(showBackground = true, showSystemUi = true)
@Preview(showBackground = true, widthDp = 320, heightDp = 640)
@Preview(showBackground = true, widthDp = 640, heightDp = 320)
@Composable
fun ResumeScanScreenPreview() {
val image = fakeImage("gallica.bnf.fr-bpt6k5530456s-1", LocalContext.current).toBitmap()
val document = fakeDocument(
listOf(1, 2).map { "gallica.bnf.fr-bpt6k5530456s-$it" }.toImmutableList(),
LocalContext.current
)
val key = PageViewKey("123", Rotation.R0, null, 0)
FairScanTheme {
ResumeScanScreen(
DocumentUiState(1, CurrentPageUiState(key,image, ColorMode.COLOR, true), document),
{}, {}
)
}
}
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -81,6 +81,7 @@ fun AboutScreen(
onBack: () -> Unit,
onCopyLogs: () -> Unit,
onContactWithLastImageClicked: () -> Unit,
onStartActivity: (Intent) -> Unit,
onViewLibraries: () -> Unit,
) {
val showLicenseDialog = rememberSaveable { mutableStateOf(false) }
@@ -99,6 +100,7 @@ fun AboutScreen(
aboutUiState,
onCopyLogs,
onContactWithLastImageClicked,
onStartActivity,
showLicenseDialog,
onViewLibraries)
}
@@ -113,6 +115,7 @@ fun AboutContent(
aboutUiState: AboutUiState,
onCopyLogs: () -> Unit,
onContactWithLastImageClicked: () -> Unit,
onStartActivity: (Intent) -> Unit,
showLicenseDialog: MutableState<Boolean>,
onViewLibraries: () -> Unit,
) {
@@ -155,7 +158,7 @@ fun AboutContent(
ContactLink(
icon = Icons.Default.Email,
text = EMAIL_ADDRESS,
onClick = { context.startActivity(createContactEmailIntent()) }
onClick = { onStartActivity(createContactEmailIntent()) }
)
val websiteUrl = "https://fairscan.org"
ContactLink(
@@ -163,7 +166,7 @@ fun AboutContent(
text = websiteUrl,
onClick = {
val intent = Intent(Intent.ACTION_VIEW, websiteUrl.toUri())
context.startActivity(intent)
onStartActivity(intent)
}
)
}
@@ -327,6 +330,6 @@ fun EmailImageButton(
fun AboutScreenPreview() {
FairScanTheme {
val state = AboutUiState(true)
AboutScreen(state, {}, {}, {}, {})
AboutScreen(state, {}, {}, {}, {}, {})
}
}
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -15,10 +15,19 @@
package org.fairscan.app.ui.screens.camera
import android.graphics.Bitmap
import android.hardware.camera2.CameraCaptureSession
import android.hardware.camera2.CameraMetadata
import android.hardware.camera2.CaptureRequest
import android.hardware.camera2.CaptureResult
import android.hardware.camera2.TotalCaptureResult
import android.util.Log
import android.util.Size
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.widget.LinearLayout
import androidx.annotation.OptIn
import androidx.camera.camera2.interop.Camera2CameraInfo
import androidx.camera.camera2.interop.Camera2Interop
import androidx.camera.camera2.interop.ExperimentalCamera2Interop
import androidx.camera.core.CameraControl
import androidx.camera.core.CameraSelector
import androidx.camera.core.FocusMeteringAction
@@ -65,12 +74,16 @@ import androidx.core.graphics.scale
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner
import org.fairscan.app.ui.components.CameraPermissionState
import org.fairscan.imageprocessing.CameraIntrinsics
import org.fairscan.imageprocessing.OpticalMeasures
import org.fairscan.imageprocessing.Point
import org.fairscan.imageprocessing.Quad
import org.fairscan.imageprocessing.cameraIntrinsics
import org.fairscan.imageprocessing.scaledTo
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import kotlin.math.max
@Composable
fun CameraPreview(
@@ -162,6 +175,7 @@ fun CameraPreview(
}
@OptIn(ExperimentalCamera2Interop::class)
fun bindCameraUseCases(
lifecycleOwner: LifecycleOwner,
cameraProvider: ProcessCameraProvider,
@@ -186,7 +200,7 @@ fun bindCameraUseCases(
.setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888).build()
imageAnalysis.setAnalyzer(executor, onImageAnalyzed)
val imageCapture = ImageCapture.Builder()
val imageCaptureBuilder = ImageCapture.Builder()
.setResolutionSelector(
ResolutionSelector.Builder()
.setResolutionStrategy(
@@ -201,17 +215,32 @@ fun bindCameraUseCases(
.build()
)
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
.build()
Camera2Interop.Extender(imageCaptureBuilder)
.setSessionCaptureCallback(object : CameraCaptureSession.CaptureCallback() {
override fun onCaptureCompleted(
session: CameraCaptureSession,
request: CaptureRequest,
result: TotalCaptureResult
) {
result.get(CaptureResult.LENS_FOCUS_DISTANCE)?.let {
captureController.lastFocusDistanceDiopters = it
}
}
})
val imageCapture = imageCaptureBuilder.build()
captureController.imageCapture = imageCapture
val camera = cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector,
imageAnalysis, preview, imageCapture)
captureController.cameraControl = camera.cameraControl
captureController.setCameraCharacteristics(Camera2CameraInfo.from(camera.cameraInfo))
}
@Composable
fun AnalysisOverlay(liveAnalysisState: LiveAnalysisState, debugMode: Boolean) {
val binaryMask = liveAnalysisState.binaryMask ?: return
val maskSize = liveAnalysisState.maskSize ?: return
val targetQuad = liveAnalysisState.stableQuad
var displayedQuad by remember { mutableStateOf<Quad?>(null) }
val quadColor = MaterialTheme.colorScheme.primary
@@ -233,14 +262,15 @@ fun AnalysisOverlay(liveAnalysisState: LiveAnalysisState, debugMode: Boolean) {
Canvas(modifier = Modifier.fillMaxSize()) {
if (debugMode) {
drawMask(this, binaryMask)
val binaryMask = liveAnalysisState.binaryMaskProvider.invoke()
binaryMask?.let { drawMask(this, it) }
}
displayedQuad?.let { quad ->
val scaledQuad = quad.scaledTo(
fromWidth = binaryMask.width,
fromHeight = binaryMask.height,
toWidth = size.width.toInt(),
toHeight = size.height.toInt()
fromWidth = maskSize.width,
fromHeight = maskSize.height,
toWidth = size.width.toDouble(),
toHeight = size.height.toDouble()
)
scaledQuad.edges().forEach {
drawLine(quadColor, it.from.toOffset(), it.to.toOffset(), 10.0f)
@@ -286,21 +316,35 @@ class CameraCaptureController {
var imageCapture: ImageCapture? = null
private val executor = Executors.newSingleThreadExecutor()
var previewView: PreviewView? = null
var cameraIntrinsics: CameraIntrinsics? = null
var canUseFocusDistance = false
@Volatile
var lastFocusDistanceDiopters: Float? = null
fun shutdown() {
executor.shutdown()
}
fun takePicture(onImageCaptured: (ImageProxy?) -> Unit) {
fun takePicture(onImageCaptured: (ImageProxy?, OpticalMeasures?) -> Unit) {
imageCapture?.takePicture(
executor,
object : ImageCapture.OnImageCapturedCallback() {
override fun onCaptureSuccess(imageProxy: ImageProxy) {
onImageCaptured(imageProxy)
val diopters = lastFocusDistanceDiopters
val subjectDistanceInMm =
if (canUseFocusDistance && diopters != null && diopters != 0.0f) {
1000 / diopters
} else {
null
}
onImageCaptured(
imageProxy,
cameraIntrinsics?.let { OpticalMeasures(it, subjectDistanceInMm) })
}
override fun onError(exception: ImageCaptureException) {
Log.e("CameraCapture", "Image capture failed: ${exception.message}", exception)
onImageCaptured(null)
onImageCaptured(null, null)
}
}
)
@@ -319,6 +363,28 @@ class CameraCaptureController {
control.startFocusAndMetering(action)
}
@OptIn(ExperimentalCamera2Interop::class)
fun setCameraCharacteristics(cameraInfo: Camera2CameraInfo) {
val focalLengths = cameraInfo.getCameraCharacteristic(
android.hardware.camera2.CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS
)
val sensorSize = cameraInfo.getCameraCharacteristic(
android.hardware.camera2.CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE
)
cameraIntrinsics =
if (focalLengths == null || focalLengths.size != 1 || sensorSize == null) {
null
} else {
cameraIntrinsics(focalLengths[0], max(sensorSize.width, sensorSize.height))
}
val calibration = cameraInfo.getCameraCharacteristic(
android.hardware.camera2.CameraCharacteristics.LENS_INFO_FOCUS_DISTANCE_CALIBRATION
)
canUseFocusDistance =
calibration == CameraMetadata.LENS_INFO_FOCUS_DISTANCE_CALIBRATION_CALIBRATED
|| calibration == CameraMetadata.LENS_INFO_FOCUS_DISTANCE_CALIBRATION_APPROXIMATE
}
}
sealed interface CameraBindState {
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -15,15 +15,13 @@
package org.fairscan.app.ui.screens.camera
import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.camera.core.ImageProxy
import androidx.camera.view.PreviewView
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.LocalIndication
@@ -34,23 +32,33 @@ import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddPhotoAlternate
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.Highlight
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -79,16 +87,21 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.delay
import org.fairscan.app.MainViewModel
import org.fairscan.app.R
import org.fairscan.app.domain.CapturedPage
import org.fairscan.app.domain.Jpeg
import org.fairscan.app.domain.PageMetadata
import org.fairscan.app.domain.Rotation.R0
import org.fairscan.app.ui.Navigation
@@ -101,6 +114,7 @@ import org.fairscan.app.ui.components.pageCountText
import org.fairscan.app.ui.dummyNavigation
import org.fairscan.app.ui.fakeDocument
import org.fairscan.app.ui.theme.FairScanTheme
import org.fairscan.imageprocessing.ColorMode
import org.fairscan.imageprocessing.Point
import org.fairscan.imageprocessing.Quad
@@ -113,21 +127,25 @@ fun CameraScreen(
cameraViewModel: CameraViewModel,
navigation: Navigation,
liveAnalysisState: LiveAnalysisState,
importState: ImportState,
onImageAnalyzed: (ImageProxy) -> Unit,
onFinalizePressed: () -> Unit,
cameraPermission: CameraPermissionState,
onImportClicked: () -> Unit,
) {
var previewView by remember { mutableStateOf<PreviewView?>(null) }
val document by viewModel.documentUiModel.collectAsStateWithLifecycle()
val thumbnailCoords = remember { mutableStateOf(Offset.Zero) }
var isDebugMode by remember { mutableStateOf(false) }
val isTorchEnabled by cameraViewModel.isTorchEnabled.collectAsStateWithLifecycle()
BackHandler { navigation.back() }
var torchReapplied by remember { mutableStateOf(false) }
val captureController = remember { CameraCaptureController() }
DisposableEffect(Unit) {
onDispose { captureController.shutdown() }
onDispose {
captureController.shutdown()
torchReapplied = false
}
}
LaunchedEffect(captureController.cameraControl, isTorchEnabled) {
captureController.cameraControl?.enableTorch(isTorchEnabled)
@@ -151,17 +169,52 @@ fun CameraScreen(
}
}
LaunchedEffect(Unit) {
cameraViewModel.resetLiveAnalysis()
}
DisposableEffect(Unit) {
onDispose {
cameraViewModel.cancelImport()
}
}
val listState = rememberLazyListState()
LaunchedEffect(document.pageCount()) {
if (!document.isEmpty()) {
listState.animateScrollToItem(document.lastIndex())
}
}
val onCapture = {
previewView?.bitmap?.let {
Log.i("FairScan", "Pressed <Capture>")
cameraViewModel.onCapturePressed(it)
captureController.takePicture(
onImageCaptured = { imageProxy, opticalMeasures ->
cameraViewModel.onImageCaptured(imageProxy, opticalMeasures)
}
)
}?: Unit
}
LaunchedEffect(Unit) {
cameraViewModel.volumeKeyEvent.collect {
onCapture()
}
}
val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
CameraScreenScaffold(
cameraPreview = {
CameraPreview(
onImageAnalyzed = onImageAnalyzed,
onImageAnalyzed = {
onImageAnalyzed(it)
// Re-apply torch once the camera session is actually active (first analysis)
if (!torchReapplied) {
captureController.cameraControl?.enableTorch(isTorchEnabled)
torchReapplied = true
}
},
captureController = captureController,
onPreviewViewReady = { view ->
previewView = view
@@ -177,25 +230,19 @@ fun CameraScreen(
onPageClick = { index -> viewModel.navigateTo(Screen.Main.Document(index)) },
onPageReorder = { id, index -> viewModel.movePage(id, index) },
listState = listState,
showPageNumbers = false,
onLastItemPosition = { offset -> thumbnailCoords.value = offset },
),
cameraUiState = CameraUiState(
document.pageCount(),
liveAnalysisState,
captureState,
importState,
showDetectionError,
isLandscape = isLandscape,
isDebugMode,
isTorchEnabled),
onCapture = {
previewView?.bitmap?.let {
Log.i("FairScan", "Pressed <Capture>")
cameraViewModel.onCapturePressed(it)
captureController.takePicture(
onImageCaptured = { imageProxy -> cameraViewModel.onImageCaptured(imageProxy) }
)
}
},
onCapture = onCapture,
onFinalizePressed = onFinalizePressed,
onDebugModeSwitched = { isDebugMode = !isDebugMode },
onTorchSwitched = {
@@ -203,7 +250,10 @@ fun CameraScreen(
},
thumbnailCoords = thumbnailCoords,
navigation = navigation,
captureController
captureController,
isCameraPermissionGranted = cameraPermission.isGranted,
onRequestCameraPermission = { cameraPermission.request() },
onImportClicked = onImportClicked,
)
}
@@ -219,6 +269,9 @@ private fun CameraScreenScaffold(
thumbnailCoords: MutableState<Offset>,
navigation: Navigation,
captureController: CameraCaptureController,
isCameraPermissionGranted: Boolean,
onRequestCameraPermission: () -> Unit,
onImportClicked: () -> Unit,
) {
var focusPoint by remember { mutableStateOf<Offset?>(null) }
LaunchedEffect(focusPoint) {
@@ -249,31 +302,75 @@ private fun CameraScreenScaffold(
MyScaffold(
navigation = navigation,
pageListState = pageListState,
onBack = navigation.back,
bottomBar = { Bar(cameraUiState.pageCount, onFinalizePressed) }
bottomBar = { Bar(cameraUiState.pageCount, onFinalizePressed, onImportClicked) }
) { modifier ->
CameraPreviewBox(
cameraPreview,
cameraUiState,
focusPoint,
onCapture,
onTorchSwitched,
modifier.pointerInput(Unit) {
detectTapGestures { offset ->
focusPoint = offset
captureController.tapToFocus(offset)
onPageCountClick()
if (cameraUiState.importState is ImportState.Selecting) {
// display nothing: photo picker is active
} else if (cameraUiState.importState is ImportState.Importing) {
ImportInProgress(cameraUiState.importState, modifier)
} else if (!isCameraPermissionGranted) {
CameraPermissionRationale(onRequestCameraPermission, modifier)
} else {
CameraPreviewBox(
cameraPreview,
cameraUiState,
focusPoint,
onCapture,
onTorchSwitched,
modifier.pointerInput(Unit) {
detectTapGestures { offset ->
focusPoint = offset
captureController.tapToFocus(offset)
onPageCountClick()
}
}
}
)
)
}
}
if (cameraUiState.captureState is CaptureState.CapturePreview) {
val page = cameraUiState.captureState.capturedPage.page
val page = cameraUiState.captureState.capturedPage.pageJpeg.toBitmap()
CapturedImage(page.asImageBitmap(), thumbnailCoords)
}
}
}
@Composable
fun ImportInProgress(state: ImportState.Importing, modifier: Modifier) {
Box(
modifier = modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.6f)),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier.fillMaxWidth().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = LocalResources.current.getQuantityString(
R.plurals.importing_photos,
state.total,
state.total
),
color = Color.White
)
Spacer(Modifier.height(16.dp))
if (state.total > 1) {
LinearProgressIndicator(
progress = { state.processed.toFloat() / state.total },
modifier = Modifier.fillMaxWidth()
)
} else {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
@Composable
private fun CameraPreviewBox(
cameraPreview: @Composable (() -> Unit),
@@ -438,7 +535,7 @@ private fun CameraPreviewWithOverlay(
.background(Color.Black.copy(alpha = 0.6f))
)
}
if (cameraUiState.showDetectionError) {
if (cameraUiState.showCaptureError) {
Box(
modifier = Modifier
.align(Alignment.Center)
@@ -446,7 +543,7 @@ private fun CameraPreviewWithOverlay(
.padding(16.dp)
) {
Text(
text = stringResource(R.string.error_no_document),
text = stringResource(R.string.error_occurred),
color = Color.White,
fontSize = 16.sp
)
@@ -487,12 +584,27 @@ fun MessageBox(inferenceTime: Long) {
private fun Bar(
pageCount: Int,
onFinalizePressed: () -> Unit,
onImportClicked: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
horizontalArrangement = Arrangement.SpaceBetween,
) {
OutlinedButton(
onClick = onImportClicked,
colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.primary
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary)
) {
Icon(Icons.Default.AddPhotoAlternate, contentDescription = null)
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.import_photos),
maxLines = 1,
overflow = TextOverflow.Ellipsis)
}
MainActionButton(
onClick = onFinalizePressed,
enabled = pageCount > 0,
@@ -502,7 +614,34 @@ private fun Bar(
}
}
@Preview(showBackground = true)
@Composable
private fun CameraPermissionRationale(onRequestCameraPermission: () -> Unit, modifier:Modifier) {
Box (modifier = modifier, contentAlignment = Alignment.Center) {
Card(
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth()
.padding(12.dp)
) {
Column(Modifier.padding(16.dp)) {
Text(
stringResource(R.string.camera_permission_rationale),
)
Spacer(Modifier.height(8.dp))
Button(
onClick = onRequestCameraPermission,
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
Text(stringResource(R.string.grant_permission))
}
}
}
}
}
@Preview(name="Pixel 1", showSystemUi = true, device = Devices.PIXEL)
@Preview(name="Pixel 4", showSystemUi = true, device = Devices.PIXEL_4)
@Preview(name="Pixel 9 pro XL", showSystemUi = true, device = Devices.PIXEL_9_PRO_XL)
@Composable
fun CameraScreenPreview() {
ScreenPreview(CaptureState.Idle)
@@ -514,11 +653,12 @@ fun CameraScreenPreviewWithProcessedImage() {
val p = Point(0 , 0)
val quad = Quad(p, p, p, p)
ScreenPreview(CaptureState.CapturePreview(
debugImage("uncropped/img01.jpg"),
debugImage("uncropped/img01.jpg").toBitmap(),
CapturedPage(
debugImage("gallica.bnf.fr-bpt6k5530456s-1.jpg"),
debugImage("gallica.bnf.fr-bpt6k5530456s-1.jpg"),
PageMetadata(quad, R0, false))))
CompletableDeferred(Jpeg(ByteArray(0))),
PageMetadata(quad, R0, ColorMode.COLOR, null, null),
ColorMode.COLOR)))
}
@Preview(showBackground = true, widthDp = 640, heightDp = 320)
@@ -527,8 +667,25 @@ fun CameraScreenPreviewInLandscapeMode() {
ScreenPreview(CaptureState.Idle, rotationDegrees = 90f)
}
@Preview
@Composable
private fun ScreenPreview(captureState: CaptureState, rotationDegrees: Float = 0f) {
fun CameraScreenPreviewWithNoPermission() {
ScreenPreview(CaptureState.Idle, isCameraPermissionGranted = false)
}
@Preview
@Composable
fun CameraScreenPreviewImporting() {
ScreenPreview(CaptureState.Idle, ImportState.Importing(2, 3))
}
@Composable
private fun ScreenPreview(
captureState: CaptureState,
importState: ImportState = ImportState.Idle,
rotationDegrees: Float = 0f,
isCameraPermissionGranted: Boolean = true,
) {
FairScanTheme {
val thumbnailCoords = remember { mutableStateOf(Offset.Zero) }
CameraScreenScaffold(
@@ -540,7 +697,7 @@ private fun ScreenPreview(captureState: CaptureState, rotationDegrees: Float = 0
contentAlignment = Alignment.TopCenter
) {
Image(
debugImage("uncropped/img01.jpg").asImageBitmap(),
debugImage("uncropped/img01.jpg").toBitmap().asImageBitmap(),
modifier=Modifier.rotate(rotationDegrees),
contentDescription = null
)
@@ -550,30 +707,32 @@ private fun ScreenPreview(captureState: CaptureState, rotationDegrees: Float = 0
CommonPageListState(
document = fakeDocument(
listOf(1, 2)
.map { "gallica.bnf.fr-bpt6k5530456s-$it.jpg" }
.map { "gallica.bnf.fr-bpt6k5530456s-$it" }
.toImmutableList(),
LocalContext.current),
onPageClick = {},
onPageReorder = { _, _ -> },
listState = LazyListState(),
showPageNumbers = false,
),
cameraUiState = CameraUiState(pageCount = 4, LiveAnalysisState(), captureState,
false, rotationDegrees > 0, false, false),
importState, false, rotationDegrees > 0, false, false),
onCapture = {},
onFinalizePressed = {},
onDebugModeSwitched = {},
onTorchSwitched = {},
thumbnailCoords = thumbnailCoords,
navigation = dummyNavigation(),
captureController = CameraCaptureController()
captureController = CameraCaptureController(),
isCameraPermissionGranted = isCameraPermissionGranted,
onRequestCameraPermission = {},
onImportClicked = {},
)
}
}
@Composable
private fun debugImage(imgName: String): Bitmap {
private fun debugImage(imgName: String): Jpeg {
val context = LocalContext.current
return context.assets.open(imgName).use { input ->
BitmapFactory.decodeStream(input)
}
return Jpeg(context.assets.open(imgName).readBytes())
}
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -16,21 +16,29 @@ package org.fairscan.app.ui.screens.camera
import android.graphics.Bitmap
import androidx.compose.runtime.Immutable
import org.fairscan.imageprocessing.ImageSize
import org.fairscan.imageprocessing.Quad
@Immutable
data class LiveAnalysisState(
val inferenceTime: Long = 0L,
val binaryMask: Bitmap? = null,
val documentQuad: Quad? = null,
val maskSize: ImageSize? = null,
val binaryMaskProvider: () -> Bitmap? = { -> null },
val stableQuad: Quad? = null,
)
sealed class ImportState {
object Idle : ImportState()
object Selecting : ImportState()
data class Importing(val processed: Int, val total: Int) : ImportState()
}
data class CameraUiState(
val pageCount: Int,
val liveAnalysisState: LiveAnalysisState,
val captureState: CaptureState,
val showDetectionError: Boolean,
val importState: ImportState,
val showCaptureError: Boolean,
val isLandscape: Boolean,
val isDebugMode: Boolean,
val isTorchEnabled: Boolean,
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -15,34 +15,30 @@
package org.fairscan.app.ui.screens.camera
import android.graphics.Bitmap
import android.graphics.Matrix
import android.net.Uri
import androidx.camera.core.ImageProxy
import androidx.core.graphics.createBitmap
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.fairscan.app.AppContainer
import org.fairscan.app.domain.CapturedPage
import org.fairscan.app.domain.ExportQuality
import org.fairscan.app.domain.PageMetadata
import org.fairscan.app.domain.Rotation
import org.fairscan.imageprocessing.Mask
import org.fairscan.imageprocessing.Quad
import org.fairscan.app.platform.extractDocumentFromBitmap
import org.fairscan.imageprocessing.ImageSize
import org.fairscan.imageprocessing.Mode
import org.fairscan.imageprocessing.OpticalMeasures
import org.fairscan.imageprocessing.detectDocumentQuad
import org.fairscan.imageprocessing.extractDocument
import org.fairscan.imageprocessing.isColoredDocument
import org.fairscan.imageprocessing.scaledTo
import org.opencv.android.Utils
import org.opencv.core.CvType
import org.opencv.core.Mat
import org.opencv.imgproc.Imgproc
import java.util.concurrent.CancellationException
sealed interface CameraEvent {
data class ImageCaptured(val page: CapturedPage) : CameraEvent
@@ -51,6 +47,8 @@ sealed interface CameraEvent {
class CameraViewModel(appContainer: AppContainer): ViewModel() {
private val imageSegmentationService = appContainer.imageSegmentationService
private val settingsRepository = appContainer.settingsRepository
private val imageLoader = appContainer.imageLoader
private val logger = appContainer.logger
private val _events = MutableSharedFlow<CameraEvent>()
@@ -63,34 +61,28 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
private val _captureState = MutableStateFlow<CaptureState>(CaptureState.Idle)
val captureState: StateFlow<CaptureState> = _captureState
private val _importState = MutableStateFlow<ImportState>(ImportState.Idle)
val importState: StateFlow<ImportState> = _importState
private var importJob: Job? = null
private val _isTorchEnabled = MutableStateFlow(false)
val isTorchEnabled: StateFlow<Boolean> = _isTorchEnabled
init {
viewModelScope.launch {
imageSegmentationService.initialize()
imageSegmentationService.segmentation
.filterNotNull()
.collect { result ->
// TODO Should we really call toBinaryMask if it's used only in debug mode?
val binaryMask = result.segmentation.toBinaryMask()
val rawQuad = detectDocumentQuad(
result.segmentation,
isLiveAnalysis = true
)
val stableQuad = quadStabilizer.update(rawQuad)
_liveAnalysisState.value = LiveAnalysisState(
inferenceTime = result.inferenceTime,
binaryMask = binaryMask,
documentQuad = rawQuad,
stableQuad = stableQuad,
)
}
}
private val _volumeKeyEvent = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val volumeKeyEvent = _volumeKeyEvent.asSharedFlow()
fun onVolumeKeyPressed() {
_volumeKeyEvent.tryEmit(Unit)
}
fun resetLiveAnalysis() {
quadStabilizer = QuadStabilizer()
_liveAnalysisState.value = LiveAnalysisState()
}
fun onCapturePressed(frozenImage: Bitmap) {
_captureState.value = CaptureState.Capturing(frozenImage)
resetLiveAnalysis()
}
private fun onCaptureProcessed(captured: CapturedPage?) {
@@ -105,26 +97,53 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
}
fun liveAnalysis(imageProxy: ImageProxy) {
if (_captureState.value !is CaptureState.Idle) {
if (_captureState.value !is CaptureState.Idle || _importState.value !is ImportState.Idle) {
imageProxy.close()
return
}
viewModelScope.launch {
imageSegmentationService.runSegmentationAndEmit(
imageProxy.toBitmap(),
imageProxy.imageInfo.rotationDegrees,
)
val rotationDegrees = imageProxy.imageInfo.rotationDegrees
val result = withContext(Dispatchers.IO) {
imageSegmentationService.runSegmentationAndReturn(imageProxy.toBitmap())
}
result?.let {
val segmentation = result.segmentation
val maskSize = segmentation.maskSize()
val originalSize = ImageSize(imageProxy.width, imageProxy.height)
val rawQuad = withContext(Dispatchers.Default) {
detectDocumentQuad(segmentation, originalSize, Mode.LIVE_ANALYSIS)
?.rotate90(rotationDegrees / 90, maskSize)
}
val binaryMaskProvider = { ->
var binaryMask: Bitmap = segmentation.toBinaryMask()
if (rotationDegrees != 0) {
binaryMask = rotateBitmap(binaryMask, rotationDegrees.toFloat())
}
binaryMask
}
val stableQuad = quadStabilizer.update(rawQuad)
_liveAnalysisState.value = LiveAnalysisState(
inferenceTime = result.inferenceTime,
binaryMaskProvider = binaryMaskProvider,
maskSize = maskSize,
stableQuad = stableQuad,
)
}
imageProxy.close()
}
}
fun onImageCaptured(imageProxy: ImageProxy?) {
fun onImageCaptured(imageProxy: ImageProxy?, opticalMeasures: OpticalMeasures?) {
if (imageProxy != null) {
viewModelScope.launch {
try {
val source = imageProxy.toBitmap()
val page = processCapturedImage(source, imageProxy.imageInfo.rotationDegrees)
val rotationDegrees = imageProxy.imageInfo.rotationDegrees
val page =
processCapturedImage(source, rotationDegrees, opticalMeasures, Mode.CAPTURE)
imageProxy.close()
onCaptureProcessed(page)
} catch (e: RuntimeException) {
@@ -139,18 +158,17 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
private suspend fun processCapturedImage(
source: Bitmap,
rotationDegrees: Int
): CapturedPage? = withContext(Dispatchers.IO) {
var result: CapturedPage? = null
val segmentation = imageSegmentationService.runSegmentationAndReturn(source, 0)
if (segmentation != null) {
val mask = segmentation.segmentation
val quad = detectDocumentQuad(mask, isLiveAnalysis = false)
if (quad != null) {
val resizedQuad = quad.scaledTo(mask.width, mask.height, source.width, source.height)
result = extractDocumentFromBitmap(source, resizedQuad, rotationDegrees, mask)
}
}
rotationDegrees: Int,
opticalMeasures: OpticalMeasures?,
mode: Mode,
): CapturedPage = withContext(Dispatchers.IO) {
val segmentation = imageSegmentationService.runSegmentationAndReturn(source)
val mask = segmentation?.segmentation
val originalSize = ImageSize(source.width, source.height)
val quad = mask?.let { detectDocumentQuad(mask, originalSize, mode) }
val defaultColorMode = settingsRepository.defaultColorMode.first()
val result = extractDocumentFromBitmap(
source, quad, rotationDegrees, mask, viewModelScope, defaultColorMode, opticalMeasures)
return@withContext result
}
@@ -177,6 +195,44 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
fun setTorchEnabled(enabled: Boolean) {
_isTorchEnabled.value = enabled
}
fun importPhotos(uris: List<Uri>) {
importJob?.cancel()
if (uris.isEmpty()) {
_importState.value = ImportState.Idle
return
}
importJob = viewModelScope.launch {
_importState.value = ImportState.Importing(0, uris.size)
uris.forEachIndexed { index, uri ->
ensureActive()
try {
val photoToImport = imageLoader.load(uri)
ensureActive()
val page = processCapturedImage(photoToImport, 0, null, Mode.IMPORT)
ensureActive()
_events.emit(CameraEvent.ImageCaptured(page))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
logger.e("Import", "Failed to import image: $uri", e)
}
_importState.value = ImportState.Importing(index + 1, uris.size)
}
_importState.value = ImportState.Idle
}
}
fun onImportClicked() {
_importState.value = ImportState.Selecting
resetLiveAnalysis()
}
fun cancelImport() {
importJob?.cancel()
importJob = null
_importState.value = ImportState.Idle
}
}
sealed class CaptureState {
@@ -191,36 +247,8 @@ sealed class CaptureState {
) : CaptureState()
}
fun extractDocumentFromBitmap(
source: Bitmap, quad: Quad, rotationDegrees: Int, mask: Mask
): CapturedPage {
val rgba = Mat()
Utils.bitmapToMat(source, rgba)
val bgr = Mat()
Imgproc.cvtColor(rgba, bgr, Imgproc.COLOR_RGBA2BGR) // CV_8UC4 → CV_8UC3
rgba.release()
val isColored = isColoredDocument(bgr, mask, quad)
val maxPixels = ExportQuality.BALANCED.maxPixels
val page = extractDocument(bgr, quad, rotationDegrees, isColored, maxPixels)
val outBgr = page
bgr.release()
val outBitmap = toBitmap(outBgr)
outBgr.release()
val normalizedQuad = quad.scaledTo(source.width, source.height, 1, 1)
val baseRotation = Rotation.fromDegrees(rotationDegrees)
val metadata = PageMetadata(normalizedQuad, baseRotation, isColored)
return CapturedPage(outBitmap, source, metadata)
}
fun toBitmap(bgr: Mat): Bitmap {
require(bgr.type() == CvType.CV_8UC3)
val rgba = Mat()
Imgproc.cvtColor(bgr, rgba, Imgproc.COLOR_BGR2RGBA)
val bmp = createBitmap(bgr.cols(), bgr.rows(), Bitmap.Config.ARGB_8888)
Utils.matToBitmap(rgba, bmp)
rgba.release()
return bmp
fun rotateBitmap(source: Bitmap, angle: Float): Bitmap {
val matrix = Matrix()
matrix.postRotate(angle)
return Bitmap.createBitmap(source, 0, 0, source.getWidth(), source.getHeight(), matrix, true)
}
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -24,6 +24,7 @@ class QuadStabilizer {
private var lastRawQuad: Quad? = null
fun update(rawQuad: Quad?): Quad? {
val previousQuad = lastRawQuad
lastRawQuad = rawQuad
if (rawQuad == null) {
@@ -31,13 +32,12 @@ class QuadStabilizer {
return null
}
val lastRaw = lastRawQuad
if (lastRaw == null) {
if (previousQuad == null) {
stableCount = 1
return null
}
val dist = lastRaw.maxCornerDistanceTo(rawQuad)
val dist = previousQuad.maxCornerDistanceTo(rawQuad)
// 20f is based on the assumption that the preview has a size of 640×480
if (dist < 20f) {
stableCount++
@@ -0,0 +1,365 @@
/*
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.ui.screens.crop
import android.annotation.SuppressLint
import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.mandatorySystemGestures
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import org.fairscan.app.R
import org.fairscan.app.ui.Navigation
import org.fairscan.app.ui.components.MainActionButton
import org.fairscan.app.ui.components.MyScaffold
import org.fairscan.app.ui.components.isLandscape
import org.fairscan.app.ui.dummyNavigation
import org.fairscan.app.ui.theme.FairScanTheme
import org.fairscan.imageprocessing.Point
import org.fairscan.imageprocessing.Quad
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CropScreen(
pageId: String,
initState: CropInitState,
navigation: Navigation,
onUpdatePageQuad: (Quad) -> Unit,
) {
val quadHandler = remember { QuadEditingHandler() }
val (bitmap, initialQuad) = if (initState is CropInitState.Ready && initState.pageId == pageId) {
Pair(initState.bitmap, initState.quad)
} else {
Pair(null, null)
}
val editableQuad = rememberSaveable(pageId, saver = QuadSaver) {
mutableStateOf(initialQuad)
}
val state = remember(pageId) {
CropScreenState(editableQuad)
}
BackHandler { navigation.back() }
val isLandscape = isLandscape(LocalConfiguration.current)
val density = LocalDensity.current
val bottomInsetForSystemGestures = with(density) {
WindowInsets.mandatorySystemGestures.getBottom(density).toDp()
}
MyScaffold(
navigation = navigation,
bottomBar = {},
) { modifier ->
Box(modifier = modifier.fillMaxSize()) {
bitmap?.let { bmp ->
val imageBitmap = remember(bmp) { bmp.asImageBitmap() }
Box(
modifier = Modifier
.fillMaxSize()
.align(Alignment.Center),
contentAlignment = Alignment.Center
) {
Image(
bitmap = imageBitmap,
contentDescription = "Image to edit",
modifier = Modifier
.fillMaxSize()
.padding(bottom = bottomInsetForSystemGestures)
.onGloballyPositioned { coordinates ->
state.containerSize = coordinates.size
},
contentScale = ContentScale.Fit,
)
DragQuadOverlay(state, quadHandler, bmp)
}
}
DragMagnifyingGlass(state, bitmap)
ActionButtons(
modifier = Modifier
.align(if (isLandscape) Alignment.CenterEnd else Alignment.BottomCenter)
.padding(16.dp)
.windowInsetsPadding(WindowInsets.safeDrawing),
onConfirm = {
state.editableQuad?.let { onUpdatePageQuad(it) }
navigation.back()
}
)
}
}
}
@Composable
private fun ActionButtons(
modifier: Modifier,
onConfirm: () -> Unit
) {
MainActionButton(
onClick = onConfirm,
text = stringResource(R.string.apply),
icon = Icons.Filled.Check,
iconDescription = stringResource(R.string.apply),
modifier = modifier
)
}
@Composable
private fun DragQuadOverlay(
state: CropScreenState,
quadHandler: QuadEditingHandler,
bmp: Bitmap
) {
if (state.editableQuad == null || state.containerSize == null) return
val containerSize = state.containerSize!!
val displaySize = QuadCoordinateUtils.calculateDisplaySize(bmp.width, bmp.height, containerSize)
val liftWiggleThresholdPx = with(LocalDensity.current) {
CropScreenState.LIFT_WIGGLE_MAX_DISTANCE.toPx()
}
QuadOverlay(
quad = state.editableQuad!!,
containerSize = containerSize,
displaySize = displaySize,
modifier = Modifier.pointerInput(Unit) {
detectDragGestures(
onDragStart = { startPos ->
val quad = state.editableQuad ?: return@detectDragGestures
state.dragPosition = startPos
// Prefer the index stored at raw touch-down (exact touch position,
// before slop). Fall back to re-detecting at the slop position only
// when the raw-touch handler missed the down event.
val cornerIndex = if (state.touchDownCornerIndex >= 0) {
state.touchDownCornerIndex
} else {
quadHandler.findTouchedCorner(startPos, quad, containerSize, displaySize)
}
if (cornerIndex >= 0) {
state.startCornerDrag(cornerIndex)
}
},
onDragEnd = {
state.rollbackLastDragStepIfLikelyLiftWiggle(liftWiggleThresholdPx)
state.endDrag()
state.onTouchUp()
},
onDragCancel = {
state.rollbackLastDragStepIfLikelyLiftWiggle(liftWiggleThresholdPx)
state.endDrag()
state.onTouchUp()
},
onDrag = { change, dragAmount ->
// change.consume() is intentionally omitted: detectDragGestures
// already calls it.consume() internally after this callback returns.
state.dragPosition = change.position
val quad = state.editableQuad ?: return@detectDragGestures
state.recordDragStep(quad, dragAmount)
val normalizedDelta = QuadCoordinateUtils.screenDeltaToNormalized(
dragAmount, displaySize
)
when {
state.draggedCornerIndex >= 0 -> {
state.updateQuad(
quadHandler.updateQuadCorner(
quad, state.draggedCornerIndex, normalizedDelta
)
)
}
}
}
)
}
// Second pointer-input: fires immediately on press (before touch slop)
// so the loupe appears as soon as the finger touches a handle.
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
val quad = state.editableQuad
if (quad != null) {
val cIdx = quadHandler.findTouchedCorner(down.position, quad, containerSize, displaySize)
if (cIdx >= 0) {
state.onTouchDown(down.position, cIdx)
}
}
// For a tap (no drag): waitForUpOrCancellation() sees the UP event and
// returns it, so we call onTouchUp() here.
// For a drag: detectDragGestures consumes move events, causing
// waitForUpOrCancellation() to return null. We do NOT call onTouchUp()
// here; onDragEnd / onDragCancel above handle that instead.
if (waitForUpOrCancellation() != null) {
state.onTouchUp()
}
}
}
)
}
@Composable
private fun DragMagnifyingGlass(state: CropScreenState, bitmap: Bitmap?) {
// showLoupe becomes true immediately on touch-down and stays true for
// one additional second after the finger is lifted.
val showLoupe = remember { mutableStateOf(false) }
// Remember the last valid focus position so the loupe keeps rendering
// correctly during the 1-second fade-out (when dragged indices are reset).
val lastKnownFocusPosition = remember { mutableStateOf<Offset?>(null) }
LaunchedEffect(state.isTouching) {
if (state.isTouching) {
showLoupe.value = true
} else {
delay(1_000)
showLoupe.value = false
}
}
if (!showLoupe.value || state.dragPosition == null || state.containerSize == null) return
val bmp = bitmap ?: return
val containerSize = state.containerSize!!
val displaySize = QuadCoordinateUtils.calculateDisplaySize(
bmp.width, bmp.height, containerSize
)
val quad = state.editableQuad
// Resolve which corner index to focus on.
// Priority: active drag > pre-drag touch-down > nothing (fade-out phase).
val activeCornerIndex = state.draggedCornerIndex.takeIf { it >= 0 }
?: state.touchDownCornerIndex.takeIf { it >= 0 }
val focusPosition = if (quad != null) {
when {
activeCornerIndex != null -> {
val corner = when (activeCornerIndex) {
0 -> quad.topLeft
1 -> quad.topRight
2 -> quad.bottomRight
3 -> quad.bottomLeft
else -> null
}
corner?.let {
QuadCoordinateUtils.normalizedToScreen(it, containerSize, displaySize)
}
}
else -> null
}
} else null
// Keep the last known focus position so it's still valid after endDrag() resets the indices.
if (focusPosition != null) lastKnownFocusPosition.value = focusPosition
// On the very first touch the drag indices are not set yet and lastKnownFocusPosition
// has never been populated, so fall back to dragPosition (the finger is on the handle).
val effectiveFocusPosition = focusPosition ?: lastKnownFocusPosition.value ?: state.dragPosition ?: return
MagnifyingGlass(
bitmap = bmp,
fingerPosition = state.dragPosition!!,
focusPosition = effectiveFocusPosition,
containerSize = containerSize,
displaySize = displaySize,
quad = state.editableQuad,
)
}
val QuadSaver: Saver<MutableState<Quad?>, *> = listSaver(
save = { state ->
state.value?.let {
listOf(
it.topLeft.x, it.topLeft.y,
it.topRight.x, it.topRight.y,
it.bottomRight.x, it.bottomRight.y,
it.bottomLeft.x, it.bottomLeft.y,
)
} ?: listOf()
},
restore = { list ->
val quad = if (list.size == 8) {
Quad(
topLeft = Point(list[0], list[1]),
topRight = Point(list[2], list[3]),
bottomRight = Point(list[4], list[5]),
bottomLeft = Point(list[6], list[7]),
)
} else null
mutableStateOf(quad)
}
)
@Composable
@Preview(showSystemUi = true)
@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, showSystemUi = true)
@Preview(name = "Landscape", showBackground = true, widthDp = 640, heightDp = 320)
@Preview(name = "RTL", locale = "ar", showSystemUi = true)
fun EditPageScreenPreview() {
FairScanTheme {
val dummyImage = LocalContext.current.assets.open("gallica.bnf.fr-bpt6k5530456s-1.jpg").use { input ->
BitmapFactory.decodeStream(input)
}
val quad = Quad(Point(.1, .1), Point(.9, .1), Point(.9, .9), Point(.1, .9))
CropScreen(
pageId = "123",
initState = CropInitState.Ready("123",dummyImage, quad),
navigation = dummyNavigation(),
onUpdatePageQuad = { _ -> },
)
}
}
@@ -0,0 +1,125 @@
/*
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.ui.screens.crop
import android.graphics.Bitmap
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.IntSize
import org.fairscan.imageprocessing.Quad
sealed interface CropInitState {
object Loading : CropInitState
object Error : CropInitState
data class Ready(
val pageId: String,
val bitmap: Bitmap,
val quad: Quad
) : CropInitState
}
class CropScreenState(editableQuad: MutableState<Quad?> = mutableStateOf(null)) {
companion object {
val LIFT_WIGGLE_MAX_DISTANCE = 8.dp
const val LIFT_WIGGLE_WINDOW_MS = 70L
}
var containerSize by mutableStateOf<IntSize?>(null)
var editableQuad: Quad? by editableQuad
private set
var draggedCornerIndex by mutableIntStateOf(-1)
var dragPosition by mutableStateOf<Offset?>(null)
/** True from the moment the finger touches a drag handle until it is lifted. */
var isTouching by mutableStateOf(false)
/**
* Corner / edge index detected at the raw touch-down (before touch-slop).
* Carried into [startCornerDrag] so that the slop-adjusted
* position in onDragStart cannot miss the handle.
*/
var touchDownCornerIndex by mutableIntStateOf(-1)
private var quadBeforeDrag: Quad? = null
private var quadBeforeLastDragStep: Quad? = null
private var lastDragStepDistancePx: Float = Float.MAX_VALUE
private var lastDragStepAtMs: Long = 0L
fun updateQuad(newQuad: Quad) {
editableQuad = newQuad
}
fun startCornerDrag(cornerIndex: Int) {
quadBeforeDrag = editableQuad
draggedCornerIndex = cornerIndex
clearLastDragStep()
}
fun recordDragStep(previousQuad: Quad, dragAmount: Offset, eventTimeMs: Long = System.currentTimeMillis()) {
quadBeforeLastDragStep = previousQuad
lastDragStepDistancePx = dragAmount.getDistance()
lastDragStepAtMs = eventTimeMs
}
fun rollbackLastDragStepIfLikelyLiftWiggle(
maxDistancePx: Float,
nowMs: Long = System.currentTimeMillis()
) {
if (quadBeforeLastDragStep == null) return
val isRecent = nowMs - lastDragStepAtMs <= LIFT_WIGGLE_WINDOW_MS
val isSmall = lastDragStepDistancePx <= maxDistancePx
if (isRecent && isSmall) {
editableQuad = quadBeforeLastDragStep
}
}
fun endDrag() {
quadBeforeDrag = null
clearLastDragStep()
draggedCornerIndex = -1
// dragPosition is intentionally kept so the loupe can still render
// during its 1-second fade-out after the finger is lifted.
}
private fun clearLastDragStep() {
quadBeforeLastDragStep = null
lastDragStepDistancePx = Float.MAX_VALUE
lastDragStepAtMs = 0L
}
/**
* Called as soon as the finger touches a drag handle (before touch-slop),
* so the loupe is shown immediately.
* [cornerIndex] is the handle index found at the exact
* touch position; it is stored so that drag start handling can use it even
* if the slop-adjusted position drifts outside the hit-test radius.
*/
fun onTouchDown(position: Offset, cornerIndex: Int = -1) {
isTouching = true
dragPosition = position
touchDownCornerIndex = cornerIndex
}
/** Called when the finger is lifted; triggers the loupe fade-out delay. */
fun onTouchUp() {
isTouching = false
touchDownCornerIndex = -1
}
fun isDragging(): Boolean = draggedCornerIndex >= 0
}
@@ -0,0 +1,269 @@
/*
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.ui.screens.crop
import android.graphics.Bitmap
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.ClipOp
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import org.fairscan.imageprocessing.Quad
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.roundToInt
private val LOUPE_BORDER_WIDTH = 3.dp
private const val ZOOM_FACTOR = 3f
/**
* Layout parameters for the magnifying-glass loupe.
*
* Use `LoupeLayoutConfig<Dp>` in Compose UI code and [toPx] to convert to
* `LoupeLayoutConfig<Float>` for pixel-level calculations.
*/
data class LoupeLayoutConfig<T>(
/** Radius of the loupe circle */
val loupeRadius: T,
/** Gap between the finger and the nearest edge of the loupe */
val verticalOffset: T,
/** Minimum space between the loupe and the screen edge */
val screenMargin: T,
) {
companion object {
val Default = LoupeLayoutConfig(
loupeRadius = 60.dp,
verticalOffset = 40.dp,
screenMargin = 8.dp,
)
}
}
/** Convert a dp-valued config to pixels using the current [LocalDensity]. */
@Composable
internal fun LoupeLayoutConfig<Dp>.toPx(): LoupeLayoutConfig<Float> {
val density = LocalDensity.current
return with(density) {
LoupeLayoutConfig(
loupeRadius = loupeRadius.toPx(),
verticalOffset = verticalOffset.toPx(),
screenMargin = screenMargin.toPx(),
)
}
}
/**
* Displays a magnifying glass / loupe showing a zoomed-in patch of [bitmap]
* centred around [focusPosition].
*
* Positioning rules:
* 1. By default, the loupe is placed **above** the finger.
* 2. If there is not enough room above, it moves to the **left** of the finger.
* 3. If there is not enough room on the left either, it moves to the **right**.
*
* @param bitmap The full source bitmap (original image).
* @param fingerPosition Current finger position in screen (container) coordinates, used for loupe placement.
* @param focusPosition The exact point to zoom into (e.g. corner or edge midpoint) in screen coordinates.
* @param containerSize Size of the full-screen container.
* @param displaySize Size of the image as rendered (letterboxed inside the container).
*/
@Composable
fun MagnifyingGlass(
bitmap: Bitmap,
fingerPosition: Offset,
focusPosition: Offset,
containerSize: IntSize,
displaySize: IntSize,
quad: Quad? = null,
configDp: LoupeLayoutConfig<Dp> = LoupeLayoutConfig.Default,
) {
val density = LocalDensity.current
val configPx = configDp.toPx()
val borderWidth = with(density) { LOUPE_BORDER_WIDTH.toPx() }
// compute loupe centre position
val loupeCenter = computeLoupeCenter(
dragPosition = fingerPosition,
configPx = configPx,
containerWidth = containerSize.width.toFloat(),
)
// compute the bitmap region to sample
val imageOffset = QuadCoordinateUtils.getImageOffset(containerSize, displaySize)
// Focus position mapped to bitmap pixel coordinates
val bitmapX = ((focusPosition.x - imageOffset.width) / displaySize.width * bitmap.width)
.coerceIn(0f, (bitmap.width - 1).toFloat())
val bitmapY = ((focusPosition.y - imageOffset.height) / displaySize.height * bitmap.height)
.coerceIn(0f, (bitmap.height - 1).toFloat())
// How many bitmap pixels the loupe shows in each direction
val bitmapRegionHalf = (bitmap.width / displaySize.width.toFloat()) * configPx.loupeRadius / ZOOM_FACTOR
val imageBitmap = remember(bitmap) { bitmap.asImageBitmap() }
val borderColor = MaterialTheme.colorScheme.primary
val quadLineColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f)
val backgroundColor = MaterialTheme.colorScheme.background
Canvas(
modifier = Modifier
.size(configDp.loupeRadius * 2)
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height) {
placeable.placeRelative(
IntOffset(
(loupeCenter.x - configPx.loupeRadius).roundToInt(),
(loupeCenter.y - configPx.loupeRadius).roundToInt()
)
)
}
}
) {
val loupeDiameter = configPx.loupeRadius * 2
val circlePath = Path().apply {
addOval(
androidx.compose.ui.geometry.Rect(0f, 0f, loupeDiameter, loupeDiameter)
)
}
clipPath(circlePath) {
// Fill background so areas outside the image are opaque
drawRect(color = backgroundColor)
// Source rect in bitmap coordinates
val srcLeft = (bitmapX - bitmapRegionHalf).toInt().coerceAtLeast(0)
val srcTop = (bitmapY - bitmapRegionHalf).toInt().coerceAtLeast(0)
val srcRight = (bitmapX + bitmapRegionHalf).toInt().coerceAtMost(bitmap.width)
val srcBottom = (bitmapY + bitmapRegionHalf).toInt().coerceAtMost(bitmap.height)
if (srcRight > srcLeft && srcBottom > srcTop) {
// Destination offset compensate when the source rect was clamped
val dstOffsetX = ((srcLeft - (bitmapX - bitmapRegionHalf)) / (2 * bitmapRegionHalf) * loupeDiameter)
val dstOffsetY = ((srcTop - (bitmapY - bitmapRegionHalf)) / (2 * bitmapRegionHalf) * loupeDiameter)
val dstWidth = ((srcRight - srcLeft) / (2 * bitmapRegionHalf) * loupeDiameter).toInt()
val dstHeight = ((srcBottom - srcTop) / (2 * bitmapRegionHalf) * loupeDiameter).toInt()
drawImage(
image = imageBitmap,
srcOffset = IntOffset(srcLeft, srcTop),
srcSize = IntSize(srcRight - srcLeft, srcBottom - srcTop),
dstOffset = IntOffset(dstOffsetX.toInt(), dstOffsetY.toInt()),
dstSize = IntSize(dstWidth, dstHeight),
)
}
// Draw quad overlay and edges inside the loupe
// Convert each normalized quad corner to bitmap-pixel coords,
// then to loupe-local coords using the same mapping as the bitmap sampling.
if (quad != null) {
val bitmapOriginX = bitmapX - bitmapRegionHalf
val bitmapOriginY = bitmapY - bitmapRegionHalf
val scale = loupeDiameter / (2 * bitmapRegionHalf)
fun normalizedToLoupe(nx: Double, ny: Double): Offset {
val bx = (nx * bitmap.width).toFloat()
val by = (ny * bitmap.height).toFloat()
return Offset(
(bx - bitmapOriginX) * scale,
(by - bitmapOriginY) * scale
)
}
val loupeCorners = listOf(
normalizedToLoupe(quad.topLeft.x, quad.topLeft.y),
normalizedToLoupe(quad.topRight.x, quad.topRight.y),
normalizedToLoupe(quad.bottomRight.x, quad.bottomRight.y),
normalizedToLoupe(quad.bottomLeft.x, quad.bottomLeft.y),
)
// Draw quad edge lines on top
for (i in 0 until 4) {
drawLine(
color = quadLineColor,
start = loupeCorners[i],
end = loupeCorners[(i + 1) % 4],
strokeWidth = 3f
)
}
}
}
// Border
drawCircle(
color = borderColor,
radius = configPx.loupeRadius - borderWidth / 2,
center = Offset(configPx.loupeRadius, configPx.loupeRadius),
style = Stroke(width = borderWidth)
)
}
}
/**
* Decides where the loupe centre should be.
*
* Priority:
* 1. Above the finger (centred horizontally, clamped to screen edges).
* 2. If no vertical room -> to the left.
* 3. If no room on the left -> to the right.
*/
internal fun computeLoupeCenter(
dragPosition: Offset,
configPx: LoupeLayoutConfig<Float>,
containerWidth: Float,
): Offset {
val loupeRadius = configPx.loupeRadius
val verticalOffset = configPx.verticalOffset
val screenMargin = configPx.screenMargin
// Try above
val aboveCenterY = dragPosition.y - verticalOffset - loupeRadius
if (aboveCenterY - loupeRadius >= screenMargin) {
// Enough room above -> place centred horizontally on the finger, clamped to screen edges
val cx = dragPosition.x.coerceIn(screenMargin + loupeRadius, containerWidth - screenMargin - loupeRadius)
return Offset(cx, aboveCenterY)
}
// Not enough room above -> try left
val leftCenterX = dragPosition.x - verticalOffset - loupeRadius
if (leftCenterX - loupeRadius >= screenMargin) {
val cy = dragPosition.y.coerceIn(screenMargin + loupeRadius, Float.MAX_VALUE)
return Offset(leftCenterX, cy)
}
// Not enough room on the left -> place right
val rightCenterX = dragPosition.x + verticalOffset + loupeRadius
val cx = rightCenterX.coerceAtMost(containerWidth - screenMargin - loupeRadius)
val cy = dragPosition.y.coerceIn(screenMargin + loupeRadius, Float.MAX_VALUE)
return Offset(cx, cy)
}
@@ -0,0 +1,60 @@
/*
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.ui.screens.crop
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.IntSize
import org.fairscan.imageprocessing.Point
object QuadCoordinateUtils {
fun calculateDisplaySize(
bitmapWidth: Int,
bitmapHeight: Int,
containerSize: IntSize
): IntSize {
val imageAspectRatio = bitmapWidth.toFloat() / bitmapHeight.toFloat()
val containerAspectRatio = containerSize.width / containerSize.height.toFloat()
return if (imageAspectRatio > containerAspectRatio) {
IntSize(containerSize.width, (containerSize.width / imageAspectRatio).toInt())
} else {
IntSize((containerSize.height * imageAspectRatio).toInt(), containerSize.height)
}
}
fun normalizedToScreen(point: Point, containerSize: IntSize, displaySize: IntSize): Offset {
val offsetX = (containerSize.width - displaySize.width) / 2
val offsetY = (containerSize.height - displaySize.height) / 2
return Offset(
x = (point.x * displaySize.width).toFloat() + offsetX,
y = (point.y * displaySize.height).toFloat() + offsetY
)
}
fun screenDeltaToNormalized(delta: Offset, displaySize: IntSize): Offset {
return Offset(
x = delta.x / displaySize.width,
y = delta.y / displaySize.height
)
}
fun getImageOffset(containerSize: IntSize, displaySize: IntSize): IntSize {
return IntSize(
(containerSize.width - displaySize.width) / 2,
(containerSize.height - displaySize.height) / 2
)
}
}
@@ -0,0 +1,85 @@
/*
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.ui.screens.crop
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.IntSize
import org.fairscan.imageprocessing.Point
import org.fairscan.imageprocessing.Quad
class QuadEditingHandler {
companion object {
const val CORNER_RADIUS = 40f
const val CORNER_TOUCH_RADIUS = 90f
}
fun findTouchedCorner(
touchPos: Offset,
quad: Quad,
containerSize: IntSize,
displaySize: IntSize
): Int {
return findTouchedCornerCandidates(touchPos, quad, containerSize, displaySize)
.firstOrNull() ?: -1
}
fun findTouchedCornerCandidates(
touchPos: Offset,
quad: Quad,
containerSize: IntSize,
displaySize: IntSize
): List<Int> {
val corners = getCornerPositions(quad, containerSize, displaySize)
return corners
.mapIndexed { index, corner -> index to (touchPos - corner).getDistance() }
.filter { (_, distance) -> distance < CORNER_TOUCH_RADIUS }
.sortedBy { (_, distance) -> distance }
.map { (index, _) -> index }
}
fun updateQuadCorner(quad: Quad, cornerIndex: Int, delta: Offset): Quad {
val normalizedDelta = Point(delta.x.toDouble(), delta.y.toDouble())
val candidate = when (cornerIndex) {
0 -> quad.copy(topLeft = clampPoint(quad.topLeft + normalizedDelta))
1 -> quad.copy(topRight = clampPoint(quad.topRight + normalizedDelta))
2 -> quad.copy(bottomRight = clampPoint(quad.bottomRight + normalizedDelta))
3 -> quad.copy(bottomLeft = clampPoint(quad.bottomLeft + normalizedDelta))
else -> quad
}
return if (candidate.isConvex()) candidate else quad
}
private fun getCornerPositions(quad: Quad, containerSize: IntSize, displaySize: IntSize): List<Offset> {
return listOf(
QuadCoordinateUtils.normalizedToScreen(quad.topLeft, containerSize, displaySize),
QuadCoordinateUtils.normalizedToScreen(quad.topRight, containerSize, displaySize),
QuadCoordinateUtils.normalizedToScreen(quad.bottomRight, containerSize, displaySize),
QuadCoordinateUtils.normalizedToScreen(quad.bottomLeft, containerSize, displaySize)
)
}
private fun clampPoint(point: Point): Point {
return Point(
point.x.coerceIn(0.0, 1.0),
point.y.coerceIn(0.0, 1.0)
)
}
private operator fun Point.plus(other: Point): Point {
return Point(this.x + other.x, this.y + other.y)
}
}
@@ -0,0 +1,102 @@
/*
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.ui.screens.crop
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.systemGestureExclusion
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.unit.IntSize
import org.fairscan.imageprocessing.Point
import org.fairscan.imageprocessing.Quad
import org.fairscan.imageprocessing.scaledTo
import kotlin.math.max
import kotlin.math.min
@Composable
fun QuadOverlay(
quad: Quad,
containerSize: IntSize,
displaySize: IntSize,
modifier: Modifier = Modifier
) {
val quadColor = MaterialTheme.colorScheme.primary
val handleColor = quadColor.copy(alpha = 0.5f)
Canvas(modifier = modifier
.fillMaxSize()
// Android limits the exclusion areas, so we cannot simply exclude the whole quad/image.
// Instead, the corners must get excluded individually.
.systemGestureExclusion { cornerExclusionRect(quad.topLeft, containerSize, displaySize) }
.systemGestureExclusion { cornerExclusionRect(quad.topRight, containerSize, displaySize) }
.systemGestureExclusion { cornerExclusionRect(quad.bottomRight, containerSize, displaySize) }
.systemGestureExclusion { cornerExclusionRect(quad.bottomLeft, containerSize, displaySize) }
) {
val scaledQuad = quad.scaledTo(
fromWidth = 1,
fromHeight = 1,
toWidth = displaySize.width,
toHeight = displaySize.height
)
val offset = QuadCoordinateUtils.getImageOffset(containerSize, displaySize)
val corners = listOf(
scaledQuad.topLeft.toOffset(),
scaledQuad.topRight.toOffset(),
scaledQuad.bottomRight.toOffset(),
scaledQuad.bottomLeft.toOffset()
).map { it.copy(x = it.x + offset.width, y = it.y + offset.height) }
// Draw edges
for (i in 0 until 4) {
drawLine(
color = quadColor,
start = corners[i],
end = corners[(i + 1) % 4],
strokeWidth = 10.0f
)
}
// Draw corner handles
corners.forEach { corner ->
drawCircle(
color = handleColor,
radius = QuadEditingHandler.CORNER_RADIUS,
center = corner
)
}
}
}
fun Point.toOffset() = Offset(x.toFloat(), y.toFloat())
private fun cornerExclusionRect(
corner: Point,
containerSize: IntSize,
displaySize: IntSize
): Rect {
val pos = QuadCoordinateUtils.normalizedToScreen(corner, containerSize, displaySize)
val r = QuadEditingHandler.CORNER_TOUCH_RADIUS
return Rect(max(.0f, pos.x - r),
max(.0f, pos.y - r),
min(containerSize.width.toFloat(), pos.x + r),
min(containerSize.height.toFloat(), pos.y + r)
)
}
@@ -0,0 +1,387 @@
/*
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.ui.screens.document
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AutoFixHigh
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Contrast
import androidx.compose.material.icons.filled.Crop
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.RotateLeft
import androidx.compose.material.icons.filled.RotateRight
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import kotlinx.collections.immutable.toImmutableList
import net.engawapg.lib.zoomable.ZoomState
import net.engawapg.lib.zoomable.zoomable
import org.fairscan.app.R
import org.fairscan.app.domain.PageViewKey
import org.fairscan.app.domain.Rotation
import org.fairscan.app.ui.Navigation
import org.fairscan.app.ui.components.CommonPageListState
import org.fairscan.app.ui.components.ConfirmationDialog
import org.fairscan.app.ui.components.MainActionButton
import org.fairscan.app.ui.components.MyScaffold
import org.fairscan.app.ui.components.SecondaryActionButton
import org.fairscan.app.ui.dummyNavigation
import org.fairscan.app.ui.fakeDocument
import org.fairscan.app.ui.fakeImage
import org.fairscan.app.ui.theme.FairScanTheme
import org.fairscan.imageprocessing.ColorMode
import org.fairscan.imageprocessing.ColorMode.COLOR
import org.fairscan.imageprocessing.ColorMode.GRAYSCALE
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DocumentScreen(
uiState: DocumentUiState,
navigation: Navigation,
onExportClick: () -> Unit,
onDeleteImage: () -> Unit,
onRotateImage: (Boolean) -> Unit,
onToggleColorMode: () -> Unit,
onCropClick: () -> Unit,
onPageReorder: (String, Int) -> Unit,
onPageSelected: (Int) -> Unit,
) {
val showDeletePageDialog = rememberSaveable { mutableStateOf(false) }
val document = uiState.document
val currentPageIndex = uiState.currentPageIndex
BackHandler { navigation.back() }
val listState = rememberLazyListState()
LaunchedEffect(currentPageIndex) {
listState.scrollToItem(currentPageIndex)
}
MyScaffold(
navigation = navigation,
pageListState = CommonPageListState(
document,
onPageClick = { index -> onPageSelected(index) },
onPageReorder = onPageReorder,
currentPageIndex = currentPageIndex,
listState = listState,
showPageNumbers = true,
),
bottomBar = {
BottomBar(onExportClick, navigation.toCameraScreen)
},
) { modifier ->
DocumentPreview(
uiState,
{ showDeletePageDialog.value = true },
onRotateImage,
onToggleColorMode,
onCropClick,
modifier
)
if (showDeletePageDialog.value) {
ConfirmationDialog(
title = stringResource(R.string.delete_page),
message = stringResource(R.string.delete_page_warning),
showDialog = showDeletePageDialog
) { onDeleteImage() }
}
}
}
@Composable
private fun DocumentPreview(
uiState: DocumentUiState,
onDeleteImage: () -> Unit,
onRotateImage: (Boolean) -> Unit,
onToggleColorMode: () -> Unit,
onCropClick: () -> Unit,
modifier: Modifier,
) {
val currentPageIndex = uiState.currentPageIndex
val document = uiState.document
Column (
modifier = modifier
.background(MaterialTheme.colorScheme.surfaceContainerLow)
) {
Box (
modifier = Modifier.fillMaxSize()
) {
val bitmap = uiState.currentPage?.bitmap
val pageKey = uiState.currentPage?.key
if (bitmap != null && pageKey != null) {
val imageBitmap = bitmap.asImageBitmap()
val zoomState = remember(pageKey) {
ZoomState(
contentSize = Size(bitmap.width.toFloat(), bitmap.height.toFloat())
)
}
Box(modifier = Modifier
.fillMaxSize(0.92f)
.align(Alignment.Center)) {
Image(
bitmap = imageBitmap,
contentDescription = null,
modifier = Modifier
.padding(4.dp)
.align(Alignment.Center)
.zoomable(zoomState)
)
}
}
if (uiState.currentPage?.isLoading ?: false) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.4f)),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
EditButtons(
uiState,
onToggleColorMode,
onCropClick,
modifier = Modifier.align(Alignment.BottomStart)
)
RotationButtons(onRotateImage, Modifier.align(Alignment.BottomCenter))
SecondaryActionButton(
Icons.Outlined.Delete,
contentDescription = stringResource(R.string.delete_page),
onClick = { onDeleteImage() },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(8.dp)
)
Text("${currentPageIndex + 1} / ${document.pageCount()}",
color = MaterialTheme.colorScheme.inverseOnSurface,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(all = 16.dp)
.background(
color = MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.7f),
shape = RoundedCornerShape(4.dp)
)
.padding(horizontal = 8.dp, vertical = 2.dp)
)
}
}
}
@Composable
fun RotationButtons(
onRotateImage: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
// RotateLeft on the left, RotateRight on the right: for both LTR and RTL languages
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
Row(modifier = modifier.padding(8.dp)) {
// Using AutoMirrored icons would lead to an opposite rotation in RTL languages
@Suppress("DEPRECATION")
SecondaryActionButton(
icon = Icons.Default.RotateLeft,
contentDescription = stringResource(R.string.rotate_left),
onClick = { onRotateImage(false) }
)
Spacer(Modifier.width(8.dp))
@Suppress("DEPRECATION")
SecondaryActionButton(
icon = Icons.Default.RotateRight,
contentDescription = stringResource(R.string.rotate_right),
onClick = { onRotateImage(true) }
)
}
}
}
@Composable
fun EditButtons(
uiState: DocumentUiState,
onToggleColorMode: () -> Unit,
onCropClick: () -> Unit,
modifier: Modifier
) {
Row(modifier = modifier.padding(8.dp)) {
uiState.currentPage?.colorMode?.let {
ColorModeButton(
currentColorMode = it,
onToggle = { onToggleColorMode() },
)
}
Spacer(Modifier.width(8.dp))
if (uiState.currentPage?.canBeCropped ?: false) {
SecondaryActionButton(
icon = Icons.Default.Crop,
contentDescription = stringResource(R.string.crop),
onClick = onCropClick,
)
}
}
}
@Composable
fun ColorModeButton(
currentColorMode: ColorMode,
onToggle: () -> Unit,
modifier: Modifier = Modifier
) {
var expanded by remember { mutableStateOf(false) }
Box(modifier = modifier) {
SecondaryActionButton(
icon = Icons.Default.AutoFixHigh,
contentDescription = stringResource(R.string.color_mode),
onClick = { expanded = true },
)
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.color_mode_color)) },
leadingIcon = { Icon(Icons.Default.Palette, contentDescription = null) },
onClick = {
if (currentColorMode != COLOR) onToggle()
expanded = false
},
trailingIcon = {
if (currentColorMode == COLOR) {
Icon(Icons.Default.Check, contentDescription = null)
}
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.color_mode_grayscale)) },
leadingIcon = { Icon(Icons.Default.Contrast, contentDescription = null) },
onClick = {
if (currentColorMode != GRAYSCALE) onToggle()
expanded = false
},
trailingIcon = {
if (currentColorMode == GRAYSCALE) {
Icon(Icons.Default.Check, contentDescription = null)
}
}
)
}
}
}
@Composable
private fun BottomBar(
onExportClick: () -> Unit,
onAddPageClick: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
OutlinedButton(
onClick = onAddPageClick,
colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.primary
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary),
modifier = Modifier.weight(1f, fill = false),
) {
Icon(Icons.Outlined.Add, contentDescription = null)
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.add_page),
maxLines = 1,
overflow = TextOverflow.Ellipsis)
}
MainActionButton(
onClick = onExportClick,
icon = Icons.Default.Done,
text = stringResource(R.string.export),
)
}
}
@Composable
@Preview
@Preview(locale = "ar")
@Preview(name = "Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true)
@Preview(name = "Landscape", showBackground = true, widthDp = 640, heightDp = 320)
fun DocumentScreenPreview() {
FairScanTheme {
val image = fakeImage("gallica.bnf.fr-bpt6k5530456s-1", LocalContext.current).toBitmap()
val document = fakeDocument(
listOf(1, 2).map { "gallica.bnf.fr-bpt6k5530456s-$it" }.toImmutableList(),
LocalContext.current
)
val key = PageViewKey("123", Rotation.R0, null, 0)
DocumentScreen(
uiState = DocumentUiState(1, CurrentPageUiState(key,image, COLOR, true), document),
navigation = dummyNavigation(),
onExportClick = {},
onDeleteImage = { },
onRotateImage = { _ -> },
onToggleColorMode = { },
onCropClick = { },
onPageReorder = { _,_ -> },
onPageSelected = { _ -> },
)
}
}
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -12,13 +12,23 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.ui.screens.home
package org.fairscan.app.ui.screens.document
import android.net.Uri
import android.graphics.Bitmap
import org.fairscan.app.domain.PageViewKey
import org.fairscan.app.ui.state.DocumentUiModel
import org.fairscan.imageprocessing.ColorMode
data class RecentDocumentUiState(
val fileUri: Uri,
val fileName: String,
val saveTimestamp: Long,
val pageCount: Int,
data class DocumentUiState(
val currentPageIndex: Int,
val currentPage: CurrentPageUiState?,
val document: DocumentUiModel,
)
data class CurrentPageUiState(
val key: PageViewKey,
val bitmap: Bitmap?,
val colorMode: ColorMode?,
val canBeCropped: Boolean = false,
val isLoading: Boolean = false,
)
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -14,51 +14,69 @@
*/
package org.fairscan.app.ui.screens.export
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.text.format.Formatter
import androidx.activity.compose.BackHandler
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
@@ -68,7 +86,9 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import kotlinx.collections.immutable.persistentListOf
import org.fairscan.app.R
import org.fairscan.app.THUMBNAIL_SIZE_DP
import org.fairscan.app.ui.Navigation
import org.fairscan.app.ui.components.AppOverflowMenu
import org.fairscan.app.ui.components.BackButton
@@ -77,73 +97,75 @@ import org.fairscan.app.ui.components.NewDocumentDialog
import org.fairscan.app.ui.components.isLandscape
import org.fairscan.app.ui.components.pageCountText
import org.fairscan.app.ui.dummyNavigation
import org.fairscan.app.ui.fakeDocument
import org.fairscan.app.ui.screens.settings.ExportFormat.PDF
import org.fairscan.app.ui.state.DocumentUiModel
import org.fairscan.app.ui.theme.FairScanTheme
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.io.IOException
@Composable
fun ExportScreenWrapper(
navigation: Navigation,
uiState: ExportUiState,
pdfActions: ExportActions,
currentDocument: DocumentUiModel,
exportActions: ExportActions,
onCloseScan: () -> Unit,
) {
BackHandler { navigation.back() }
val showConfirmationDialog = rememberSaveable { mutableStateOf(false) }
val filename = remember { mutableStateOf(defaultFilename()) }
LaunchedEffect(Unit) {
pdfActions.setFilename(filename.value)
pdfActions.initializeExportScreen()
}
val onFilenameChange = { newName:String ->
filename.value = newName
pdfActions.setFilename(newName)
LaunchedEffect(Unit) {
exportActions.prepareExportIfNeeded()
}
val ensureCorrectFileName = {
val value = filename.value.trim().ifEmpty { defaultFilename() }
if (value != filename.value) {
onFilenameChange(value)
DisposableEffect(Unit) {
onDispose {
exportActions.cancelPreparationJob()
}
}
val onFilenameChange = { newName:String ->
exportActions.setFilename(newName)
}
ExportScreen(
filename = filename,
onFilenameChange = onFilenameChange,
uiState = uiState,
currentDocument = currentDocument,
navigation = navigation,
onShare = {
ensureCorrectFileName()
pdfActions.share()
if (!uiState.isSaving) {
exportActions.share()
}
},
onSave = {
ensureCorrectFileName()
pdfActions.save()
if (!uiState.isSaving) {
exportActions.save()
}
},
onOpen = pdfActions.open,
onOpen = exportActions.open,
onCloseScan = {
if (uiState.hasSavedOrShared)
onCloseScan()
else
showConfirmationDialog.value = true
if (!uiState.isSaving) {
if (uiState.hasSavedOrShared)
onCloseScan()
else
showConfirmationDialog.value = true
}
},
)
if (showConfirmationDialog.value) {
NewDocumentDialog(onCloseScan, showConfirmationDialog, stringResource(R.string.end_scan))
NewDocumentDialog(onCloseScan, showConfirmationDialog, stringResource(R.string.scan_new))
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ExportScreen(
filename: MutableState<String>,
onFilenameChange: (String) -> Unit,
uiState: ExportUiState,
currentDocument: DocumentUiModel,
navigation: Navigation,
onShare: () -> Unit,
onSave: () -> Unit,
@@ -163,15 +185,16 @@ fun ExportScreen(
) { innerPadding ->
val containerModifier = Modifier
.padding(innerPadding)
.padding(16.dp)
.padding(horizontal = 16.dp)
val onThumbnailClick = navigation.toDocumentScreen
if (!isLandscape(LocalConfiguration.current)) {
Column(
modifier = containerModifier.fillMaxSize(),
modifier = containerModifier.fillMaxSize().padding(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
TextFieldAndPdfInfos(filename, onFilenameChange, uiState, onOpen)
PdfInfosAndResultBar(uiState, currentDocument, onOpen, onThumbnailClick)
Spacer(Modifier.weight(1f)) // push buttons down
MainActions(uiState, onShare, onSave, onCloseScan)
MainActions(onFilenameChange, uiState, onShare, onSave, onCloseScan)
}
} else {
Row(
@@ -179,13 +202,14 @@ fun ExportScreen(
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Column(
modifier = Modifier.weight(1f),
modifier = Modifier.weight(1f)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
TextFieldAndPdfInfos(filename, onFilenameChange, uiState, onOpen)
PdfInfosAndResultBar(uiState, currentDocument, onOpen, onThumbnailClick)
}
Column(modifier = Modifier.weight(1f)) {
MainActions(uiState, onShare, onSave, onCloseScan)
MainActions(onFilenameChange, uiState, onShare, onSave, onCloseScan)
}
}
@@ -194,64 +218,143 @@ fun ExportScreen(
}
@Composable
private fun TextFieldAndPdfInfos(
filename: MutableState<String>,
onFilenameChange: (String) -> Unit,
private fun PdfInfosAndResultBar(
uiState: ExportUiState,
currentDocument: DocumentUiModel,
onOpen: (SavedItem) -> Unit,
onThumbnailClick: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
PdfInfoCard {
PdfInfos(uiState, currentDocument, onThumbnailClick)
SaveStatusBar(uiState, onOpen)
}
uiState.error?.let {
ErrorBar(it)
}
}
}
@Composable
private fun PdfInfoCard(
content: @Composable ColumnScope.() -> Unit
) {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 1.dp,
modifier = Modifier.fillMaxWidth()
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(12.dp)
) {
content()
}
}
}
@Composable
private fun PdfInfos(
uiState: ExportUiState,
currentDocument: DocumentUiModel,
onThumbnailClick: () -> Unit,
) {
val result = uiState.result
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
val thumbnail = currentDocument.thumbnail(0)
thumbnail?.let {
Card(
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
shape = RoundedCornerShape(6.dp),
modifier = Modifier
.padding(4.dp)
.heightIn(max = THUMBNAIL_SIZE_DP.dp)
.widthIn(max = THUMBNAIL_SIZE_DP.dp)
) {
Image(
bitmap = thumbnail.asImageBitmap(),
contentDescription = null,
modifier = Modifier.clickable { onThumbnailClick() }
)
}
}
// PDF infos
Column(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
val pageCount = result?.pageCount ?: uiState.progress?.totalPages
pageCount?.let { count -> Text(text = pageCountText(count)) }
uiState.ocrActivation?.let { activated ->
Text(
if (activated)
stringResource(R.string.text_recognition_enabled)
else
stringResource(R.string.text_recognition_disabled),
style = MaterialTheme.typography.bodySmall,
)
}
if (uiState.isGenerating) {
Text(
text = stringResource(R.string.creating_export),
fontStyle = FontStyle.Italic,
modifier = Modifier.padding(top = 8.dp),
)
uiState.progress?.let{ p ->
if (p.totalPages == 1) {
LinearProgressIndicator()
} else {
LinearProgressIndicator({ p.progress })
}
}
} else if (result != null) {
val context = LocalContext.current
val formattedFileSize = formatFileSize(result.sizeInBytes, context)
val sizeMessageKey =
if (result.files.size == 1) R.string.file_size else R.string.file_size_total
Text(
text = stringResource(sizeMessageKey, formattedFileSize),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
}
}
@Composable
private fun SaveStatusBar(
uiState: ExportUiState,
onOpen: (SavedItem) -> Unit,
) {
FilenameTextField(filename, onFilenameChange)
val result = uiState.result
// PDF infos
Column(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
if (uiState.isGenerating) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
when {
uiState.isSaving -> {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
Text(
text = stringResource(R.string.creating_export),
fontStyle = FontStyle.Italic
)
}
} else if (result != null) {
val context = LocalContext.current
val formattedFileSize = formatFileSize(result.sizeInBytes, context)
Text(text = pageCountText(result.pageCount))
val sizeMessageKey =
if (result.files.size == 1) R.string.file_size else R.string.file_size_total
Text(
text = stringResource(sizeMessageKey, formattedFileSize),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
if (uiState.savedBundle != null) {
SaveInfoBar(uiState.savedBundle, onOpen)
}
if (uiState.errorMessage != null) {
ErrorBar(uiState.errorMessage)
uiState.savedBundle != null -> {
SaveInfoBar(uiState.savedBundle, onOpen)
}
}
}
@Composable
private fun FilenameTextField(
filename: MutableState<String>,
filename: String,
onFilenameChange: (String) -> Unit,
) {
val focusRequester = remember { FocusRequester() }
OutlinedTextField(
value = filename.value,
value = filename,
onValueChange = onFilenameChange,
label = { Text(stringResource(R.string.filename)) },
singleLine = true,
@@ -259,9 +362,9 @@ private fun FilenameTextField(
.fillMaxWidth()
.focusRequester(focusRequester),
trailingIcon = {
if (filename.value.isNotEmpty()) {
if (filename.isNotEmpty()) {
IconButton(onClick = {
filename.value = ""
onFilenameChange("")
focusRequester.requestFocus()
}) {
Icon(Icons.Default.Clear, stringResource(R.string.clear_text))
@@ -273,6 +376,7 @@ private fun FilenameTextField(
@Composable
private fun MainActions(
onFilenameChange: (String) -> Unit,
uiState: ExportUiState,
onShare: () -> Unit,
onSave: () -> Unit,
@@ -281,30 +385,35 @@ private fun MainActions(
Column(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
ExportButton(
onClick = onShare,
enabled = uiState.result != null,
isPrimary = !uiState.hasSavedOrShared,
icon = Icons.Default.Share,
text = stringResource(R.string.share),
modifier = Modifier.weight(1f)
)
ExportButton(
onClick = onSave,
enabled = uiState.result != null,
isPrimary = !uiState.hasSavedOrShared,
icon = Icons.Default.Download,
text = stringResource(R.string.save),
modifier = Modifier.weight(1f)
)
ActionSurface {
FilenameTextField(uiState.filename, onFilenameChange)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
ExportButton(
onClick = onShare,
enabled = uiState.result != null,
isPrimary = !uiState.hasSavedOrShared,
icon = Icons.Default.Share,
text = stringResource(R.string.share),
modifier = Modifier.weight(1f)
)
ExportButton(
onClick = onSave,
enabled = uiState.result != null,
isPrimary = !uiState.hasSavedOrShared,
icon = Icons.Default.Download,
text = stringResource(R.string.save),
modifier = Modifier.weight(1f)
.alpha(if (uiState.isSaving) 0.6f else 1f)
)
}
}
ExportButton(
icon = Icons.Default.Done,
text = stringResource(R.string.end_scan),
text = stringResource(R.string.scan_new),
onClick = onCloseScan,
modifier = Modifier.fillMaxWidth(),
isPrimary = uiState.hasSavedOrShared,
@@ -312,6 +421,27 @@ private fun MainActions(
}
}
@Composable
private fun ActionSurface(
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
) {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
tonalElevation = 1.dp,
modifier = modifier.fillMaxWidth()
) {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(12.dp)
) {
content()
}
}
}
@Composable
fun ExportButton(
icon: ImageVector,
@@ -345,63 +475,189 @@ fun ExportButton(
modifier = modifier
) {
Icon(icon, contentDescription = null)
Spacer(Modifier.width(8.dp))
Spacer(Modifier.width(4.dp))
Text(text)
}
}
@Composable
private fun SaveInfoBar(savedBundle: SavedBundle, onOpen: (SavedItem) -> Unit) {
val dirName = savedBundle.exportDirName?:stringResource(R.string.download_dirname)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Absolute.SpaceBetween,
private fun SaveInfoBar(
savedBundle: SavedBundle,
onOpen: (SavedItem) -> Unit,
) {
val dirName = savedBundle.saveDir?.name ?: stringResource(R.string.download_dirname)
val items = savedBundle.items
val nbFiles = items.size
val firstFileName = items[0].fileName
Surface(
shape = RoundedCornerShape(10.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
tonalElevation = 0.dp,
modifier = Modifier.fillMaxWidth()
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(12.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.width(8.dp))
Text(
text = LocalResources.current.getQuantityString(
R.plurals.files_saved_to,
nbFiles,
nbFiles, firstFileName, dirName
),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
}
if (nbFiles == 1) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
MainActionButton(
onClick = { onOpen(items[0]) },
text = stringResource(R.string.open),
icon = Icons.AutoMirrored.Filled.OpenInNew,
)
}
}
}
}
}
@Composable
private fun ErrorBar(error: ExportError) {
val (summary, details) = error.toDisplayText()
val context = LocalContext.current
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(vertical = 8.dp, horizontal = 16.dp),
.border(
1.dp,
MaterialTheme.colorScheme.error,
RoundedCornerShape(12.dp)
)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
val items = savedBundle.items
val nbFiles = items.size
val firstFileName = items[0].fileName
Text(
text = LocalResources.current.getQuantityString(
R.plurals.files_saved_to,
nbFiles,
nbFiles, firstFileName, dirName
),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
if (nbFiles == 1) {
Spacer(Modifier.width(8.dp))
MainActionButton(
onClick = { onOpen(items[0]) },
text = stringResource(R.string.open),
icon = Icons.AutoMirrored.Filled.OpenInNew,
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = summary,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.weight(1f)
)
if (details != null) {
IconButton(
onClick = {
val clipboard =
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val text = buildString {
append(summary)
append("\n\n")
append(details)
}
clipboard.setPrimaryClip(
ClipData.newPlainText("Export error", text)
)
},
) {
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = stringResource(R.string.copy_logs),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.error
)
}
}
}
if (details != null) {
Text(
text = details,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error.copy(alpha = 0.8f)
)
}
}
}
@Composable
private fun ErrorBar(errorMessage: String) {
Text(
text = stringResource(R.string.error, errorMessage),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.errorContainer)
.padding(16.dp),
)
private fun ExportError.toDisplayText(): Pair<String, String?> {
return when (this) {
is ExportError.OnPrepareOrShare -> {
val summary = message
val details = throwable.message
summary to details
}
is ExportError.OnSave -> {
val summary = stringResource(messageRes)
val contextLines = buildErrorContextLines(saveDir)
val details = buildString {
if (contextLines.isNotEmpty()) {
append(contextLines.joinToString("\n"))
}
throwable?.message?.let {
if (isNotEmpty()) append("\n\n")
append(it)
}
}.ifEmpty { null }
summary to details
}
}
}
fun defaultFilename(): String {
val timestamp = SimpleDateFormat("yyyy-MM-dd HH.mm.ss", Locale.getDefault()).format(Date())
return "Scan $timestamp"
@Composable
private fun buildErrorContextLines(
saveDir: SaveDir?,
): List<String> {
val defaultDirName = stringResource(R.string.download_dirname)
val folderLine = when {
saveDir == null ->
stringResource(R.string.error_context_folder, defaultDirName)
saveDir.name != null ->
stringResource(R.string.error_context_folder, saveDir.name)
else -> null
}
val providerLine = saveDir?.uri?.authority
?.let(::providerLabel)
?.let { stringResource(R.string.error_context_provider, it) }
return listOfNotNull(folderLine, providerLine)
}
fun providerLabel(authority: String): String =
when {
authority.contains("nextcloud", ignoreCase = true) ->
"Nextcloud"
authority == "com.android.externalstorage.documents" ->
"Local storage"
else ->
authority
}
fun formatFileSize(sizeInBytes: Long?, context: Context): String {
return if (sizeInBytes == null) context.getString(R.string.unknown_size)
else Formatter.formatShortFileSize(context, sizeInBytes)
@@ -411,7 +667,11 @@ fun formatFileSize(sizeInBytes: Long?, context: Context): String {
@Composable
fun PreviewExportScreenDuringGeneration() {
ExportPreviewToCustomize(
uiState = ExportUiState(isGenerating = true)
uiState = ExportUiState(
isGenerating = true,
progress = ExportProgress(1, 3),
ocrActivation = true,
)
)
}
@@ -422,6 +682,7 @@ fun PreviewExportScreenAfterGeneration() {
ExportPreviewToCustomize(
uiState = ExportUiState(
result = ExportResult.Pdf(file, 442897L, 3),
ocrActivation = false,
),
)
}
@@ -434,7 +695,7 @@ fun PreviewExportScreenAfterSave() {
uiState = ExportUiState(
result = ExportResult.Pdf(file, 442897L, 3),
savedBundle = SavedBundle(
listOf(SavedItem(file.toUri(), defaultFilename() + ".pdf", PDF))
listOf(SavedItem(file.toUri(), "12345.pdf", PDF))
),
),
)
@@ -444,7 +705,8 @@ fun PreviewExportScreenAfterSave() {
@Composable
fun ExportScreenPreviewWithError() {
ExportPreviewToCustomize(
ExportUiState(errorMessage = "PDF generation failed")
ExportUiState(error =
ExportError.OnPrepareOrShare("PDF generation failed", IOException("Boom")))
)
}
@@ -457,7 +719,7 @@ fun PreviewExportScreenAfterSaveHorizontal() {
result = ExportResult.Pdf(file, 442897L, 3),
savedBundle = SavedBundle(
listOf(SavedItem(file.toUri(), "my_file.pdf", PDF)),
exportDirName="MyVeryVeryLongDirectoryName"),
SaveDir("fil:///root/dir".toUri() ,"MyVeryVeryLongDirectoryName")),
),
)
}
@@ -466,10 +728,13 @@ fun PreviewExportScreenAfterSaveHorizontal() {
fun ExportPreviewToCustomize(uiState: ExportUiState) {
FairScanTheme {
ExportScreen(
filename = remember { mutableStateOf("Scan 2025-07-02 17.40.42") },
onFilenameChange = {_->},
navigation = dummyNavigation(),
uiState = uiState,
currentDocument = fakeDocument(
persistentListOf("gallica.bnf.fr-bpt6k5530456s-1"),
LocalContext.current
),
navigation = dummyNavigation(),
onShare = {},
onSave = {},
onOpen = {},
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -19,15 +19,26 @@ import org.fairscan.app.ui.screens.settings.ExportFormat
data class ExportUiState(
val format: ExportFormat = ExportFormat.PDF,
val filename: String = "",
val isGenerating: Boolean = false,
val progress: ExportProgress? = null,
val ocrActivation: Boolean? = null,
val isSaving: Boolean = false,
val result: ExportResult? = null,
val savedBundle: SavedBundle? = null,
val hasShared: Boolean = false,
val errorMessage: String? = null,
val error: ExportError? = null,
) {
val hasSavedOrShared get() = savedBundle != null || hasShared
}
data class ExportProgress(
val completedPages: Int,
val totalPages: Int,
) {
val progress = completedPages.toFloat() / totalPages
}
data class SavedItem(
val uri: Uri,
val fileName: String,
@@ -36,6 +47,24 @@ data class SavedItem(
data class SavedBundle(
val items: List<SavedItem>,
val exportDir: Uri? = null,
val exportDirName: String? = null,
val saveDir: SaveDir? = null,
)
data class SaveDir(
val uri: Uri,
val name: String?,
)
sealed class ExportError {
data class OnPrepareOrShare(
val message: String,
val throwable: Throwable,
) : ExportError()
data class OnSave(
val messageRes: Int,
val saveDir: SaveDir?,
val throwable: Throwable? = null,
): ExportError()
}
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -27,6 +27,8 @@ import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -39,21 +41,27 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.fairscan.app.AppContainer
import org.fairscan.app.RecentDocument
import org.fairscan.app.R
import org.fairscan.app.data.FileManager
import org.fairscan.app.data.ImageRepository
import org.fairscan.app.domain.ExportQuality
import org.fairscan.app.domain.jpegsForExport
import org.fairscan.app.domain.PageViewKey
import org.fairscan.app.domain.pagesToExport
import org.fairscan.app.ui.screens.settings.ExportFormat
import org.fairscan.app.ui.screens.settings.ExportFormat.PDF
import java.io.File
import java.io.FileInputStream
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.CancellationException
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
sealed interface ExportEvent {
data object RequestSave : ExportEvent
data object SaveError : ExportEvent
data class Share(val result: ExportResult) : ExportEvent
}
class ExportViewModel(container: AppContainer, val imageRepository: ImageRepository): ViewModel() {
@@ -61,127 +69,204 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit
private val preparationDir = container.preparationDir
private val fileManager = container.fileManager
private val settingsRepository = container.settingsRepository
private val recentDocumentsDataStore = container.recentDocumentsDataStore
private val ocrService = container.ocrService
private val logger = container.logger
private val _events = MutableSharedFlow<ExportEvent>()
val events = _events.asSharedFlow()
private suspend fun generatePdf(
exportQuality: ExportQuality
exportQuality: ExportQuality,
disableOcr: Boolean,
onProgress: (Int) -> Unit,
): ExportResult.Pdf = withContext(Dispatchers.IO) {
val jpegs = jpegsForExport(imageRepository, exportQuality)
val pdf = fileManager.generatePdf(jpegs)
val pageToExports = pagesToExport(imageRepository, exportQuality)
val pdf = fileManager.generatePdf(pageToExports, disableOcr, onProgress)
return@withContext ExportResult.Pdf(pdf.file, pdf.sizeInBytes, pdf.pageCount)
}
suspend fun generatePdfForExternalCall(): ExportResult.Pdf {
return generatePdf(ExportQuality.BALANCED)
val pdf = generatePdf(ExportQuality.BALANCED, true) {}
val sourceFile = pdf.file
val targetFile = File(sourceFile.parentFile, defaultFilename() + ".pdf")
if (sourceFile.absolutePath == targetFile.absolutePath) return pdf
if (targetFile.exists() || !sourceFile.renameTo(targetFile)) return pdf
return pdf.copy(file = targetFile)
}
private val _uiState = MutableStateFlow(ExportUiState())
val uiState: StateFlow<ExportUiState> = _uiState.asStateFlow()
private var lastPreparationKey: ExportPreparationKey? = null
private var preparationJob: Job? = null
private var desiredFilename: String = ""
private var exportFormat = ExportFormat.PDF
fun setFilename(name: String) {
desiredFilename = name
_uiState.update {
it.copy(filename = name)
}
}
fun initializeExportScreen() {
cancelPreparation()
fun resetFilename() {
_uiState.update {
it.copy(filename = "")
}
}
preparationJob = viewModelScope.launch {
private fun defaultFilename(): String {
val timestamp = SimpleDateFormat("yyyy-MM-dd HH.mm.ss", Locale.getDefault()).format(Date())
return "Scan $timestamp"
}
private fun ensureValidFilename() {
_uiState.update {
val normalized = it.filename.trim().ifEmpty { defaultFilename() }
if (normalized != it.filename) {
it.copy(filename = normalized)
} else it
}
}
private suspend fun currentPageKeys(): ImmutableList<PageViewKey> =
imageRepository.pages().map { it.key() }.toImmutableList()
fun cancelPreparationJob() {
preparationJob?.let {
// keep result if job is not active
if (it.isActive) {
_uiState.update { ExportUiState() }
}
it.cancel()
}
}
fun prepareExportIfNeeded() {
ensureValidFilename()
viewModelScope.launch {
val exportQuality = settingsRepository.exportQuality.first()
exportFormat = settingsRepository.exportFormat.first()
_uiState.update { it.copy(format = exportFormat, isGenerating = true) }
try {
val t1 = System.currentTimeMillis()
val result = if (exportFormat == ExportFormat.JPEG) {
generateJpegs(exportQuality)
} else {
generatePdf(exportQuality)
}
val exportFormat = settingsRepository.exportFormat.first()
val ocrLanguageString = ocrService.languageString()
val currentPageKeys = currentPageKeys()
val key = ExportPreparationKey(
currentPageKeys, exportFormat, exportQuality, ocrLanguageString)
if (key == lastPreparationKey && _uiState.value.result != null) {
return@launch
}
val pageCount = currentPageKeys.size
lastPreparationKey = key
preparationJob?.cancel()
preparationJob = launch {
val ocrActivation = if (exportFormat == PDF) ocrLanguageString.isNotEmpty() else null
_uiState.update {
it.copy(isGenerating = false, result = result)
}
val t2 = System.currentTimeMillis()
val pageCount = result.pageCount
Log.i("Export", "Generation: $pageCount pages, $exportQuality, ${t2-t1} ms")
} catch (e: Exception) {
val message = "Failed to prepare $exportFormat export"
logger.e("FairScan", message, e)
_uiState.update {
it.copy(
isGenerating = false,
errorMessage = message
ExportUiState(
filename = it.filename,
format = exportFormat,
isGenerating = true,
progress = ExportProgress(0, pageCount),
ocrActivation = ocrActivation,
)
}
val onProgress: (Int) -> Unit = { completedPages ->
_uiState.update {
it.copy(progress = ExportProgress(completedPages, pageCount))
}
}
try {
val t1 = System.currentTimeMillis()
val result = if (exportFormat == ExportFormat.JPEG) {
generateJpegs(exportQuality, onProgress)
} else {
generatePdf(exportQuality, false, onProgress)
}
_uiState.update { it.copy(result = result) }
val t2 = System.currentTimeMillis()
Log.i("Export", "Generation: $pageCount pages, $exportQuality, ${t2 - t1} ms")
} catch (e: CancellationException) {
// Preparation cancelled: do nothing
throw e
} catch (e: Exception) {
val message = "Failed to prepare $exportFormat export"
logger.e("FairScan", message, e)
_uiState.update {
it.copy(error = ExportError.OnPrepareOrShare(message, e))
}
} finally {
_uiState.update { it.copy(isGenerating = false) }
}
}
}
}
private suspend fun generateJpegs(
exportQuality: ExportQuality
exportQuality: ExportQuality,
onProgress: (Int) -> Unit,
): ExportResult.Jpeg = withContext(Dispatchers.IO) {
val jpegs = jpegsForExport(imageRepository, exportQuality)
val pageToExports = pagesToExport(imageRepository, exportQuality)
val timestamp = System.currentTimeMillis()
preparationDir.mkdirs()
val files = jpegs.mapIndexed { index, bytes ->
val files = pageToExports.mapIndexed { index, page ->
val file = File(preparationDir, "$timestamp-${index + 1}.jpg")
file.writeBytes(bytes)
file.writeBytes(page.jpeg.get().bytes)
onProgress(index + 1)
file
}.toList()
val sizeInBytes = files.sumOf { it.length() }
ExportResult.Jpeg(files, sizeInBytes)
}
fun cancelPreparation() {
preparationJob?.cancel()
_uiState.value = ExportUiState()
private fun renameFile(source: File, target: File) {
if (source.absolutePath == target.absolutePath) return
if (target.exists() && !target.delete()) {
throw IOException("Cannot delete existing file ${target.absolutePath}")
}
if (!source.renameTo(target)) {
throw IOException("Failed to rename ${source.name} to ${target.name}")
}
}
fun setAsShared() {
_uiState.update { it.copy(hasShared = true) }
}
fun applyRenaming(): ExportResult? {
val result = _uiState.value.result ?: return null
when (result) {
private fun applyRenaming(): ExportResult {
val result = _uiState.value.result
?: throw IllegalStateException("Export result missing")
ensureValidFilename()
val filename = _uiState.value.filename
val updated = when (result) {
is ExportResult.Pdf -> {
val fileName = FileManager.addPdfExtensionIfMissing(desiredFilename)
val fileName = FileManager.addPdfExtensionIfMissing(filename)
val newFile = File(result.file.parentFile, fileName)
val tempFile = result.file
if (tempFile.absolutePath != newFile.absolutePath) {
if (newFile.exists()) newFile.delete()
val success = tempFile.renameTo(newFile)
if (!success) return null
_uiState.update {
it.copy(result = ExportResult.Pdf(
newFile, result.sizeInBytes, result.pageCount)
)
}
}
renameFile(result.file, newFile)
ExportResult.Pdf(newFile, result.sizeInBytes, result.pageCount)
}
is ExportResult.Jpeg -> {
val base = desiredFilename.removeSuffix(".jpg")
val base = filename.removeSuffix(".jpg")
val files = result.files
val renamedFiles = files.mapIndexed { index, file ->
val indexSuffix = if (files.size == 1) "" else "_${index + 1}"
val newFile = File(file.parentFile, "${base}${indexSuffix}.jpg")
if (file.absolutePath != newFile.absolutePath) {
if (newFile.exists()) newFile.delete()
file.renameTo(newFile)
}
renameFile(file, newFile)
newFile
}
val updated = result.copy(jpegFiles = renamedFiles)
_uiState.update { it.copy(result = updated) }
result.copy(jpegFiles = renamedFiles)
}
}
_uiState.update { it.copy(result = updated) }
return updated
}
fun onShareClicked() {
viewModelScope.launch {
try {
val result = applyRenaming()
_events.emit(ExportEvent.Share(result))
_uiState.update { it.copy(hasShared = true) }
} catch (e: Exception) {
val message = "Failed to prepare share"
logger.e("FairScan", message, e)
_uiState.update { it.copy(error = ExportError.OnPrepareOrShare(message, e)) }
}
}
return _uiState.value.result
}
fun onSaveClicked() {
@@ -192,23 +277,45 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit
fun onRequestSave(context: Context) {
viewModelScope.launch {
_uiState.update {it.copy(isSaving = true, error = null, savedBundle = null) }
val exportFormat = uiState.value.format
val saveDir = saveDir(context)
try {
save(context)
// Must not run on the main thread: some SAF providers (e.g. Nextcloud)
// may perform network I/O
withContext(Dispatchers.IO) {
save(context, saveDir, exportFormat)
}
} catch (e: MissingExportDirPermissionException) {
logger.e("FairScan", "Missing export dir permission", e)
_uiState.update {
it.copy(error =
ExportError.OnSave(R.string.error_export_dir_permission_lost, saveDir))
}
} catch (e: Exception) {
logger.e("FairScan", "Failed to save PDF", e)
_events.emit(ExportEvent.SaveError)
_uiState.update {
it.copy(error = ExportError.OnSave(R.string.error_save, saveDir, e))
}
} finally {
_uiState.update { it.copy(isSaving = false) }
}
}
}
private suspend fun save(context:Context) {
val result = applyRenaming() ?: return
val exportDir = settingsRepository.exportDirUri.first()?.toUri()
private suspend fun saveDir(context:Context): SaveDir? {
val uri = settingsRepository.exportDirUri.first()?.toUri() ?: return null
val name = resolveExportDirName(context, uri)
return SaveDir(uri, name)
}
private suspend fun save(context: Context, saveDir: SaveDir?, exportFormat: ExportFormat) {
val result = applyRenaming()
val savedItems = mutableListOf<SavedItem>()
val filesForMediaScan = mutableListOf<File>()
for (file in result.files) {
val saved = if (exportDir == null) {
val saved = if (saveDir == null) {
// No export dir defined -> save to Downloads
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Android 10+: use MediaStore API
@@ -223,22 +330,20 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit
}
} else {
// Use Storage Access Framework to save to the chosen directory
val safFile = saveViaSaf(context, file, exportDir, exportFormat)
if (!context.contentResolver.persistedUriPermissions.any { perm ->
perm.uri == saveDir.uri && perm.isWritePermission
}) {
throw MissingExportDirPermissionException(saveDir.uri)
}
val safFile = saveViaSaf(context, file, saveDir.uri, exportFormat)
SavedItem(safFile.uri, safFile.name ?: file.name, exportFormat)
}
savedItems += saved
}
val exportDirName = resolveExportDirName(context, exportDir)
val bundle = SavedBundle(savedItems, exportDir, exportDirName)
val bundle = SavedBundle(savedItems, saveDir)
_uiState.update { it.copy(savedBundle = bundle) }
if (exportFormat == ExportFormat.PDF) {
savedItems.forEach { item ->
addRecentDocument(item.uri, item.fileName, result.pageCount)
}
}
filesForMediaScan.forEach { f -> mediaScan(context, f, exportFormat.mimeType) }
}
@@ -318,29 +423,15 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit
DocumentFile.fromTreeUri(context, exportDirUri)?.name
}
}
fun addRecentDocument(fileUri: Uri, fileName: String, pageCount: Int) {
viewModelScope.launch {
recentDocumentsDataStore.updateData { current ->
val newDoc = RecentDocument.newBuilder()
.setFileUri(fileUri.toString())
.setFileName(fileName)
.setPageCount(pageCount)
.setCreatedAt(System.currentTimeMillis())
.build()
current.toBuilder()
.addDocuments(0, newDoc)
.also { builder ->
while (builder.documentsCount > 3) {
builder.removeDocuments(builder.documentsCount - 1)
}
}
.build()
}
}
}
}
data class ExportPreparationKey(
val pages: ImmutableList<PageViewKey>,
val format: ExportFormat,
val quality: ExportQuality,
val ocrLanguageString: String,
)
sealed class ExportResult {
abstract val files: List<File>
abstract val sizeInBytes: Long
@@ -353,7 +444,7 @@ sealed class ExportResult {
override val pageCount: Int,
) : ExportResult() {
override val files get() = listOf(file)
override val format: ExportFormat = ExportFormat.PDF
override val format: ExportFormat = PDF
}
data class Jpeg(
@@ -367,9 +458,16 @@ sealed class ExportResult {
}
data class ExportActions(
val initializeExportScreen: () -> Unit,
val prepareExportIfNeeded: () -> Unit,
val setFilename: (String) -> Unit,
val share: () -> Unit,
val save: () -> Unit,
val open: (SavedItem) -> Unit,
val cancelPreparationJob: () -> Unit,
)
class MissingExportDirPermissionException(
val uri: Uri
) : IllegalStateException(
"Missing persisted write permission for export dir: $uri"
)
@@ -1,322 +0,0 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.ui.screens.home
import android.net.Uri
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material.icons.filled.PictureAsPdf
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import kotlinx.collections.immutable.persistentListOf
import org.fairscan.app.R
import org.fairscan.app.ui.Navigation
import org.fairscan.app.ui.components.AppOverflowMenu
import org.fairscan.app.ui.components.CameraPermissionState
import org.fairscan.app.ui.components.formatDate
import org.fairscan.app.ui.components.pageCountText
import org.fairscan.app.ui.components.rememberCameraPermissionState
import org.fairscan.app.ui.dummyNavigation
import org.fairscan.app.ui.fakeDocument
import org.fairscan.app.ui.state.DocumentUiModel
import org.fairscan.app.ui.theme.FairScanTheme
import java.io.File
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
cameraPermission: CameraPermissionState,
currentDocument: DocumentUiModel,
navigation: Navigation,
onClearScan: () -> Unit,
recentDocuments: List<RecentDocumentUiState>,
onOpenPdf: (Uri) -> Unit,
) {
Scaffold (
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.app_name)) },
actions = { AppOverflowMenu(navigation) }
)
},
) { padding ->
Column (
modifier = Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
Spacer(Modifier.weight(1f))
if (!cameraPermission.isGranted) {
CameraPermissionRationale(cameraPermission)
} else {
ScanButton(
onClick = {
onClearScan()
navigation.toCameraScreen()
},
Modifier.align(Alignment.CenterHorizontally)
)
}
Spacer(Modifier.weight(1f))
if (!currentDocument.isEmpty()) {
OngoingScanBanner(
currentDocument,
onResumeScan = navigation.toDocumentScreen,
onClearScan = onClearScan,
)
} else if (recentDocuments.isNotEmpty()) {
RecentDocumentList(recentDocuments, onOpenPdf)
}
}
}
}
@Composable
private fun CameraPermissionRationale(cameraPermission: CameraPermissionState) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
) {
Column(Modifier.padding(16.dp)) {
Text(
stringResource(R.string.camera_permission_rationale),
)
Spacer(Modifier.height(8.dp))
Button(
onClick = { cameraPermission.request() },
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
Text(stringResource(R.string.grant_permission))
}
}
}
}
@Composable
fun ScanButton(onClick: () -> Unit, modifier: Modifier) {
Button(
onClick = onClick,
modifier = modifier.padding(32.dp),
elevation = ButtonDefaults.buttonElevation(defaultElevation = 6.dp)
) {
Icon(
imageVector = Icons.Default.PhotoCamera,
contentDescription = null,
modifier = Modifier.size(48.dp)
)
Spacer(Modifier.width(8.dp))
Text(
text = stringResource(R.string.scan_button),
style = MaterialTheme.typography.titleLarge
)
}
}
@Composable
fun OngoingScanBanner(
currentDocument: DocumentUiModel,
onResumeScan: () -> Unit,
onClearScan: () -> Unit
) {
Surface(
tonalElevation = 2.dp,
shadowElevation = 4.dp,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
currentDocument.load(0)?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = null,
modifier = Modifier
.size(60.dp)
.clip(RoundedCornerShape(4.dp)),
contentScale = ContentScale.Fit
)
}
Spacer(Modifier.width(12.dp))
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = stringResource(R.string.scan_in_progress),
style = MaterialTheme.typography.bodyLarge
)
Text(
text = pageCountText(currentDocument.pageCount()),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f))
}
IconButton(
onClick = onClearScan,
modifier = Modifier.size(24.dp)
) {
Icon(
imageVector = Icons.Default.DeleteOutline,
contentDescription = stringResource(R.string.discard_scan),
tint = MaterialTheme.colorScheme.primary
)
}
Spacer(Modifier.width(12.dp))
Button(onClick = onResumeScan) {
Text(stringResource(R.string.resume))
}
}
}
}
@Composable
private fun RecentDocumentList(
recentDocuments: List<RecentDocumentUiState>,
onOpenPdf: (Uri) -> Unit
) {
Spacer(Modifier.height(8.dp))
Text(
stringResource(R.string.last_saved_pdf_files),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 12.dp, bottom = 4.dp)
)
Column {
recentDocuments.forEach { doc ->
ListItem(
headlineContent = {
Text(
doc.fileName,
style = MaterialTheme.typography.bodyMedium
)
},
supportingContent = {
Text(
text = pageCountText(doc.pageCount) + "" +
formatDate(doc.saveTimestamp, LocalContext.current),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
},
leadingContent = {
Icon(
Icons.Default.PictureAsPdf,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
},
modifier = Modifier.clickable { onOpenPdf(doc.fileUri) }
)
}
}
}
@Preview
@Composable
fun HomeScreenPreviewOnFirstLaunch() {
FairScanTheme {
HomeScreen(
cameraPermission = rememberCameraPermissionState(),
currentDocument = fakeDocument(),
navigation = dummyNavigation(),
onClearScan = {},
recentDocuments = listOf(),
onOpenPdf = {},
)
}
}
@Preview
@Composable
fun HomeScreenPreviewWithCurrentDocument() {
FairScanTheme {
HomeScreen(
cameraPermission = rememberCameraPermissionState(),
currentDocument = fakeDocument(
persistentListOf("gallica.bnf.fr-bpt6k5530456s-1.jpg"),
LocalContext.current
),
navigation = dummyNavigation(),
onClearScan = {},
recentDocuments = listOf(),
onOpenPdf = {},
)
}
}
@Preview
@Composable
fun HomeScreenPreviewWithLastSavedFiles() {
FairScanTheme {
HomeScreen(
cameraPermission = rememberCameraPermissionState(),
currentDocument = fakeDocument(),
navigation = dummyNavigation(),
onClearScan = {},
recentDocuments = listOf(
RecentDocumentUiState(
File("/path/my_file.pdf").toUri(), "my_file.pdf", 1755971180000, 3),
RecentDocumentUiState(
"content:///path/scan2.pdf".toUri(), "scan2.pdf",1755000500000, 1)
),
onOpenPdf = {},
)
}
}
@@ -1,75 +0,0 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.ui.screens.home
import android.content.Context
import android.net.Uri
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import org.fairscan.app.AppContainer
import java.io.File
class HomeViewModel(appContainer: AppContainer, appContext: Context): ViewModel() {
private val recentDocumentsDataStore = appContainer.recentDocumentsDataStore
val recentDocuments: StateFlow<List<RecentDocumentUiState>> =
recentDocumentsDataStore.data.map {
it.documentsList.mapNotNull { doc ->
var fileName = doc.fileName
var uri: Uri? = null
if (doc.fileUri.isNullOrEmpty()) {
if (!doc.filePath.isNullOrEmpty()) {
val file = File(doc.filePath)
uri = file.toUri()
fileName = file.name
}
} else {
uri = doc.fileUri.toUri()
}
if (uri != null) {
RecentDocumentUiState(
fileUri = uri,
fileName = fileName,
saveTimestamp = doc.createdAt,
pageCount = doc.pageCount,
)
} else null
}.filter { item -> uriExists(appContext, item.fileUri) }
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
)
private fun uriExists(context: Context, uri: Uri): Boolean {
return if (uri.scheme == "file") {
File(uri.path.orEmpty()).exists()
} else {
try {
DocumentFile.fromSingleUri(context, uri)?.exists() == true
} catch (_: Exception) {
false
}
}
}
}
@@ -0,0 +1,396 @@
/*
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option)
* any later version.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
* more details.
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.fairscan.app.ui.screens.settings
import android.content.Context
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.fairscan.app.R
import org.fairscan.app.data.OcrLanguage
import org.fairscan.app.ui.components.BackButton
import org.fairscan.app.ui.theme.FairScanTheme
import android.text.format.Formatter
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.TextButton
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
enum class LanguageState {
ACTIVE,
INACTIVE,
NOT_INSTALLED,
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OcrLanguagesScreen(
uiState: SettingsUiState,
onBack: () -> Unit,
onLanguageClick: (String) -> Unit,
onRemoveLanguage: (String) -> Unit,
onCancelOcrDownload: () -> Unit,
) {
BackHandler { onBack() }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.settings_ocr_languages)) },
navigationIcon = { BackButton(onBack) },
)
}
) { paddingValues ->
OcrLanguagesContent(
uiState = uiState,
onLanguageClick = onLanguageClick,
onCancelOcrDownload = onCancelOcrDownload,
onRemoveLanguage = onRemoveLanguage,
modifier = Modifier.padding(paddingValues),
)
}
}
@Composable
private fun OcrLanguagesContent(
uiState: SettingsUiState,
onLanguageClick: (String) -> Unit,
onRemoveLanguage: (String) -> Unit,
onCancelOcrDownload: () -> Unit,
modifier: Modifier = Modifier,
) {
val locale = Locale.current.platformLocale
val installed = remember(
uiState.installedOcrLanguages,
uiState.enabledOcrLanguages,
locale,
) {
uiState.installedOcrLanguages
.map { OcrLanguage(it) }
.sortedWith(compareBy { it.displayName(locale) })
}
val available = remember(
uiState.installedOcrLanguages,
locale,
) {
OcrLanguage.AVAILABLE_LANGUAGE_CODES
.filterNot { it in uiState.installedOcrLanguages }
.map { OcrLanguage(it) }
.sortedBy { it.displayName(locale) }
}
val suggested = available.firstOrNull {
it.locale.displayLanguage == locale.displayLanguage
}
LazyColumn(
modifier = modifier.fillMaxSize()
) {
item {
Text(
text = stringResource(R.string.settings_ocr_download_intro),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
if (installed.isNotEmpty()) {
item {
SectionTitle(
stringResource(R.string.settings_ocr_languages_installed)
)
}
items(
items = installed,
key = { it.code }
) { lang ->
LanguageItem(
language = lang,
state = if (lang.code in uiState.enabledOcrLanguages) {
LanguageState.ACTIVE
} else {
LanguageState.INACTIVE
},
onClick = { onLanguageClick(lang.code) },
onRemove = { onRemoveLanguage(lang.code) }
)
}
}
if (suggested != null) {
item {
SectionTitle(
stringResource(R.string.settings_ocr_suggested)
)
}
item {
LanguageItem(
language = suggested,
state = LanguageState.NOT_INSTALLED,
onClick = {
onLanguageClick(suggested.code)
}
)
}
}
item {
SectionTitle(
stringResource(R.string.settings_ocr_languages_available)
)
}
items(
available.filter { it != suggested },
key = { it.code }
) { lang ->
LanguageItem(
language = lang,
state = LanguageState.NOT_INSTALLED,
onClick = {
onLanguageClick(lang.code)
}
)
}
}
uiState.currentDownload?.let { download ->
OcrDownloadDialog(
state = download,
onCancel = onCancelOcrDownload,
)
}
}
@Composable
private fun LanguageItem(
language: OcrLanguage,
state: LanguageState,
onClick: () -> Unit,
onRemove: (() -> Unit)? = null,
) {
var menuExpanded by remember { mutableStateOf(false) }
val locale = Locale.current.platformLocale
ListItem(
headlineContent = {
Text(language.displayName(locale))
},
leadingContent = {
when (state) {
LanguageState.ACTIVE ->
Icon(
Icons.Default.CheckCircle,
contentDescription = null
)
LanguageState.INACTIVE ->
Icon(
Icons.Default.RadioButtonUnchecked,
contentDescription = null
)
LanguageState.NOT_INSTALLED ->
Icon(
Icons.Default.Download,
contentDescription = null
)
}
},
trailingContent = {
if (onRemove != null) {
Box {
IconButton(onClick = { menuExpanded = true }) {
Icon(Icons.Default.MoreVert, contentDescription = null)
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false }
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.delete)) },
onClick = {
menuExpanded = false
onRemove()
}
)
}
}
}
},
modifier = Modifier.clickable(onClick = onClick)
)
}
@Composable
private fun SectionTitle(
text: String,
) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(
horizontal = 16.dp,
vertical = 16.dp,
)
)
}
@Composable
fun OcrDownloadDialog(
state: OcrDownloadUiState,
onCancel: () -> Unit,
) {
AlertDialog(
onDismissRequest = {}, // tapping outside the dialog should not cancel
title = {
Text(
stringResource(R.string.settings_ocr_downloading),
style = MaterialTheme.typography.titleMedium
)
},
text = {
if (state.failed) {
Text(
text = stringResource(R.string.settings_ocr_download_error),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
} else {
Column {
Text(state.language.displayName(Locale.current.platformLocale))
Spacer(Modifier.height(16.dp))
val progress =
state.totalBytes?.let { total ->
state.downloadedBytes.toFloat() / total
}
if (progress != null) {
LinearProgressIndicator(progress = { progress }, Modifier.fillMaxWidth())
} else {
LinearProgressIndicator(Modifier.fillMaxWidth())
}
Spacer(Modifier.height(12.dp))
Text(
buildProgressText(
state.downloadedBytes,
state.totalBytes,
LocalContext.current
),
style = MaterialTheme.typography.bodyMedium,
)
}
}
},
dismissButton = {
TextButton(onClick = onCancel) {
Text(stringResource(if (state.failed) R.string.close else R.string.cancel))
}
},
confirmButton = {}
)
}
private fun buildProgressText(
downloadedBytes: Long,
totalBytes: Long?,
context: Context,
): String {
return if (totalBytes != null) {
listOf(
Formatter.formatShortFileSize(context, downloadedBytes),
Formatter.formatShortFileSize(context, totalBytes),
).joinToString(" / ")
} else {
Formatter.formatShortFileSize(context, downloadedBytes)
}
}
@Preview
@Composable
fun OcrLanguagesScreenPreviewBasic() {
OcrLanguagesScreenPreview(
SettingsUiState(
installedOcrLanguages = setOf("fra", "deu"),
enabledOcrLanguages = setOf("fra")
)
)
}
@Preview
@Composable
fun OcrLanguagesScreenPreviewWithDownloadDialog() {
OcrLanguagesScreenPreview(
SettingsUiState(currentDownload =
OcrDownloadUiState(OcrLanguage("eng"), 500_000, 1_200_000)
)
)
}
@Preview
@Composable
fun OcrLanguagesScreenPreviewWithDownloadError() {
OcrLanguagesScreenPreview(
SettingsUiState(currentDownload =
OcrDownloadUiState(OcrLanguage("eng"), failed = true)
)
)
}
@Composable
fun OcrLanguagesScreenPreview(uiState: SettingsUiState) {
FairScanTheme {
OcrLanguagesScreen(uiState, {}, {}, {}, {})
}
}
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -15,28 +15,49 @@
package org.fairscan.app.ui.screens.settings
import android.content.Context
import androidx.core.net.toUri
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import androidx.documentfile.provider.DocumentFile
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.fairscan.app.R
import org.fairscan.app.domain.ExportQuality
import org.fairscan.imageprocessing.ColorMode
private val Context.dataStore by preferencesDataStore(name = "fairscan_settings")
class SettingsRepository(private val context: Context) {
class SettingsRepository(
private val context: Context,
private val dataStore: DataStore<Preferences>,
) {
private val DEFAULT_COLOR_MODE = stringPreferencesKey("default_color_mode")
private val EXPORT_DIR_URI = stringPreferencesKey("export_dir_uri")
private val EXPORT_FORMAT = stringPreferencesKey("export_format")
private val EXPORT_QUALITY = stringPreferencesKey("export_quality")
val defaultColorMode: Flow<DefaultColorMode> =
dataStore.data.map { prefs ->
when (prefs[DEFAULT_COLOR_MODE]) {
"AUTO" -> DefaultColorMode.AUTO
"COLOR" -> DefaultColorMode.COLOR
"GRAYSCALE" -> DefaultColorMode.GRAYSCALE
else -> DefaultColorMode.AUTO
}
}
val exportDirUri: Flow<String?> =
context.dataStore.data.map { prefs ->
dataStore.data.map { prefs ->
prefs[EXPORT_DIR_URI]
}
fun resolveExportDirName(uri: String): String? {
return DocumentFile.fromTreeUri(context, uri.toUri())?.name
}
val exportFormat: Flow<ExportFormat> =
context.dataStore.data.map { prefs ->
dataStore.data.map { prefs ->
when (prefs[EXPORT_FORMAT]) {
"JPEG" -> ExportFormat.JPEG
"PDF", null -> ExportFormat.PDF
@@ -45,7 +66,7 @@ class SettingsRepository(private val context: Context) {
}
val exportQuality: Flow<ExportQuality> =
context.dataStore.data.map { prefs ->
dataStore.data.map { prefs ->
when (prefs[EXPORT_QUALITY]) {
"LOW" -> ExportQuality.LOW
"HIGH" -> ExportQuality.HIGH
@@ -54,8 +75,14 @@ class SettingsRepository(private val context: Context) {
}
}
suspend fun setDefaultColorMode(mode: DefaultColorMode) {
dataStore.edit { prefs ->
prefs[DEFAULT_COLOR_MODE] = mode.name
}
}
suspend fun setExportDirUri(uri: String?) {
context.dataStore.edit { prefs ->
dataStore.edit { prefs ->
if (uri == null) {
prefs.remove(EXPORT_DIR_URI)
} else {
@@ -65,18 +92,24 @@ class SettingsRepository(private val context: Context) {
}
suspend fun setExportFormat(format: ExportFormat) {
context.dataStore.edit { prefs ->
dataStore.edit { prefs ->
prefs[EXPORT_FORMAT] = format.name
}
}
suspend fun setExportQuality(quality: ExportQuality) {
context.dataStore.edit { prefs ->
dataStore.edit { prefs ->
prefs[EXPORT_QUALITY] = quality.name
}
}
}
enum class DefaultColorMode(val colorMode: ColorMode?, val labelResource: Int) {
AUTO(null, R.string.color_mode_auto),
COLOR(ColorMode.COLOR, R.string.color_mode_color),
GRAYSCALE(ColorMode.GRAYSCALE, R.string.color_mode_grayscale),
}
enum class ExportFormat(val mimeType: String) {
PDF("application/pdf"),
JPEG("image/jpeg"),
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -14,8 +14,6 @@
*/
package org.fairscan.app.ui.screens.settings
import android.content.Context
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
@@ -30,142 +28,276 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import org.fairscan.app.R
import org.fairscan.app.data.OcrLanguage
import org.fairscan.app.domain.ExportQuality
import org.fairscan.app.ui.Navigation
import org.fairscan.app.ui.components.BackButton
import org.fairscan.app.ui.dummyNavigation
import org.fairscan.app.ui.theme.FairScanTheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
uiState: SettingsUiState,
onDefaultColorModeChanged: (DefaultColorMode) -> Unit,
onChooseDirectoryClick: () -> Unit,
onResetExportDirClick: () -> Unit,
onExportFormatChanged: (ExportFormat) -> Unit,
onExportQualityChanged: (ExportQuality) -> Unit,
onBack: () -> Unit,
navigation: Navigation,
) {
BackHandler { onBack() }
BackHandler { navigation.back() }
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.settings)) },
navigationIcon = { BackButton(onBack) },
navigationIcon = { BackButton(navigation.back) },
)
}
) { paddingValues ->
SettingsContent(
uiState,
onDefaultColorModeChanged,
onChooseDirectoryClick,
onResetExportDirClick,
onExportFormatChanged,
onExportQualityChanged,
modifier = Modifier.padding(paddingValues))
navigation,
modifier = Modifier.padding(paddingValues),
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SettingsContent(
uiState: SettingsUiState,
onDefaultColorModeChanged: (DefaultColorMode) -> Unit,
onChooseDirectoryClick: () -> Unit,
onResetExportDirClick: () -> Unit,
onExportFormatChanged: (ExportFormat) -> Unit,
onExportQualityChanged: (ExportQuality) -> Unit,
navigation: Navigation,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val folderName = remember(uiState.exportDirUri) {
extractFolderName(uiState.exportDirUri, context)
val displayLocale = Locale.current.platformLocale
val export = uiState.export
val (folderLabel, folderLabelColor) = when {
export.dirUri == null ->
stringResource(R.string.download_dirname) to
MaterialTheme.colorScheme.onSurface
export.dirName != null ->
export.dirName to
MaterialTheme.colorScheme.onSurface
else ->
stringResource(R.string.export_folder_permission_lost) to
MaterialTheme.colorScheme.error
}
Column(
modifier
.fillMaxSize()
.padding(20.dp)
.padding(vertical = 8.dp, horizontal = 24.dp)
.verticalScroll(rememberScrollState())
) {
DirectorySettingItem(
label = stringResource(R.string.export_directory),
folderName = folderName,
onClick = onChooseDirectoryClick
val context = LocalResources.current
Text(stringResource(R.string.settings_section_scan), style = MaterialTheme.typography.titleLarge)
SingleChoiceSetting(
title = stringResource(R.string.color_mode_default),
entries = DefaultColorMode.entries,
onValueChanged = onDefaultColorModeChanged,
label = { t -> context.getString(t.labelResource) },
selectedValue = uiState.defaultColorMode,
)
Spacer(Modifier.height(12.dp))
Spacer(Modifier.height(16.dp))
HorizontalDivider()
Spacer(Modifier.height(16.dp))
if (uiState.exportDirUri != null) {
OutlinedButton(
Text(stringResource(R.string.settings_section_export), style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.height(16.dp))
DirectorySettingItem(
label = stringResource(R.string.export_directory),
folderLabel,
folderLabelColor,
onClick = onChooseDirectoryClick,
)
if (export.dirUri != null) {
TextButton(
onClick = onResetExportDirClick,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary),
modifier = Modifier.padding(start = 4.dp),
) {
Text(stringResource(R.string.reset_to_default))
}
}
Spacer(Modifier.height(32.dp))
Spacer(Modifier.height(8.dp))
Text(stringResource(R.string.export_quality), style = MaterialTheme.typography.titleLarge)
SingleChoiceSetting(
title = stringResource(R.string.export_quality),
entries = ExportQuality.entries.reversed(),
selectedValue = export.quality,
onValueChanged = onExportQualityChanged,
label = { t -> context.getString(t.labelResource) },
)
ExportQuality.entries.reversed().forEach { quality ->
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = uiState.exportQuality == quality,
onClick = { onExportQualityChanged(quality) },
SingleChoiceSetting(
title = stringResource(R.string.export_format),
entries = ExportFormat.entries,
selectedValue = export.format,
onValueChanged = onExportFormatChanged,
label = { it.name },
)
Spacer(Modifier.height(16.dp))
HorizontalDivider()
Spacer(Modifier.height(16.dp))
Text(
stringResource(R.string.settings_section_ocr),
style = MaterialTheme.typography.titleLarge
)
ListItem(
headlineContent = { Text(stringResource(R.string.settings_ocr_languages)) },
supportingContent = {
Text(uiState.enabledOcrLanguages
.map { OcrLanguage(it).displayName(displayLocale) }
.sorted()
.joinToString("")
.ifEmpty { stringResource(R.string.settings_ocr_languages_disabled) }
)
Text(stringResource(quality.labelResource))
}
}
},
trailingContent = {
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null
)
},
modifier = Modifier.clickable { navigation.toOcrLanguagesScreen() }
)
}
}
Spacer(Modifier.height(32.dp))
@Composable
fun <T> SingleChoiceSetting(
title: String,
entries: List<T>,
selectedValue: T,
onValueChanged: (T) -> Unit,
label: (T) -> String,
) {
var showDialog by rememberSaveable { mutableStateOf(false) }
Text(stringResource(R.string.export_format), style = MaterialTheme.typography.titleLarge)
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = uiState.exportFormat == ExportFormat.PDF,
onClick = { onExportFormatChanged(ExportFormat.PDF) },
ListItem(
headlineContent = {
Text(title)
},
supportingContent = {
Text(label(selectedValue))
},
trailingContent = {
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null
)
Text("PDF")
},
modifier = Modifier.clickable {
showDialog = true
}
)
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = uiState.exportFormat == ExportFormat.JPEG,
onClick = { onExportFormatChanged(ExportFormat.JPEG) },
)
Text("JPEG")
}
if (showDialog) {
AlertDialog(
onDismissRequest = {
showDialog = false
},
title = {
Text(title)
},
text = {
Column {
entries.forEach { entry ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
onValueChanged(entry)
showDialog = false
}
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selectedValue == entry,
onClick = {
onValueChanged(entry)
showDialog = false
}
)
Text(
text = label(entry),
modifier = Modifier.padding(start = 8.dp)
)
}
}
}
},
confirmButton = {},
dismissButton = {}
)
}
}
@Composable
fun DirectorySettingItem(
label: String,
folderName: String,
folderLabel: String,
folderLabelColor: Color,
onClick: () -> Unit,
) {
Column {
) {
Column (
modifier = Modifier.padding(vertical = 0.dp, horizontal = 12.dp)
) {
Text(
text = label,
style = MaterialTheme.typography.titleLarge
style = MaterialTheme.typography.bodyLarge
)
Spacer(Modifier.height(8.dp))
@@ -174,7 +306,9 @@ fun DirectorySettingItem(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
shape = MaterialTheme.shapes.medium
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.cardColors(containerColor = Color.Transparent),
border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.onSurfaceVariant),
) {
Row(
modifier = Modifier
@@ -184,8 +318,9 @@ fun DirectorySettingItem(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = folderName,
style = MaterialTheme.typography.bodyLarge
text = folderLabel,
style = MaterialTheme.typography.bodyLarge,
color = folderLabelColor,
)
Icon(
@@ -197,24 +332,22 @@ fun DirectorySettingItem(
}
}
private fun extractFolderName(uriString: String?, context: Context): String {
if (uriString == null) return context.getString(R.string.download_dirname)
return runCatching {
val uri = uriString.toUri()
uri.lastPathSegment?.substringAfter(':')?.substringAfter('/') ?: uriString
}.getOrElse { uriString }
}
@Preview
@Preview(heightDp = 780)
@Composable
fun SettingsScreenPreviewWithoutDir() {
SettingsScreenPreview(SettingsUiState(null))
SettingsScreenPreview(SettingsUiState(
installedOcrLanguages = setOf("fra", "eng", "deu"),
enabledOcrLanguages = setOf("fra", "eng")
))
}
@Preview
@Composable
fun SettingsScreenPreviewWithDir() {
SettingsScreenPreview(SettingsUiState("content://root/dir"))
SettingsScreenPreview(
SettingsUiState(export= ExportSettingsUiState(dirUri = "content://root/dir"))
)
}
@Composable
@@ -222,11 +355,12 @@ fun SettingsScreenPreview(uiState: SettingsUiState) {
FairScanTheme {
SettingsScreen(
uiState,
onDefaultColorModeChanged = {},
onChooseDirectoryClick = {},
onResetExportDirClick = {},
onExportFormatChanged = {},
onExportQualityChanged = {},
onBack = {}
navigation = dummyNavigation(),
)
}
}
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -16,30 +16,77 @@ package org.fairscan.app.ui.screens.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.fairscan.app.AppContainer
import org.fairscan.app.data.OcrLanguage
import org.fairscan.app.domain.ExportQuality
data class SettingsUiState(
val exportDirUri: String? = null,
val exportFormat: ExportFormat = ExportFormat.PDF,
val exportQuality: ExportQuality = ExportQuality.BALANCED,
val defaultColorMode: DefaultColorMode = DefaultColorMode.AUTO,
val export: ExportSettingsUiState = ExportSettingsUiState(),
val installedOcrLanguages: Set<String> = emptySet(),
val enabledOcrLanguages: Set<String> = emptySet(),
val currentDownload: OcrDownloadUiState? = null
)
data class ExportSettingsUiState(
val dirUri: String? = null,
val dirName: String? = null,
val format: ExportFormat = ExportFormat.PDF,
val quality: ExportQuality = ExportQuality.BALANCED,
)
data class OcrDownloadUiState(
val language: OcrLanguage,
val downloadedBytes: Long = 0,
val totalBytes: Long? = null,
val failed: Boolean = false,
)
class SettingsViewModel(container: AppContainer) : ViewModel() {
private val repo = container.settingsRepository
private val ocrLanguageRepo = container.ocrLanguageRepository
private val logger = container.logger
private val _installedLanguages = MutableStateFlow<Set<String>>(emptySet())
private val _ocrDownload = MutableStateFlow<OcrDownloadUiState?>(null)
private var downloadJob: Job? = null
private val _dirName = MutableStateFlow<String?>(null)
val dirName: StateFlow<String?> = _dirName
private val exportSettingsState =
combine(
repo.exportDirUri,
dirName,
repo.exportFormat,
repo.exportQuality,
) {
dirUri, dirName, format, quality ->
ExportSettingsUiState(dirUri, dirName, format, quality)
}
val uiState = combine(
repo.exportDirUri,
repo.exportFormat,
repo.exportQuality,
) { dir, format, quality ->
repo.defaultColorMode,
exportSettingsState,
_installedLanguages,
ocrLanguageRepo.enabledLanguages,
_ocrDownload,
) { colorMode, exportSettings, installed, enabled, download ->
SettingsUiState(
exportDirUri = dir,
exportFormat = format,
exportQuality = quality,
defaultColorMode = colorMode,
export = exportSettings,
installedOcrLanguages = installed,
enabledOcrLanguages = enabled,
currentDownload = download,
)
}.stateIn(
viewModelScope,
@@ -47,9 +94,27 @@ class SettingsViewModel(container: AppContainer) : ViewModel() {
SettingsUiState()
)
private suspend fun refreshInstalledLanguages() {
_installedLanguages.value =
ocrLanguageRepo.getInstalledLanguages()
}
init {
viewModelScope.launch {
refreshInstalledLanguages()
}
}
fun setDefaultColorMode(pref: DefaultColorMode) {
viewModelScope.launch {
repo.setDefaultColorMode(pref)
}
}
fun setExportDirUri(uri: String?) {
viewModelScope.launch {
repo.setExportDirUri(uri)
refreshExportDirName()
}
}
@@ -64,4 +129,61 @@ class SettingsViewModel(container: AppContainer) : ViewModel() {
repo.setExportQuality(quality)
}
}
fun refreshExportDirName() {
viewModelScope.launch {
val uri = repo.exportDirUri.first()
_dirName.value = uri?.let { repo.resolveExportDirName(it) }
}
}
fun onLanguageClick(code: String) {
if (uiState.value.installedOcrLanguages.contains(code)) {
setOcrLanguageEnabled(code, !uiState.value.enabledOcrLanguages.contains(code))
} else {
installLanguage(code)
}
}
fun installLanguage(code: String) {
downloadJob?.cancel()
downloadJob = viewModelScope.launch {
_ocrDownload.value = OcrDownloadUiState(OcrLanguage(code))
try {
ocrLanguageRepo.downloadLanguage(code) { progress ->
_ocrDownload.value =
_ocrDownload.value?.copy(
downloadedBytes = progress.downloadedBytes,
totalBytes = progress.totalBytes,
)
}
ocrLanguageRepo.setLanguageEnabled(code, true)
refreshInstalledLanguages()
_ocrDownload.value = null
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
logger.e("OcrDownload", "Download failed for $code", e)
_ocrDownload.value = _ocrDownload.value?.copy(failed = true)
}
}
}
fun cancelOcrDownload() {
downloadJob?.cancel()
_ocrDownload.value = null
}
fun setOcrLanguageEnabled(code: String, enabled: Boolean) {
viewModelScope.launch {
ocrLanguageRepo.setLanguageEnabled(code, enabled)
}
}
fun onRemoveLanguage(code: String) {
viewModelScope.launch {
ocrLanguageRepo.deleteLanguage(code)
refreshInstalledLanguages()
}
}
}
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -16,29 +16,28 @@ package org.fairscan.app.ui.state
import android.graphics.Bitmap
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import org.fairscan.app.domain.Jpeg
import org.fairscan.app.domain.PageViewKey
data class DocumentUiModel(
val pageKeys: ImmutableList<PageViewKey>,
private val imageLoader: (PageViewKey) -> Bitmap?,
private val thumbnailLoader: (PageViewKey) -> Bitmap?
val pages: ImmutableList<PageThumbnail> = persistentListOf(),
) {
fun pageCount(): Int {
return pageKeys.size
}
fun pageId(index: Int): String {
return pageKeys[index].pageId
return pages.size
}
fun isEmpty(): Boolean {
return pageKeys.isEmpty()
return pages.isEmpty()
}
fun lastIndex(): Int {
return pageKeys.lastIndex
return pages.lastIndex
}
fun load(index: Int): Bitmap? {
return imageLoader(pageKeys[index])
fun thumbnail(index: Int): Bitmap? {
return pages.getOrNull(index)?.thumbnail?.toBitmap()
}
fun loadThumbnail(index: Int): Bitmap? {
return thumbnailLoader(pageKeys[index])
}
}
}
data class PageThumbnail(
val key: PageViewKey,
val thumbnail: Jpeg?,
)
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
@@ -1,5 +1,5 @@
/*
* Copyright 2025-2026 Pierre-Yves Nicolas
* Copyright 2025-2026 The FairScan authors
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License as published by the Free
-16
View File
@@ -1,16 +0,0 @@
syntax = "proto3";
option java_package = "org.fairscan.app";
option java_multiple_files = true;
message RecentDocument {
string file_path = 1;
int64 created_at = 2; // timestamp in ms
int32 page_count = 3;
string file_uri = 4;
string file_name = 5;
}
message RecentDocuments {
repeated RecentDocument documents = 1;
}
+114
View File
@@ -0,0 +1,114 @@
<resources>
<string name="about">عن</string>
<string name="add_page">أضف صفحة</string>
<string name="app_tagline">تطبيق بسيط ومحترم لمسح مستنداتك ضوئيًا.</string>
<string name="apply">تطبيق</string>
<string name="back">ارجع</string>
<string name="camera_permission_denied">رُفض إذن الوصول إلى الكاميرا</string>
<string name="camera_permission_rationale">يتطلب التطبيق الوصول إلى الكاميرا لمسح المستندات ضوئيًا. تُخزن الصور الملتقطة على هذا الجهاز فقط، وسيتم حذفها عند إغلاق المسح الحالي.</string>
<string name="cancel">ألغِ</string>
<string name="change_directory">غيّر المجلد</string>
<string name="clear_text">امحُ النص</string>
<string name="close">إغلاق</string>
<string name="color_mode">فلتر</string>
<string name="color_mode_auto">تلقائي</string>
<string name="color_mode_color">ألوان</string>
<string name="color_mode_default">الفلتر الافتراضي</string>
<string name="color_mode_grayscale">تدرج الرمادي</string>
<string name="contact">تواصل</string>
<string name="copied_logs">نُسخت السجلات إلى الحافظة</string>
<string name="copy_logs">انسخ السجلات</string>
<string name="creating_export">يجهز التصدير…</string>
<string name="crop">اقتصاص</string>
<string name="delete">حذف</string>
<string name="delete_page">احذف الصفحة</string>
<string name="delete_page_warning">هل تريد حذف هذه الصفحة؟</string>
<string name="developer">مطوّر</string>
<string name="download_dirname">التنزيلات</string>
<string name="error_context_folder">مجلد التصدير: %1$s</string>
<string name="error_context_provider">المزود: %1$s</string>
<string name="error_export_dir_permission_lost">لم يعد من الممكن الوصول إلى مجلد التصدير المحدّد. يُرجى اختيار مجلد آخر.</string>
<string name="error_no_app">لم يُعثر على تطبيق لفتح هذا الملف</string>
<string name="error_occurred">حدث خطأ</string>
<string name="error_save">فشل حفظ الملف</string>
<string name="export">صدِّر</string>
<string name="export_as">صدِّر ك %1$s</string>
<string name="export_directory">مجلد التصدير</string>
<string name="export_folder_permission_lost">فُقدت صلاحية الوصول إلى المجلد</string>
<string name="export_format">صيغة التصدير</string>
<string name="export_quality">جودة تصدير</string>
<string name="export_quality_low">منخفضة</string>
<string name="export_quality_balanced">متوازنة</string>
<string name="export_quality_high">عالية</string>
<string name="file_size">حجم الملف: %1$s</string>
<string name="file_size_total">الحجم الكلي: %1$s</string>
<string name="filename">اسم الملف</string>
<string name="grant_permission">امنح الأذن</string>
<string name="import_photos">استيراد</string>
<string name="libraries">المكتبات</string>
<string name="libraries_open_source">مكتبات مفتوحة المصدر</string>
<string name="license">الترخيص</string>
<string name="licensed_under">هذا التطبيق مرخص بموجب رخصة جنو العمومية العامة، الإصدار 3.0.</string>
<string name="menu">قائمة</string>
<string name="new_document_warning">سيتم فقدان عملية المسح الحالية. أتريد الاستمرار؟</string>
<string name="open">افتح</string>
<string name="open_file">افتح الملف</string>
<string name="reset_to_default">صفّر للإعدادات المبدئية</string>
<string name="rotate_left">تدوير إلى اليسار</string>
<string name="rotate_right">تدوير إلى اليمين</string>
<string name="save">احفظ</string>
<string name="scan_new">مسح جديد</string>
<string name="scan_current">المسح الحالي</string>
<string name="scan_discard_current">تجاهل المسح الحالي</string>
<string name="scan_resume">متابعة المسح</string>
<string name="settings">الإعدادات</string>
<string name="settings_ocr_download_error">فشل التنزيل. تحقق من اتصال الإنترنت وتأكد من السماح لهذا التطبيق بالوصول إلى الشبكة.</string>
<string name="settings_ocr_download_intro">يتم تنزيل ملفات اللغة من مشروع Tesseract الرسمي. تبقى عملية التعرّف على النصوص دون اتصال بالإنترنت بالكامل.</string>
<string name="settings_ocr_downloading">جارٍ تنزيل ملف اللغة</string>
<string name="settings_ocr_languages">اللغات</string>
<string name="settings_ocr_languages_available">اللغات المتاحة</string>
<string name="settings_ocr_languages_disabled">معطّل</string>
<string name="settings_ocr_languages_installed">مثبّتة</string>
<string name="settings_ocr_suggested">مقترح</string>
<string name="settings_section_ocr">التعرّف على النصوص</string>
<string name="settings_section_scan">المسح</string>
<string name="settings_section_export">التصدير</string>
<string name="share">شارك</string>
<string name="share_document">شارك المستند</string>
<string name="storage_permission_denied">تعذر حفظ الملف: رُفض الإذن</string>
<string name="support">المساعدة</string>
<string name="support_last_image">أبلِغ عن مشكلة في آخر صورة مُلتقطَة </string>
<string name="text_recognition_disabled">التعرّف على النصوص: معطّل</string>
<string name="text_recognition_enabled">التعرّف على النصوص: مفعّل</string>
<string name="turn_off_torch">أطفئ الكشاف</string>
<string name="turn_on_torch">شغّل الكشاف</string>
<string name="unknown_size">حجم مجهول</string>
<string name="version">الإصدار</string>
<string name="view_full_list">اعرض القائمة الكاملة</string>
<string name="view_the_full_license">اعرض الترخيص الكامل</string>
<string name="yes">نعم</string>
<plurals name="files_saved_to">
<item quantity="zero">لم يُحفظ أي ملفات في %3$s</item>
<item quantity="one">حُفظ %2$s في %3$s</item>
<item quantity="two">حُفظ ملفين في %3$s</item>
<item quantity="few">حُفظ %1$d ملفات في %3$s</item>
<item quantity="many">حُفظ %1$d ملفًا في %3$s</item>
<item quantity="other">حُفظ %1$d ملف في %3$s</item>
</plurals>
<plurals name="importing_photos">
<item quantity="zero">جارٍ استيراد 0 صورة</item>
<item quantity="one">جارٍ استيراد صورة واحدة</item>
<item quantity="two">جارٍ استيراد صورتين</item>
<item quantity="few">جارٍ استيراد %d صور</item>
<item quantity="many">جارٍ استيراد %d صورة</item>
<item quantity="other">جارٍ استيراد %d صورة</item>
</plurals>
<plurals name="page_count">
<item quantity="zero">لا صفحات</item>
<item quantity="one">%d صفحة</item>
<item quantity="two">%d صفحتان</item>
<item quantity="few">%d صفحات</item>
<item quantity="many">%d صفحة</item>
<item quantity="other">%d صفحة</item>
</plurals>
</resources>
+41 -9
View File
@@ -2,29 +2,39 @@
<string name="about">O aplikaci</string>
<string name="add_page">Přidat stránku</string>
<string name="app_tagline">Jednoduchá a respektující aplikace pro skenování vašich dokumentů</string>
<string name="apply">Použít</string>
<string name="back">Zpět</string>
<string name="camera_permission_denied">Byl odepřen přístup k fotoaparátu</string>
<string name="camera_permission_rationale">Aby bylo možné skenovat dokumenty, tato aplikace potřebuje přístup k fotoaparátu. Nasnímané obrázky jsou ukládány pouze do tohoto zařízení a budou smazány, když zavřete stávající dokument.</string>
<string name="camera_permission_rationale">Aby bylo možné skenovat dokumenty, tato aplikace potřebuje přístup k fotoaparátu. Nasnímané obrázky jsou ukládány pouze do tohoto zařízení a budou smazány, když ukončíte aktuální sken.</string>
<string name="cancel">Zrušit</string>
<string name="change_directory">Změnit složku</string>
<string name="clear_text">Smazat text</string>
<string name="close">Zavřít</string>
<string name="color_mode">Filtr</string>
<string name="color_mode_auto">Automaticky</string>
<string name="color_mode_color">Barva</string>
<string name="color_mode_default">Výchozí filtr</string>
<string name="color_mode_grayscale">Odstíny šedi</string>
<string name="contact">Kontakt</string>
<string name="copied_logs">Protokoly zkopírovány do schránky</string>
<string name="copy_logs">Kopírovat protokoly</string>
<string name="creating_export">Příprava exportu…</string>
<string name="crop">Oříznout</string>
<string name="delete">Smazat</string>
<string name="delete_page">Smazat stránku</string>
<string name="delete_page_warning">Chcete smazat tuto stránku?</string>
<string name="developer">Vývojář</string>
<string name="discard_scan">Zrušit skenování</string>
<string name="download_dirname">stažených</string>
<string name="end_scan">Ukončit skenování</string>
<string name="error">Chyba: %1$s</string>
<string name="error_context_folder">Složka exportu: %1$s</string>
<string name="error_context_provider">Poskytovatel: %1$s</string>
<string name="error_export_dir_permission_lost">Vybraná složka pro export již není dostupná. Vyberte prosím jinou složku.</string>
<string name="error_no_app">Nebyla nalezena žádná aplikace pro otevření tohoto souboru</string>
<string name="error_no_document">Nebyl rozpoznán žádná dokument</string>
<string name="error_occurred">Došlo k chybě</string>
<string name="error_save">Soubor se nepodařilo uložit</string>
<string name="export">Exportovat</string>
<string name="export_as">Exportovat jako %1$s</string>
<string name="export_directory">Složka pro export</string>
<string name="export_folder_permission_lost">Ztracený přístup ke složce</string>
<string name="export_format">Formát exportu</string>
<string name="export_quality">Kvalita exportu</string>
<string name="export_quality_low">Nízká</string>
@@ -34,7 +44,7 @@
<string name="file_size_total">Celková velikost: %1$s</string>
<string name="filename">Název souboru</string>
<string name="grant_permission">Povolit přístup</string>
<string name="last_saved_pdf_files">Poslední PDF uložené v tomto zařízení:</string>
<string name="import_photos">Import</string>
<string name="libraries">Kníhovny</string>
<string name="libraries_open_source">Open-source knihovny</string>
<string name="license">Licence</string>
@@ -44,16 +54,32 @@
<string name="open">Otevřít</string>
<string name="open_file">Otevřít soubor</string>
<string name="reset_to_default">Obnovit výchozí</string>
<string name="resume">Obnovit</string>
<string name="rotate_left">Otočit doleva</string>
<string name="rotate_right">Otočit doprava</string>
<string name="save">Uložit</string>
<string name="scan_button">Nové skenování</string>
<string name="scan_in_progress">Probíhá skenování</string>
<string name="scan_new">Nové skenování</string>
<string name="scan_current">Aktuální sken</string>
<string name="scan_discard_current">Zahodit aktuální sken</string>
<string name="scan_resume">Pokračovat ve skenování</string>
<string name="settings">Nastavení</string>
<string name="settings_ocr_download_error">Stahování se nezdařilo. Zkontrolujte připojení k internetu a ujistěte se, že má tato aplikace povolený přístup k síti.</string>
<string name="settings_ocr_download_intro">Jazykové soubory jsou stahovány z oficiálního projektu Tesseract. Rozpoznávání textu zůstává zcela offline.</string>
<string name="settings_ocr_downloading">Stahování jazykového souboru</string>
<string name="settings_ocr_languages">Jazyky</string>
<string name="settings_ocr_languages_available">Dostupné jazyky</string>
<string name="settings_ocr_languages_disabled">Vypnuto</string>
<string name="settings_ocr_languages_installed">Nainstalované</string>
<string name="settings_ocr_suggested">Doporučeno</string>
<string name="settings_section_ocr">Rozpoznávání textu</string>
<string name="settings_section_scan">Skenování</string>
<string name="settings_section_export">Export</string>
<string name="share">Sdílet</string>
<string name="share_document">Sdílet dokument</string>
<string name="storage_permission_denied">Nelze uložit soubor: oprávnění bylo odmítnuto</string>
<string name="support">Podpora</string>
<string name="support_last_image">Nahlásit problém s posledním zachyceným obrázkem</string>
<string name="text_recognition_disabled">Rozpoznávání textu: vypnuto</string>
<string name="text_recognition_enabled">Rozpoznávání textu: zapnuto</string>
<string name="turn_off_torch">Vypnout svítilnu</string>
<string name="turn_on_torch">Zapnout svítilnu</string>
<string name="unknown_size">Neznámá velikost</string>
@@ -67,6 +93,12 @@
<item quantity="many">%1$d souborů uloženo do %3$s</item>
<item quantity="other">%1$d souborů uloženo do %3$s</item>
</plurals>
<plurals name="importing_photos">
<item quantity="one">Importování 1 fotografie</item>
<item quantity="few">Importování %d fotografií</item>
<item quantity="many">Importování %d fotografií</item>
<item quantity="other">Importování %d fotografií</item>
</plurals>
<plurals name="page_count">
<item quantity="one">%d stránka</item> <!-- 1 -->
<item quantity="few">%d stránky</item> <!-- 24 -->
+39 -9
View File
@@ -2,29 +2,39 @@
<string name="about">Über</string>
<string name="add_page">Seite hinzufügen</string>
<string name="app_tagline">Eine einfache und respektvolle App zum Scannen Ihrer Dokumente.</string>
<string name="apply">Anwenden</string>
<string name="back">Zurück</string>
<string name="camera_permission_denied">Kamerazugriff wurde verweigert</string>
<string name="camera_permission_rationale">Die App benötigt Zugriff auf die Kamera, um Dokumente zu scannen. Aufgenommene Bilder werden nur auf diesem Gerät gespeichert und beim Schließen des aktuellen Dokuments gelöscht.</string>
<string name="camera_permission_rationale">Die App benötigt Zugriff auf die Kamera, um Dokumente zu scannen. Aufgenommene Bilder werden nur auf diesem Gerät gespeichert und beim Schließen des aktuellen Scans gelöscht.</string>
<string name="cancel">Abbrechen</string>
<string name="change_directory">Ordner ändern</string>
<string name="clear_text">Text löschen</string>
<string name="close">Schließen</string>
<string name="color_mode">Filter</string>
<string name="color_mode_auto">Automatisch</string>
<string name="color_mode_color">Farbe</string>
<string name="color_mode_default">Standardfilter</string>
<string name="color_mode_grayscale">Graustufen</string>
<string name="contact">Kontakt</string>
<string name="copied_logs">Logs in die Zwischenablage kopiert</string>
<string name="copy_logs">Logs kopieren</string>
<string name="creating_export">Export wird vorbereitet…</string>
<string name="crop">Zuschneiden</string>
<string name="delete">Löschen</string>
<string name="delete_page">Seite löschen</string>
<string name="delete_page_warning">Möchten Sie diese Seite löschen?</string>
<string name="developer">Entwickler</string>
<string name="discard_scan">Löschen</string>
<string name="download_dirname">Downloads</string>
<string name="end_scan">Scan beenden</string>
<string name="error">Fehler: %1$s</string>
<string name="error_context_folder">Exportordner: %1$s</string>
<string name="error_context_provider">Anbieter: %1$s</string>
<string name="error_export_dir_permission_lost">Der ausgewählte Exportordner ist nicht mehr zugänglich. Bitte wählen Sie einen anderen Ordner.</string>
<string name="error_no_app">Keine App zum Öffnen dieser Datei gefunden</string>
<string name="error_no_document">Kein Dokument erkannt</string>
<string name="error_occurred">Ein Fehler ist aufgetreten</string>
<string name="error_save">Datei konnte nicht gespeichert werden</string>
<string name="export">Exportieren</string>
<string name="export_as">Als %1$s exportieren</string>
<string name="export_directory">Exportordner</string>
<string name="export_folder_permission_lost">Zugriff auf Ordner verloren</string>
<string name="export_format">Exportformat</string>
<string name="export_quality">Exportqualität</string>
<string name="export_quality_low">Niedrig</string>
@@ -34,7 +44,7 @@
<string name="file_size_total">Gesamtgröße: %1$s</string>
<string name="filename">Dateiname</string>
<string name="grant_permission">Berechtigung erteilen</string>
<string name="last_saved_pdf_files">Zuletzt auf diesem Gerät gespeicherte PDFs:</string>
<string name="import_photos">Importieren</string>
<string name="libraries">Bibliotheken</string>
<string name="libraries_open_source">Open-Source-Bibliotheken</string>
<string name="license">Lizenz</string>
@@ -44,16 +54,32 @@
<string name="open">Öffnen</string>
<string name="open_file">Datei öffnen</string>
<string name="reset_to_default">Auf Standard zurücksetzen</string>
<string name="resume">Fortsetzen</string>
<string name="rotate_left">Nach links drehen</string>
<string name="rotate_right">Nach rechts drehen</string>
<string name="save">Speichern</string>
<string name="scan_button">Neuer Scan</string>
<string name="scan_in_progress">Scan läuft</string>
<string name="scan_new">Neuer Scan</string>
<string name="scan_current">Aktueller Scan</string>
<string name="scan_discard_current">Aktuellen Scan verwerfen</string>
<string name="scan_resume">Scan fortsetzen</string>
<string name="settings">Einstellungen</string>
<string name="settings_ocr_download_error">Download fehlgeschlagen. Überprüfen Sie Ihre Internetverbindung und stellen Sie sicher, dass der Netzwerkzugriff für diese App erlaubt ist.</string>
<string name="settings_ocr_download_intro">Sprachdateien werden vom offiziellen Tesseract-Projekt heruntergeladen. Die Texterkennung bleibt vollständig offline.</string>
<string name="settings_ocr_downloading">Sprachdatei wird heruntergeladen</string>
<string name="settings_ocr_languages">Sprachen</string>
<string name="settings_ocr_languages_available">Verfügbare Sprachen</string>
<string name="settings_ocr_languages_disabled">Deaktiviert</string>
<string name="settings_ocr_languages_installed">Installiert</string>
<string name="settings_ocr_suggested">Empfohlen</string>
<string name="settings_section_ocr">Texterkennung</string>
<string name="settings_section_scan">Scan</string>
<string name="settings_section_export">Export</string>
<string name="share">Teilen</string>
<string name="share_document">Dokument teilen</string>
<string name="storage_permission_denied">Datei kann nicht gespeichert werden: Berechtigung verweigert</string>
<string name="support">Support</string>
<string name="support_last_image">Problem mit dem zuletzt aufgenommenen Bild melden</string>
<string name="text_recognition_disabled">Texterkennung: deaktiviert</string>
<string name="text_recognition_enabled">Texterkennung: aktiviert</string>
<string name="turn_off_torch">Taschenlampe ausschalten</string>
<string name="turn_on_torch">Taschenlampe einschalten</string>
<string name="unknown_size">Unbekannte Größe</string>
@@ -65,6 +91,10 @@
<item quantity="one">%2$s gespeichert in %3$s</item>
<item quantity="other">%1$d Dateien gespeichert in %3$s</item>
</plurals>
<plurals name="importing_photos">
<item quantity="one">1 Foto wird importiert</item>
<item quantity="other">%d Fotos werden importiert</item>
</plurals>
<plurals name="page_count">
<item quantity="one">%d Seite</item>
<item quantity="other">%d Seiten</item>
+39 -9
View File
@@ -2,29 +2,39 @@
<string name="about">Acerca de</string>
<string name="add_page">Agregar página</string>
<string name="app_tagline">Una aplicación sencilla y respetuosa para escanear tus documentos.</string>
<string name="apply">Aplicar</string>
<string name="back">Atrás</string>
<string name="camera_permission_denied">Permiso de cámara denegado</string>
<string name="camera_permission_rationale">La aplicación necesita acceso a la cámara para escanear documentos. Las imágenes capturadas se guardan solo en este dispositivo y se eliminarán cuando cierres el documento actual.</string>
<string name="camera_permission_rationale">La aplicación necesita acceso a la cámara para escanear documentos. Las imágenes capturadas se guardan solo en este dispositivo y se eliminarán cuando cierres el escaneo actual.</string>
<string name="cancel">Cancelar</string>
<string name="change_directory">Cambiar carpeta</string>
<string name="clear_text">Borrar texto</string>
<string name="close">Cerrar</string>
<string name="color_mode">Filtro</string>
<string name="color_mode_auto">Automático</string>
<string name="color_mode_color">Color</string>
<string name="color_mode_default">Filtro predeterminado</string>
<string name="color_mode_grayscale">Escala de grises</string>
<string name="contact">Contacto</string>
<string name="copied_logs">Registros copiados al portapapeles</string>
<string name="copy_logs">Copiar registros</string>
<string name="creating_export">Preparando la exportación…</string>
<string name="crop">Recortar</string>
<string name="delete">Eliminar</string>
<string name="delete_page">Eliminar página</string>
<string name="delete_page_warning">¿Quieres eliminar esta página?</string>
<string name="developer">Desarrollador</string>
<string name="discard_scan">Descartar escaneo</string>
<string name="download_dirname">Descargas</string>
<string name="end_scan">Finalizar escaneo</string>
<string name="error">Error: %1$s</string>
<string name="error_context_folder">Carpeta de exportación: %1$s</string>
<string name="error_context_provider">Proveedor: %1$s</string>
<string name="error_export_dir_permission_lost">La carpeta de exportación seleccionada ya no es accesible. Por favor, elija otra carpeta.</string>
<string name="error_no_app">No se encontró ninguna aplicación para abrir este archivo</string>
<string name="error_no_document">No se detectó ningún documento</string>
<string name="error_occurred">Se produjo un error</string>
<string name="error_save">No se pudo guardar el archivo</string>
<string name="export">Exportar</string>
<string name="export_as">Exportar como %1$s</string>
<string name="export_directory">Carpeta de exportación</string>
<string name="export_folder_permission_lost">Acceso a la carpeta perdido</string>
<string name="export_format">Formato de exportación</string>
<string name="export_quality">Calidad de exportación</string>
<string name="export_quality_low">Baja</string>
@@ -34,7 +44,7 @@
<string name="file_size_total">Tamaño total: %1$s</string>
<string name="filename">Nombre del archivo</string>
<string name="grant_permission">Conceder permiso</string>
<string name="last_saved_pdf_files">PDF recientes guardados en este dispositivo:</string>
<string name="import_photos">Importar</string>
<string name="libraries">Bibliotecas</string>
<string name="libraries_open_source">Bibliotecas de código abierto</string>
<string name="license">Licencia</string>
@@ -44,16 +54,32 @@
<string name="open">Abrir</string>
<string name="open_file">Abrir archivo</string>
<string name="reset_to_default">Restablecer valores predeterminados</string>
<string name="resume">Reanudar</string>
<string name="rotate_left">Girar a la izquierda</string>
<string name="rotate_right">Girar a la derecha</string>
<string name="save">Guardar</string>
<string name="scan_button">Nuevo escaneo</string>
<string name="scan_in_progress">Escaneo en curso</string>
<string name="scan_new">Nuevo escaneo</string>
<string name="scan_current">Escaneo actual</string>
<string name="scan_discard_current">Descartar el escaneo actual</string>
<string name="scan_resume">Reanudar escaneo</string>
<string name="settings">Ajustes</string>
<string name="settings_ocr_download_error">Descarga fallida. Comprueba tu conexión a Internet y asegúrate de que esta aplicación tenga permitido el acceso a la red.</string>
<string name="settings_ocr_download_intro">Los archivos de idioma se descargan desde el proyecto oficial Tesseract. El reconocimiento de texto sigue siendo completamente local.</string>
<string name="settings_ocr_downloading">Descargando archivo de idioma</string>
<string name="settings_ocr_languages">Idiomas</string>
<string name="settings_ocr_languages_available">Idiomas disponibles</string>
<string name="settings_ocr_languages_disabled">Desactivado</string>
<string name="settings_ocr_languages_installed">Instalados</string>
<string name="settings_ocr_suggested">Sugerido</string>
<string name="settings_section_ocr">Reconocimiento de texto</string>
<string name="settings_section_scan">Escaneo</string>
<string name="settings_section_export">Exportación</string>
<string name="share">Compartir</string>
<string name="share_document">Compartir documento</string>
<string name="storage_permission_denied">No se puede guardar el archivo: permiso denegado</string>
<string name="support">Soporte</string>
<string name="support_last_image">Informar de un problema con la última imagen capturada</string>
<string name="text_recognition_disabled">Reconocimiento de texto: desactivado</string>
<string name="text_recognition_enabled">Reconocimiento de texto: activado</string>
<string name="turn_off_torch">Apagar linterna</string>
<string name="turn_on_torch">Encender linterna</string>
<string name="unknown_size">Tamaño desconocido</string>
@@ -65,6 +91,10 @@
<item quantity="one">%2$s guardado en %3$s</item>
<item quantity="other">%1$d archivos guardados en %3$s</item>
</plurals>
<plurals name="importing_photos" tools:ignore="MissingQuantity">
<item quantity="one">Importando 1 foto</item>
<item quantity="other">Importando %d fotos</item>
</plurals>
<plurals name="page_count" tools:ignore="MissingQuantity">
<item quantity="one">%d página</item>
<item quantity="other">%d páginas</item>
+103
View File
@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="about">Rakenduse teave</string>
<string name="add_page">Lisa leht</string>
<string name="app_tagline">Lihtsaltkasutatav ja viisakas rakendus dokumentide skaneerimiseks.</string>
<string name="apply">Rakenda</string>
<string name="back">Tagasi</string>
<string name="camera_permission_denied">Kaamera kasutamise õigused on puudu</string>
<string name="camera_permission_rationale">Et dokumentide skaneerimine toimiks, vajab see rakendus õigust kasutada kaamerat. Pildihõive käigus jäädvustatud pildid salvestuvad vaid siin selles seadmes ja kustutatakse töösoleva skaneerimise lõppedes.</string>
<string name="cancel">Katkesta</string>
<string name="change_directory">Muuda kausta</string>
<string name="clear_text">Eemalda tekst</string>
<string name="close">Sulge</string>
<string name="color_mode">Filtreeri</string>
<string name="color_mode_auto">Automaatne</string>
<string name="color_mode_color">Värvid</string>
<string name="color_mode_default">Vaikimisi filter</string>
<string name="color_mode_grayscale">Halltoonides</string>
<string name="contact">Kontakt</string>
<string name="copied_logs">Logid on kopeeritud lõikelauale</string>
<string name="copy_logs">Kopeeri logid</string>
<string name="creating_export">Valmistun eksportima…</string>
<string name="crop">Kadreeri</string>
<string name="delete">Kustuta</string>
<string name="delete_page">Lehe kustutamine</string>
<string name="delete_page_warning">Kas kindlasti soovid selle lehe kustutada?</string>
<string name="developer">Arendaja</string>
<string name="download_dirname">Allalaadimised</string>
<string name="error_context_folder">Ekspordikaust: %1$s</string>
<string name="error_context_provider">Teenusepakkuja: %1$s</string>
<string name="error_export_dir_permission_lost">Valitud ekspordikaust pole enam kättesaadav. Palun vali mõnu muu kaust.</string>
<string name="error_no_app">Salle faili avamiseks ei leidu rakendust</string>
<string name="error_occurred">Tekkis viga</string>
<string name="error_save">Faili salvestamine ei õnnestunud</string>
<string name="export">Ekspordi</string>
<string name="export_as">Eksportimise vorming: %1$s</string>
<string name="export_directory">Ekspordikaust</string>
<string name="export_folder_permission_lost">Ligipääs kaustale on kadunud</string>
<string name="export_format">Ekspordivorming</string>
<string name="export_quality">Ekspordi kvaliteet</string>
<string name="export_quality_low">Madal</string>
<string name="export_quality_balanced">Tasakaalustatud</string>
<string name="export_quality_high">Kõrge</string>
<string name="file_size">Faili suurus: %1$s</string>
<string name="file_size_total">Maht kokku: %1$s</string>
<string name="filename">Failinimi</string>
<string name="grant_permission">Anna vastavad õigused</string>
<string name="import_photos">Impordi</string>
<string name="libraries">Teegid</string>
<string name="libraries_open_source">Avatud lähtekoodil põhinevad teegid</string>
<string name="license">Litsents</string>
<string name="menu">Menüü</string>
<string name="new_document_warning">Hetkel skaneeritud sisu läheb kaotsi. Kas kindlasti tahad jätkata?</string>
<string name="open">Ava</string>
<string name="open_file">Ava fail</string>
<string name="reset_to_default">Taasta algväärtused</string>
<string name="rotate_left">Pööra vasakule</string>
<string name="rotate_right">Pööra paremale</string>
<string name="save">Salvesta</string>
<string name="scan_new">Uus skaneerimine</string>
<string name="scan_current">Praegune skaneerimine</string>
<string name="scan_discard_current">Loobu hetkel skaneeritust</string>
<string name="licensed_under">Käesolev rakendus on litsentseeritud GNU Üldise Avaliku Litsentsi versiooni 3.0 alusel.</string>
<string name="scan_resume">Jätka skaneerimisega</string>
<string name="settings">Seadistused</string>
<string name="settings_ocr_download_error">Allalaadimine ei õnnestunud. Palun vaata, et sinu nutiseadme internetiühendus toimiks ja ligipääs võrgule on selle rakenduse jaoks lubatud.</string>
<string name="settings_ocr_download_intro">Keelefailid on laaditud alla Tesseracti projekti ametlikust veebisaidist. Tekstituvastus toimib täies mahus ilma võrguühendust kasutamata.</string>
<string name="settings_ocr_downloading">Keelefaili allalaadimine</string>
<string name="settings_ocr_languages">Keeled</string>
<string name="settings_ocr_languages_available">Saadaval keeled</string>
<string name="settings_ocr_languages_disabled">Pole kasutusel</string>
<string name="settings_ocr_languages_installed">Paigaldatud</string>
<string name="settings_ocr_suggested">Meie soovitus</string>
<string name="settings_section_ocr">Tekstituvastus</string>
<string name="settings_section_scan">Skaneerimine</string>
<string name="settings_section_export">Eksport</string>
<string name="share">Jaga</string>
<string name="share_document">Jaga dokumenti</string>
<string name="storage_permission_denied">Faili salvestamine ei õnnestu: õigused on puudu</string>
<string name="support">Kasutajatugi</string>
<string name="support_last_image">Teavita viimase pildihõivega seotud veast või probleemist</string>
<string name="text_recognition_disabled">Tekstituvastus: pole kasutusel</string>
<string name="text_recognition_enabled">Tekstituvastus: on kasutusel</string>
<string name="turn_off_torch">Lülita taskulamp välja</string>
<string name="turn_on_torch">Lülita taskulamp sisse</string>
<string name="unknown_size">Tundmatu suurus</string>
<string name="version">Versioon</string>
<string name="view_full_list">Vaata täismahus loendit</string>
<string name="view_the_full_license">Vaata täismahus litsentsi</string>
<string name="yes">Jah</string>
<plurals name="files_saved_to">
<item quantity="one">%2$s fail on salvestatud kausta %3$s</item>
<item quantity="other">%1$d faili on salvestatud kausta %3$s</item>
</plurals>
<plurals name="importing_photos">
<item quantity="one">Importimisel on 1 foto</item>
<item quantity="other">Importimisel on %d fotot</item>
</plurals>
<plurals name="page_count">
<item quantity="one">%d leht</item>
<item quantity="other">%d lehte</item>
</plurals>
</resources>
+41 -10
View File
@@ -1,30 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="about">À propos</string>
<string name="add_page">Ajouter une page</string>
<string name="app_tagline">Une application simple et respectueuse pour scanner vos documents.</string>
<string name="apply">Appliquer</string>
<string name="back">Retour</string>
<string name="camera_permission_denied">L\'autorisation d\'accès à la caméra a été refusée</string>
<string name="camera_permission_rationale">Lapplication a besoin daccéder à lappareil photo pour scanner des documents. Les images capturées sont enregistrées uniquement sur cet appareil et seront supprimées lorsque vous fermerez le document en cours.</string>
<string name="camera_permission_rationale">Lapplication a besoin daccéder à lappareil photo pour scanner des documents. Les images capturées sont enregistrées uniquement sur cet appareil et seront supprimées lorsque vous fermerez le scan en cours.</string>
<string name="cancel">Annuler</string>
<string name="change_directory">Changer de dossier</string>
<string name="clear_text">Effacer le text</string>
<string name="clear_text">Effacer le texte</string>
<string name="close">Fermer</string>
<string name="color_mode">Filtre</string>
<string name="color_mode_auto">Automatique</string>
<string name="color_mode_color">Couleur</string>
<string name="color_mode_default">Filtre par défaut</string>
<string name="color_mode_grayscale">Niveaux de gris</string>
<string name="contact">Contact</string>
<string name="copied_logs">Logs copiés dans le presse-papiers</string>
<string name="copy_logs">Copier les logs</string>
<string name="creating_export">Préparation de lexport…</string>
<string name="crop">Recadrer</string>
<string name="delete">Supprimer</string>
<string name="delete_page">Supprimer la page</string>
<string name="delete_page_warning">Voulez-vous supprimer cette page ?</string>
<string name="developer">Développeur</string>
<string name="discard_scan">Supprimer le scan</string>
<string name="download_dirname">Téléchargements</string>
<string name="end_scan">Terminer le scan</string>
<string name="error">Erreur : %1$s</string>
<string name="error_context_folder">Dossier dexport : %1$s</string>
<string name="error_context_provider">Fournisseur : %1$s</string>
<string name="error_export_dir_permission_lost">Le dossier dexport sélectionné nest plus accessible. Veuillez choisir un autre dossier.</string>
<string name="error_no_app">Aucune application trouvée pour ouvrir ce fichier</string>
<string name="error_no_document">Aucun document détecté</string>
<string name="error_occurred">Une erreur sest produite</string>
<string name="error_save">Échec de l\'enregistrement du fichier</string>
<string name="export">Exporter</string>
<string name="export_as">Exporter en %1$s</string>
<string name="export_directory">Dossier dexport</string>
<string name="export_folder_permission_lost">Accès au dossier perdu</string>
<string name="export_format">Format dexport</string>
<string name="export_quality">Qualité dexport</string>
<string name="export_quality_low">Basse</string>
@@ -34,7 +45,7 @@
<string name="file_size_total">Taille totale : %1$s</string>
<string name="filename">Nom de fichier</string>
<string name="grant_permission">Autoriser</string>
<string name="last_saved_pdf_files">Derniers PDF enregistrés sur lappareil :</string>
<string name="import_photos">Importer</string>
<string name="libraries">Bibliothèques</string>
<string name="libraries_open_source">Bibliothèques open source</string>
<string name="license">Licence</string>
@@ -44,16 +55,32 @@
<string name="open">Ouvrir</string>
<string name="open_file">Ouvrir le fichier</string>
<string name="reset_to_default">Réinitialiser par défaut</string>
<string name="resume">Reprendre</string>
<string name="rotate_left">Pivoter vers la gauche</string>
<string name="rotate_right">Pivoter vers la droite</string>
<string name="save">Enregistrer</string>
<string name="scan_button">Nouveau scan</string>
<string name="scan_in_progress">Scan en cours</string>
<string name="scan_new">Nouveau scan</string>
<string name="scan_current">Scan en cours</string>
<string name="scan_discard_current">Abandonner le scan en cours</string>
<string name="scan_resume">Reprendre le scan</string>
<string name="settings">Paramètres</string>
<string name="settings_ocr_download_error">Échec du téléchargement. Vérifiez votre connexion Internet et assurez-vous que l\'accès au réseau est autorisé pour cette application.</string>
<string name="settings_ocr_download_intro">Les fichiers de langue sont téléchargés depuis le projet officiel Tesseract. La reconnaissance de texte reste entièrement hors ligne.</string>
<string name="settings_ocr_downloading">Téléchargement du fichier de langue</string>
<string name="settings_ocr_languages">Langues</string>
<string name="settings_ocr_languages_available">Langues disponibles</string>
<string name="settings_ocr_languages_disabled">Désactivé</string>
<string name="settings_ocr_languages_installed">Installées</string>
<string name="settings_ocr_suggested">Suggéré</string>
<string name="settings_section_ocr">Reconnaissance de texte</string>
<string name="settings_section_scan">Scan</string>
<string name="settings_section_export">Export</string>
<string name="share">Partager</string>
<string name="share_document">Partager le document</string>
<string name="storage_permission_denied">Impossible denregistrer le fichier : permission refusée</string>
<string name="support">Support</string>
<string name="support_last_image">Signaler un problème avec la dernière image capturée</string>
<string name="text_recognition_disabled">Reconnaissance de texte : désactivée</string>
<string name="text_recognition_enabled">Reconnaissance de texte : activée</string>
<string name="turn_off_torch">Éteindre la torche</string>
<string name="turn_on_torch">Allumer la torche</string>
<string name="unknown_size">Taille inconnue</string>
@@ -65,6 +92,10 @@
<item quantity="one">%2$s enregistré dans %3$s</item>
<item quantity="other">%1$d fichiers enregistrés dans %3$s</item>
</plurals>
<plurals name="importing_photos" tools:ignore="MissingQuantity">
<item quantity="one">Import de %d photo</item>
<item quantity="other">Import de %d photos</item>
</plurals>
<plurals name="page_count" tools:ignore="MissingQuantity">
<item quantity="one">%d page</item>
<item quantity="other">%d pages</item>
+102
View File
@@ -0,0 +1,102 @@
<resources>
<string name="about">Sobre</string>
<string name="add_page">Engadir páxina</string>
<string name="app_tagline">Unha aplicación sinxela e respectuosa para escanear os teus documentos.</string>
<string name="apply">Aplicar</string>
<string name="back">Atrás</string>
<string name="camera_permission_denied">Permiso da cámara denegado</string>
<string name="camera_permission_rationale">A aplicación precisa acceso á cámara para escanear documentos. As imaxes capturadas gárdanse só neste dispositivo e eliminaranse ao pechar o escaneo actual.</string>
<string name="cancel">Cancelar</string>
<string name="change_directory">Trocar cartafol</string>
<string name="clear_text">Borrar texto</string>
<string name="close">Pechar</string>
<string name="color_mode">Filtro</string>
<string name="color_mode_auto">Automático</string>
<string name="color_mode_color">Cor</string>
<string name="color_mode_default">Filtro predeterminado</string>
<string name="color_mode_grayscale">Escala de grises</string>
<string name="contact">Contacto</string>
<string name="copied_logs">Rexistros copiados ao portapapeis</string>
<string name="copy_logs">Copiar rexistros</string>
<string name="creating_export">Preparando exportación…</string>
<string name="crop">Recortar</string>
<string name="delete">Eliminar</string>
<string name="delete_page">Eliminar páxina</string>
<string name="delete_page_warning">Queres eliminar esta páxina?</string>
<string name="developer">Desenvolvedor</string>
<string name="download_dirname">Descargas</string>
<string name="error_context_folder">Cartafol de exportación: %1$s</string>
<string name="error_context_provider">Provedor: %1$s</string>
<string name="error_export_dir_permission_lost">O cartafol de exportación seleccionado xa non é accesible. Escolle outro cartafol.</string>
<string name="error_no_app">Non se atopou ningunha aplicación para abrir este ficheiro</string>
<string name="error_occurred">Produciuse un erro</string>
<string name="error_save">Produciuse un erro ao gardar o ficheiro</string>
<string name="export">Exportar</string>
<string name="export_as">Exportar como %1$s</string>
<string name="export_directory">Cartafol de exportación</string>
<string name="export_folder_permission_lost">Perdeuse o acceso ao cartafol</string>
<string name="export_format">Formato de exportación</string>
<string name="export_quality">Calidade de exportación</string>
<string name="export_quality_low">Baixa</string>
<string name="export_quality_balanced">Equilibrada</string>
<string name="export_quality_high">Alta</string>
<string name="file_size">Tamaño do ficheiro: %1$s</string>
<string name="file_size_total">Tamaño total: %1$s</string>
<string name="filename">Nome do ficheiro</string>
<string name="grant_permission">Conceder permiso</string>
<string name="import_photos">Importar</string>
<string name="libraries">Bibliotecas</string>
<string name="libraries_open_source">Bibliotecas de código aberto</string>
<string name="license">Licenza</string>
<string name="licensed_under">Esta aplicación está licenciada baixo a GNU General Public License v3.0.</string>
<string name="menu">Menú</string>
<string name="new_document_warning">O escaneo actual perderase. Queres continuar?</string>
<string name="open">Abrir</string>
<string name="open_file">Abrir ficheiro</string>
<string name="reset_to_default">Restablecer valores predeterminados</string>
<string name="rotate_left">Xirar á esquerda</string>
<string name="rotate_right">Xirar á dereita</string>
<string name="save">Gardar</string>
<string name="scan_new">Novo escaneo</string>
<string name="scan_current">Escaneo actual</string>
<string name="scan_discard_current">Descartar o escaneo actual</string>
<string name="scan_resume">Retomar escaneo</string>
<string name="settings">Configuración</string>
<string name="settings_ocr_download_error">Produciuse un erro na descarga. Comproba a túa conexión á Internet e asegúrate de que esta aplicación ten permitido o acceso á rede.</string>
<string name="settings_ocr_download_intro">Os ficheiros de idioma descárganse desde o proxecto oficial Tesseract. O recoñecemento de texto segue sendo completamente sen conexión.</string>
<string name="settings_ocr_downloading">Descargando ficheiro de idioma</string>
<string name="settings_ocr_languages">Idiomas</string>
<string name="settings_ocr_languages_available">Idiomas dispoñibles</string>
<string name="settings_ocr_languages_disabled">Desactivado</string>
<string name="settings_ocr_languages_installed">Instalados</string>
<string name="settings_ocr_suggested">Suxerido</string>
<string name="settings_section_ocr">Recoñecemento de texto</string>
<string name="settings_section_scan">Escaneo</string>
<string name="settings_section_export">Exportación</string>
<string name="share">Compartir</string>
<string name="share_document">Compartir documento</string>
<string name="storage_permission_denied">Non se pode gardar o ficheiro: permiso denegado</string>
<string name="support">Soporte</string>
<string name="support_last_image">Informar dun problema coa derradeira imaxe capturada</string>
<string name="text_recognition_disabled">Recoñecemento de texto: desactivado</string>
<string name="text_recognition_enabled">Recoñecemento de texto: activado</string>
<string name="turn_off_torch">Apagar lanterna</string>
<string name="turn_on_torch">Acender lanterna</string>
<string name="unknown_size">Tamaño descoñecido</string>
<string name="version">Versión</string>
<string name="view_full_list">Ver lista completa</string>
<string name="view_the_full_license">Ver a licenza completa</string>
<string name="yes">Si</string>
<plurals name="files_saved_to">
<item quantity="one">%2$s gardado en %3$s</item>
<item quantity="other">%1$d ficheiros gardados en %3$s</item>
</plurals>
<plurals name="importing_photos">
<item quantity="one">Importando 1 foto</item>
<item quantity="other">Importando %d fotos</item>
</plurals>
<plurals name="page_count">
<item quantity="one">%d páxina</item>
<item quantity="other">%d páxinas</item>
</plurals>
</resources>
+102
View File
@@ -0,0 +1,102 @@
<resources>
<string name="about">Névjegy</string>
<string name="add_page">Új oldal</string>
<string name="app_tagline">Egy egyszerű, a felhasználót tiszteletben tartó dokumentum szkenner alkalmazás.</string>
<string name="apply">Alkalmaz</string>
<string name="back">Vissza</string>
<string name="camera_permission_denied">Kamerahozzáférés megtagadva</string>
<string name="camera_permission_rationale">Az alkalmazásnak a dokumentumok beolvasásához kamerahozzáférésre van szüksége. A rögzített képek csak ezen az eszközön kerülnek tárolásra, és törlődnek, amikor bezárod a folyamatban lévő szkennelést.</string>
<string name="cancel">Mégse</string>
<string name="change_directory">Mappaválasztás</string>
<string name="clear_text">Szöveg törlése</string>
<string name="close">Bezárás</string>
<string name="color_mode">Színszűrő</string>
<string name="color_mode_auto">Automatikus</string>
<string name="color_mode_color">Színes</string>
<string name="color_mode_default">Alapértelmezett színszűrő</string>
<string name="color_mode_grayscale">Szürkeárnyalatos</string>
<string name="contact">Kapcsolat</string>
<string name="copied_logs">Napló vágólapra másolva</string>
<string name="copy_logs">Napló másolása</string>
<string name="creating_export">Dokumentum előkészítése…</string>
<string name="crop">Levágás</string>
<string name="delete">Törlés</string>
<string name="delete_page">Oldal törlése</string>
<string name="delete_page_warning">Törölni szeretnéd ezt az oldalt?</string>
<string name="developer">Fejlesztő</string>
<string name="download_dirname">Letöltések</string>
<string name="error_context_folder">Exportálási mappa: %1$s</string>
<string name="error_context_provider">Szolgáltató: %1$s</string>
<string name="error_export_dir_permission_lost">A kijelölt exportálási mappa már nem elérhető. Kérlek válassz másik mappát.</string>
<string name="error_no_app">Nem található alkalmazás a fájl megnyitásához</string>
<string name="error_occurred">Hiba történt</string>
<string name="error_save">Fájl mentése sikertelen</string>
<string name="export">Exportálás</string>
<string name="export_as">%1$s exportálása</string>
<string name="export_directory">Exportálás helye</string>
<string name="export_folder_permission_lost">Mappahozzáférés elveszett</string>
<string name="export_format">Formátum</string>
<string name="export_quality">Minőség</string>
<string name="export_quality_low">Alacsony</string>
<string name="export_quality_balanced">Kiegyensúlyozott</string>
<string name="export_quality_high">Magas</string>
<string name="file_size">Fájlméret: %1$s</string>
<string name="file_size_total">Teljes méret: %1$s</string>
<string name="filename">Fájlnév</string>
<string name="grant_permission">Engedélyezés</string>
<string name="import_photos">Importálás</string>
<string name="libraries">Programkönyvtárak</string>
<string name="libraries_open_source">Nyílt forráskódú programkönyvtárak</string>
<string name="license">Licenc</string>
<string name="licensed_under">Ez az alkalmazás a GNU General Public License v3.0 (GPLv3) licenc alatt érhető el.</string>
<string name="menu">Menü</string>
<string name="new_document_warning">A jelenlegi szkennelés el fog veszni. Biztosan folytatod?</string>
<string name="open">Megnyitás</string>
<string name="open_file">Fájl megnyitása</string>
<string name="reset_to_default">Alapértelmezés visszaállítása</string>
<string name="rotate_left">Forgatás balra</string>
<string name="rotate_right">Forgatás jobbra</string>
<string name="save">Mentés</string>
<string name="scan_new">Új szkennelés</string>
<string name="scan_current">Folyamatban lévő szkennelés</string>
<string name="scan_discard_current">A folyamatban lévő szkennelés elvetése</string>
<string name="scan_resume">Szkennelés folytatása</string>
<string name="settings">Beállítások</string>
<string name="settings_ocr_download_error">A letöltés sikertelen. Ellenőrizd az internetkapcsolatodat, valamint hogy a hálózati hozzáférés engedélyezett-e az alkalmazás számára.</string>
<string name="settings_ocr_download_intro">"A nyelvi fájlok a hivatalos Tesseract projektből kerülnek letöltésre. A szövegfelismerés ezt követően teljes mértékben offline történik."</string>
<string name="settings_ocr_downloading">Nyelvi fájl letöltése</string>
<string name="settings_ocr_languages">Nyelvek</string>
<string name="settings_ocr_languages_available">Elérhető nyelvek</string>
<string name="settings_ocr_languages_disabled">Kikapcsolva</string>
<string name="settings_ocr_languages_installed">Telepítve</string>
<string name="settings_ocr_suggested">Ajánlott</string>
<string name="settings_section_ocr">Szövegfelismerés</string>
<string name="settings_section_scan">Szkennelés</string>
<string name="settings_section_export">Exportálás</string>
<string name="share">Megosztás</string>
<string name="share_document">Dokumentum megosztása</string>
<string name="storage_permission_denied">Fájl mentése sikertelen: engedély megtagadva</string>
<string name="support">Támogatás</string>
<string name="support_last_image">A legutóbb rögzített képpel kapcsolatos probléma jelentése</string>
<string name="text_recognition_disabled">Szövegfelismerés: kikapcsolva</string>
<string name="text_recognition_enabled">Szövegfelismerés: bekapcsolva</string>
<string name="turn_off_torch">Zseblámpa kikapcsolása</string>
<string name="turn_on_torch">Zseblámpa bekapcsolása</string>
<string name="unknown_size">Ismeretlen méret</string>
<string name="version">Verzió</string>
<string name="view_full_list">Teljes lista megtekintése</string>
<string name="view_the_full_license">Teljes licenc megtekintése</string>
<string name="yes">Igen</string>
<plurals name="files_saved_to">
<item quantity="one">%2$s mentve ide: %3$s</item>
<item quantity="other">%1$d fájl mentve ide: %3$s</item>
</plurals>
<plurals name="importing_photos">
<item quantity="one">%d fénykép importálása</item>
<item quantity="other">%d fénykép importálása</item>
</plurals>
<plurals name="page_count">
<item quantity="one">%d oldal</item>
<item quantity="other">%d oldal</item>
</plurals>
</resources>
+102
View File
@@ -0,0 +1,102 @@
<resources>
<string name="about">Tentang</string>
<string name="add_page">Tambahkan halaman</string>
<string name="app_tagline">Aplikasi sederhana dan aman untuk memindai dokumen Anda</string>
<string name="apply">Terapkan</string>
<string name="back">Kembali</string>
<string name="camera_permission_denied">Izin penggunaan kamera ditolak</string>
<string name="camera_permission_rationale">Aplikasi ini memerlukan akses kamera untuk memindai dokumen. Gambar yang diambil hanya disimpan di perangkat ini dan akan dihapus saat Anda menutup pemindaian saat ini.</string>
<string name="cancel">Batalkan</string>
<string name="change_directory">Ganti folder</string>
<string name="clear_text">Bersihkan teks</string>
<string name="close">Tutup</string>
<string name="color_mode">Filter</string>
<string name="color_mode_auto">Otomatis</string>
<string name="color_mode_color">Warna</string>
<string name="color_mode_default">Filter bawaan</string>
<string name="color_mode_grayscale">Abu-abu</string>
<string name="contact">Kontak</string>
<string name="copied_logs">Log disalin ke papan klip</string>
<string name="copy_logs">Salin log</string>
<string name="creating_export">Mempersiapkan ekspor…</string>
<string name="crop">Potong</string>
<string name="delete">Hapus</string>
<string name="delete_page">Hapus halaman</string>
<string name="delete_page_warning">Apakah Anda ingin menghapus halaman ini?</string>
<string name="developer">Pengembang</string>
<string name="download_dirname">Undugan</string>
<string name="error_context_folder">Folder ekspor %1$s</string>
<string name="error_context_provider">Penyedia: %1$s</string>
<string name="error_export_dir_permission_lost">Folder ekspor yang dipilih tidak lagi dapat diakses. Silakan pilih folder lain.</string>
<string name="error_no_app">Tidak ditemukan aplikasi untuk membuka file ini.</string>
<string name="error_occurred">Terjadi kesalahan</string>
<string name="error_save">Gagal menyimpan file</string>
<string name="export">Ekspor</string>
<string name="export_as">Ekspor sebagai %1$s</string>
<string name="export_directory">Folder ekspor</string>
<string name="export_folder_permission_lost">Akses folder hilang</string>
<string name="export_format">Format ekspor</string>
<string name="export_quality">Kualitas ekspor</string>
<string name="export_quality_low">Rendah</string>
<string name="export_quality_balanced">Sedang</string>
<string name="export_quality_high">Tinggi</string>
<string name="file_size">Ukuran file: %1$s</string>
<string name="file_size_total">Ukuran total: %1$s</string>
<string name="filename">Nama file</string>
<string name="grant_permission">Berikan izin</string>
<string name="import_photos">Impor</string>
<string name="libraries">Perpustakaan</string>
<string name="libraries_open_source">Pustaka sumber terbuka</string>
<string name="license">Lisensi</string>
<string name="licensed_under">Aplikasi ini dilisensikan berdasarkan Lisensi Publik Umum GNU v3.0.</string>
<string name="menu">Menu</string>
<string name="new_document_warning">Pemindaian saat ini akan hilang. Apakah Anda ingin melanjutkan?</string>
<string name="open">Buka</string>
<string name="open_file">Buka file</string>
<string name="reset_to_default">Kembalikan ke bawaan</string>
<string name="rotate_left">Putar ke kiri</string>
<string name="rotate_right">Putar ke kanan</string>
<string name="save">Simpan</string>
<string name="scan_new">Pindai baru</string>
<string name="scan_current">Pindaian terkini</string>
<string name="scan_discard_current">Abaikan pemindaian saat ini.</string>
<string name="scan_resume">Ulangi pemindaian</string>
<string name="settings">Pengaturan</string>
<string name="settings_ocr_download_error">Pengunduhan gagal. Periksa koneksi internet Anda dan pastikan akses jaringan diizinkan untuk aplikasi ini.</string>
<string name="settings_ocr_download_intro">"Berkas bahasa diunduh dari proyek resmi Tesseract. Pengenalan teks tetap sepenuhnya offline."</string>
<string name="settings_ocr_downloading">Mengunduh berkas bahasa</string>
<string name="settings_ocr_languages">Bahasa</string>
<string name="settings_ocr_languages_available">Bahasa yang tersedia</string>
<string name="settings_ocr_languages_disabled">Matikan</string>
<string name="settings_ocr_languages_installed">Terpasang</string>
<string name="settings_ocr_suggested">Disarankan</string>
<string name="settings_section_ocr">Pengenalan teks</string>
<string name="settings_section_scan">Pindah</string>
<string name="settings_section_export">Ekspor</string>
<string name="share">Bagikan</string>
<string name="share_document">Bagikan dokumen</string>
<string name="storage_permission_denied">Tidak dapat menyimpan file: izin ditolak.</string>
<string name="support">Dukung</string>
<string name="support_last_image">Laporkan masalah pada gambar yang terakhir diambil</string>
<string name="text_recognition_disabled">Pengenalan teks: matikan</string>
<string name="text_recognition_enabled">Pengenalan teks: diaktifkan</string>
<string name="turn_off_torch">Matikan senter</string>
<string name="turn_on_torch">Hidupkan senter</string>
<string name="unknown_size">Ukuran tidak diketahui</string>
<string name="version">Versi</string>
<string name="view_full_list">Lihat daftar lengkap</string>
<string name="view_the_full_license">Lihat lisensi lengkapnya</string>
<string name="yes">Yes</string>
<plurals name="files_saved_to">
<item quantity="one">%2$s disimpan ke %3$s</item>
<item quantity="other">%1$d berkas yang disimpan ke %3$s</item>
</plurals>
<plurals name="importing_photos">
<item quantity="one">Impor 1 foto</item>
<item quantity="other">Mengimpor %d foto</item>
</plurals>
<plurals name="page_count">
<item quantity="one">%d halaman</item>
<item quantity="other">%d halaman</item>
</plurals>
</resources>
+39 -9
View File
@@ -2,29 +2,39 @@
<string name="about">Informazioni</string>
<string name="add_page">Aggiungi pagina</string>
<string name="app_tagline">Un\'app semplice e rispettosa per scansionare i tuoi documenti.</string>
<string name="apply">Applica</string>
<string name="back">Indietro</string>
<string name="camera_permission_denied">Autorizzazione fotocamera negata</string>
<string name="camera_permission_rationale">L\'app richiede l\'accesso alla fotocamera per scansionare documenti. Le immagini catturate sono salvate solo in questo dispositivo e verranno eliminate quando chiudi il documento attuale.</string>
<string name="camera_permission_rationale">L\'app richiede l\'accesso alla fotocamera per scansionare documenti. Le immagini catturate sono salvate solo in questo dispositivo e verranno eliminate quando chiudi la scansione corrente.</string>
<string name="cancel">Annulla</string>
<string name="change_directory">Cambia cartella</string>
<string name="clear_text">Svuota testo</string>
<string name="close">Chiudi</string>
<string name="color_mode">Filtro</string>
<string name="color_mode_auto">Automatico</string>
<string name="color_mode_color">Colore</string>
<string name="color_mode_default">Filtro predefinito</string>
<string name="color_mode_grayscale">Scala di grigi</string>
<string name="contact">Contatti</string>
<string name="copied_logs">Log copiati negli appunti</string>
<string name="copy_logs">Copia log</string>
<string name="creating_export">Preparazione dellesportazione…</string>
<string name="crop">Ritaglia</string>
<string name="delete">Elimina</string>
<string name="delete_page">Elimina pagina</string>
<string name="delete_page_warning">Vuoi eliminare questa pagina?</string>
<string name="developer">Sviluppatore</string>
<string name="discard_scan">Scarta scansione</string>
<string name="download_dirname">Download</string>
<string name="end_scan">Termina scansione</string>
<string name="error">Errore: %1$s</string>
<string name="error_context_folder">Cartella di esportazione: %1$s</string>
<string name="error_context_provider">Provider: %1$s</string>
<string name="error_export_dir_permission_lost">La cartella di esportazione selezionata non è più accessibile. Scegli unaltra cartella.</string>
<string name="error_no_app">Nessuna app trovata per aprire questo file</string>
<string name="error_no_document">Nessun documento rilevato</string>
<string name="error_occurred">Si è verificato un errore</string>
<string name="error_save">Impossibile salvare il file</string>
<string name="export">Esporta</string>
<string name="export_as">Esporta come %1$s</string>
<string name="export_directory">Cartella di esportazione</string>
<string name="export_folder_permission_lost">Accesso alla cartella perso</string>
<string name="export_format">Formato di esportazione</string>
<string name="export_quality">Qualità di esportazione</string>
<string name="export_quality_low">Bassa</string>
@@ -34,7 +44,7 @@
<string name="file_size_total">Dimensione totale: %1$s</string>
<string name="filename">Nome file</string>
<string name="grant_permission">Concendi autorizzazione</string>
<string name="last_saved_pdf_files">PDF recenti salvati su questo dispositivo:</string>
<string name="import_photos">Importa</string>
<string name="libraries">Librerie</string>
<string name="libraries_open_source">Librerie open source</string>
<string name="license">Licenza</string>
@@ -44,16 +54,32 @@
<string name="open">Apri</string>
<string name="open_file">Apri file</string>
<string name="reset_to_default">Ripristina impostazioni predefinite</string>
<string name="resume">Riprendi</string>
<string name="rotate_left">Ruota a sinistra</string>
<string name="rotate_right">Ruota a destra</string>
<string name="save">Salva</string>
<string name="scan_button">Nuova scansione</string>
<string name="scan_in_progress">Scansione in corso</string>
<string name="scan_new">Nuova scansione</string>
<string name="scan_current">Scansione corrente</string>
<string name="scan_discard_current">Elimina la scansione corrente</string>
<string name="scan_resume">Riprendi scansione</string>
<string name="settings">Impostazioni</string>
<string name="settings_ocr_download_error">Download non riuscito. Controlla la connessione a Internet e assicurati che l\'accesso alla rete sia consentito per questa app.</string>
<string name="settings_ocr_download_intro">I file di lingua vengono scaricati dal progetto ufficiale Tesseract. Il riconoscimento del testo rimane completamente offline.</string>
<string name="settings_ocr_downloading">Download del file di lingua</string>
<string name="settings_ocr_languages">Lingue</string>
<string name="settings_ocr_languages_available">Lingue disponibili</string>
<string name="settings_ocr_languages_disabled">Disattivato</string>
<string name="settings_ocr_languages_installed">Installate</string>
<string name="settings_ocr_suggested">Suggerito</string>
<string name="settings_section_ocr">Riconoscimento del testo</string>
<string name="settings_section_scan">Scansione</string>
<string name="settings_section_export">Esportazione</string>
<string name="share">Condividi</string>
<string name="share_document">Condividi documento</string>
<string name="storage_permission_denied">Impossibile salvare il file: permesso negato</string>
<string name="support">Supporto</string>
<string name="support_last_image">Segnala un problema con lultima immagine acquisita</string>
<string name="text_recognition_disabled">Riconoscimento del testo: disattivato</string>
<string name="text_recognition_enabled">Riconoscimento del testo: attivato</string>
<string name="turn_off_torch">Spegni la torcia</string>
<string name="turn_on_torch">Accendi la torcia</string>
<string name="unknown_size">Dimensione sconosciuta</string>
@@ -65,6 +91,10 @@
<item quantity="one">%2$s salvato in %3$s</item>
<item quantity="other">%1$d file salvati in %3$s</item>
</plurals>
<plurals name="importing_photos" tools:ignore="MissingQuantity">
<item quantity="one">Importazione di 1 foto</item>
<item quantity="other">Importazione di %d foto</item>
</plurals>
<plurals name="page_count" tools:ignore="MissingQuantity">
<item quantity="one">%d pagina</item>
<item quantity="other">%d pagine</item>
+99
View File
@@ -0,0 +1,99 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="about">このアプリについて</string>
<string name="add_page">ページを追加</string>
<string name="app_tagline">シンプルで安心なドキュメントスキャナーです。</string>
<string name="apply">適用</string>
<string name="back">戻る</string>
<string name="camera_permission_denied">カメラの権限が拒否されました</string>
<string name="camera_permission_rationale">ドキュメントをスキャンするためにカメラへのアクセスが必要です。撮影した画像はこのデバイスにのみ保存され、現在のスキャンを閉じると削除されます。</string>
<string name="cancel">キャンセル</string>
<string name="change_directory">フォルダを変更</string>
<string name="clear_text">テキストを消去</string>
<string name="close">閉じる</string>
<string name="color_mode">フィルター</string>
<string name="color_mode_auto">自動</string>
<string name="color_mode_color">カラー</string>
<string name="color_mode_default">デフォルトフィルター</string>
<string name="color_mode_grayscale">グレースケール</string>
<string name="contact">連絡先</string>
<string name="copied_logs">ログをクリップボードにコピーしました</string>
<string name="copy_logs">ログをコピー</string>
<string name="creating_export">エクスポートを準備中…</string>
<string name="crop">トリミング</string>
<string name="delete">削除</string>
<string name="delete_page">ページを削除</string>
<string name="delete_page_warning">このページを削除しますか?</string>
<string name="developer">開発者</string>
<string name="download_dirname">ダウンロード</string>
<string name="error_context_folder">エクスポートフォルダ: %1$s</string>
<string name="error_context_provider">プロバイダー: %1$s</string>
<string name="error_export_dir_permission_lost">選択したエクスポートフォルダにアクセスできなくなりました。別のフォルダを選択してください。</string>
<string name="error_no_app">このファイルを開けるアプリが見つかりません</string>
<string name="error_occurred">エラーが発生しました</string>
<string name="error_save">ファイルの保存に失敗しました</string>
<string name="export">エクスポート</string>
<string name="export_as">%1$s としてエクスポート</string>
<string name="export_directory">エクスポートフォルダ</string>
<string name="export_folder_permission_lost">フォルダへのアクセスが失われました</string>
<string name="export_format">エクスポート形式</string>
<string name="export_quality">エクスポート品質</string>
<string name="export_quality_low"></string>
<string name="export_quality_balanced">バランス</string>
<string name="export_quality_high"></string>
<string name="file_size">ファイルサイズ: %1$s</string>
<string name="file_size_total">合計サイズ: %1$s</string>
<string name="filename">ファイル名</string>
<string name="grant_permission">権限を許可</string>
<string name="import_photos">インポート</string>
<string name="libraries">ライブラリ</string>
<string name="libraries_open_source">オープンソースライブラリ</string>
<string name="license">ライセンス</string>
<string name="licensed_under">このアプリは GNU General Public License v3.0 の下でライセンスされています。</string>
<string name="menu">メニュー</string>
<string name="new_document_warning">現在のスキャンは失われます。続けますか?</string>
<string name="open">開く</string>
<string name="open_file">ファイルを開く</string>
<string name="reset_to_default">デフォルトに戻す</string>
<string name="rotate_left">左に回転</string>
<string name="rotate_right">右に回転</string>
<string name="save">保存</string>
<string name="scan_new">新規スキャン</string>
<string name="scan_current">現在のスキャン</string>
<string name="scan_discard_current">現在のスキャンを破棄</string>
<string name="scan_resume">スキャンを再開</string>
<string name="settings">設定</string>
<string name="settings_ocr_download_error">ダウンロードに失敗しました。インターネット接続を確認し、このアプリのネットワークアクセスが許可されていることを確認してください。</string>
<string name="settings_ocr_download_intro">言語ファイルは公式の Tesseract プロジェクトからダウンロードされます。テキスト認識は完全にオフラインで実行されます。</string>
<string name="settings_ocr_downloading">言語ファイルをダウンロード中</string>
<string name="settings_ocr_languages">言語</string>
<string name="settings_ocr_languages_available">利用可能な言語</string>
<string name="settings_ocr_languages_disabled">無効</string>
<string name="settings_ocr_languages_installed">インストール済み</string>
<string name="settings_ocr_suggested">おすすめ</string>
<string name="settings_section_ocr">テキスト認識</string>
<string name="settings_section_scan">スキャン</string>
<string name="settings_section_export">エクスポート</string>
<string name="share">共有</string>
<string name="share_document">ドキュメントを共有</string>
<string name="storage_permission_denied">権限が拒否されたため、ファイルを保存できません</string>
<string name="support">サポート</string>
<string name="support_last_image">最後に撮影した画像の問題を報告</string>
<string name="text_recognition_disabled">テキスト認識: 無効</string>
<string name="text_recognition_enabled">テキスト認識: 有効</string>
<string name="turn_off_torch">ライトをオフ</string>
<string name="turn_on_torch">ライトをオン</string>
<string name="unknown_size">サイズ不明</string>
<string name="version">バージョン</string>
<string name="view_full_list">全リストを表示</string>
<string name="view_the_full_license">ライセンス全文を表示</string>
<string name="yes">はい</string>
<plurals name="files_saved_to" tools:ignore="MissingQuantity">
<item quantity="other">%3$s に %1$d 件のファイルを保存しました</item>
</plurals>
<plurals name="importing_photos" tools:ignore="MissingQuantity">
<item quantity="other">%d 枚の写真をインポート中</item>
</plurals>
<plurals name="page_count" tools:ignore="MissingQuantity">
<item quantity="other">%d ページ</item>
</plurals>
</resources>
+106
View File
@@ -0,0 +1,106 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="export_quality_balanced">Sabalansēta</string>
<string name="close">Aizvērt</string>
<string name="save">Saglabāt</string>
<string name="add_page">Pievienot lapu</string>
<string name="error_occurred">Radās kļūda</string>
<string name="view_full_list">Skatīt visu sarakstu</string>
<string name="developer">Izstrādātājs</string>
<string name="rotate_left">Pagriezt pa kreisi</string>
<string name="settings_ocr_languages_installed">Uzstādīta</string>
<plurals name="page_count">
<item quantity="zero">%d lapas</item>
<item quantity="one">%d lapa</item>
<item quantity="other">%d lapas</item>
</plurals>
<string name="delete_page">Dzēst lapu</string>
<string name="settings_ocr_downloading">Lejupielādē valodas datni</string>
<string name="text_recognition_enabled">Teksta atpazīšana: iespējota</string>
<string name="yes"></string>
<string name="filename">Datnes nosaukums</string>
<string name="camera_permission_denied">Piekļuve kamerai liegta</string>
<plurals name="files_saved_to">
<item quantity="zero">%1$d datnes saglabātas %3$s mapē</item>
<item quantity="one">%2$s saglabāts %3$s mapē</item>
<item quantity="other">%1$d datnes saglabātas %3$s mapē</item>
</plurals>
<string name="text_recognition_disabled">Teksta atpazīšana: atspējota</string>
<string name="back">Atpakaļ</string>
<string name="delete">Dzēst</string>
<string name="support">Atbalsts</string>
<string name="version">Versija</string>
<string name="export_quality_low">Zema</string>
<string name="export_quality_high">Augsta</string>
<string name="contact">Kontakti</string>
<string name="libraries_open_source">Atvērtā pirmkoda bibliotēkas</string>
<string name="error_save">Neizdevās saglabāt datni</string>
<string name="settings_ocr_languages_disabled">Atspējotas</string>
<string name="license">Licence</string>
<string name="settings_ocr_languages">Valodas</string>
<string name="about">Par lietotni</string>
<string name="turn_on_torch">Ieslēgt zibspuldzi/lukturīti</string>
<string name="cancel">Atcelt</string>
<string name="delete_page_warning">Vai tiešām vēlaties dzēst šo lapu?</string>
<string name="open">Atvērt</string>
<string name="view_the_full_license">Skatīt pašu licenci</string>
<string name="file_size">Datnes izmērs: %1$s</string>
<string name="open_file">Atvērt datni</string>
<string name="settings">Iestatījumi</string>
<string name="libraries">Bibliotēkas</string>
<string name="settings_section_ocr">Teksta atpazīšana</string>
<string name="unknown_size">Nezināms izmērs</string>
<string name="turn_off_torch">Izslēgt zibspuldzi/lukturīti</string>
<string name="rotate_right">Pagriezt pa labi</string>
<string name="share">Kopīgot</string>
<string name="file_size_total">Kopējais izmērs: %1$s</string>
<string name="crop">Apgriezt</string>
<string name="share_document">Kopīgot dokumentu</string>
<string name="settings_ocr_languages_available">Pieejamās valodas</string>
<string name="settings_ocr_download_intro">Valodu datnes lejupielādē no oficiālās Tesseract projekta vietnes. Teksta atpazīšana pilnībā notiek bezsaistē.</string>
<string name="download_dirname">Lejupielādes</string>
<string name="menu">Izvēlne</string>
<string name="settings_section_export">Veidošana</string>
<string name="export">Veidot</string>
<string name="settings_section_scan">Skenējums</string>
<string name="change_directory">Mainīt mapi</string>
<string name="clear_text">Notīrīt tekstu</string>
<string name="error_context_provider">Pakalpojuma sniedzējs: %1$s</string>
<string name="error_export_dir_permission_lost">Atlasītā veidošanas mape vairs nav pieejama. Lūdzu, atlasiet citu mapi.</string>
<string name="export_as">Veidot kā %1$s</string>
<string name="export_format">Veidošanas formāts</string>
<string name="export_directory">Mape, kur saglabāt</string>
<string name="export_quality">Veidošanas kvalitāte</string>
<string name="camera_permission_rationale">Lai varētu veikt skenējumus, lietotnei ir nepieciešama piekļuve kamerai. Uzņemtie attēli tiek saglabāti tikai šajā ierīcē un tiks dzēsti, kad aizvērsiet pašreizējo skenējumu.</string>
<string name="color_mode_auto">Automātiski</string>
<string name="copied_logs">Žurnāla ieraksti kopēti starpliktuvē</string>
<string name="copy_logs">Kopēt žurnāla ierakstus</string>
<string name="error_no_app">Nav atrasta neviena lietotne, kas varētu atvērt šo datni</string>
<string name="export_folder_permission_lost">Zaudēta piekļuve mapei</string>
<string name="grant_permission">Dot atļauju</string>
<string name="licensed_under">Šī lietotne ir licencēta saskaņā ar GNU General Public License v3.0 noteikumiem.</string>
<string name="new_document_warning">Pašreizējais skenējums tiks zaudēts. Vai tiešām vēlaties turpināt?</string>
<string name="reset_to_default">Atiestatīt uz noklusējuma iestatījumu</string>
<string name="scan_new">Jauns skenējums</string>
<string name="scan_current">Pašreizējais skenējums</string>
<string name="scan_discard_current">Atmestīs pašreizējo skenējumu</string>
<string name="scan_resume">Turpināt skenējumu</string>
<string name="settings_ocr_download_error">Neizdevās lejupielādēt. Pārbaudiet savu interneta savienojumu un pārliecinieties, ka šai lietotnei ir tīkla piekļuves atļauja.</string>
<string name="settings_ocr_suggested">Iesaka</string>
<string name="app_tagline">Vienkārša un cieņpilna lietotne, kas veic jūsu dokumentu skenējumus.</string>
<string name="color_mode">Krāsu filtrs</string>
<string name="color_mode_default">Noklusējuma krāsu filtrs</string>
<string name="color_mode_grayscale">Pelēktoņu</string>
<string name="import_photos">Pievienot</string>
<string name="storage_permission_denied">Nevar saglabāt datni: piekļuve liegta</string>
<string name="support_last_image">Ziņot par problēmu saistībā ar pēdējo uzņemto attēlu</string>
<plurals name="importing_photos">
<item quantity="zero">Pievieno %d fotoattēlus</item>
<item quantity="one">Pievieno %d fotoattēlu</item>
<item quantity="other">Pievieno %d fotoattēlus</item>
</plurals>
<string name="color_mode_color">Pilnkrāsu</string>
<string name="creating_export">Sagatavo…</string>
<string name="apply">Apstiprināt</string>
<string name="error_context_folder">Mape, kur saglabā: %1$s</string>
</resources>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<color name="splash_background">#FF313332</color>
</resources>
+39 -9
View File
@@ -2,29 +2,39 @@
<string name="about">Sobre</string>
<string name="add_page">Adicionar página</string>
<string name="app_tagline">Um aplicativo simples e respeitoso para digitalizar seus documentos.</string>
<string name="apply">Aplicar</string>
<string name="back">Voltar</string>
<string name="camera_permission_denied">Permissão da câmera negada</string>
<string name="camera_permission_rationale">O aplicativo precisa de acesso à câmera para digitalizar documentos. As imagens capturadas são armazenadas apenas neste dispositivo e serão excluídas quando você fechar o documento atual.</string>
<string name="camera_permission_rationale">O aplicativo precisa de acesso à câmera para digitalizar documentos. As imagens capturadas são armazenadas apenas neste dispositivo e serão excluídas quando você fechar a digitalização atual.</string>
<string name="cancel">Cancelar</string>
<string name="change_directory">Alterar diretório</string>
<string name="clear_text">Limpar texto</string>
<string name="close">Fechar</string>
<string name="color_mode">Filtro</string>
<string name="color_mode_auto">Automático</string>
<string name="color_mode_color">Cor</string>
<string name="color_mode_default">Filtro padrão</string>
<string name="color_mode_grayscale">Escala de cinza</string>
<string name="contact">Contato</string>
<string name="copied_logs">Registros copiados para a área de transferência</string>
<string name="copy_logs">Copiar registros</string>
<string name="creating_export">Preparando exportação…</string>
<string name="crop">Recortar</string>
<string name="delete">Excluir</string>
<string name="delete_page">Excluir página</string>
<string name="delete_page_warning">Deseja excluir esta página?</string>
<string name="developer">Desenvolvedor</string>
<string name="discard_scan">Descartar digitalização</string>
<string name="download_dirname">Downloads</string>
<string name="end_scan">Finalizar digitalização</string>
<string name="error">Erro: %1$s</string>
<string name="error_context_folder">Pasta de exportação: %1$s</string>
<string name="error_context_provider">Provedor: %1$s</string>
<string name="error_export_dir_permission_lost">A pasta de exportação selecionada não está mais acessível. Escolha outra pasta.</string>
<string name="error_no_app">Nenhum app encontrado para abrir este arquivo</string>
<string name="error_no_document">Nenhum documento detectado</string>
<string name="error_occurred">Ocorreu um erro</string>
<string name="error_save">Falha ao salvar o arquivo</string>
<string name="export">Exportar</string>
<string name="export_as">Exportar como %1$s</string>
<string name="export_directory">Diretório de exportação</string>
<string name="export_folder_permission_lost">Acesso à pasta perdido</string>
<string name="export_format">Formato de exportação</string>
<string name="export_quality">Qualidade de exportação</string>
<string name="export_quality_low">Baixa</string>
@@ -34,7 +44,7 @@
<string name="file_size_total">Tamanho total: %1$s</string>
<string name="filename">Nome do arquivo</string>
<string name="grant_permission">Conceder permissão</string>
<string name="last_saved_pdf_files">PDFs recentes salvos neste dispositivo:</string>
<string name="import_photos">Importar</string>
<string name="libraries">Bibliotecas</string>
<string name="libraries_open_source">Bibliotecas de código aberto</string>
<string name="license">Licença</string>
@@ -44,16 +54,32 @@
<string name="open">Abrir</string>
<string name="open_file">Abrir arquivo</string>
<string name="reset_to_default">Restaurar padrão</string>
<string name="resume">Retomar</string>
<string name="rotate_left">Girar para a esquerda</string>
<string name="rotate_right">Girar para a direita</string>
<string name="save">Salvar</string>
<string name="scan_button">Nova digitalização</string>
<string name="scan_in_progress">Digitalização em andamento</string>
<string name="scan_new">Nova digitalização</string>
<string name="scan_current">Digitalização atual</string>
<string name="scan_discard_current">Descartar a digitalização atual</string>
<string name="scan_resume">Retomar digitalização</string>
<string name="settings">Configurações</string>
<string name="settings_ocr_download_error">Falha no download. Verifique sua conexão com a Internet e certifique-se de que o acesso à rede está permitido para este aplicativo.</string>
<string name="settings_ocr_download_intro">Os arquivos de idioma são baixados do projeto oficial Tesseract. O reconhecimento de texto permanece totalmente offline.</string>
<string name="settings_ocr_downloading">Baixando arquivo de idioma</string>
<string name="settings_ocr_languages">Idiomas</string>
<string name="settings_ocr_languages_available">Idiomas disponíveis</string>
<string name="settings_ocr_languages_disabled">Desativado</string>
<string name="settings_ocr_languages_installed">Instalados</string>
<string name="settings_ocr_suggested">Sugerido</string>
<string name="settings_section_ocr">Reconhecimento de texto</string>
<string name="settings_section_scan">Digitalização</string>
<string name="settings_section_export">Exportação</string>
<string name="share">Compartilhar</string>
<string name="share_document">Compartilhar documento</string>
<string name="storage_permission_denied">Não foi possível salvar o arquivo: permissão negada</string>
<string name="support">Suporte</string>
<string name="support_last_image">Relatar um problema com a última imagem capturada</string>
<string name="text_recognition_disabled">Reconhecimento de texto: desativado</string>
<string name="text_recognition_enabled">Reconhecimento de texto: ativado</string>
<string name="turn_off_torch">Desligar lanterna</string>
<string name="turn_on_torch">Ligar lanterna</string>
<string name="unknown_size">Tamanho desconhecido</string>
@@ -65,6 +91,10 @@
<item quantity="one">%2$s salvo em %3$s</item>
<item quantity="other">%1$d arquivos salvos em %3$s</item>
</plurals>
<plurals name="importing_photos" tools:ignore="MissingQuantity">
<item quantity="one">Importando %d foto</item>
<item quantity="other">Importando %d fotos</item>
</plurals>
<plurals name="page_count" tools:ignore="MissingQuantity">
<item quantity="one">%d página</item>
<item quantity="other">%d páginas</item>
+41 -9
View File
@@ -2,29 +2,39 @@
<string name="about">О приложении</string>
<string name="add_page">Добавить страницу</string>
<string name="app_tagline">Простое и открытое приложение для сканирования Ваших документов.</string>
<string name="apply">Применить</string>
<string name="back">Назад</string>
<string name="camera_permission_denied">В доступе к камере отказано</string>
<string name="camera_permission_rationale">Для сканирования документов приложению требуется доступ к камере. Отснятые изображения хранятся только на данном устройстве и удаляются по окончании работы с текущим документом.</string>
<string name="camera_permission_rationale">Для сканирования документов приложению требуется доступ к камере. Отснятые изображения хранятся только на данном устройстве и удаляются по окончании текущего сканирования.</string>
<string name="cancel">Отмена</string>
<string name="change_directory">Изменить папку</string>
<string name="clear_text">Стереть текст</string>
<string name="close">Закрыть</string>
<string name="color_mode">Фильтр</string>
<string name="color_mode_auto">Автоматически</string>
<string name="color_mode_color">Цвет</string>
<string name="color_mode_default">Фильтр по умолчанию</string>
<string name="color_mode_grayscale">Оттенки серого</string>
<string name="contact">Контакты</string>
<string name="copied_logs">Журналы скопированы в буфер обмена</string>
<string name="copy_logs">Копировать журналы</string>
<string name="creating_export">Подготовка экспорта…</string>
<string name="crop">Обрезать</string>
<string name="delete">Удалить</string>
<string name="delete_page">Удалить страницу</string>
<string name="delete_page_warning">Вы желаете удалить эту страницу?</string>
<string name="developer">Разработчик</string>
<string name="discard_scan">Отказаться</string>
<string name="download_dirname">Download</string>
<string name="end_scan">Закончить</string>
<string name="error">Ошибка: %1$s</string>
<string name="error_context_folder">Папка экспорта: %1$s</string>
<string name="error_context_provider">Провайдер: %1$s</string>
<string name="error_export_dir_permission_lost">Выбранная папка экспорта больше недоступна. Пожалуйста, выберите другую папку.</string>
<string name="error_no_app">Не найдено приложение для открытия этого файла</string>
<string name="error_no_document">Документ не обнаружен</string>
<string name="error_occurred">Произошла ошибка</string>
<string name="error_save">Не удалось сохранить файл</string>
<string name="export">Экспорт</string>
<string name="export_as">Экспортировать как %1$s</string>
<string name="export_directory">Папка экспорта</string>
<string name="export_folder_permission_lost">Доступ к папке потерян</string>
<string name="export_format">Формат экспорта</string>
<string name="export_quality">Качество экспорта</string>
<string name="export_quality_low">Низкое</string>
@@ -34,7 +44,7 @@
<string name="file_size_total">Общий размер: %1$s</string>
<string name="filename">Имя файла</string>
<string name="grant_permission">Предоставить разрешение</string>
<string name="last_saved_pdf_files">Последние PDF, сохранённые на этом устройстве:</string>
<string name="import_photos">Импорт</string>
<string name="libraries">Библиотеки</string>
<string name="libraries_open_source">Библиотеки с открытым исходным кодом</string>
<string name="license">Лицензия</string>
@@ -44,16 +54,32 @@
<string name="open">Открыть</string>
<string name="open_file">Открыть файл</string>
<string name="reset_to_default">Сбросить по умолчанию</string>
<string name="resume">Продолжить</string>
<string name="rotate_left">Повернуть влево</string>
<string name="rotate_right">Повернуть вправо</string>
<string name="save">Сохранить</string>
<string name="scan_button">Начать</string>
<string name="scan_in_progress">Сканирование выполняется</string>
<string name="scan_new">Начать</string>
<string name="scan_current">Текущее сканирование</string>
<string name="scan_discard_current">Отменить текущее сканирование</string>
<string name="scan_resume">Продолжить сканирование</string>
<string name="settings">Настройки</string>
<string name="settings_ocr_download_error">Не удалось загрузить файл. Проверьте подключение к Интернету и убедитесь, что для этого приложения разрешён доступ к сети.</string>
<string name="settings_ocr_download_intro">Языковые файлы загружаются из официального проекта Tesseract. Распознавание текста остаётся полностью офлайн.</string>
<string name="settings_ocr_downloading">Загрузка языкового файла</string>
<string name="settings_ocr_languages">Языки</string>
<string name="settings_ocr_languages_available">Доступные языки</string>
<string name="settings_ocr_languages_disabled">Отключено</string>
<string name="settings_ocr_languages_installed">Установленные</string>
<string name="settings_ocr_suggested">Рекомендуется</string>
<string name="settings_section_ocr">Распознавание текста</string>
<string name="settings_section_scan">Сканирование</string>
<string name="settings_section_export">Экспорт</string>
<string name="share">Поделиться</string>
<string name="share_document">Поделиться документом</string>
<string name="storage_permission_denied">Невозможно сохранить файл: доступ запрещён</string>
<string name="support">Поддержка</string>
<string name="support_last_image">Сообщить о проблеме с последним сделанным изображением</string>
<string name="text_recognition_disabled">Распознавание текста: отключено</string>
<string name="text_recognition_enabled">Распознавание текста: включено</string>
<string name="turn_off_torch">Выключить фонарик</string>
<string name="turn_on_torch">Включить фонарик</string>
<string name="unknown_size">Неизвестный размер</string>
@@ -67,6 +93,12 @@
<item quantity="many">%1$d файлов сохранено в %3$s</item>
<item quantity="other">%1$d файла сохранено в %3$s</item>
</plurals>
<plurals name="importing_photos">
<item quantity="one">Импорт %d фото</item>
<item quantity="few">Импорт %d фото</item>
<item quantity="many">Импорт %d фото</item>
<item quantity="other">Импорт %d фото</item>
</plurals>
<plurals name="page_count">
<item quantity="one">%d страница</item>
<item quantity="few">%d страницы</item>
+102
View File
@@ -0,0 +1,102 @@
<resources>
<string name="about">Om</string>
<string name="add_page">Lägg till sida</string>
<string name="app_tagline">En enkel och respektfull app för att skanna dina dokument.</string>
<string name="apply">Använd</string>
<string name="back">Tillbaka</string>
<string name="camera_permission_denied">Kamerabehörighet nekades</string>
<string name="camera_permission_rationale">Appen behöver åtkomst till kameran för att kunna skanna dokument. Tagna bilder lagras endast på den här enheten och raderas när du stänger den aktuella skanningen.</string>
<string name="cancel">Avbryt</string>
<string name="change_directory">Byt mapp</string>
<string name="clear_text">Rensa text</string>
<string name="close">Stäng</string>
<string name="color_mode">Filter</string>
<string name="color_mode_auto">Automatiskt</string>
<string name="color_mode_color">Färg</string>
<string name="color_mode_default">Standardfilter</string>
<string name="color_mode_grayscale">Gråskala</string>
<string name="contact">Kontakt</string>
<string name="copied_logs">Loggarna har kopierats till urklipp</string>
<string name="copy_logs">Kopiera loggar</string>
<string name="creating_export">Förbereder export…</string>
<string name="crop">Beskär</string>
<string name="delete">Ta bort</string>
<string name="delete_page">Ta bort sida</string>
<string name="delete_page_warning">Vill du ta bort den här sidan?</string>
<string name="developer">Utvecklare</string>
<string name="download_dirname">Nedladdningar</string>
<string name="error_context_folder">Exportmapp: %1$s</string>
<string name="error_context_provider">Leverantör: %1$s</string>
<string name="error_export_dir_permission_lost">Den valda exportmappen är inte längre tillgänglig. Välj en annan mapp.</string>
<string name="error_no_app">Ingen app för att öppna den här filen hittades</string>
<string name="error_occurred">Ett fel uppstod</string>
<string name="error_save">Det gick inte att spara filen</string>
<string name="export">Exportera</string>
<string name="export_as">Exportera som %1$s</string>
<string name="export_directory">Exportmapp</string>
<string name="export_folder_permission_lost">Åtkomsten till mappen har upphört</string>
<string name="export_format">Exportformat</string>
<string name="export_quality">Exportkvalitet</string>
<string name="export_quality_low">Låg</string>
<string name="export_quality_balanced">Balanserad</string>
<string name="export_quality_high">Hög</string>
<string name="file_size">Filstorlek: %1$s</string>
<string name="file_size_total">Total storlek: %1$s</string>
<string name="filename">Filnamn</string>
<string name="grant_permission">Bevilja behörighet</string>
<string name="import_photos">Importera</string>
<string name="libraries">Bibliotek</string>
<string name="libraries_open_source">Bibliotek med öppen källkod</string>
<string name="license">Licens</string>
<string name="licensed_under">Den här appen är licensierad under GNU General Public License v3.0.</string>
<string name="menu">Meny</string>
<string name="new_document_warning">Den aktuella skanningen raderas. Vill du fortsätta?</string>
<string name="open">Öppna</string>
<string name="open_file">Öppna fil</string>
<string name="reset_to_default">Återställ till standard</string>
<string name="rotate_left">Rotera åt vänster</string>
<string name="rotate_right">Rotera åt höger</string>
<string name="save">Spara</string>
<string name="scan_new">Ny skanning</string>
<string name="scan_current">Aktuell skanning</string>
<string name="scan_discard_current">Kassera aktuell skanning</string>
<string name="scan_resume">Återuppta skanning</string>
<string name="settings">Inställningar</string>
<string name="settings_ocr_download_error">Nedladdningen misslyckades. Kontrollera din internetanslutning och att nätverksåtkomst är tillåten för den här appen.</string>
<string name="settings_ocr_download_intro">Språkfiler hämtas från det officiella Tesseract-projektet. Textigenkänning förblir helt offline.</string>
<string name="settings_ocr_downloading">Hämtar språkfil</string>
<string name="settings_ocr_languages">Språk</string>
<string name="settings_ocr_languages_available">Tillgängliga språk</string>
<string name="settings_ocr_languages_disabled">Inaktiverad</string>
<string name="settings_ocr_languages_installed">Installerade</string>
<string name="settings_ocr_suggested">Föreslagen</string>
<string name="settings_section_ocr">Textigenkänning</string>
<string name="settings_section_scan">Skanning</string>
<string name="settings_section_export">Export</string>
<string name="share">Dela</string>
<string name="share_document">Dela dokument</string>
<string name="storage_permission_denied">Det går inte att spara filen: behörighet nekades</string>
<string name="support">Support</string>
<string name="support_last_image">Rapportera ett problem med den senast tagna bilden</string>
<string name="text_recognition_disabled">Textigenkänning: inaktiverad</string>
<string name="text_recognition_enabled">Textigenkänning: aktiverad</string>
<string name="turn_off_torch">Stäng av ficklampan</string>
<string name="turn_on_torch">Tänd ficklampan</string>
<string name="unknown_size">Okänd storlek</string>
<string name="version">Version</string>
<string name="view_full_list">Visa hela listan</string>
<string name="view_the_full_license">Visa hela licensen</string>
<string name="yes">Ja</string>
<plurals name="files_saved_to">
<item quantity="one">%2$s har sparats i %3$s</item>
<item quantity="other">%1$d filer har sparats i %3$s</item>
</plurals>
<plurals name="importing_photos">
<item quantity="one">Importerar 1 foto</item>
<item quantity="other">Importerar %d foton</item>
</plurals>
<plurals name="page_count">
<item quantity="one">%d sida</item>
<item quantity="other">%d sidor</item>
</plurals>
</resources>
+39 -9
View File
@@ -2,29 +2,39 @@
<string name="about">Hakkında</string>
<string name="add_page">Sayfa ekle</string>
<string name="app_tagline">Belgelerinizi taramak için basit ve saygılı bir uygulama.</string>
<string name="apply">Uygula</string>
<string name="back">Geri</string>
<string name="camera_permission_denied">Kamera izni reddedildi</string>
<string name="camera_permission_rationale">Uygulama, belgeleri taramak için kamera erişimi gerektirir. Yakalanan görüntüler yalnızca bu cihazda saklanır ve mevcut belgeyi kapattığınızda silinir.</string>
<string name="camera_permission_rationale">Uygulama, belgeleri taramak için kamera erişimi gerektirir. Yakalanan görüntüler yalnızca bu cihazda saklanır ve geçerli taramayı kapattığınızda silinir.</string>
<string name="cancel">İptal</string>
<string name="change_directory">Dizini değiştir</string>
<string name="clear_text">Metni temizle</string>
<string name="close">Kapat</string>
<string name="color_mode">Filtre</string>
<string name="color_mode_auto">Otomatik</string>
<string name="color_mode_color">Renkli</string>
<string name="color_mode_default">Varsayılan filtre</string>
<string name="color_mode_grayscale">Gri tonlama</string>
<string name="contact">İletişim</string>
<string name="copied_logs">Günlükler panoya kopyalandı</string>
<string name="copy_logs">Günlükleri kopyala</string>
<string name="creating_export">Dışa aktarma hazırlanıyor…</string>
<string name="crop">Kırp</string>
<string name="delete">Sil</string>
<string name="delete_page">Sayfayı sil</string>
<string name="delete_page_warning">Bu sayfayı silmek istiyor musunuz?</string>
<string name="developer">Geliştirici</string>
<string name="discard_scan">Taramayı at</string>
<string name="download_dirname">İndirilenler</string>
<string name="end_scan">Taramayı bitir</string>
<string name="error">Error: %1$s</string>
<string name="error_context_folder">Dışa aktarma klasörü: %1$s</string>
<string name="error_context_provider">Sağlayıcı: %1$s</string>
<string name="error_export_dir_permission_lost">Seçilen dışa aktarma dizini artık erişilebilir değil. Lütfen başka bir dizin seçin.</string>
<string name="error_no_app">No app found to open this file</string>
<string name="error_no_document">No document detected</string>
<string name="error_occurred">Bir hata oluştu</string>
<string name="error_save">Failed to save file</string>
<string name="export">Dışa aktar</string>
<string name="export_as">%1$s olarak dışa aktar</string>
<string name="export_directory">Dışa aktarma dizini</string>
<string name="export_folder_permission_lost">Klasör erişimi kaybedildi</string>
<string name="export_format">Dışa aktarma biçimi</string>
<string name="export_quality">Dışa aktarma kalitesi</string>
<string name="export_quality_low">Düşük</string>
@@ -34,7 +44,7 @@
<string name="file_size_total">Toplam boyut: %1$s</string>
<string name="filename">Dosya adı</string>
<string name="grant_permission">İzin ver</string>
<string name="last_saved_pdf_files">Bu cihaza kaydedilen son PDF\'ler:</string>
<string name="import_photos">İçe aktar</string>
<string name="libraries">Kütüphaneler</string>
<string name="libraries_open_source">Açık kaynaklı kütüphaneler</string>
<string name="license">Lisans</string>
@@ -44,16 +54,32 @@
<string name="open"></string>
<string name="open_file">Dosyayı</string>
<string name="reset_to_default">Varsayılana sıfırla</string>
<string name="resume">Devam et</string>
<string name="rotate_left">Sola döndür</string>
<string name="rotate_right">Sağa döndür</string>
<string name="save">Kaydet</string>
<string name="scan_button">Yeni Tarama</string>
<string name="scan_in_progress">Tarama devam ediyor</string>
<string name="scan_new">Yeni Tarama</string>
<string name="scan_current">Geçerli tarama</string>
<string name="scan_discard_current">Geçerli taramayı sil</string>
<string name="scan_resume">Taramaya devam et</string>
<string name="settings">Ayarlar</string>
<string name="settings_ocr_download_error">İndirme başarısız oldu. İnternet bağlantınızı kontrol edin ve bu uygulama için ağ erişimine izin verildiğinden emin olun.</string>
<string name="settings_ocr_download_intro">Dil dosyaları resmi Tesseract projesinden indirilir. Metin tanıma tamamen çevrimdışı olarak çalışır.</string>
<string name="settings_ocr_downloading">Dil dosyası indiriliyor</string>
<string name="settings_ocr_languages">Diller</string>
<string name="settings_ocr_languages_available">Kullanılabilir diller</string>
<string name="settings_ocr_languages_disabled">Devre dışı</string>
<string name="settings_ocr_languages_installed">Yüklü</string>
<string name="settings_ocr_suggested">Önerilen</string>
<string name="settings_section_ocr">Metin tanıma</string>
<string name="settings_section_scan">Tarama</string>
<string name="settings_section_export">Dışa aktarma</string>
<string name="share">Paylaş</string>
<string name="share_document">Belgeyi paylaş</string>
<string name="storage_permission_denied">Dosya kaydedilemiyor: izin reddedildi</string>
<string name="support">Destek</string>
<string name="support_last_image">Son yakalanan görüntüyle ilgili bir sorunu bildir</string>
<string name="text_recognition_disabled">Metin tanıma: devre dışı</string>
<string name="text_recognition_enabled">Metin tanıma: etkin</string>
<string name="turn_off_torch">El fenerini kapat</string>
<string name="turn_on_torch">El fenerini aç</string>
<string name="unknown_size">Bilinmeyen boyut</string>
@@ -65,6 +91,10 @@
<item quantity="one">%2$s, %3$s konumuna kaydedildi</item>
<item quantity="other">%1$d dosya %3$s konumuna kaydedildi</item>
</plurals>
<plurals name="importing_photos">
<item quantity="one">1 foto içe aktarılıyor</item>
<item quantity="other">%d foto içe aktarılıyor</item>
</plurals>
<plurals name="page_count">
<item quantity="one">%d sayfa</item>
<item quantity="other">%d sayfa</item>
+38 -9
View File
@@ -2,29 +2,39 @@
<string name="about">關於</string>
<string name="add_page">新增頁面</string>
<string name="app_tagline">一個簡單且尊重隱私的文件掃描應用程式。</string>
<string name="apply">套用</string>
<string name="back">返回</string>
<string name="camera_permission_denied">已拒絕相機權限</string>
<string name="camera_permission_rationale">此應用程式需要相機存取權限才能掃描文件。擷取的影像僅儲存在此裝置上,並會在您關閉目前文件時刪除。</string>
<string name="camera_permission_rationale">此應用程式需要相機存取權限才能掃描文件。擷取的影像僅儲存在此裝置上,並會在您關閉目前掃描時刪除。</string>
<string name="cancel">取消</string>
<string name="change_directory">變更目錄</string>
<string name="clear_text">清除文字</string>
<string name="close">關閉</string>
<string name="color_mode">濾鏡</string>
<string name="color_mode_auto">自動</string>
<string name="color_mode_color">彩色</string>
<string name="color_mode_default">預設濾鏡</string>
<string name="color_mode_grayscale">灰階</string>
<string name="contact">聯絡我們</string>
<string name="copied_logs">日誌已複製到剪貼簿</string>
<string name="copy_logs">複製日誌</string>
<string name="creating_export">正在準備匯出…</string>
<string name="crop">裁切</string>
<string name="delete">刪除</string>
<string name="delete_page">刪除頁面</string>
<string name="delete_page_warning">確定要刪除此頁面嗎?</string>
<string name="developer">開發者</string>
<string name="discard_scan">捨棄掃描</string>
<string name="download_dirname">下載 (Downloads)</string>
<string name="end_scan">結束掃描</string>
<string name="error">錯誤%1$s</string>
<string name="error_context_folder">匯出資料夾:%1$s</string>
<string name="error_context_provider">提供者%1$s</string>
<string name="error_export_dir_permission_lost">所選的匯出目錄已無法存取。請選擇其他目錄。</string>
<string name="error_no_app">找不到可開啟此檔案的應用程式</string>
<string name="error_no_document">未偵測到文件</string>
<string name="error_occurred">發生錯誤</string>
<string name="error_save">儲存檔案失敗</string>
<string name="export">匯出</string>
<string name="export_as">匯出為 %1$s</string>
<string name="export_directory">匯出目錄</string>
<string name="export_folder_permission_lost">資料夾存取權限已遺失</string>
<string name="export_format">匯出格式</string>
<string name="export_quality">匯出品質</string>
<string name="export_quality_low"></string>
@@ -34,7 +44,7 @@
<string name="file_size_total">總計大小:%1$s</string>
<string name="filename">檔案名稱</string>
<string name="grant_permission">授予權限</string>
<string name="last_saved_pdf_files">此裝置上最近儲存的 PDF</string>
<string name="import_photos">匯入</string>
<string name="libraries">函式庫</string>
<string name="libraries_open_source">開放原始碼函式庫</string>
<string name="license">許可證</string>
@@ -44,16 +54,32 @@
<string name="open">開啟</string>
<string name="open_file">開啟檔案</string>
<string name="reset_to_default">重設為預設值</string>
<string name="resume">繼續</string>
<string name="rotate_left">向左旋轉</string>
<string name="rotate_right">向右旋轉</string>
<string name="save">儲存</string>
<string name="scan_button">開始掃描</string>
<string name="scan_in_progress">掃描進行中</string>
<string name="scan_new">開始掃描</string>
<string name="scan_current">目前掃描</string>
<string name="scan_discard_current">放棄目前掃描</string>
<string name="scan_resume">繼續掃描</string>
<string name="settings">設定</string>
<string name="settings_ocr_download_error">下載失敗。請檢查您的網路連線,並確認已允許此應用程式存取網路。</string>
<string name="settings_ocr_download_intro">語言檔案會從官方 Tesseract 專案下載。文字辨識始終完全離線運作。</string>
<string name="settings_ocr_downloading">正在下載語言檔案</string>
<string name="settings_ocr_languages">語言</string>
<string name="settings_ocr_languages_available">可用語言</string>
<string name="settings_ocr_languages_disabled">已停用</string>
<string name="settings_ocr_languages_installed">已安裝</string>
<string name="settings_ocr_suggested">推薦</string>
<string name="settings_section_ocr">文字辨識</string>
<string name="settings_section_scan">掃描</string>
<string name="settings_section_export">匯出</string>
<string name="share">分享</string>
<string name="share_document">分享文件</string>
<string name="storage_permission_denied">無法儲存檔案:權限遭拒</string>
<string name="support">支援</string>
<string name="support_last_image">回報最近拍攝影像的問題</string>
<string name="text_recognition_disabled">文字辨識:已停用</string>
<string name="text_recognition_enabled">文字辨識:已啟用</string>
<string name="turn_off_torch">關閉閃光燈</string>
<string name="turn_on_torch">開啟閃光燈</string>
<string name="unknown_size">未知大小</string>
@@ -65,6 +91,9 @@
<item quantity="one">%2$s 已儲存至 %3$s</item>
<item quantity="other">%1$d 個檔案已儲存至 %3$s</item>
</plurals>
<plurals name="importing_photos">
<item quantity="other">正在匯入 %d 張照片</item>
</plurals>
<plurals name="page_count">
<item quantity="one">%d 頁</item>
<item quantity="other">%d 頁</item>
+38 -9
View File
@@ -2,29 +2,39 @@
<string name="about">关于</string>
<string name="add_page">添加页面</string>
<string name="app_tagline">一个简单且克制的文档扫描应用</string>
<string name="apply">应用</string>
<string name="back">返回</string>
<string name="camera_permission_denied">相机权限被拒绝</string>
<string name="camera_permission_rationale">应用请求相机权限访问扫描文档。捕获的图像仅存储在此设备上,并且在关闭当前文档时将被删除。</string>
<string name="camera_permission_rationale">应用请求相机权限访问扫描文档。捕获的图像仅存储在此设备上,并且在关闭当前扫描时将被删除。</string>
<string name="cancel">取消</string>
<string name="change_directory">更改目录</string>
<string name="clear_text">清除文字</string>
<string name="close">关闭</string>
<string name="color_mode">滤镜</string>
<string name="color_mode_auto">自动</string>
<string name="color_mode_color">彩色</string>
<string name="color_mode_default">默认滤镜</string>
<string name="color_mode_grayscale">灰度</string>
<string name="contact">联系人</string>
<string name="copied_logs">日志已复制到剪贴板</string>
<string name="copy_logs">复制日志</string>
<string name="creating_export">正在准备导出…</string>
<string name="crop">裁剪</string>
<string name="delete">删除</string>
<string name="delete_page">删除页面</string>
<string name="delete_page_warning">是否要删除此页面?</string>
<string name="developer">开发者</string>
<string name="discard_scan">放弃扫描</string>
<string name="download_dirname">下载</string>
<string name="end_scan">结束扫描</string>
<string name="error">错误: %1$s</string>
<string name="error_context_folder">导出文件夹:%1$s</string>
<string name="error_context_provider">提供方:%1$s</string>
<string name="error_export_dir_permission_lost">所选的导出目录已无法访问。请选择其他目录。</string>
<string name="error_no_app">未找到可打开此文件的应用</string>
<string name="error_no_document">未检测到任何文档</string>
<string name="error_occurred">发生错误</string>
<string name="error_save">无法保存文件</string>
<string name="export">导出</string>
<string name="export_as">导出为 %1$s</string>
<string name="export_directory">导出目录</string>
<string name="export_folder_permission_lost">文件夹访问权限已丢失</string>
<string name="export_format">导出格式</string>
<string name="export_quality">导出质量</string>
<string name="export_quality_low"></string>
@@ -34,7 +44,7 @@
<string name="file_size_total">总大小:%1$s</string>
<string name="filename">文件名字</string>
<string name="grant_permission">授予权限</string>
<string name="last_saved_pdf_files">最近保存在此设备上的 PDF</string>
<string name="import_photos">导入</string>
<string name="libraries"></string>
<string name="libraries_open_source">开源库</string>
<string name="license">许可证</string>
@@ -44,16 +54,32 @@
<string name="open">打开</string>
<string name="open_file">打开文件</string>
<string name="reset_to_default">恢复默认设置</string>
<string name="resume">恢复</string>
<string name="rotate_left">向左旋转</string>
<string name="rotate_right">向右旋转</string>
<string name="save">保存</string>
<string name="scan_button">新建扫描</string>
<string name="scan_in_progress">正在进行扫描</string>
<string name="scan_new">新建扫描</string>
<string name="scan_current">当前扫描</string>
<string name="scan_discard_current">放弃当前扫描</string>
<string name="scan_resume">继续扫描</string>
<string name="settings">设置</string>
<string name="settings_ocr_download_error">下载失败。请检查您的网络连接,并确认已允许此应用访问网络。</string>
<string name="settings_ocr_download_intro">语言文件从官方 Tesseract 项目下载。文本识别始终完全离线运行。</string>
<string name="settings_ocr_downloading">正在下载语言文件</string>
<string name="settings_ocr_languages">语言</string>
<string name="settings_ocr_languages_available">可用语言</string>
<string name="settings_ocr_languages_disabled">已禁用</string>
<string name="settings_ocr_languages_installed">已安装</string>
<string name="settings_ocr_suggested">推荐</string>
<string name="settings_section_ocr">文本识别</string>
<string name="settings_section_scan">扫描</string>
<string name="settings_section_export">导出</string>
<string name="share">共享</string>
<string name="share_document">分享文档</string>
<string name="storage_permission_denied">无法保存文件:权限被拒绝</string>
<string name="support">支持</string>
<string name="support_last_image">报告最近拍摄图像的问题</string>
<string name="text_recognition_disabled">文本识别:已禁用</string>
<string name="text_recognition_enabled">文本识别:已启用</string>
<string name="turn_off_torch">关闭手电筒</string>
<string name="turn_on_torch">打开手电筒</string>
<string name="unknown_size">未知大小</string>
@@ -64,6 +90,9 @@
<plurals name="files_saved_to">
<item quantity="other">%1$d 个文件已保存到 %3$s</item>
</plurals>
<plurals name="importing_photos">
<item quantity="other">正在导入 %d 张照片</item>
</plurals>
<plurals name="page_count">
<item quantity="other">%d 页</item>
</plurals>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<color name="splash_background">#FFFBFCFC</color>
</resources>

Some files were not shown because too many files have changed in this diff Show More