PageScribe 0f72c94f36 Play a shutter click when a photo is taken
A camera you are not looking at has to say it heard the tap. A synthesised
shutter_click.wav (110 ms, two filtered noise transients for the shutter
open and close) is bundled and played through SoundPool on each accepted
shot.

The click sits behind the capture guards, so a tap the camera refuses -
still busy, or not ready - stays silent and the click always means
"taken". SoundPool loads asynchronously, so a tap that beats the sample
into memory plays a short system tone instead of nothing. The sample is
released in onDestroy.

Verified on device: with system touch sounds disabled, dumpsys audio shows
player piid:1263 (package:com.bm.pagescribe, type:android.media.SoundPool)
emitting event:started on the tap, while AudioFlinger's mixer leaves
standby at the same moment.
2026-10-15 11:34:42 -07:00

PageScribe

Photograph the open spread of a book, page by page. PageScribe reads the text off each photo in the background, puts the pages back in order by their printed page numbers, and writes them into a single .txt file named after the book. The photos themselves are shrunk down and kept alongside the text.

You never have to wait for recognition: shoot the next spread while the previous one is still being read.

What it does

  • One photo = one two-page spread. The camera screen shows the spread and a shutter; each shot is dropped straight into the book's queue.
  • Background OCR. A WorkManager chain drains the queue, so you can keep shooting. Progress is shown on the shelf, in the book, and on the camera screen, and as a notification.
  • A text file per book. Pages are ordered by the folio (printed page number) they carry, not by the order you happened to photograph them. The finished file is <book title>.txt.
  • Coverage you can see. Because the folios are read anyway, the app knows which pages have been captured. The book screen shows Pages 42–51 · 7 of 10 captured · 3 to shoot, listing every folio in order — the ones already read with their opening lines, the ones never photographed as not captured yet, and the ones whose photo came out blank as no readable text — photograph this page again. The page numbers steer the process but are never written into the text.
  • Figures and captions are dropped. Isolated short lines that sit under picture content (with their one or two credit lines) are filtered out, while a short line over blank paper is kept.
  • Small archived photos. Each source JPEG is scaled so the smallest text line on the page is about 20 px tall, converted to greyscale, and encoded at the lowest quality on a ladder that still keeps the text region close to the original. The smaller of JPEG and WebP wins. A ~350 KB source becomes a ~35 KB archive.

Everything lives in app-private storage (files/books/<title>/); the Export text button hands the assembled .txt to the system share sheet, from where it can go to Drive, email, or the Files app.

Screens

  • Shelf (MainActivity) — one row per book, plus a + to create one.
  • Capture (CaptureActivity) — CameraX preview, flash toggle, shutter, and a jump to the book. Every accepted shot is confirmed with a short shutter click (a bundled sample played through SoundPool), so a batch of spreads can be shot by feel. A tap that the camera refuses — still busy, not ready — stays silent, so the click always means "taken".
  • Book (BookActivity) — the pages read so far, the log, Export text, Capture, and Delete book.

How the pipeline works

Each queued photo goes through SpreadProcessor:

  1. Decode the JPEG (EXIF rotation honoured), longest edge OCR_MAX_EDGE.
  2. Find the gutter between the two pages as a tilted line, so a slightly skewed shot still splits cleanly. Falls back to the image centre.
  3. Run ML Kit text recognition over the whole spread (bundled Latin model, so it works offline).
  4. Split the recognised lines by the gutter and read each half's folio — the bare page number near the outer edge, top or bottom. A page whose body failed to OCR often leaves only its folio readable, so even a near-empty half is searched for one.
  5. Drop captions: short, narrow lines isolated below picture content.
  6. Reflow the body into paragraphs, gluing hyphenated line breaks and trimming running heads.
  7. Write pages/%04d.txt (folio = sort key) and archive the image.

At the end, assembleTextFile sorts the page files by folio, joins each page to the next when a sentence runs across the spread, and writes <title>.txt.

Tracking coverage and knowing what to re-shoot

Every stored page file is named after its folio, so the set of folios in pages/ is a record of which pages have been seen. PageCoverage turns that into, for the span [low, high] of folios seen so far:

  • captured — a page file with body text;
  • needs re-capture — a page file that exists but is blank (the folio was read, the body was not), written by SpreadProcessor as the one-byte file "\n";
  • missing — a folio in the span with no page file at all.

The book screen lists every folio in that span in order and marks the two kinds of gap, so a page that was never shot and a page that came out blank are both pointed at by number. A book can never reveal how long it really is, so pages beyond the highest folio seen are unknown rather than missing. A photo that contains no text at all has no folio to attribute, so it is parked in failed/ and listed separately as could not be read.

Storage layout

files/books/<sanitized title>/
  meta.properties        book title, created-at
  queue/                 photos waiting to be read (claimed by the worker)
  work/                  in-flight photos
  pages/                 %04d.txt, one per recognised page
  images/                the archived (shrunk) photos, same numbering
  failed/                photos given up on after MAX_JOB_ATTEMPTS
  log.txt                human-readable progress log
  failures.properties    why each failed photo failed
  <title>.txt            the assembled book

The queue is crash-safe: PageScribeApp.onCreate reclaims photos orphaned in work/ older than two minutes and re-arms the worker, and failed photos are retried up to MAX_JOB_ATTEMPTS before being parked in failed/.

Building

./gradlew :app:assembleDebug        # APK at app/build/outputs/apk/debug/
./gradlew :app:testDebugUnitTest    # 98 unit tests (JVM + Robolectric)

Install on a connected device:

adb install -r app/build/outputs/apk/debug/app-debug.apk

Release builds read signing credentials from keystore.properties at the repo root (git-ignored); without it a release APK is produced unsigned.

Tech

  • Kotlin, Material Components, minSdk 26, targetSdk 36.
  • CameraX 1.6.2 for preview and capture.
  • ML Kit text-recognition 16.0.1 (bundled Latin model — no Play services, no network).
  • WorkManager 2.12.0 for the durable OCR queue.

Note: from targetSdk 35 on, Android enforces edge-to-edge and the built-in AppCompat action bar is no longer inset correctly, so each screen uses a MaterialToolbar in an AppBarLayout and applies the system-bar insets itself (util/EdgeToEdge.kt).

Tests

app/src/test/ holds 98 JVM/Robolectric tests covering the parts that do not need a camera:

Suite What it covers
PageCoverageTest folio spans, missing pages vs pages that need re-shooting
GutterDetectorTest tilted-gutter fit and centre fallback
PageNumberDetectorTest folio geometry, margin isolation, sparse pages
PageNumberResolverTest choosing the right folio per half
CaptionFilterTest dropping captions under figures, keeping orphans
ParagraphReflowTest hyphen gluing, running heads, across-page join
BitmapsTest EXIF-aware downsample and scaling
ImageArchiverTest archive scale and JPEG/WebP quality ladder
BookRepositoryTest create/list/delete and navigation
BookTest queue claim/fail/complete, page files, text assembly, coverage

Known limitations

  • The archive size is driven by the smallest text on the page, so a page with a tiny folio or caption archives a little larger than a body-text floor would suggest.
  • A figure that interrupts a paragraph produces a paragraph break (treated as a section break).
  • Two-page spreads whose text runs past the gutter (an illustration, or a very wide table) can only be split at the fitted gutter line.
Description
No description provided
Readme Apache-2.0 1.5 MiB
Languages
Kotlin 92%
Python 8%