Skip to content

feat(booth): photo-booth strip — 5-4-3-2-1 ×3 capture into a printed strip - #138

Merged
stozo04 merged 9 commits into
mainfrom
feature/photo-booth
Aug 21, 2026
Merged

feat(booth): photo-booth strip — 5-4-3-2-1 ×3 capture into a printed strip#138
stozo04 merged 9 commits into
mainfrom
feature/photo-booth

Conversation

@stozo04

@stozo04 stozo04 commented Aug 20, 2026

Copy link
Copy Markdown
Owner

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 → MediaStore Pictures/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):

  • No CaptureMode entry, no new OpenLoopUiState — booth is ephemeral UI state in CameraScreen; the exhaustive router when is 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). BoothStripComposer is a thin Canvas pass; B&W is a zero-saturation ColorMatrixColorFilter on 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 mirroring photoSaveInProgress, and a §5.4 guard — an incomplete frame set (null grab abort) surfaces the existing capture-failed snackbar and never saves a partial strip.
  • Accessibility (in the never-simplify-away bucket): countdown digits + "Shot n of 3" are polite live regions; Booth, Cancel, and the B&W chip carry content descriptions; the chip has switch semantics.
  • Cancel / predictive back abort the sequence and discard frames (Lesson 015); shutter, mode selector, and camera flip are disabled mid-sequence; the B&W chip stays live mid-countdown via rememberUpdatedState (Lesson 034).

Related Issue

Closes #135

Type of Change

  • Feature: A new feature (non-breaking change which adds functionality)

How Has This Been Tested?

  • Automated: :app:testDebugUnitTest — 125 tests in the touched suites, 13 new (BoothStripLayoutTest ×7, OpenLoopViewModelTest booth ×6), 0 failures. :app:connectedDebugAndroidTest on 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 KB zipalign -c -P 16 real pass (every .so (OK) at 16384-multiples).
  • Manual (Pixel 8 emulator, full flow driven end-to-end): Booth tap → 5-4-3-2-1 ×3 with flash → share sheet opens on the composited strip → "Saved" → strip in the in-app gallery and 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.
  • Static analysis: Android Lint clean (Engine 1). Engine 2 "Inspect Code" not run — headless inspection is non-functional on this machine (see docs/STATIC_ANALYSIS.md fallback); substituted lint + zero compiler warnings.

Screenshots

Idle controls Countdown Share sheet
Color strip B&W strip Gallery

