feat(booth): photo-booth strip — 5-4-3-2-1 ×3 capture into a printed strip - #138
Conversation
…strip Implements docs/PRD-photo-booth.md (Issue #135). A provisional Booth button on the camera screen runs a self-driving sequence: 5-second countdown per shot, flash flicker, three viewfinder grabs (lens tray stays usable — the countdown is the lens-swap window), each top-bias square-cropped at grab time. The three frames composite into a classic booth strip — white borders, "OPENLOOP · date" footer, color by default with a B&W toggle — and ride the existing photo path (savePhoto → MediaStore → share sheet → gallery) unchanged. - media/BoothStripLayout.kt: pure, Android-free geometry (top-biased crop per D3, strip layout, footer text) — JVM-tested, incl. the "center crop clips the crown" regression on a 9:19.5 source. - media/BoothStripComposer.kt: thin Canvas pass; B&W = zero-saturation filter on the frame draws only (D4). - OpenLoopViewModel.captureBoothStrip: injectable composer seam (Lesson 029 — no real-dispatcher hop in tests), re-entrancy guard, incomplete-frame-set rejection (§5.4 — no partial strips), then the shipped photo save path. - CameraScreen: booth is ephemeral UI state — no CaptureMode entry, router untouched (§5.1, Lesson 014). Countdown digits and shot progress are polite live regions; Cancel and predictive back abort and discard (Lesson 015); shutter/mode/flip disabled mid-sequence; monochrome read via rememberUpdatedState so the mid-countdown toggle isn't frozen (Lesson 034). Verified: debug+release builds, 125 JVM tests (13 new) and 121 instrumented tests (3 new) all green, lint clean, 16 KB zipalign pass, full flow driven on a Pixel 8 emulator (proof in docs/e2e/2026-08-20-booth-*). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014W2oDvqCgazqBaBjJ2kJWC
…D4 alias promises The chip lived only in the idle BoothControls row, which Cancel replaces the moment the sequence starts — so "toggleable before the composite" silently meant "before the first countdown", and the sequence effect's rememberUpdatedState alias guarded nothing but a launch-frame race. Extract BoothMonochromeChip and render it beside Cancel while the booth runs; the alias now genuinely delivers a mid-countdown toggle to the composite. Proven on the emulator: sequence started in color, chip toggled during shot 1's countdown, strip rendered fully grayscale. CameraScreenTest (18 tests, booth ×3 included) green; lint clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014W2oDvqCgazqBaBjJ2kJWC
…erms markdownlint: fence language + blank lines around the scope lists in PRD-photo-booth.md. cspell: add the booth PRD's legitimate jargon (toggleable, grayscale, photoreal, wordmark, ARGB, rects, behaviourally, entrancy, Kayley) plus the long-standing CLAUDE.md miss (muxes) to the project dictionary. All three Tier 3 checks now pass on the changed docs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014W2oDvqCgazqBaBjJ2kJWC
PR Review — Google Android Standards ComplianceReviewer: Claude (Automated) PASS
FAILNone. WARNINGNone introduced by this PR. (Pre-existing lint RECOMMENDATIONS
Summary
Static analysis engines: Engine 1 (Android Lint) ran — VerdictAPPROVE This PR does what the signed-off PRD says and nothing it doesn't: the strip geometry is pure and regression-tested against the exact crop mistake the PRD forbids, the capture sequence adds zero camera-binding risk, the save path is reused rather than re-implemented, and the accessibility work (live-region countdown, labeled controls, switch semantics) is real rather than decorative. Verification is unusually complete — both build variants, 246 green tests, a genuine 16 KB zipalign pass, and an emulator-driven end-to-end run with the produced JPEGs inspected (color, B&W, and a mid-countdown toggle). The one thing to do before merge: run Engine 2 (IDE Inspect Code) locally per |
- Lifecycle-gate each viewfinder grab: backgrounding mid-countdown now aborts like Cancel instead of baking frozen TextureView frames into a strip that saves and shares itself (new Lesson 036) - ShutterButton gains a real `enabled` and is genuinely disabled mid-booth: TalkBack announces it, no phantom confirm haptic (PRD 5.1) - Reset the flash Animatable and prime shot/digit at sequence start so a cancelled run cannot veil or mis-announce the next one - captureBoothStrip: frame-count reject checked first (the abort snackbar can no longer be swallowed by an in-flight save), state guard dropped (a completed strip survives the gallery-tap race), takes ownership of frames on every path, and catches the strip composite's OutOfMemoryError into the snackbar instead of a process kill - One shared savePhotoAndShare tail + one shared photoSaveInProgress guard for photo and booth; glassPill()/PillTextStyle dedupe the four pill chromes on the camera screen - The booth crop hops to Dispatchers.Default so each flash starts on an unblocked main thread - New instrumented test mounts the real CameraScreen (tap Booth -> overlay + 5.1 gates); unit tests cover the reordered guards, the OOM degrade, and eager frame recycling Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAJBoDJMkWq4rab3MDRkNC
…ries Cleared by upgrading, per STATIC_ANALYSIS.md (version nags are fixed by upgrading, never silenced): - AGP 9.2.1 -> 9.3.1, Kotlin 2.3.21 -> 2.4.10, compileSdk 36 -> 37 - compose BOM 2026.08.00, media3 1.11.0, core-ktx 1.19.0, lifecycle 2.11.0, fragment 1.9.0, work 2.11.2, androidx-test rules/core 1.7.0, uiautomator 2.4.0, firebase BOM 34.18.0, google-services 4.5.0, crashlytics 3.0.8, baselineprofile/benchmark 1.5.0-rc01 - UseKtx: core-ktx createBitmap in LensSurfaceProcessor - OldTargetApi: suppressed at the source with the reason (targetSdk 36 is a deliberate tracked decision - Play floor is 35, and a target bump is its own reviewed project per the Issue #7 precedent) - Pre-existing createComposeRule v1 deprecation compile warnings (9 test files) suppressed with the suite's documented rationale; VideoFilterTest swapped to @UnstableApi propagation (kotlin @OptIn warns "no effect" against media3's Java-declared opt-in marker) :app:lintDebug now reports an empty issue list (was 25 warnings). Verified: clean assembleDebug+assembleRelease green (exit 0); 16 KB zipalign real (OK) on every .so; 536 unit tests 0 failures; 122 instrumented tests 0 failures on Pixel_8 (API 37 image); app driven live on the emulator - booth strip end-to-end and a FORWARD_THEN_REVERSE boomerang save (reverse.complete, frames=9 sync=9, zero FATAL). Proof in docs/e2e/. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAJBoDJMkWq4rab3MDRkNC
cspell: add "backgrounding" + "sharesheet" to the project dictionary (grow the words list, never disable the check). markdownlint on CLAUDE.md: mailto brackets for the bare email (MD034), "text" language on the source-layout and state-machine fences (MD040) - pre-existing, surfaced by Tier 3's file-level scoping. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAJBoDJMkWq4rab3MDRkNC
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit b7e7f2e. Configure here.
… the flash Cursor (round 2) was right: removing the ReadyToCapture guard only closed the narrow race where the effect's last resume beats disposal. On the dominant path a gallery tap during the final 250 ms flash disposes CameraScreen, cancels the effect at animateTo BEFORE captureBoothStrip runs, and the finally recycled all three captured frames. The point of no return is now the third grab: the frame set is handed to the ViewModel before the cosmetic final flash, so no input in that window - gallery, Cancel, back, ON_STOP - can discard a completed strip. This is deterministic, and it is exactly D4's documented boundary (the B&W chip applies "until the last grab"). Docs moved with the code (Lesson 007): PRD 5.1/5.4/scope/success-criteria now state the committed-at-third-grab contract; ViewModel KDoc updated. Also refreshed README.md (compileSdk 37, photo-booth feature bullets, 536/122 test counts, detekt note now cites Kotlin 2.4.x) and STATIC_ANALYSIS.md's version stamps; six pre-existing domain terms added to the cspell dictionary. Verified: debug+release builds green, zero e:/w:; 536 unit tests 0 failures (incl. the completed-strip-after-navigate contract test); 122 instrumented tests 0 failures; live emulator booth run post-reorder ends in a saved strip + share sheet, zero FATAL. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SAJBoDJMkWq4rab3MDRkNC
…acement decided)
The floating Booth + B&W pills are gone; the idle viewfinder is clean. The
bottom-left button now opens a two-tab drawer: a Photo Booth | Lenses slider
(Lenses default) above the tab's content. The Photo Booth tab swaps the lens
carousel for the D4 Color | Black & White radio pair and ARMS the booth — the
shutter starts the strip while armed, with "Start photo booth" semantics.
- Armed state survives closing the drawer; the lime lens button carries the cue
- Picking Camera/Video in the top-right selector disarms (explicit "back to normal")
- The booth tab's X cancels a running sequence, disarms, and closes — it IS the
booth's Cancel (predictive back remains the buttonless abort); the separate
mid-sequence Cancel/B&W row is deleted, the drawer is the control surface
- D5 banner while the sequence runs ("Swap lenses between shots"): lime-trimmed
glass chip, top-center, sharing the gallery button's center line
- PRD-photo-booth D2/scope/open-questions amended with the decided design
- Instrumented tests reworked around the drawer flow (arm -> shutter -> cancel
via X; radio pairs driven both directions per Lesson 034)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wkq4T1GP4jcvxkitbxeQAS
UI redesign — booth moves into the lens drawer (D2 placement decided)Commit ef7abbf lands the owner-approved placement from the "Photo Booth Controls" design canvas (Option E, iterated live 2026-08-20):
Verification (Definition of Done)
Manual QA still worth a device pass
🤖 Generated with Claude Code |
The 1.0.44 bump originally landed as 41f289c on feature/control-speed the same minute PR #136 merged and never reached main; fresh commit here so the photo-booth release ships with an unconsumed versionCode. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UYnJd1K9tbrjaj8DZCStev
…tion reading (owner-delegated) The owner delegated the call on PR #144's Task-5 proposal. Applying the same reading as 8d6093b (2026-08-10): every proof for an already-merged PR goes unless a live doc cites it - git history and the PR descriptions keep the evidence. Deleted: in-app-review (#124), localization (#132), launcher-icon (#133), lens-removal (#134), photo-booth + upgrade-reverse-save (#138). Kept (doc-cited): camera-lenses proof (PRD-camera-lenses), speed-curve PNGs (PRD-speed-curves), twisted-tongue PNG (twisted-tounge/GUIDE). The e2e test skills and suites are untouched - this deletes stale screenshots, not tests. docs/e2e: 13.21 MiB -> 3.78 MiB. No dangling references (grepped). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JVeJv8UjqrwGSzHMZHbVu9

Description
Implements docs/PRD-photo-booth.md — the classic photo-booth artifact: a provisional Booth button runs a self-driving 5-4-3-2-1 countdown ×3 with a flash flicker per shot, top-bias square-crops each viewfinder grab at grab time, and composites the three frames into one vertical strip (white borders, "OPENLOOP · AUG 20 2026" footer, color by default with a B&W toggle). The strip saves through the unmodified photo path:
savePhoto→ MediaStorePictures/OpenLoop→ share sheet → in-app gallery. The lens tray stays interactive during every countdown — the swap window is the product hook.Key design points (all per PRD sign-off):
CaptureModeentry, no newOpenLoopUiState— booth is ephemeral UI state inCameraScreen; the exhaustive routerwhenis untouched (Lesson 014). Zero camera-binding changes (Lesson 031 never comes into play).BoothStripLayout(pure, Android-free, JVM-tested) owns all geometry — including the D3 regression test that a geometric center crop of a 9:19.5 source is rejected (it clips the crown / above-eye lens art).BoothStripComposeris a thin Canvas pass; B&W is a zero-saturationColorMatrixColorFilteron the frame draws only, so borders/footer stay crisp either way (D4).captureBoothStrip: injectable composer seam (JVM tests stay on the TestDispatcher — Lesson 029), re-entrancy guard mirroringphotoSaveInProgress, and a §5.4 guard — an incomplete frame set (null grab abort) surfaces the existing capture-failed snackbar and never saves a partial strip.rememberUpdatedState(Lesson 034).Related Issue
Closes #135
Type of Change
How Has This Been Tested?
:app:testDebugUnitTest— 125 tests in the touched suites, 13 new (BoothStripLayoutTest×7,OpenLoopViewModelTestbooth ×6), 0 failures.:app:connectedDebugAndroidTeston Pixel 8 (API 36 AVD) — 121 tests, 0 failures incl. 3 new booth UI tests. Debug and release builds green;:app:lintDebug— 0 findings in any booth file (one KTX warning found and fixed pre-commit); 16 KBzipalign -c -P 16real pass (every.so(OK)at 16384-multiples).Pictures/OpenLoop. Second run with B&W toggled → grayscale frames, crisp borders/footer. Cancel mid-sequence → idle restored, file count unchanged. Countdown digits/Cancel/tray never appear in the captured frames.docs/STATIC_ANALYSIS.mdfallback); substituted lint + zero compiler warnings.Screenshots
What could not be verified + manual QA checklist (needs a real face / device)
enabled, not a no-op) — needs a TalkBack pass to hear itreverse.complete, frames=9 sync=9); the Samsung encoder try-order lanes (Lessons 023/027) need the RTL/OEM sweep or a real GalaxyChecklist
🤖 Generated with Claude Code
https://claude.ai/code/session_014W2oDvqCgazqBaBjJ2kJWC
Review follow-up (2026-08-20)
Two commits after the /code-review high pass + Cursor Bugbot review:
captureBoothStripguard reorder (abort snackbar can't be swallowed; completed strip survives the gallery race; frames owned + recycled on every path;OutOfMemoryErroron the composite degrades to the snackbar), one sharedsavePhotoAndSharetail + one shared save guard,glassPill()dedupe, crop off-main, and a real-CameraScreeninstrumented test.animateTobeforecaptureBoothStripran) is gone by construction, and the point of no return matches D4's "until the last grab" boundary. PRD + KDoc updated; README refreshed (compileSdk 37, booth feature, 536/122 counts).UseKtxfixed;OldTargetApisuppressed at the source with the reason (targetSdk 36 is the deliberate, tracked decision — Play floor is 35).:app:lintDebugnow reports an empty issue list.Post-change gate: clean
assembleDebug+assembleReleasegreen (exit 0, zeroe:, zerow:after fixing the 10 compile warnings a clean build surfaced) · 16 KBzipalign -c -P 16real(OK)on every.so· 536 unit tests, 0 failures · 122 instrumented tests, 0 failures (Pixel_8 AVD — note the image is now API 37) · app driven live: booth 5-4-3-2-1 ×3 → strip → share sheet, and a 3.2 s FORWARD_THEN_REVERSE boomerang save (reverse.complete | frames=9 sync=9, zero FATAL in logcat). Engine 2 "Inspect Code" not run (headless inspection non-functional on this machine —STATIC_ANALYSIS.mdfallback; substituted lint + zero compiler warnings).Note
Medium Risk
Touches camera capture, bitmap memory, and the shared photo-save path, plus a Media3/AGP/Kotlin/compileSdk upgrade. targetSdk stays 36, but reverse/export and OEM lanes should be re-checked after the stack bump.
Overview
Adds a photo-booth strip: arm Photo Booth in the lens drawer, then the shutter runs a 5-4-3-2-1 countdown ×3, square-crops each viewfinder grab (top-biased so crown/lens hair stay in frame), and composites a vertical JPEG (white borders, OpenLoop + date footer, color or B&W). The strip reuses the existing photo save/share path — no new
CaptureModeor camera bind.Booth is ephemeral UI on
CameraScreen. Mid-sequence the shutter/mode/flip are disabled, lenses stay live for swaps, and cancel/back abort until the last grab (then the strip is committed before the cosmetic flash). Backgrounding aborts becausePreviewView.getBitmap()can return a frozen frame after ON_STOP (Lesson 036). Incomplete sets, overlapping saves, and OOM on composite all fail to the existing capture-failed snackbar and recycle bitmaps.Also bumps compileSdk 37, AGP 9.3.1, Kotlin 2.4.10, Compose BOM, Media3 1.11.0, and related libs; targetSdk stays 36 on purpose. Version 1.0.44. JVM layout tests plus ViewModel/UI coverage for the booth flow.
Reviewed by Cursor Bugbot for commit 44f4e47. Bugbot is set up for automated code reviews on this repo. Configure here.