What could not be verified + manual QA checklist (needs a real face / device)

  • Elvis pompadour / broccoli crown stays inside each square on a tall selfie (the crop math is JVM-tested; the visual needs a face — emulator's virtual scene has none)
  • Swapping lenses during a countdown bakes different lenses into different frames, in color (success criterion 6)
  • TalkBack pass: countdown ticks and "Shot n of 3" announced politely; Booth/Cancel/B&W labels read correctly
  • Known accepted behaviors (all documented in §5.1/§5.4 as of 0fe00ef): home (gallery) button mid-sequence silently aborts; backgrounding (ON_STOP) mid-sequence silently aborts — after started-then-stopped the TextureView serves a frozen frame, so grabs can't be trusted (Lesson 036); activity recreation mid-sequence resets to idle; after the third grab the strip is committed (hand-over precedes the cosmetic final flash, ebbdf87) — Cancel/gallery/back/backgrounding during that 250 ms can no longer discard it
  • TalkBack: shutter announces disabled mid-booth (real enabled, not a no-op) — needs a TalkBack pass to hear it
  • Backgrounding abort on a real device: take a phone call mid-countdown → sequence discards, no strip, no share sheet (emulator equivalent verified by code path only)
  • Samsung/Exynos reverse on media3 1.11.0: the upgrade is emulator-verified (reverse.complete, frames=9 sync=9); the Samsung encoder try-order lanes (Lessons 023/027) need the RTL/OEM sweep or a real Galaxy

Checklist

  • 🧪 My changes have been verified locally and work as expected.
  • 🔍 I have performed a self-review of my own code.
  • ✍️ I have commented my code, particularly in hard-to-understand areas.
  • 📖 My changes generate no new compile warnings or errors.
  • 🧹 The git branch is clean and references to obsolete branches have been pruned.

🤖 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:

  • 0fe00ef — all 10 review findings + both Cursor comments closed. Lifecycle-gated grabs (backgrounding = Cancel, Lesson 036 added), genuinely disabled shutter mid-booth, flash/overlay state reset at sequence start, captureBoothStrip guard reorder (abort snackbar can't be swallowed; completed strip survives the gallery race; frames owned + recycled on every path; OutOfMemoryError on the composite degrades to the snackbar), one shared savePhotoAndShare tail + one shared save guard, glassPill() dedupe, crop off-main, and a real-CameraScreen instrumented test.
  • ebbdf87 — Cursor round 2 (gallery tap drops finished strip): valid, fixed. The hand-over now happens at the third grab, before the final flash — the dominant disposal race (effect cancelled at animateTo before captureBoothStrip ran) 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).
  • 487d338 — all 25 pre-existing lint warnings cleared. AGP 9.3.1 · Kotlin 2.4.10 · compileSdk 37 · compose BOM 2026.08.00 · media3 1.11.0 · core-ktx/lifecycle/fragment/work/test/uiautomator/firebase/plugins to current stable; UseKtx fixed; OldTargetApi suppressed at the source with the reason (targetSdk 36 is the deliberate, tracked decision — Play floor is 35). :app:lintDebug now reports an empty issue list.

Post-change gate: clean assembleDebug + assembleRelease green (exit 0, zero e:, zero w: after fixing the 10 compile warnings a clean build surfaced) · 16 KB zipalign -c -P 16 real (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.md fallback; substituted lint + zero compiler warnings).

Post-fix countdown Reverse save on media3 1.11.0

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 CaptureMode or 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 because PreviewView.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.

…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
Comment thread app/src/main/java/io/github/stozo04/openloop/ui/CameraScreen.kt
Comment thread app/src/main/java/io/github/stozo04/openloop/ui/CameraScreen.kt Outdated
stozo04 and others added 2 commits August 20, 2026 10:42
…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
@stozo04

stozo04 commented Aug 20, 2026

Copy link
Copy Markdown
Owner Author

PR Review — Google Android Standards Compliance

Reviewer: Claude (Automated)
Date: 2026-08-20
PR: #138 — feat(booth): photo-booth strip — 5-4-3-2-1 ×3 capture into a printed strip
Standards sourced from: Target API level requirements · Compose performance best practices · Compose phases · Compose semantics / accessibility · Merging & clearing semantics · Coroutines best practices · CameraX architecture · UI layer / UDF · Testing strategies · Request runtime permissions · DataStore · Android 16 behavior changes
Files reviewed: 18 changed (2 new Kotlin sources, 2 modified Kotlin sources, 3 test files, strings, CLAUDE.md, PRD, cspell.json, 6 proof images) plus the untouched files booth depends on (MainActivity.kt event handling, VideoStorageRepositoryImpl.savePhoto, CameraManager)


PASS

  • Architecture Booth bypasses CaptureMode and adds no OpenLoopUiState entry — the exhaustive router when is untouched, exactly as PRD-photo-booth §5.1 and Lesson 014 require. captureBoothStrip follows UDF: UI hands frames up, state/events flow down (OpenLoopViewModel.kt). The composer enters via constructor injection (composeBoothStrip seam) mirroring the logged publishPhotoToLibrary pattern; the ViewModel stays Context-free (a Bitmap is plain data — Lesson 004).
  • Compose Countdown state reads are deferred into BoothCountdownOverlay's own scope via digit: () -> Int / shot: () -> Int lambdas, and the flash alpha is read inside graphicsLayer { } (draw phase) — a 1 Hz tick or a flash frame never recomposes the AndroidView viewfinder tree (defer reads). Effect-captured values are aliased through rememberUpdatedState (currentBoothMonochrome), closing the Lesson 034 trap for the mid-countdown B&W toggle — and the chip is actually reachable mid-sequence (rendered beside Cancel), so the alias does real work.
  • CameraX Zero camera-binding changes: no new use case, no rebind, no CameraManager edit. The grab is PreviewView.getBitmap() per the shipped PRD-photo-capture D1 decision. Camera flip is disabled mid-sequence, so no rebind can blank the ritual. Lessons 012/022/031 invariants hold (grep "CameraScreen(" MainActivity.kt still returns 1).
  • Coroutines All work in viewModelScope; the composite runs on Dispatchers.Default behind an injected suspending seam (tests substitute a TestDispatcher-bound fake — no real-dispatcher hop for virtual time to miss, Lesson 029). CancellationException is rethrown before the best-effort catch (OpenLoopViewModel.captureBoothStrip). The one broad catch (Exception) is the MediaStore publish, a documented deliberate deviation carried over from capturePhoto (PRD-photo-capture §5.5).
  • Media & Storage The strip rides the existing photo path wholesale — savePhoto → JPEG in filesDir/videos/ (inside the file_paths.xml share boundary) → best-effort MediaStore publish → share sheet with ClipData (Lesson 028, unchanged code). Bitmap memory is bounded: crop-at-grab retains ~1080² per frame, frames are recycled after composite, the strip after save, and cropToBoothSquare guards the documented createBitmap aliasing case (Bitmap docs) before recycling the source.
  • Testing 13 new JVM tests + 3 new instrumented tests, all green (125 unit / 121 connected total, 0 failures). Highlights: BoothStripLayoutTest locks the D3 regression (a center crop of a 9:19.5 source is explicitly rejected); the re-entrancy test parks savePhoto on a gate so overlap is enforced, not hoped for (Lesson 029); the B&W chip test drives the toggle both ways to catch a frozen lambda (Lesson 034). Pure math lives in an Android-free file per the repo's extract-the-math pattern.
  • Accessibility All three new controls are ≥48dp, carry content descriptions, and use semantic roles (Role.Button, Role.Switch on the toggleable chip). The countdown digits and "Shot n of 3" are LiveRegionMode.Polite live regions, so TalkBack announces the ticks and shot advances — a timed visual-only countdown would have excluded non-sighted users (Compose semantics). The digit node stays mounted through the grab beat so the live region persists.
  • Permissions No new permissions; booth runs entirely inside the already-granted camera session. Degradation is graceful: a null grab aborts to the existing "Couldn't take that photo" snackbar (MainActivity.kt:382), never a crash or a partial strip.
  • DataStore Untouched; the deliberate non-persistence of the B&W choice is logged in the PRD (out of scope) rather than an omission.
  • Play Store / Android 16 The API 36 floor is now in force for new apps/updates as of August 31, 2026 (requirements, confirmed today) — OpenLoop already targets 36, so this PR ships inside the window. Predictive back is handled: the booth-active flag joins the gated BackHandler and back aborts through UI state, per the target-36 rules (ANDROID_STANDARDS §11, Lesson 015).

FAIL

None.

WARNING

None introduced by this PR. (Pre-existing lint OldTargetApi and dependency-freshness warnings predate the PR — see Static Analysis below.)

RECOMMENDATIONS

  • [i18n] boothFooterText uppercases the formatted month with Locale.getDefault()media/BoothStripLayout.kt. In Turkish (dotted/dotless I) and some non-Latin locales the uppercased month can render oddly next to the English "OPENLOOP" wordmark.
    • Why: a printed artifact with mixed-script casing looks like a bug to those users; pinning the date format to the wordmark's locale (or accepting and documenting default-locale dates) makes it deliberate either way.
    • Effort: low.
  • [Compose/UX] The B&W chip fires no haptic on toggle while its sibling CaptureModeSelector performs ToggleOn/ToggleOff (ui/CameraScreen.kt, issue Camera/video mode toggle gives no feedback, and the icon is hidden under your finger while tapping #126 rationale: the tap should confirm itself through touch).
    • Why: consistent feedback across the two adjacent toggle surfaces; trivially testable with the existing recording-haptics pattern in CameraScreenTest.
    • Effort: low.
  • [Timing] The countdown accumulates delay(1_000L) ticks, so the ~18 s ritual stretches on CPU-starved devices (the same drift Lesson 012's hand-off note measured on the recording timer). No correctness impact — nothing is capped by it — but if the booth cadence ever needs to be wall-clock-honest, inject a monotonic time source as that note prescribes.
    • Why: documented repo-known drift; cheap to fix later because the loop is one constant.
    • Effort: low (when needed).
  • [Static analysis noise, pre-existing] 25 lint warnings on main carry into this branch unchanged: 20× GradleDependency, 2× AndroidGradlePluginVersion, 1× NewerVersionAvailable (expected dependency-freshness background per docs/STATIC_ANALYSIS.md), 1× OldTargetApi (the app targets 36, the current floor — noise until a 37 target exists), and 1× UseKtx at camera/lens/LensSurfaceProcessor.kt:625. None are from this PR; the one UseKtx the PR did introduce was caught and fixed before merge (BoothStripComposer.kt now uses the KTX createBitmap).
    • Why: keeping the "every finding is live" no-baseline policy honest.
    • Effort: n/a for this PR.

Summary

Category Pass Fail Warning Rec
Architecture 1 0 0 0
DataStore 1 0 0 0
Permissions 1 0 0 0
Compose 1 0 0 1
CameraX 1 0 0 0
Media & Audio 1 0 0 0
Coroutines 1 0 0 1
Testing 1 0 0 0
Accessibility 1 0 0 1
Play Store 1 0 0 0
Android Version 1 0 0 0
Static Analysis (Lint + IDE Inspect) 1 0 0 1
Total 12 0 0 4

Static analysis engines: Engine 1 (Android Lint) ran:app:lintDebug clean of PR-introduced findings (0 in any booth file; the 25 carried warnings are itemized above). Engine 2 (IDE "Inspect Code") was NOT run — headless inspection is non-functional on this machine (docs/STATIC_ANALYSIS.md fallback applies); it must be run locally in Android Studio before merge, or its absence accepted explicitly. Tier 3 OSS fallback ran on the changed Markdown: markdownlint, cspell, and link-check all pass after in-PR fixes (fence language + list spacing in the PRD; nine legitimate jargon terms added to cspell.json).

Verdict

APPROVE

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 docs/STATIC_ANALYSIS.md, and keep the PR's own manual-QA checklist for the face-dependent checks (lens hair inside the square, lens-swap-per-frame) on a real device — the emulator cannot exercise either.

stozo04 and others added 3 commits August 20, 2026 11:42
- 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

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread app/src/main/java/io/github/stozo04/openloop/ui/CameraScreen.kt
stozo04 and others added 2 commits August 20, 2026 13:37
… 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
@stozo04

stozo04 commented Aug 20, 2026

Copy link
Copy Markdown
Owner Author

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):

  • Idle viewfinder is clean — the provisional floating Booth + B&W pills are gone.
  • Two-tab drawer behind the bottom-left button: Photo Booth | Lenses slider (Lenses default). The Photo Booth tab swaps the carousel for the D4 Color | Black & White radio pair and arms the booth; the shutter starts the strip while armed (announced as "Start photo booth").
  • Armed state survives closing the drawer (lime lens button = the cue); picking Camera/Video disarms; the booth tab's ✕ cancels a running sequence + disarms + closes (predictive back still aborts). No separate mid-sequence Cancel/B&W row — the drawer is the control surface.
  • D5 banner during the sequence: "Swap lenses between shots", lime-trimmed glass chip, center-aligned with the gallery button.

Verification (Definition of Done)

Gate Result
:app:assembleDebug / :app:assembleRelease BUILD SUCCESSFUL, exit 0, zero e:
:app:testDebugUnitTest 536 tests, 0 failures
:app:connectedDebugAndroidTest (Pixel_8 AVD, API 37) 121 tests, 0 failures, 1 pre-existing skip — full suite re-run after the final UI tweak. One earlier run hit an empty <failure/> on SpeedTabPanelCurveTest (instrumentation process death under load, no assertion message); it passed 15/15 in isolation and the full re-run was green
:app:lintDebug No issues found (0 errors, 0 warnings)
Engine 2 (IDE Inspect Code) Not run — cannot execute headlessly on this machine (per docs/STATIC_ANALYSIS.md fallback: lint + compiler-warning-free build substituted)
16 KB zipalign Not re-run — this commit is pure Kotlin/resources/docs; no packaging or native-lib change
Run on emulator + screenshots App driven through onboarding → camera → drawer → arm → countdown; screenshots captured of idle, Lenses tab, armed Photo Booth tab, and the running countdown with banner (owner also exercised the flow live on hardware during the iteration)

Manual QA still worth a device pass

  • Full 3-shot strip on hardware with a face: arm from drawer → strip saves via the unchanged photo path, Color and B&W both
  • Lens swap mid-countdown from the drawer's Lenses tab bakes different lenses into different frames (D5)
  • Back-gesture priority mid-sequence with the drawer open (closes drawer first, then cancels)

🤖 Generated with Claude Code

https://claude.ai/code/session_01Wkq4T1GP4jcvxkitbxeQAS

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
@stozo04
stozo04 merged commit fca64d8 into main Aug 21, 2026
3 checks passed
@stozo04
stozo04 deleted the feature/photo-booth branch August 21, 2026 19:15
stozo04 added a commit that referenced this pull request Aug 24, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Photo Booth Style!

1 participant