diff --git a/.claude/commands/e2e-fold-loop.md b/.claude/commands/e2e-fold-loop.md index 69d7db1..7b04e20 100644 --- a/.claude/commands/e2e-fold-loop.md +++ b/.claude/commands/e2e-fold-loop.md @@ -2,10 +2,9 @@ > **Status: GOAL MET 2026-06-04** (branch `feature/e2e-fold-loop-fixes`). Iteration 1 found and > fixed BUG-1 (pass-1 jitter subsampling → 15 fps + corrupted reverse half, commit `731b26b`); -> iteration 2 confirmed a zero-change clean sweep on all three devices. Reports + state: -> `docs/e2e/fold-loop/`. BUG-2 (skip-at-input corruption for >30 fps sources) remains open — -> see STATE.md. Re-run this loop after any media-pipeline change, or whenever a Pixel-family -> regression is suspected. +> iteration 2 confirmed a zero-change clean sweep on all three devices. Historical reports were +> archived during doc cleanup. BUG-2 (skip-at-input corruption for >30 fps sources) may still apply — +> re-run this loop after any media-pipeline change, or whenever a Pixel-family regression is suspected. ## THE GOAL @@ -33,16 +32,14 @@ needed a mid-run fix doesn't count; the next iteration must confirm). ## Session bootstrap (context does not persist between sessions) -1. Read `CLAUDE.md` + every numbered lesson in `docs/lessons_learned/` (013, 018–023 are the - media-pipeline ones you'll need). -2. Read `docs/e2e/fold-loop/STATE.md` — iteration number, open bugs with fix-attempt counts, - what to do first. Missing = iteration 1: create it (template at bottom). -3. Read `.claude/skills/run-e2e-pixel-sweep/SKILL.md` in full — **it IS the mechanics** (the +1. Read `CLAUDE.md` + skim `docs/PRD-mission-control.md` media-pipeline sections and + `docs/guides/oem-regression-testing.md` (media-pipeline / codec lanes). +2. Read `.claude/skills/run-e2e-pixel-sweep/SKILL.md` — **it IS the mechanics** (the scripted prep/drive/gate phases, validated 2026-06-04). Its parent `run-e2e` skill supplies `scan-logcat.ps1` + the signature catalog and the honesty rules; both apply verbatim. -4. Git: branch `feature/e2e-fold-loop-fixes` (create from `main` if absent). Never commit to +3. Git: branch `feature/e2e-fold-loop-fixes` (create from `main` if absent). Never commit to `main`. Never commit the video (gitignored). -5. **Do not trust training data.** Before any claim about Android API behavior or any fix +4. **Do not trust training data.** Before any claim about Android API behavior or any fix design, WebSearch `developer.android.com` scoped to the repo's actual versions (re-read the table in `CLAUDE.md` / `gradle/libs.versions.toml` — currently targetSdk 36, Kotlin 2.3.21, Media3 1.10.1, CameraX 1.6.1). Pure-math fixes verified by device evidence in front of you diff --git a/.claude/skills/crashlytics-triage/SKILL.md b/.claude/skills/crashlytics-triage/SKILL.md index 894f4bd..f472eff 100644 --- a/.claude/skills/crashlytics-triage/SKILL.md +++ b/.claude/skills/crashlytics-triage/SKILL.md @@ -26,9 +26,8 @@ Official workflow: [Crashlytics AI assistance MCP](https://firebase.google.com/d | Android **appId** (required on every MCP call) | `1:95815153197:android:c30254bb713d1e6ae96aa4` | | Package / applicationId | `io.github.stozo04.openloop` | | `google-services.json` | `app/google-services.json` (gitignored; plugins apply only when present) | -| Non-fatal reverse failures | `ReverseCrashlytics` — keys like `reverse_outcome`, `video_mime` | -| Product doc | `docs/diagnostics/firebase-crashlytics-trimming.md` | -| Issue `3a506c4e` verification | `docs/diagnostics/crashlytics-issue-3a506c4e-verification.md` | +| Non-fatal reverse failures | `ReverseCrashlytics.kt` — keys like `reverse_outcome`, `video_mime` | +| Codec churn / issue `3a506c4e` | [`docs/lessons_learned/020-*.md`](../../../docs/lessons_learned/020-imported-clips-hdr-codec-and-reverse-failure-recovery.md), [`023-*.md`](../../../docs/lessons_learned/023-media-pipeline-stages-must-count-output-samples.md), [`reverse-video-research.md`](../../../docs/guides/reverse-video-research.md) | Non-fatals upload on **next app launch**, not instantly. @@ -99,7 +98,7 @@ When the user gives an issue id, console URL, or picks one from Mode A: 3. `crashlytics_batch_get_events` using `sampleEvent` from the issue response; more samples via `crashlytics_list_events` with the same filters (`issueId`, versions, error types). 4. `crashlytics_list_notes` — resume prior on-call context. 5. Read stack frames in the repo (`VideoReverser`, `OpenLoopViewModel`, `ReverseCrashlytics`, etc.). -6. Cross-check `docs/active/` and `docs/diagnostics/` for known incidents (e.g. editor OOM `ef2823cf…`, trimming doc). +6. Cross-check [`docs/lessons_learned/020-imported-clips-hdr-codec-and-reverse-failure-recovery.md`](../../../docs/lessons_learned/020-imported-clips-hdr-codec-and-reverse-failure-recovery.md), [`023-media-pipeline-stages-must-count-output-samples.md`](../../../docs/lessons_learned/023-media-pipeline-stages-must-count-output-samples.md), and [`docs/guides/reverse-video-research.md`](../../../docs/guides/reverse-video-research.md) for known reverse/codec incidents. 7. Produce the investigation plan (do not implement until user confirms): ```markdown diff --git a/.claude/skills/run-e2e-pixel-sweep/SKILL.md b/.claude/skills/run-e2e-pixel-sweep/SKILL.md index c617bdd..8c55c94 100644 --- a/.claude/skills/run-e2e-pixel-sweep/SKILL.md +++ b/.claude/skills/run-e2e-pixel-sweep/SKILL.md @@ -59,7 +59,7 @@ Loopify save with `InvalidForegroundServiceTypeException: Starting FGS with type (Crashlytics 9663c743, Galaxy A55). The fix gates to `dataSync` on API 29–34. After the three API-35+ emulators pass, this device is the **mandatory FGS regression gate**: a green save + `Worker result SUCCESS` for `BoomerangRenderWorker` and **zero** `InvalidForegroundServiceTypeException` -lines in logcat. See `docs/e2e/2026-06-22_082330-api34-fgs-fix.md` for the first verified run. +lines in logcat. See [`docs/guides/oem-regression-testing.md`](../../../docs/guides/oem-regression-testing.md) lane 1 (API 34 FGS). ```powershell $avd = "Pixel_6" # then Pixel_8, then Pixel_10_Pro_Fold, then Pixel_8_API34 @@ -102,7 +102,7 @@ and block `setprop`; the emulator still reports `Google` / goldfish codecs. ### Samsung RTL sweep (real Galaxy hardware) -Setup: [`docs/guides/samsung-rtl-steps.md`](../../docs/guides/samsung-rtl-steps.md) — reserve device, +Setup: [`docs/guides/samsung-rtl-steps.md`](../../../docs/guides/samsung-rtl-steps.md) — reserve device, start RDB, verify `adb devices` shows `localhost:`. ```powershell diff --git a/.claude/skills/run-e2e/references/logcat-signatures.md b/.claude/skills/run-e2e/references/logcat-signatures.md index 6a0786e..19f1577 100644 --- a/.claude/skills/run-e2e/references/logcat-signatures.md +++ b/.claude/skills/run-e2e/references/logcat-signatures.md @@ -25,7 +25,7 @@ What `scan-logcat.ps1` greps for, what each means, and how to treat it. Classes: ## CHURN — advisory (resource-pressure smell) These rarely fail a run on their own, but a cluster of them around a reverse that stalled is the -fingerprint of the editor-codec-churn disease (`docs/active/editor-codec-churn/IMPLEMENTATION.md`). +fingerprint of the editor-codec-churn disease (see [`docs/lessons_learned/023-media-pipeline-stages-must-count-output-samples.md`](../../../../docs/lessons_learned/023-media-pipeline-stages-must-count-output-samples.md)). | Signature | Meaning | |-----------|---------| @@ -50,6 +50,7 @@ one that stalls to the 120 s timeout — clip length is not the driver, editor c ## Related repo context -- `docs/active/editor-codec-churn/IMPLEMENTATION.md` — root-cause analysis of the churn/timeout. -- `docs/diagnostics/crashlytics-issue-3a506c4e-verification.md` — the two Crashlytics issues. +- [`docs/lessons_learned/023-media-pipeline-stages-must-count-output-samples.md`](../../../../docs/lessons_learned/023-media-pipeline-stages-must-count-output-samples.md) — codec churn / output-sample counting +- [`docs/lessons_learned/020-imported-clips-hdr-codec-and-reverse-failure-recovery.md`](../../../../docs/lessons_learned/020-imported-clips-hdr-codec-and-reverse-failure-recovery.md) — Crashlytics issues `3a506c4e` / `b09e527` +- `ReverseCrashlytics.kt` — custom keys and non-fatal recording - Memory `project-trim-handle-touch-sensitivity-bug` — why the trim handles fight you. diff --git a/.github/workflows/doc-layout.yml b/.github/workflows/doc-layout.yml new file mode 100644 index 0000000..e260054 --- /dev/null +++ b/.github/workflows/doc-layout.yml @@ -0,0 +1,61 @@ +name: Doc layout + +# Hard gate: new Markdown files must live under docs/ (except root README.md and CLAUDE.md). +# Design: docs/README.md § Enforcement + +on: + pull_request: + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + markdown-placement: + name: New .md file placement + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Fail on new Markdown outside docs/ + run: | + if [ -n "${{ github.event.pull_request.base.sha }}" ]; then + BASE='${{ github.event.pull_request.base.sha }}' + HEAD='${{ github.event.pull_request.head.sha }}' + else + git fetch --no-tags --depth=1 origin "${{ github.event.repository.default_branch }}" || true + BASE="origin/${{ github.event.repository.default_branch }}" + HEAD='HEAD' + fi + + ADDED=$(git diff --name-only --diff-filter=A "$BASE" "$HEAD" -- '*.md' || true) + if [ -z "$ADDED" ]; then + echo "No new Markdown files in this diff." + exit 0 + fi + + echo "New Markdown files:" + echo "$ADDED" + + VIOLATIONS="" + for f in $ADDED; do + case "$f" in + README.md|CLAUDE.md) + ;; + docs/*) + ;; + *) + VIOLATIONS="${VIOLATIONS}${f}\n" + ;; + esac + done + + if [ -n "$VIOLATIONS" ]; then + echo "::error::New Markdown must live under docs/ (except root README.md and CLAUDE.md)." + printf '%b' "$VIOLATIONS" + exit 1 + fi + + echo "All new Markdown files are in allowed locations." diff --git a/.gitignore b/.gitignore index 9c44eb0..2d01cd5 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ keystore.properties # Personal local-only notes (signing playbook, passwords reminders, etc.) — never commit local/ +docs/local/ # Files generated by Gradle .gradle diff --git a/CLAUDE.md b/CLAUDE.md index d59f992..e36f862 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,7 +67,7 @@ Show reasoning, not just conclusions. I value breadth and rigor equally — cast When operating in a specific subfolder that has its own CLAUDE.md, respect that folder's voice and approach. The root CLAUDE.md (this file) provides defaults; subfolder overrides take precedence. -All project documentation (`.md` files) belongs in the `docs/` directory — not the project root. The only exceptions are `CLAUDE.md` and `README.md` which live at the root by convention. +All project documentation (`.md` files) belongs in the `docs/` directory — not the project root. The only exceptions are `CLAUDE.md` and `README.md` which live at the root by convention. **Folder map and placement rules:** [`docs/README.md`](docs/README.md) (markdown layout, image assets, gitignored `docs/local/` for private notes). ## Architecture Snapshot @@ -83,7 +83,7 @@ All project documentation (`.md` files) belongs in the `docs/` directory — not | Build | Gradle 9.0.0, AGP 8.13.2 | — | | Target | compileSdk 36, minSdk 26, targetSdk 36 | — | -> **SDK status (shipped via [Issue #7](https://github.com/stozo04/OpenLoop/issues/7)):** the app targets **API 36 (Android 16)** — `compileSdk`/`targetSdk` 36, `minSdk` stays 26 — clearing Google Play's target-API floor (currently API 35). The upgrade also moved to Kotlin 2.3.21 + the Compose Compiler Gradle plugin (required by the latest CameraX/Media3), and the native libraries are 16 KB page-aligned (uncompressed packaging). Behavior-change detail: [`docs/android-16/`](docs/android-16/README.md). Play's requirement: [Target API Level Requirements](https://developer.android.com/google/play/requirements/target-sdk). +> **SDK status (shipped via [Issue #7](https://github.com/stozo04/OpenLoop/issues/7)):** the app targets **API 36 (Android 16)** — `compileSdk`/`targetSdk` 36, `minSdk` stays 26 — clearing Google Play's target-API floor (currently API 35). Behavior changes: [Android 16 behavior changes](https://developer.android.com/about/versions/16/behavior-changes-16) and `ANDROID_STANDARDS.md` §11. Play's requirement: [Target API Level Requirements](https://developer.android.com/google/play/requirements/target-sdk). ### Source Layout @@ -138,17 +138,13 @@ All design tokens, storage patterns, testing strategy, and engineering decisions | Document | Purpose | |----------|--------| +| [`docs/README.md`](docs/README.md) | **Documentation layout** — where every `.md` and doc image belongs; enforcement rules | | `docs/DEFINITION_OF_DONE.md` | **The "Ready for PR" verification gate** — build + test + *run the app + screenshot* before anything is called done. Non-negotiable for non-trivial changes. | | `docs/lessons_learned/` | **Distilled rules from past PR reviews and bugs. Read every file at session start — see "Required Reading" above.** | | `docs/PRD-mission-control.md` | **Authoritative architecture and component specs.** Read before any structural change. | -| `docs/TEST_COVERAGE.md` | **Testing strategy and inventory.** Defines test directories, pyramid, frameworks, coroutine testing, current coverage, and gaps. Sourced from Google docs. **OEM lanes:** [`docs/guides/oem-regression-testing.md`](guides/oem-regression-testing.md). | +| `docs/TEST_COVERAGE.md` | **Testing strategy and inventory.** Defines test directories, pyramid, frameworks, coroutine testing, current coverage, and gaps. Sourced from Google docs. **OEM lanes:** [`docs/guides/oem-regression-testing.md`](docs/guides/oem-regression-testing.md). | | `docs/ANDROID_STANDARDS.md` | **Google Android best practices.** Non-negotiable standards with links to official specs. Consult before introducing new patterns or libraries. §11 covers Android-16 / target-36 rules (now in force — the app targets 36 as of Issue #7). | | `docs/STATIC_ANALYSIS.md` | **The "Inspect Code" merge gate.** How OpenLoop reproduces Android Studio's two inspection engines headlessly — Engine 1 (Android Lint, automated by the pr-reviewer skill) and Engine 2 (IDE inspections + proofreading, run locally). Exact commands, the `lint-baseline.xml` policy, and severity mapping. | -| `docs/android-16/` | **Android 16 (API 36) upgrade knowledge hub.** Per-page summaries of Google's Android 16 docs, each with an OpenLoop impact verdict and the official source URL. Durable reference for the `targetSdk 36` upgrade (Issue #7) — does not move to `completed/`. | -| `docs/active/` | **Active feature folders.** Each feature gets a folder with at least one IMPLEMENTATION.md. See `docs/active/README.md` for the convention. | -| `docs/completed/` | **Shipped features.** Moved here from `docs/active/` after merge to main. | -| `docs/guides/` | **Plain-English how-to guides.** Beginner-friendly walkthroughs (e.g. `jetpack-datastore-explained.md`, **`oem-regression-testing.md`** — API 34 / Samsung / LG regression lanes). | +| `docs/guides/` | **Plain-English how-tos and durable reference** (reverse algorithm, Robolectric, OEM lanes). Index: [`docs/guides/README.md`](docs/guides/README.md). | +| `docs/play-store/` | **Play Console submission pack** — privacy policy, data safety, store listing copy, signing. | | `.github/` | PR template, branch naming (`feature/`), and workflow conventions. | - - -Words of wisdom from a previous Claude `HEY_CLAUDE_ITS_ME.md`. diff --git a/GOOGLE_PLAY_PRODUCTION_AUDIT.md b/GOOGLE_PLAY_PRODUCTION_AUDIT.md deleted file mode 100644 index f107193..0000000 --- a/GOOGLE_PLAY_PRODUCTION_AUDIT.md +++ /dev/null @@ -1,89 +0,0 @@ -# Google Play Production Readiness Audit: OpenLoop -**Auditor:** Google Android Developer Relations (Simulated) -**Date:** June 5, 2026 -**Target API Level:** 36 (Android 16) -**Status:** 🟢 READY FOR PRODUCTION (with minor optimizations) - ---- - -## 1. Executive Summary -OpenLoop is a high-quality, privacy-first media application that leverages modern Android Jetpack libraries. The project demonstrates an exceptional commitment to "Android Standards" (referencing your `docs/ANDROID_STANDARDS.md`). - -The app successfully navigates the transition to **Android 16 (API 36)**, specifically addressing high-priority platform changes like 16KB page alignment and edge-to-edge enforcement. To reach "Excellent" status on Google Play, the following audit highlights critical successes and recommended improvements. - ---- - -## 2. Platform Compliance (API 36 & Hardware) - -### ✅ 16 KB Page Alignment -* **Finding:** The app uses native libraries (CameraX, Media3). -* **Verdict:** **PASSED.** Your `app/build.gradle.kts` uses `useLegacyPackaging = false`, ensuring native `.so` files are uncompressed and 16 KB page-aligned. This is mandatory for Android 15+ and future-proofs the app for high-performance hardware. - -### ✅ Edge-to-Edge Enforcement -* **Finding:** Android 16 removes the ability to opt-out of edge-to-edge enforcement. -* **Verdict:** **PASSED.** Your `MainActivity` and `BoomerangEditorScreen` use `statusBarsPadding()` and `navigationBarsPadding()`. This ensures the UI doesn't collide with system bars on API 36. - -### ✅ Predictive Back Gesture -* **Finding:** `android:enableOnBackInvokedCallback="true"` is set in the Manifest. -* **Verdict:** **PASSED.** The app is ready for the system-wide predictive back animations enforced at target 36. - ---- - -## 3. Privacy & Data Safety (Play Store) - -### ✅ Advertising ID (AD_ID) Removal -* **Finding:** The Manifest explicitly removes the `AD_ID` permission and disables collection in metadata. -* **Verdict:** **EXCELLENT.** This allows you to claim "No data collected" and "Does not use Advertising ID" in the Play Console Data Safety section, which is a significant trust signal for users. - -### ✅ Scoped Storage & Permissions -* **Finding:** The app uses `FileProvider` for sharing and internal storage for scratch files. -* **Verdict:** **PASSED.** Minimal permission surface (Camera only). No broad storage permissions are requested, aligning with modern privacy guidelines. - ---- - -## 4. Performance & Stability - -### ⚠️ MISSING: Baseline Profiles -* **Finding:** No `:baselineprofile` module or `BaselineProfileGenerator` detected. -* **Risk:** Jetpack Compose apps suffer from "First Frame Junk" (JIT compilation lag) during the first run after installation or update. -* **Recommendation:** **HIGH PRIORITY.** Implement a Baseline Profile generator using the `androidx.baselineprofile` plugin. This will pre-compile critical paths (Capture, Trim, Save) to AOT (Ahead-of-Time), reducing startup time by up to 30%. - -### ✅ Lifecycle-Aware Collection -* **Finding:** Extensive use of `collectAsStateWithLifecycle()` in `BoomerangEditorScreen.kt`. -* **Verdict:** **PASSED.** This prevents resource leaks and unnecessary processing when the app is in the background (Lesson 002 compliance). - -### ✅ Media3 Transformer Pipeline -* **Finding:** Robust handling of HDR-to-SDR tone mapping in `VideoReverser.kt` using `requestSdrToneMapping()`. -* **Verdict:** **PASSED.** This is critical for preventing crashes on devices that capture in 10-bit HDR but cannot process it in the reverse pipeline. - ---- - -## 5. User Experience (UX/UI) - -### ✅ Accessibility (a11y) -* **Finding:** UI components use `semantics` blocks for content descriptions and progress info. -* **Verdict:** **PASSED.** Taps and gestures in the editor (like the Save checkmark) have clear accessibility labels. - -### 💡 Improvement: Haptic Feedback Depth -* **Finding:** `LocalHapticFeedback` is used for the Save button. -* **Suggestion:** Add subtle `HapticFeedbackType.TextHandleMove` (or equivalent) during horizontal drag gestures in the `Trim` screen for a more tactile, premium feel. - -### 💡 Improvement: Material You (Dynamic Color) -* **Finding:** The app uses a high-contrast brand palette (`ElectricLime`). -* **Suggestion:** Ensure that while brand colors are primary, system-generated Dynamic Colors (Material You) are integrated for non-branded components (like the `Discard` dialog) to feel native to the user's OS theme. - ---- - -## 6. Actionable Roadmap for Production - -### Phase 1: Critical (Pre-Submission) -1. **Baseline Profiles:** Generate and bundle a baseline profile to eliminate Compose jank. -2. **Pixel Fold Audit:** Since targeting API 36, perform a "Desktop Mode" and "Folded/Unfolded" transition test. Target 36 ignores some manifest orientation restrictions. - -### Phase 2: Recommended (Post-Launch) -1. **Play Integrity API:** Consider integrating Play Integrity to ensure the app is running in a genuine environment before allowing heavy video processing. -2. **Asset Proofreading:** Re-run your `images/build_*.py` scripts to ensure the "No Transparency" rule for the Play Store icon is strictly followed for the final 512x512 PNG. - ---- - -**Final Audit Result:** This application is technically superior to 90% of its category competitors. It follows "The Google Way" with almost no technical debt. **Proceed to Production.** diff --git a/HEY_CLAUDE_ITS_ME.md b/HEY_CLAUDE_ITS_ME.md deleted file mode 100644 index 78382c0..0000000 --- a/HEY_CLAUDE_ITS_ME.md +++ /dev/null @@ -1,88 +0,0 @@ -# HEY CLAUDE, IT'S ME 🪃 - -A note from a past Claude Code session to you, the next one. Steven asked me to leave this -at the repo root (yes, against the "docs go in `docs/`" rule — he made the call, so enjoy your -one sanctioned exception). It's not the source of truth; it's the hand on your shoulder pointing -you at the source of truth, plus the stuff I wish someone had told *me* at minute one. - -_Written 2026-05-28 by the Opus 4.7 session that took OpenLoop to `targetSdk 36`._ - ---- - -## Who you're working with - -Steven — solo dev, Android/Kotlin, sharp, builds with AI on purpose and well. What he actually -wants from a CC partner (this is the real gold): - -- **Take initiative to ensure success.** The moment this session clicked for him was when I didn't - just *say* "the build passes" — I booted an emulator, installed the app, launched it, and - screenshotted onboarding. Then later drove his actual phone over adb. He didn't ask for either. - Close the loop. Prove it works. That instinct is the job. -- **Push back and flag contradictions.** When "use latest deps" collided with the doc's "keep - Kotlin 1.9.22" decision, the right move was to stop and surface the tension, not silently pick one. - He values that more than speed. -- **No fluff, no sycophancy.** Rigor and breadth, said plainly. He can read a diff. -- **PRD-first, sign-off before build-file changes, explicit "proceed" before anything irreversible** - (especially Play submission). He'll tell you to go autonomous when he wants that. - -## How we work here (read these — they're not optional) - -1. `CLAUDE.md` — operating instructions. Start here every session. -2. `docs/DEFINITION_OF_DONE.md` — **the bar.** A change isn't done because it compiles. Baseline → - clean build (debug **and** release) → requirement checks → unit + instrumented tests → **run the - app and screenshot it** → state what you couldn't verify → attach proof to the PR. This came out - of *this* session and Steven made it a hard standard. Live it. -3. `docs/lessons_learned/` — read every file. They were expensive to learn. Don't re-pay. -4. **Do NOT trust your training data on versions/behavior.** Web-verify against developer.android.com - and Google Maven *this session*. The "latest stable" you remember is already stale. (Mine was: - the deps had moved two minor versions past what the plan named.) - -## Environment cheat-sheet (Windows — hard-won, saves you an hour) - -- **Java:** no `java` on PATH. Use Android Studio's JBR: - `JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr"` (it's JDK 21; fine for AGP 8.13 / Gradle 9). -- **Build:** `.\gradlew.bat ...` (PowerShell). Capture the real result — - `BUILD SUCCESSFUL` **and** `$LASTEXITCODE -eq 0` **and** zero `e:` lines. -- **The `| tail` trap:** piping Gradle through `| tail` gives you *tail's* exit code. A failed build - will look green. It fooled me once on the baseline. Read the verdict line itself. -- **adb on-device paths + Git Bash = pain:** MSYS rewrites `/sdcard/foo` into - `C:/Program Files/Git/sdcard/foo` and the pull fails. Run those adb commands from **PowerShell** - (or `MSYS_NO_PATHCONV=1`). -- **Foldables / multi-display:** `exec-out screencap -p > file` gets a "Multiple displays" warning - bled into the PNG and corrupts it. Use `screencap -p /sdcard/x.png` then `pull`. -- **Multiple devices / ghost `offline` entries:** always `-s ` (or `$env:ANDROID_SERIAL`); - `adb kill-server; adb start-server` clears dead ghosts. -- SDK lives at `C:\Users\gates\AppData\Local\Android\Sdk` (`platform-tools\adb.exe`, - `build-tools\\zipalign.exe`, `emulator\emulator.exe`). -- Steven runs a **physical Pixel 10 Pro Fold (API 36)** — adb commands hit *real hardware*. Be - considerate (force-stop closes his app on his screen). The `/reset-storage` skill handles the - OpenLoop reset safely. - -## Gotchas this codebase will throw at you - -- **16 KB native libs:** `zipalign -c -P 16` reading `(OK - compressed)` is a *fake* pass. You need - `useLegacyPackaging = false` so libs are uncompressed, and you get a real `(OK)`. See lesson 011. -- **Latest AndroidX ⇒ Kotlin 2.x:** CameraX/Media3's newest releases ship Kotlin 2.1 metadata; a 1.9 - compiler can't read it. Migrating means the Compose compiler moves to the - `kotlin.plugin.compose` Gradle plugin and `kotlinOptions` → `compilerOptions`. -- **Release-only failures are real:** R8 + resource crunch catch things debug never will. Three - "PNG" onboarding drawables were actually JPEGs (mislabeled `.png`) — fine in debug, fatal in the - release crunch. Always build release too. - -## Where things stand (snapshot — verify with `git`/`gh`, this rots) - -- **API 36 upgrade:** shipped on `feature/target-sdk-36` → **PR #15** (open). Build/tests/16 KB green. -- **Issue #7** (the upgrade) left **open** on purpose — closes after Steven's on-device QA. -- **Issue #14** — Play-release mechanics (signing, .aab, Data Safety, privacy policy), fully spec'd. -- **Still pending:** PR #15's `pr-reviewer` standards gate (repo merge requirement), the unfolded - ≥600 dp large-screen pass, and the full camera→preview→gallery→delete walkthrough on API 36. -- **Skills:** `/reset-storage` (re-show onboarding; asks keep-vs-delete videos) and `pr-reviewer` - (the standards gate). Both under `.claude/skills/`. - -## Last thing - -Steven's a great partner — give him the same energy back: do the work, prove it ran, tell him the -truth about what you didn't check, and don't be a pushover when something smells wrong. You've got -this. Go close some loops. - -— your past self 🪃 diff --git a/README.md b/README.md index 83d1655..792d6a4 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ Every boomerang/loop app on the Play Store either costs money, runs ads, or send | **Testing** | JUnit 4 + MockK + Compose UI Test | Unit tests for ViewModel logic, UI regression tests for layout-critical composables | | **Performance** | Baseline Profiles | Pre-compiles "hot" code paths to eliminate Compose jank and speed up startup | -**SDK levels:** `minSdk 26` (Android 8.0) · `compileSdk 36` · `targetSdk 36` — upgraded to **API 36 (Android 16)** for Google Play readiness (`minSdk` stays 26), tracked in [Issue #7](https://github.com/stozo04/OpenLoop/issues/7), with the full behavior-change breakdown in [`docs/android-16/`](docs/android-16/README.md). Google Play's target-API rule: [Target API Level Requirements](https://developer.android.com/google/play/requirements/target-sdk). +**SDK levels:** `minSdk 26` (Android 8.0) · `compileSdk 36` · `targetSdk 36` — upgraded to **API 36 (Android 16)** for Google Play readiness, tracked in [Issue #7](https://github.com/stozo04/OpenLoop/issues/7). Behavior changes: [Android 16 behavior changes](https://developer.android.com/about/versions/16/behavior-changes-16) and [`docs/ANDROID_STANDARDS.md`](docs/ANDROID_STANDARDS.md) §11. Google Play's target-API rule: [Target API Level Requirements](https://developer.android.com/google/play/requirements/target-sdk). ### State Machine @@ -206,6 +206,8 @@ Plain-English, beginner-friendly walkthroughs live in [`docs/guides/`](docs/guid - [**Jetpack DataStore — Explained Like You're Five**](docs/guides/jetpack-datastore-explained.md) — what the app's little "memory" is, where it lives on the phone, three ways to peek inside it, and how to reset onboarding for testing. +Full documentation layout (where every `.md` and image belongs): [`docs/README.md`](docs/README.md). + ## Brand Assets The visual identity in one place — colors, the on-device launcher icon, and the assets Google Play hosts on the store listing. @@ -224,7 +226,7 @@ For the full Play Store submission pack (copy, data safety, content rating, sign ## Development Standards -This project follows Google's official Android development guidance. See [`docs/ANDROID_STANDARDS.md`](docs/ANDROID_STANDARDS.md) for the full standards reference with links to Google's specs. We treat these as non-negotiable — if Google recommends it, we follow it. For Android 16 / `targetSdk 36`-specific guidance behind the in-progress upgrade, see the [`docs/android-16/`](docs/android-16/README.md) knowledge hub. +This project follows Google's official Android development guidance. See [`docs/ANDROID_STANDARDS.md`](docs/ANDROID_STANDARDS.md) for the full standards reference with links to Google's specs. For Android 16 / `targetSdk 36`, see §11 and [Google's behavior changes](https://developer.android.com/about/versions/16/behavior-changes-16). ### PR Merge Policy @@ -256,11 +258,7 @@ Android Studio's *Analyze → Inspect Code* produces, run headlessly. There are ### Fixing Review Feedback -When a PR gets review feedback, open a new Cowork session with the OpenLoop folder mounted and say: - -> Start addressing PR feedback following `docs/prompts/PR-FEEDBACK-RESOLUTION.md` — here is the PR: https://github.com/stozo04/OpenLoop/pull/XX - -Replace `XX` with your PR number. The agent will read the review comments, web-search Google's latest standards to verify each finding, fix the code, push, post a response comment explaining what was fixed and why, then run a fresh review to confirm zero FAILs. +When a PR gets review feedback, open a new session with the OpenLoop folder mounted and ask the agent to address each review comment: verify findings against Google's latest docs, fix the code, push, post a response comment explaining what was fixed and why, then run a fresh standards review to confirm zero FAILs. Point it at the PR URL (e.g. `https://github.com/stozo04/OpenLoop/pull/XX`). ## Build Status diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e5db1bb..7af1961 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -24,8 +24,8 @@ android { applicationId = "io.github.stozo04.openloop" minSdk = 26 targetSdk = 36 - versionCode = 25 - versionName = "1.0.25" + versionCode = 26 + versionName = "1.0.26" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -201,6 +201,8 @@ dependencies { testImplementation(libs.robolectric) testImplementation(libs.androidx.test.core) testImplementation(libs.androidx.test.ext.junit) + // WorkManager test harness (TestListenableWorkerBuilder) for Robolectric worker guard tests + testImplementation(libs.androidx.work.testing) // Compose UI Testing (instrumented) androidTestImplementation(platform(libs.androidx.compose.bom)) @@ -219,7 +221,7 @@ dependencies { } // Crashlytics mapping upload + Firebase config only when the console JSON is present locally. -// See docs/diagnostics/firebase-crashlytics-trimming.md and app/google-services.json.README. +// See ReverseCrashlytics.kt and app/google-services.json.README. if (file("google-services.json").exists()) { apply(plugin = "com.google.gms.google-services") apply(plugin = "com.google.firebase.crashlytics") diff --git a/app/google-services.json.README b/app/google-services.json.README index c7232ab..f28343a 100644 --- a/app/google-services.json.README +++ b/app/google-services.json.README @@ -11,6 +11,6 @@ npx -y firebase-tools@latest apps:sdkconfig ANDROID \ Save stdout (JSON only) as `app/google-services.json`. -Setup and Crashlytics viewing: `docs/diagnostics/firebase-crashlytics-trimming.md` +Setup and Crashlytics triage: `ReverseCrashlytics.kt` and the `/crashlytics-triage` skill (`.claude/skills/crashlytics-triage/SKILL.md`). This file is not used by the build; the JSON is gitignored. diff --git a/app/src/main/java/io/github/stozo04/openloop/MainActivity.kt b/app/src/main/java/io/github/stozo04/openloop/MainActivity.kt index 97cf567..fe11fdd 100644 --- a/app/src/main/java/io/github/stozo04/openloop/MainActivity.kt +++ b/app/src/main/java/io/github/stozo04/openloop/MainActivity.kt @@ -121,7 +121,7 @@ class MainActivity : ComponentActivity() { WorkManagerBoomerangRenderScheduler(WorkManager.getInstance(applicationContext)), // Firebase Analytics reporter — falls back to NoOpAnalyticsReporter when // google-services.json is absent (CI / fresh clone). See - // docs/active/firebase-analytics/IMPLEMENTATION.md for the staged rollout. + // AnalyticsReporter wired here; see AnalyticsReporter.kt and docs/play-store/data-safety.md. FirebaseAnalyticsReporterImpl.create(applicationContext), // Proactive low-memory probe (ActivityManager.getMemoryInfo). Android 14+ delivers no // foreground onTrimMemory pressure levels, so the ViewModel polls this at editor entry @@ -428,12 +428,9 @@ class MainActivity : ComponentActivity() { } private fun maybeRequestPostNotificationsPermission() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return - if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == + val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED - ) { - return - } + if (!shouldRequestPostNotificationsPermission(Build.VERSION.SDK_INT, granted)) return requestPostNotificationsLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } @@ -494,12 +491,27 @@ private const val TAG = "MainActivity" @Composable private fun rememberNotificationExportHint(): Boolean { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return false val context = LocalContext.current - return ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != + val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + return shouldShowNotificationExportHint(Build.VERSION.SDK_INT, granted) } +/** + * Pure API-33+ gate for [maybeRequestPostNotificationsPermission]: request only when + * [Manifest.permission.POST_NOTIFICATIONS] is not yet granted. Below API 33 the permission does + * not exist — always no-op. + */ +fun shouldRequestPostNotificationsPermission(sdkInt: Int, notificationsGranted: Boolean): Boolean = + sdkInt >= Build.VERSION_CODES.TIRAMISU && !notificationsGranted + +/** + * Pure API-33+ gate for [rememberNotificationExportHint] on [ProcessingScreen]: show the + * background-export hint when notification permission is missing. Below API 33 always false. + */ +fun shouldShowNotificationExportHint(sdkInt: Int, notificationsGranted: Boolean): Boolean = + sdkInt >= Build.VERSION_CODES.TIRAMISU && !notificationsGranted + /** * Build the `ACTION_SEND` intent that shares a rendered boomerang at content [uri] with the given * [subject] (slice 06). Extracted as a pure function so the intent's shape (action / MIME type / diff --git a/app/src/main/java/io/github/stozo04/openloop/diagnostics/AnalyticsReporter.kt b/app/src/main/java/io/github/stozo04/openloop/diagnostics/AnalyticsReporter.kt index 046fb79..2db0323 100644 --- a/app/src/main/java/io/github/stozo04/openloop/diagnostics/AnalyticsReporter.kt +++ b/app/src/main/java/io/github/stozo04/openloop/diagnostics/AnalyticsReporter.kt @@ -6,8 +6,8 @@ package io.github.stozo04.openloop.diagnostics * interface — never on the [com.google.firebase.analytics.FirebaseAnalytics] SDK directly — so unit * tests can swap in a fake without touching Firebase. * - * See [docs/active/firebase-analytics/IMPLEMENTATION.md] for the staged rollout (option 1 — abstraction - * only; option 2 — screen tracking; option 3 — full PRD instrumentation) and the event schema. + * See [docs/play-store/data-safety.md] for the analytics disclosure schema. Staged rollout: option 1 + * (abstraction only) is shipped; screen tracking and custom events are not wired yet. * * Firebase Analytics has hard constraints on names and params: * - Event/property names: ≤40 chars, alphanumeric + underscore, must start with a letter. @@ -45,8 +45,7 @@ interface AnalyticsReporter { * No-op fallback used in three places: * - Unit tests that don't care about analytics calls. * - CI / fresh-clone builds where `app/google-services.json` is absent — see - * [docs/diagnostics/firebase-crashlytics-trimming.md] for the parallel Crashlytics no-op pattern - * in `ReverseCrashlytics.crashlyticsOrNull()`. + * `ReverseCrashlytics.crashlyticsOrNull()` for the parallel Crashlytics no-op pattern. * - As the constructor fallback inside [FirebaseAnalyticsReporterImpl] if `FirebaseApp` failed to * initialize (no `google-services.json` present at runtime). * diff --git a/app/src/main/java/io/github/stozo04/openloop/media/DeviceMediaHints.kt b/app/src/main/java/io/github/stozo04/openloop/media/DeviceMediaHints.kt index 14c84b3..5cb4c7f 100644 --- a/app/src/main/java/io/github/stozo04/openloop/media/DeviceMediaHints.kt +++ b/app/src/main/java/io/github/stozo04/openloop/media/DeviceMediaHints.kt @@ -21,7 +21,7 @@ internal val SAMSUNG_POST_TRANSFORM_CODEC_SETTLE = 500.milliseconds * This is a *settle*, not a handshake: `ExoPlayer.release()` is synchronous, but the OS codec-slot * reclaim it triggers is **not observable from app code**, so we cannot deterministically wait for * "slot free." This delay covers that unobservable reclaim window. See - * `docs/active/editor-codec-churn/IMPLEMENTATION.md` (finding 2 / step 2). + * `docs/lessons_learned/020-imported-clips-hdr-codec-and-reverse-failure-recovery.md` (finding 2 / step 2). */ internal val PRE_REVERSE_CODEC_SETTLE = 400.milliseconds diff --git a/app/src/main/java/io/github/stozo04/openloop/media/VideoReverser.kt b/app/src/main/java/io/github/stozo04/openloop/media/VideoReverser.kt index b4a77e9..f2e6648 100644 --- a/app/src/main/java/io/github/stozo04/openloop/media/VideoReverser.kt +++ b/app/src/main/java/io/github/stozo04/openloop/media/VideoReverser.kt @@ -26,7 +26,7 @@ import kotlin.coroutines.coroutineContext * Produces a time-reversed copy of a video's trimmed window using a two-pass MediaCodec pipeline. * * Media3 1.10.x ships no reverse effect and FFmpegKit is retired, so reversal is done by hand - * (the verified rationale + algorithm live in `docs/active/boomerang-rollout/RESEARCH-reverse-video.md`, + * (the verified rationale + algorithm live in `docs/guides/reverse-video-research.md`, * mirroring the MIT-licensed sisik.eu reference at github.com/sixo/reverse-video): * * - **Pass 1** transcodes the trim window `[trimStartMs, trimEndMs]` to an intermediate MP4 in which @@ -280,7 +280,7 @@ class VideoReverser( // returned the SAME dims and there was no mismatch; a >1080p import is the first to exercise // the downscale). The resolution cap now lives ONLY in the Media3 render (Presentation), // which downscales the forward AND reversed clips together via a correct GL pipeline — so the - // halves still match. See docs/lessons_learned (reverse-downscale-surface-mismatch). + // halves still match. See docs/lessons_learned/021-reverse-downscale-surface-mismatch.md. val width = evenDown(srcWidth) val height = evenDown(srcHeight) val frameRate = inputFormat.frameRateOrDefault().coerceAtMost(MAX_PASS1_ENCODE_FPS) @@ -292,7 +292,7 @@ class VideoReverser( // muxer below, so an auto-rotating decoder would DOUBLE-rotate the reversed half. Clearing // it forces coded-orientation pixels + a metadata-only hint — structurally identical to // the source — so Media3 rotates the forward and reversed halves symmetrically. Verify on - // a real portrait recording (see docs/lessons_learned/HANDOFF + SECOND-REVIEW notes). + // a real portrait recording (see docs/lessons_learned/019-reverse-rotation-strip-decoder-restamp-muxer.md). val rotationDegrees = inputFormat.rotationDegreesOrZero() inputFormat.setInteger(MediaFormat.KEY_ROTATION, 0) inputFormat.requestSdrToneMapping() // HDR/10-bit source → SDR for the 8-bit AVC encoder diff --git a/app/src/main/java/io/github/stozo04/openloop/ui/OpenLoopViewModel.kt b/app/src/main/java/io/github/stozo04/openloop/ui/OpenLoopViewModel.kt index 56a5789..6afd45f 100644 --- a/app/src/main/java/io/github/stozo04/openloop/ui/OpenLoopViewModel.kt +++ b/app/src/main/java/io/github/stozo04/openloop/ui/OpenLoopViewModel.kt @@ -121,7 +121,7 @@ class OpenLoopViewModel( private val videoImporter: VideoImporter, private val renderScheduler: BoomerangRenderScheduler, // 6th param wired in for the staged Firebase Analytics rollout — see - // docs/active/firebase-analytics/IMPLEMENTATION.md. Option 1 ships the abstraction only; + // AnalyticsReporter (option 1 — abstraction only; see AnalyticsReporter.kt KDoc). // options 2 (screen tracking) and 3 (custom events) populate call sites incrementally. The // production impl comes from FirebaseAnalyticsReporterImpl.create(applicationContext); tests and // CI builds without google-services.json fall back to NoOpAnalyticsReporter. diff --git a/app/src/test/java/io/github/stozo04/openloop/PostNotificationsGateRobolectricTest.kt b/app/src/test/java/io/github/stozo04/openloop/PostNotificationsGateRobolectricTest.kt new file mode 100644 index 0000000..0103f71 --- /dev/null +++ b/app/src/test/java/io/github/stozo04/openloop/PostNotificationsGateRobolectricTest.kt @@ -0,0 +1,82 @@ +package io.github.stozo04.openloop + +import android.Manifest +import android.app.Application +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.content.ContextCompat +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.Config + +/** + * Robolectric coverage for the API-33 [Manifest.permission.POST_NOTIFICATIONS] gate extracted + * from [MainActivity.maybeRequestPostNotificationsPermission] and + * [rememberNotificationExportHint] (Tier 2 #5). ViewModel permission *state* stays in + * [io.github.stozo04.openloop.ui.OpenLoopViewModelTest]. + */ +@RunWith(RobolectricTestRunner::class) +class PostNotificationsGateRobolectricTest { + + private val context: Context get() = ApplicationProvider.getApplicationContext() + + @Test + @Config(sdk = [32]) + fun api32_deniedPermission_requestAndHintAreNoOp() { + denyPostNotifications() + + val granted = isPostNotificationsGranted() + assertFalse(shouldRequestPostNotificationsPermission(Build.VERSION.SDK_INT, granted)) + assertFalse(shouldShowNotificationExportHint(Build.VERSION.SDK_INT, granted)) + } + + @Test + @Config(sdk = [32]) + fun api32_grantedPermission_requestAndHintAreNoOp() { + grantPostNotifications() + + val granted = isPostNotificationsGranted() + assertFalse(shouldRequestPostNotificationsPermission(Build.VERSION.SDK_INT, granted)) + assertFalse(shouldShowNotificationExportHint(Build.VERSION.SDK_INT, granted)) + } + + @Test + @Config(sdk = [33]) + fun api33_deniedPermission_gateIsActive() { + denyPostNotifications() + + val granted = isPostNotificationsGranted() + assertTrue(shouldRequestPostNotificationsPermission(Build.VERSION.SDK_INT, granted)) + assertTrue(shouldShowNotificationExportHint(Build.VERSION.SDK_INT, granted)) + } + + @Test + @Config(sdk = [33]) + fun api33_grantedPermission_gateIsInactive() { + grantPostNotifications() + + val granted = isPostNotificationsGranted() + assertFalse(shouldRequestPostNotificationsPermission(Build.VERSION.SDK_INT, granted)) + assertFalse(shouldShowNotificationExportHint(Build.VERSION.SDK_INT, granted)) + } + + private fun isPostNotificationsGranted(): Boolean = + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + + private fun grantPostNotifications() { + Shadows.shadowOf(context.applicationContext as Application) + .grantPermissions(Manifest.permission.POST_NOTIFICATIONS) + } + + private fun denyPostNotifications() { + Shadows.shadowOf(context.applicationContext as Application) + .denyPermissions(Manifest.permission.POST_NOTIFICATIONS) + } +} diff --git a/app/src/test/java/io/github/stozo04/openloop/data/UserPreferencesRepositoryImplRobolectricTest.kt b/app/src/test/java/io/github/stozo04/openloop/data/UserPreferencesRepositoryImplRobolectricTest.kt new file mode 100644 index 0000000..29c8392 --- /dev/null +++ b/app/src/test/java/io/github/stozo04/openloop/data/UserPreferencesRepositoryImplRobolectricTest.kt @@ -0,0 +1,44 @@ +package io.github.stozo04.openloop.data + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import java.io.File +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Real [preferencesDataStore] round-trip on the JVM — covers the production + * [UserPreferencesRepositoryImpl] that ViewModel fakes stand in for. + */ +@RunWith(RobolectricTestRunner::class) +class UserPreferencesRepositoryImplRobolectricTest { + + private val context: Context get() = ApplicationProvider.getApplicationContext() + + @Before + fun clearDataStoreFile() { + File(context.filesDir, "datastore/openloop_preferences.preferences_pb").delete() + } + + @Test + fun freshStore_defaultsOnboardingIncomplete() = runTest { + val repo = UserPreferencesRepositoryImpl(context.dataStore) + + assertFalse(repo.hasCompletedOnboarding.first()) + } + + @Test + fun setOnboardingCompleted_persistsAndReadsBack() = runTest { + val repo = UserPreferencesRepositoryImpl(context.dataStore) + + repo.setOnboardingCompleted(true) + + assertTrue(repo.hasCompletedOnboarding.first()) + } +} diff --git a/app/src/test/java/io/github/stozo04/openloop/data/VideoImporterImportRobolectricTest.kt b/app/src/test/java/io/github/stozo04/openloop/data/VideoImporterImportRobolectricTest.kt new file mode 100644 index 0000000..6dbaf61 --- /dev/null +++ b/app/src/test/java/io/github/stozo04/openloop/data/VideoImporterImportRobolectricTest.kt @@ -0,0 +1,86 @@ +package io.github.stozo04.openloop.data + +import android.content.Context +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import java.io.ByteArrayInputStream +import java.io.File +import java.io.InputStream +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.shadows.ShadowContentResolver + +/** + * Device-free coverage of [VideoImporterImpl.importToFile] — the stream-copy contract the ViewModel + * relies on to show a snackbar instead of crashing when a picked URI is unreadable. + */ +@RunWith(RobolectricTestRunner::class) +class VideoImporterImportRobolectricTest { + + private val context: Context get() = ApplicationProvider.getApplicationContext() + + @Test + fun importToFile_copiesRegisteredContentUri() = runTest { + val uri = Uri.parse("content://test/import.mp4") + val payload = "fake-mp4-bytes".toByteArray() + shadowContentResolver().registerInputStreamSupplier(uri) { + ByteArrayInputStream(payload) + } + + val dest = File(context.cacheDir, "imported.mp4") + val importer = VideoImporterImpl(context) + + assertTrue(importer.importToFile(uri, dest)) + assertArrayEquals(payload, dest.readBytes()) + } + + @Test + fun importToFile_returnsFalseWhenSecurityExceptionOnRead() = runTest { + val uri = Uri.parse("content://test/revoked.mp4") + shadowContentResolver().registerInputStream( + uri, + object : InputStream() { + override fun read(): Int = throw SecurityException("URI permission revoked") + }, + ) + + val dest = File(context.cacheDir, "imported-revoked.mp4") + val importer = VideoImporterImpl(context) + + assertFalse(importer.importToFile(uri, dest)) + } + + @Test + fun importToFile_returnsFalseWhenFileNotFound() = runTest { + val uri = Uri.fromFile(File(context.cacheDir, "missing-import-source.mp4")) + val dest = File(context.cacheDir, "imported-missing.mp4") + val importer = VideoImporterImpl(context) + + assertFalse(importer.importToFile(uri, dest)) + } + + @Test + fun importToFile_returnsFalseWhenStreamThrowsOnRead() = runTest { + val uri = Uri.parse("content://test/corrupt.mp4") + shadowContentResolver().registerInputStream( + uri, + object : InputStream() { + override fun read(): Int = throw java.io.IOException("simulated revoked URI") + }, + ) + + val dest = File(context.cacheDir, "imported-corrupt.mp4") + val importer = VideoImporterImpl(context) + + assertFalse(importer.importToFile(uri, dest)) + } + + private fun shadowContentResolver(): ShadowContentResolver = + Shadows.shadowOf(context.contentResolver) +} diff --git a/app/src/test/java/io/github/stozo04/openloop/work/BoomerangRenderNotificationsRobolectricTest.kt b/app/src/test/java/io/github/stozo04/openloop/work/BoomerangRenderNotificationsRobolectricTest.kt new file mode 100644 index 0000000..ab7da86 --- /dev/null +++ b/app/src/test/java/io/github/stozo04/openloop/work/BoomerangRenderNotificationsRobolectricTest.kt @@ -0,0 +1,116 @@ +package io.github.stozo04.openloop.work + +import android.app.Notification +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.test.core.app.ApplicationProvider +import io.github.stozo04.openloop.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.annotation.Config + +/** + * Robolectric coverage for [BoomerangRenderNotifications] channel setup, progress notification + * content, and [PendingIntent] immutability — complementing FGS-type tests in + * [BoomerangRenderForegroundInfoRobolectricTest] and pure SDK mapping in + * [BoomerangRenderNotificationsTest]. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class BoomerangRenderNotificationsRobolectricTest { + + private val context: Context get() = ApplicationProvider.getApplicationContext() + + private val notificationManager: NotificationManager + get() = context.getSystemService(NotificationManager::class.java)!! + + @Before + fun deleteExportChannel() { + notificationManager.deleteNotificationChannel(BoomerangRenderNotifications.CHANNEL_ID) + } + + @Test + fun ensureChannel_createsSingleImportanceLowChannel() { + BoomerangRenderNotifications.ensureChannel(context) + + val channel = notificationManager.getNotificationChannel(BoomerangRenderNotifications.CHANNEL_ID) + assertNotNull(channel) + assertEquals(NotificationManager.IMPORTANCE_LOW, channel!!.importance) + assertEquals( + context.getString(R.string.notification_loop_export_channel_name), + channel.name.toString(), + ) + assertEquals( + context.getString(R.string.notification_loop_export_channel_desc), + channel.description, + ) + assertFalse(channel.canShowBadge()) + } + + @Test + fun ensureChannel_isIdempotent() { + BoomerangRenderNotifications.ensureChannel(context) + val first = notificationManager.getNotificationChannel(BoomerangRenderNotifications.CHANNEL_ID) + + BoomerangRenderNotifications.ensureChannel(context) + val second = notificationManager.getNotificationChannel(BoomerangRenderNotifications.CHANNEL_ID) + + assertEquals(first?.importance, second?.importance) + assertEquals(first?.name, second?.name) + } + + @Test + fun buildProgressNotification_clampsProgressAndSetsContent() { + val notification = BoomerangRenderNotifications.buildProgressNotification(context, 150) + + assertEquals(100, notification.extras.getInt(Notification.EXTRA_PROGRESS)) + assertEquals(100, notification.extras.getInt(Notification.EXTRA_PROGRESS_MAX)) + assertEquals( + context.getString(R.string.notification_loop_export_title), + NotificationCompat.getContentTitle(notification)?.toString(), + ) + assertEquals( + context.getString(R.string.notification_loop_export_progress, 100), + NotificationCompat.getContentText(notification)?.toString(), + ) + assertTrue(notification.flags and Notification.FLAG_ONGOING_EVENT != 0) + } + + @Test + fun buildProgressNotification_clampsNegativeProgressToZero() { + val notification = BoomerangRenderNotifications.buildProgressNotification(context, -20) + + assertEquals(0, notification.extras.getInt(Notification.EXTRA_PROGRESS)) + assertEquals( + context.getString(R.string.notification_loop_export_progress, 0), + NotificationCompat.getContentText(notification)?.toString(), + ) + } + + @Test + fun buildProgressNotification_usesImmutablePendingIntent() { + val notification = BoomerangRenderNotifications.buildProgressNotification(context, 50) + val pendingIntent = requireNotNull(notification.contentIntent) + + val flags = Shadows.shadowOf(pendingIntent).flags + assertTrue(flags and PendingIntent.FLAG_IMMUTABLE != 0) + } + + @Test + fun buildCompleteNotification_usesImmutablePendingIntent() { + val notification = BoomerangRenderNotifications.buildCompleteNotification(context) + val pendingIntent = requireNotNull(notification.contentIntent) + + val flags = Shadows.shadowOf(pendingIntent).flags + assertTrue(flags and PendingIntent.FLAG_IMMUTABLE != 0) + } +} diff --git a/app/src/test/java/io/github/stozo04/openloop/work/BoomerangRenderWorkerRobolectricTest.kt b/app/src/test/java/io/github/stozo04/openloop/work/BoomerangRenderWorkerRobolectricTest.kt new file mode 100644 index 0000000..6370bc4 --- /dev/null +++ b/app/src/test/java/io/github/stozo04/openloop/work/BoomerangRenderWorkerRobolectricTest.kt @@ -0,0 +1,133 @@ +package io.github.stozo04.openloop.work + +import android.content.Context +import android.content.pm.ServiceInfo +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.test.core.app.ApplicationProvider +import androidx.work.Data +import androidx.work.ListenableWorker +import androidx.work.testing.TestListenableWorkerBuilder +import com.google.common.util.concurrent.ListenableFuture +import io.github.stozo04.openloop.data.ScratchCapture +import io.github.stozo04.openloop.media.BoomerangMode +import io.github.stozo04.openloop.media.VideoFilter +import java.io.File +import java.util.UUID +import java.util.concurrent.ExecutionException +import java.util.concurrent.Executor +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Robolectric guard tests for [BoomerangRenderWorker] orchestration (Tier 2 #4). Covers input + * validation, [getForegroundInfo], and the FGS-promotion-denied branch only — never reaches + * [io.github.stozo04.openloop.media.VideoProcessor.renderBoomerang] (encode stays in + * [BoomerangRenderWorkerTest] on device). + */ +@OptIn(UnstableApi::class) +@RunWith(RobolectricTestRunner::class) +class BoomerangRenderWorkerRobolectricTest { + + private val context: Context get() = ApplicationProvider.getApplicationContext() + + @Test + fun doWork_emptyInputData_returnsFailureWithoutEncode() { + val worker = TestListenableWorkerBuilder(context) + .setInputData(Data.EMPTY) + .build() + + val result = worker.startWork().get(10, TimeUnit.SECONDS) + + assertTrue("expected Result.failure(), was $result", result is ListenableWorker.Result.Failure) + } + + @Test + fun doWork_partialInputData_returnsFailureWithoutEncode() { + val worker = TestListenableWorkerBuilder(context) + .setInputData( + Data.Builder() + .putString(BoomerangRenderWorkerKeys.SCRATCH_PATH, "/tmp/scratch.mp4") + .build(), + ) + .build() + + val result = worker.startWork().get(10, TimeUnit.SECONDS) + + assertTrue("expected Result.failure(), was $result", result is ListenableWorker.Result.Failure) + } + + @Test + @Config(sdk = [34]) + fun getForegroundInfo_returnsValidForegroundInfo() = runTest { + val worker = TestListenableWorkerBuilder(context).build() + + val info = worker.getForegroundInfo() + + assertNotNull(info.notification) + assertEquals( + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + info.foregroundServiceType, + ) + } + + @Test + fun fgsPromotionDenied_failsGracefullyAndDeletesStalePartial() { + val workDir = File(context.cacheDir, "boomerang_worker_robo_${System.nanoTime()}").apply { mkdirs() } + try { + val scratchFile = File(workDir, "raw_fgs_denied.mp4").apply { writeBytes(ByteArray(64)) } + val rawId = 9_003L + val outputFile = File(workDir, "boom_stale_partial_from_$rawId.mp4").apply { + writeBytes(ByteArray(128)) + } + + val renderRequest = BoomerangRenderRequest( + scratch = ScratchCapture("fgs-denied-${UUID.randomUUID()}", scratchFile), + trimStartMs = 0L, + trimEndMs = 500L, + mode = BoomerangMode.FORWARD, + speed = 2.0f, + filter = VideoFilter.ORIGINAL, + repetitions = 1, + rawId = rawId, + outputFile = outputFile, + returnToGallery = false, + ) + + val worker = TestListenableWorkerBuilder(context) + .setInputData(BoomerangRenderWorkerInput.toInputData(renderRequest)) + .setForegroundUpdater { _, _, _ -> + immediateFailedFuture( + IllegalStateException("foreground service start not allowed (test)"), + ) + } + .build() + + val result = worker.startWork().get(10, TimeUnit.SECONDS) + + assertTrue("expected Result.failure(), was $result", result is ListenableWorker.Result.Failure) + assertFalse("stale partial output must be deleted", outputFile.exists()) + } finally { + workDir.deleteRecursively() + } + } + + /** Dependency-free failed [ListenableFuture] (guava's Futures is not on this classpath). */ + private fun immediateFailedFuture(error: Throwable): ListenableFuture = + object : ListenableFuture { + override fun addListener(listener: Runnable, executor: Executor) = executor.execute(listener) + override fun cancel(mayInterruptIfRunning: Boolean): Boolean = false + override fun isCancelled(): Boolean = false + override fun isDone(): Boolean = true + override fun get(): V = throw ExecutionException(error) + override fun get(timeout: Long, unit: TimeUnit): V = throw ExecutionException(error) + } +} diff --git a/docs/ANDROID_STANDARDS.md b/docs/ANDROID_STANDARDS.md index d2490ba..2bef13e 100644 --- a/docs/ANDROID_STANDARDS.md +++ b/docs/ANDROID_STANDARDS.md @@ -193,7 +193,7 @@ Platform Layer → CameraX, Media3, system APIs **Target API level.** New apps and app updates submitted to Google Play must currently target at least **API 35 (Android 15)** — in force since **August 31, 2025** (the extension window closed November 1, 2025). The floor is expected to rise to **API 36 (Android 16)** around **August 2026** on Google's annual cadence, but as of this review the [requirements page](https://developer.android.com/google/play/requirements/target-sdk) had **not** published an exact date for the API 36 requirement — re-verify before release rather than trusting this line. (Wear OS, Android Automotive, and Android TV trail by one level — not relevant to OpenLoop.) -> **OpenLoop status — satisfied ([Issue #7](https://github.com/stozo04/OpenLoop/issues/7)):** the app targets **API 36** (`app/build.gradle.kts`: `compileSdk`/`targetSdk` 36, `minSdk` 26), clearing the current Play floor of API 35 — going straight to 36 to avoid a second bump when the floor rises. Native libraries are 16 KB page-aligned (CameraX/Media3 upgraded, uncompressed packaging). See the [Android 16 hub](android-16/README.md) for the behavior-change detail behind the upgrade. +> **OpenLoop status — satisfied ([Issue #7](https://github.com/stozo04/OpenLoop/issues/7)):** the app targets **API 36** (`app/build.gradle.kts`: `compileSdk`/`targetSdk` 36, `minSdk` 26), clearing the current Play floor of API 35 — going straight to 36 to avoid a second bump when the floor rises. Native libraries are 16 KB page-aligned (CameraX/Media3 upgraded, uncompressed packaging). Behavior changes: [Android 16 behavior changes](https://developer.android.com/about/versions/16/behavior-changes-16) and §11 below. **64-bit support.** All apps must include 64-bit native libraries if they include any native code. @@ -243,9 +243,9 @@ These conventions apply specifically to OpenLoop and are consistent with the Goo ## 11. Android Version Targeting (API 36 / Android 16) -**Google Guide:** [Behavior changes — targeting Android 16](https://developer.android.com/about/versions/16/behavior-changes-16) · **OpenLoop detail:** [`docs/android-16/`](android-16/README.md) +**Google Guide:** [Behavior changes — targeting Android 16](https://developer.android.com/about/versions/16/behavior-changes-16) -These rules are **in force** — OpenLoop targets **API 36** (see §8). Each was carried as `Status: pending — Issue #7` during the docs-prep phase and flipped to satisfied when the [007 upgrade](completed/007-target-sdk-upgrade/IMPLEMENTATION.md) landed, keeping this doc honest rather than aspirational ([Lesson 007](lessons_learned/007-standards-doc-must-match-code.md)). +These rules are **in force** — OpenLoop targets **API 36** (see §8). Each was carried as `Status: pending — Issue #7` during the docs-prep phase and flipped to satisfied when the [Issue #7 upgrade](https://github.com/stozo04/OpenLoop/issues/7) landed, keeping this doc honest rather than aspirational ([Lesson 007](lessons_learned/007-standards-doc-must-match-code.md)). **Target the current Play floor.** `compileSdk` and `targetSdk` track Google Play's required level (see §8). Bump in a dedicated upgrade, never bundled with feature work ([Lesson 005](lessons_learned/005-play-store-target-api-level.md)). `Status: satisfied (Issue #7)` — `compileSdk`/`targetSdk` = 36 in `app/build.gradle.kts`. diff --git a/docs/PRD-mission-control.md b/docs/PRD-mission-control.md index b555ff5..2adb2dd 100644 --- a/docs/PRD-mission-control.md +++ b/docs/PRD-mission-control.md @@ -147,7 +147,7 @@ Gradients: `NeonCoral → NeonPurple` horizontal for primary actions. Theme: `da **Purpose:** 3-page horizontal carousel introducing the app. **Architecture:** -- Page data modeled as `OnboardingPage(title, drawableRes, glowColor, videoRawRes?)` data class with a list of 3 instances. `videoRawRes` is optional: when set, the card autoplays a muted, looping `res/raw` clip via an ExoPlayer-in-`AndroidView` (`OnboardingVideoCard`) instead of the static `drawableRes`; `drawableRes` remains the inspection-mode (`@Preview`) fallback. See `docs/active/onboarding-video/IMPLEMENTATION.md`. +- Page data modeled as `OnboardingPage(title, drawableRes, glowColor, videoRawRes?)` data class with a list of 3 instances. `videoRawRes` is optional: when set, the card autoplays a muted, looping `res/raw` clip via an ExoPlayer-in-`AndroidView` (`OnboardingVideoCard`) instead of the static `drawableRes`; `drawableRes` remains the inspection-mode (`@Preview`) fallback. See `OnboardingVideoCard` in `OnboardingScreen.kt`. - Navigation extracted into `OnboardingNavigation` (internal composable) — **MUST remain extracted to avoid ColumnScope.AnimatedVisibility bug** (see decision log) - Dot indicators use `animateFloatAsState` for smooth size transitions - Navigation buttons use `AnimatedVisibility` with `fadeIn + scaleIn` / `fadeOut + scaleOut` diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..c1eea9b --- /dev/null +++ b/docs/README.md @@ -0,0 +1,68 @@ +# Documentation layout — OpenLoop + +**Single rule:** all project documentation lives under `docs/`. The only exceptions at the repo +root are `README.md` and `CLAUDE.md` (convention). + +**Private docs:** owner-only notes (keystore paths, personal checklists) go in `docs/local/`. +That folder is **gitignored** — never commit secrets there. + +**No archive folders.** Shipped work is captured in git history, `docs/lessons_learned/`, and +`docs/guides/`. In-flight features use GitHub issues/PRs — not `docs/active/`, `docs/completed/`, +or `docs/diagnostics/`. + +--- + +## Folder map + +| Path | What belongs here | Tracked in git? | +|------|-------------------|-----------------| +| [`PRD-mission-control.md`](PRD-mission-control.md) | Architecture, components, design tokens — read before structural changes | Yes | +| [`ANDROID_STANDARDS.md`](ANDROID_STANDARDS.md) | Google Android practices (linked specs) | Yes | +| [`DEFINITION_OF_DONE.md`](DEFINITION_OF_DONE.md) | Ready-for-PR verification gate | Yes | +| [`STATIC_ANALYSIS.md`](STATIC_ANALYSIS.md) | Lint + Inspect Code merge policy | Yes | +| [`TEST_COVERAGE.md`](TEST_COVERAGE.md) | Testing pyramid, inventory, frameworks | Yes | +| [`guides/`](guides/README.md) | Plain-English how-tos + durable reference (reverse algorithm, Robolectric, OEM lanes) | Yes | +| [`play-store/`](play-store/README.md) | Play Console paste text + **store upload graphics** | Yes | +| [`lessons_learned/`](lessons_learned/README.md) | Distilled rules from past PR reviews — **read every session** | Yes | +| [`e2e/`](e2e/) | Agent/human E2E run reports + proof screenshots (timestamped `.md` + PNG) | Yes | +| [`local/`](local/) | **Private** owner notes (signing playbook, personal paths) — **never commit** | **No** (gitignored) | +| [`privacy-policy.html`](privacy-policy.html) | GitHub Pages host for Play privacy URL | Yes | + +**Do not create:** `docs/active/`, `docs/completed/`, `docs/diagnostics/`, `docs/android-16/`, +`docs/prompts/`, or loose `.md` files outside the folders above (except the five root-level +standards files). + +**Android version policy:** web-search [Google's behavior changes](https://developer.android.com/about/versions/16/behavior-changes-16) and read `ANDROID_STANDARDS.md` §11 — do not maintain a local Android-16 mirror. + +**Crashlytics / codec issues:** `ReverseCrashlytics.kt`, `DeviceMediaHints.kt`, and lessons 020 / 023 — not a separate diagnostics folder. + +--- + +## Image & asset layout + +| Path | What belongs here | Do not use for | +|------|-------------------|----------------| +| `app/src/main/res/` | In-app drawables, mipmaps, raw — **only** what ships in the APK | Play Store uploads, docs screenshots | +| [`docs/play-store/`](play-store/) | **Play Console graphics** (`play_store_icon_512.png`, `feature_graphic_1024x500.png`) | In-app launcher icons | +| [`docs/e2e/`](e2e/) | E2E proof screenshots tied to a run report | Marketing, store listing | + +In-app launcher assets live only under `app/src/main/res/` (see root [`README.md` → Brand Assets](../README.md#brand-assets)). + +--- + +## Enforcement + +1. **Agents:** `CLAUDE.md` mandates reading `docs/lessons_learned/` and this layout before adding docs. +2. **PR review:** the [`pr-reviewer`](../.claude/skills/pr-reviewer/SKILL.md) skill flags new `.md` outside `docs/` (except root `README.md` / `CLAUDE.md`). +3. **CI — doc layout gate:** [`.github/workflows/doc-layout.yml`](../.github/workflows/doc-layout.yml) fails PRs that **add** new `*.md` outside allowed paths. +4. **CI / Tier 3 static analysis:** [`STATIC_ANALYSIS.md`](STATIC_ANALYSIS.md) — `markdown-link-check` on changed Markdown. +5. **Secrets:** `keystore.properties`, `*.jks`, and `docs/local/` are gitignored. + +--- + +## Quick links + +- Play Store submission pack: [`play-store/README.md`](play-store/README.md) +- Play Store beginner walkthrough (private): `docs/local/play-store-beginner-guide.md` on your machine +- Testing guides index: [`guides/README.md`](guides/README.md) +- Reverse algorithm reference: [`guides/reverse-video-research.md`](guides/reverse-video-research.md) diff --git a/docs/STATIC_ANALYSIS.md b/docs/STATIC_ANALYSIS.md index d3871d2..f42e4fd 100644 --- a/docs/STATIC_ANALYSIS.md +++ b/docs/STATIC_ANALYSIS.md @@ -115,9 +115,7 @@ locally before opening/merging a PR, and the merge policy requires it to be clea - **Slow** — minutes, because it boots a headless Studio. It's a pre-merge step, not a fast loop. - **Gradle/IDE lock** — do **not** run while Android Studio has this project open, or while a - `gradlew` task is running. Same build-lock deadlock documented in Lesson 012's hand-off notes - (`docs/lessons_learned/012-camera-bound-screen-single-call-site.md`, lands on `main` with the - slice-01 PR — referenced as a path, not a link, so this doc stays valid before that merges). + `gradlew` task is running. Same build-lock deadlock documented in [Lesson 012](lessons_learned/012-camera-bound-screen-single-call-site.md). - **Environment-gated** — if `inspect.bat` isn't present (a cloud/CI runner without Studio), the reviewer must state Engine 2 was **not run** rather than implying a pass. See Tier 3. @@ -179,12 +177,16 @@ locally. Re-evaluate when detekt 2.0 ships stable; tracked in #21. Running `markdown-link-check` across the changed docs immediately surfaced genuine **pre-existing broken references on `main`** (not introduced by this work): -- `README.md` and `CLAUDE.md` linked to **`docs/android-16/README.md`**, which existed on no - branch — the hub had been renamed to `docs/completed/android-16/` against its documented - evergreen convention. **Fixed in [#23](https://github.com/stozo04/OpenLoop/pull/23)** (restored to root). +- `README.md` and `CLAUDE.md` once linked to a missing **`docs/android-16/README.md`** hub (path drift vs git). **Fixed in [#23](https://github.com/stozo04/OpenLoop/pull/23)**; the hub was later removed in the doc-layout cleanup — Android 16 policy now lives in `ANDROID_STANDARDS.md` §11 and Google's behavior-changes page. - `README.md` linked to a **`LICENSE`** file that did not exist (the project states Apache 2.0). **Fixed in [#23](https://github.com/stozo04/OpenLoop/pull/23)** (added verbatim Apache 2.0 text). +### Doc layout gate — GitHub Actions (hard) + +**`.github/workflows/doc-layout.yml`** runs on every pull request. It fails if the PR **adds** any +`*.md` file outside `docs/` (allowed exceptions: root `README.md`, `CLAUDE.md`). Policy: +[`docs/README.md`](README.md) § Enforcement. + ### Hosting Tier 3 — GitHub Actions (active) Tier 3 runs in CI via **`.github/workflows/static-analysis.yml`** (`pull_request` on `**/*.md`, diff --git a/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md index 179ba00..a579e40 100644 --- a/docs/TEST_COVERAGE.md +++ b/docs/TEST_COVERAGE.md @@ -82,6 +82,8 @@ LG hardware codec). Stock emulators cannot spoof Samsung/LG identity or vendor c **Authoritative guide:** [`docs/guides/oem-regression-testing.md`](guides/oem-regression-testing.md) +**Robolectric inventory (all JVM framework tests, run commands):** [`docs/guides/robolectric-test-catalog.md`](guides/robolectric-test-catalog.md) + | Lane | What | Where | |------|------|-------| | API 34 FGS | `Pixel_8_API34` AVD + save smoke; Robolectric `ForegroundInfo` tests | Emulator + `test/` | @@ -103,7 +105,8 @@ media-pipeline or FGS changes. | **MockK** | Kotlin-first mocking library | `test/` (for Android framework classes like `Context`, `Log`) | | **Fakes** | Hand-written test doubles using real interfaces | `test/` (preferred over mocks for Flow-based interfaces) | | **kotlinx-coroutines-test** | `TestDispatcher`, `runTest`, virtual time control | `test/` | -| **Robolectric 4.16.1** | Run real Android framework code on the JVM; `@Config(sdk=[...])` for version-specific behavior | `test/` (see [`docs/guides/robolectric-testing-explained.md`](guides/robolectric-testing-explained.md)) | +| **Robolectric 4.16.1** | Run real Android framework code on the JVM; `@Config(sdk=[...])` for version-specific behavior | `test/` (tutorial: [`robolectric-testing-explained.md`](guides/robolectric-testing-explained.md) · catalog: [`robolectric-test-catalog.md`](guides/robolectric-test-catalog.md)) | +| **androidx.work.testing** | `TestListenableWorkerBuilder` for worker guard tests without a device | `test/` (+ `androidTest/` for full encode) | | **Compose UI Test** | `createComposeRule`, semantic matchers, UI assertions | `androidTest/` | | **AndroidJUnit4** | Instrumented test runner | `androidTest/` | @@ -173,13 +176,20 @@ Compose tests use a `ComposeTestRule` to set content, find nodes via the semanti ### Local Unit Tests (`app/src/test/`) — see `./gradlew :app:testDebugUnitTest` for current count +**Robolectric suite (Phases 1–2):** full method list and `./gradlew :app:testDebugUnitTest --tests "*RobolectricTest"` in [`guides/robolectric-test-catalog.md`](guides/robolectric-test-catalog.md). + **OEM / API regression (Robolectric + JVM):** | Test class | What it validates | |------------|-------------------| | `BoomerangRenderNotificationsTest` | SDK → FGS type mapping (API 34 must not get `mediaProcessing`) | | `BoomerangRenderForegroundInfoRobolectricTest` | Real `ForegroundInfo.foregroundServiceType` under `@Config(sdk=[34/35/36])` | +| `BoomerangRenderNotificationsRobolectricTest` | Channel idempotency, progress clamp, `PendingIntent.FLAG_IMMUTABLE` | +| `VideoImporterImportRobolectricTest` | `importToFile` copy contract via `ShadowContentResolver` | +| `UserPreferencesRepositoryImplRobolectricTest` | Real DataStore round-trip (onboarding flag) | | `DeviceMediaHintsOemRobolectricTest` | `ShadowBuild` Samsung/LG identity → preview cap + encoder order | +| `BoomerangRenderWorkerRobolectricTest` | Worker guard paths: invalid input, `getForegroundInfo`, FGS denied + partial cleanup | +| `PostNotificationsGateRobolectricTest` | POST_NOTIFICATIONS gate: API 32 no-op vs API 33+ active/denied | | `SamsungReversePreviewRegressionTest` | Samsung encoder ranking (RTL-derived, explicit `isSamsung=true`) | **Core ViewModel / storage (representative rows):** @@ -240,13 +250,15 @@ These are areas that need tests but don't have them yet: | Area | Why It's Missing | Priority | |------|-----------------|----------| -| `UserPreferencesRepositoryImpl` | Robolectric DataStore round-trip planned — see [`robolectric-testing-explained.md`](guides/robolectric-testing-explained.md) | Medium | | Samsung vendor codec on emulator | **Impossible** — use Samsung RTL sweep ([`oem-regression-testing.md`](guides/oem-regression-testing.md)) | N/A (use RTL) | | LG on literal LM-X540 hardware | No LG RTL; instrumented injection covers logic only | Low | | `CameraManager` | Hardware-dependent, hard to unit test | Low (manual testing) | | Accessibility (contrast, touch targets) | Needs automated accessibility checks | High | | Screen UI tests beyond onboarding | Gallery, Camera, Preview screens untested | Medium | | Error recovery flows | Corrupted DataStore, permission revocation mid-use | Medium | +| Robolectric Compose (Tier 3) | No JVM Compose harness yet; onboarding interaction tests stay on device | Low | + +**Closed gaps (Robolectric):** real DataStore implementation (`UserPreferencesRepositoryImplRobolectricTest`), import stream copy (`VideoImporterImportRobolectricTest`), FGS/notification channel (`BoomerangRender*`), worker guards (`BoomerangRenderWorkerRobolectricTest`), POST_NOTIFICATIONS gate (`PostNotificationsGateRobolectricTest`). --- @@ -255,11 +267,12 @@ These are areas that need tests but don't have them yet: When adding a new feature, follow this checklist: 1. **Write unit tests first** (`test/`) — cover every state transition, error path, and edge case in the ViewModel -2. **Use fakes for repositories** — implement the interface with `MutableStateFlow` under the hood -3. **Use MockK for framework classes** — `Context`, `Log`, Android SDK types -4. **Add UI tests if layout matters** (`androidTest/`) — if a button position, visibility, or centering is critical, guard it with a Compose UI test -5. **Name tests descriptively** — use backtick-delimited names: `` `returning user skips onboarding on second launch` `` -6. **Test error paths** — if something can fail, test that it fails gracefully +2. **Use Robolectric when the code touches Android framework objects but not real codecs** — see [`guides/robolectric-testing-explained.md`](guides/robolectric-testing-explained.md) and add to [`guides/robolectric-test-catalog.md`](guides/robolectric-test-catalog.md) +3. **Use fakes for repositories** — implement the interface with `MutableStateFlow` under the hood +4. **Use MockK for framework classes** — `Context`, `Log`, Android SDK types +5. **Add UI tests if layout matters** (`androidTest/`) — if a button position, visibility, or centering is critical, guard it with a Compose UI test +6. **Name tests descriptively** — use backtick-delimited names: `` `returning user skips onboarding on second launch` `` +7. **Test error paths** — if something can fail, test that it fails gracefully --- diff --git a/docs/active/CLAUDE.md b/docs/active/CLAUDE.md deleted file mode 100644 index af7d49f..0000000 --- a/docs/active/CLAUDE.md +++ /dev/null @@ -1,44 +0,0 @@ -# Active Features - -This directory tracks features currently in development. Each feature gets its own folder containing at least one implementation document. - -## Structure - -``` -docs/active/ -├── README.md ← Index of active features (single source of truth) -├── boomerang-editor/ -│ └── IMPLEMENTATION.md ← Parent design doc -├── boomerang-rollout/ -│ ├── README.md ← Delivery plan: the 7 thin slices -│ └── NN-.md ← One PRD per slice -└── / - ├── IMPLEMENTATION.md ← Required: at minimum, the implementation plan - ├── RESEARCH.md ← Optional: background research, alternatives considered - └── DECISIONS.md ← Optional: feature-specific decisions not in the PRD -``` - -## Rules - -**Every active feature must be a folder.** No loose `.md` files in `docs/active/`. The folder name is the feature being built, matching the git branch name where possible (e.g., branch `feature/datastore-preferences` maps to folder `datastore-preferences/`). - -**Every feature folder must contain at least one `.md` file.** At minimum, an implementation plan covering what's being built, why, how, and what "done" looks like. Use `IMPLEMENTATION.md` as the default name. - -**Implementation docs should cover:** - -- Problem statement — what's broken or missing -- Scope — what's in and what's explicitly out -- Architecture — how it fits into the existing system (reference `docs/PRD-mission-control.md`) -- Implementation steps — ordered, with file-level specifics -- Testing plan — what new tests are needed -- Acceptance criteria — how you know it's done - -**When a feature ships:** Move the folder from `docs/active/` to `docs/completed/`. The implementation doc becomes the historical record of what was built and why. - -## Current Active Features - -See [`README.md`](./README.md) for the live active-features table (kept in one place to avoid drift). -In short: the active work is the **Boomerang editor rollout** — -[`boomerang-editor/`](./boomerang-editor/IMPLEMENTATION.md) (parent design) + -[`boomerang-rollout/`](./boomerang-rollout/README.md) (the 7 slices). Slices 01–02 are shipped (in -`docs/completed/`); slice 03 is built (PR #25); slice 04 (Speed) is next. diff --git a/docs/active/README.md b/docs/active/README.md deleted file mode 100644 index 68cf7fb..0000000 --- a/docs/active/README.md +++ /dev/null @@ -1,62 +0,0 @@ -# Active Features - -This directory tracks features currently in development. Each feature gets its own folder containing at least one implementation document. - -## Structure - -``` -docs/active/ -├── README.md ← You are here -├── boomerang-editor/ -│ └── IMPLEMENTATION.md ← Parent design doc (the "what are we building") -├── boomerang-rollout/ -│ ├── README.md ← Delivery plan: the 7 thin slices -│ └── NN-.md ← One PRD per slice -└── / - ├── IMPLEMENTATION.md ← Required: at minimum, the implementation plan - ├── RESEARCH.md ← Optional: background research, alternatives considered - └── DECISIONS.md ← Optional: feature-specific decisions not in the PRD -``` - -## Rules - -**Every active feature must be a folder.** No loose `.md` files in `docs/active/`. The folder name is the feature being built, matching the git branch name where possible (e.g., branch `feature/datastore-preferences` maps to folder `datastore-preferences/`). - -**Every feature folder must contain at least one `.md` file.** At minimum, an implementation plan covering what's being built, why, how, and what "done" looks like. Use `IMPLEMENTATION.md` as the default name. - -**Implementation docs should cover:** - -- Problem statement — what's broken or missing -- Scope — what's in and what's explicitly out -- Architecture — how it fits into the existing system (reference `docs/PRD-mission-control.md`) -- Implementation steps — ordered, with file-level specifics -- Testing plan — what new tests are needed -- Acceptance criteria — how you know it's done - -**When a feature ships:** Move the folder from `docs/active/` to `docs/completed/`. The implementation doc becomes the historical record of what was built and why. - -## Current Active Features - -The **Boomerang editor rollout** is the active work — the core "loop generation" feature, delivered as -7 thin vertical slices. The parent design lives in [`boomerang-editor/`](./boomerang-editor/IMPLEMENTATION.md); -the slice-by-slice delivery plan and per-slice PRDs live in [`boomerang-rollout/`](./boomerang-rollout/README.md). - -| Feature | Branch | Status | -|---------|--------|--------| -| Boomerang slice 03 — Tabbed editor + Direction tab | `feature/boomerang-slice-03-direction-tab` | Built; PR #25 open (awaiting merge) | -| Boomerang slice 04 — Speed tab | `feature/boomerang-slice-04-speed-tab` | Next up — see `boomerang-rollout/04-editor-speed-tab.md` + the slice-04 KICKOFF | -| Boomerang slices 05–07 — Reps / Share sheet / Gallery edit | (per slice) | Planned — specs in `boomerang-rollout/` | -| Onboarding page-1 video | `feature/loop-1` | Built; PR #34 open — see [`onboarding-video/`](./onboarding-video/IMPLEMENTATION.md) | -| Editor **Trimming..** overlay stuck (library import) | `feature/UI-Updates` | **Fixed** — see [`editor-trimming-overlay-stuck/HANDOFF.md`](./editor-trimming-overlay-stuck/HANDOFF.md) | -| Editor memory / OOM hardening (Crashlytics `ef2823cf…`) | `feature/editor-memory-oom` (suggested) | Spec — [`editor-memory-oom/IMPLEMENTATION.md`](./editor-memory-oom/IMPLEMENTATION.md) | -| Crashlytics reverse-preview codec (`3a506c4e`, `b09e527`) | `fix/crashlytics-3a506c4e-pass1-codec-lifecycle` | PR [#62](https://github.com/stozo04/OpenLoop/pull/62) — [`crashlytics-reverse-preview/HANDOFF.md`](./crashlytics-reverse-preview/HANDOFF.md) | -| Editor codec churn (reverse timeout / slot pressure) | (follow-up on #62 branch or new) | [`editor-codec-churn/IMPLEMENTATION.md`](./editor-codec-churn/IMPLEMENTATION.md) | -| Reverse output validation + save failure debug report (Samsung S23 empty pass-2) | `fix/reverse-output-validation` (suggested) | Spec amended 2026-06-04 with logcat forensics — [`reverse-output-validation/IMPLEMENTATION.md`](./reverse-output-validation/IMPLEMENTATION.md) + [`RESEARCH.md`](./reverse-output-validation/RESEARCH.md) | - -> Now in `docs/completed/`: Boomerang slice 01 (variable-length capture) & slice 02 (auto-route trim + -> default save), VideoStorageRepository / remove Context from ViewModel (Issue #10, PR #17), Target SDK -> Upgrade + Android 16 Doc-Prep (Issue #7, PRs #13 & #15), Jetpack DataStore Preferences (PR #5), and -> Permission Rationale Flow (PR #12). -> -> The earlier monolithic `loop-generation/` and `full-feature/` plans were **superseded** by the -> boomerang-rollout slices and removed. diff --git a/docs/active/boomerang-editor/IMPLEMENTATION.md b/docs/active/boomerang-editor/IMPLEMENTATION.md deleted file mode 100644 index d9805f2..0000000 --- a/docs/active/boomerang-editor/IMPLEMENTATION.md +++ /dev/null @@ -1,599 +0,0 @@ -# Boomerang Editor — Implementation Plan - -Feature folder for the **capture-to-edit flow** and the **Boomerang Editor screen**: the -piece of OpenLoop that takes a raw clip (just captured, or picked from the gallery) and turns -it into a configurable looped boomerang. - -> Status: **Spec / sign-off pending.** No code written yet. Owner: Steven. -> Branch (when started): see the per-slice branches under [`../boomerang-rollout/`](../boomerang-rollout/README.md). - ---- - -## Status updates (most-recent first) - -**2026-05-28 — Reverse-video implementation locked.** Verified via web research -that Media3 1.10.x has no built-in reverse effect and FFmpegKit is retired -(archived June 2025, no Maven artifacts). The path forward is a hand-rolled -two-pass MediaCodec reverser (`media/VideoReverser.kt`) shared between the -live preview and the render pipeline via a per-trim cache. Full rationale, -algorithm, alternatives, and performance estimates in -[`../boomerang-rollout/RESEARCH-reverse-video.md`](../boomerang-rollout/RESEARCH-reverse-video.md). -This resolves Q-4 below. - -**2026-05-28 — Rollout broken into 7 slices.** Per Steven's request, this -parent doc remains the design/PRD ("what are we building?"); the new -[`../boomerang-rollout/`](../boomerang-rollout/README.md) folder owns delivery -sequencing ("in what order do we ship?") as 7 thin, vertical, PR-sized slices. - -**2026-05-28 — Decisions finalized since v1 of this doc.** Inputs from Steven's -review: - -- **D-1 default mode:** `FORWARD_THEN_REVERSE` is the default — confirmed (was - flagged as override-able; locked). -- **D-1 recording cap:** changed from 10 s to **30 s** to give users headroom - ("people don't always start the camera at the perfect time" — trim is the - escape valve). Storage cost ~80 MB at 1080p is acceptable. -- **Editor entry pattern:** **no buttons.** After capture finalizes, the app - auto-routes into the editor flow (no "Make Boomerang" button anywhere). Same - pattern for gallery items: tap a raw clip → autoloads into the editor flow. -- **Trim is its own screen** before the tabbed editor, not a section *inside* - the editor. Inspired by reference Boomerang's flow: Capture → **Trim** (with - `NEXT`) → **Tabbed Editor** (with checkmark save). The §4 editor layout in - this doc is now obsolete on that point — see the per-slice docs for the - current layout. -- **D-7 post-save flow:** lift the Android share sheet from reference Boomerang - on save (genuinely useful), drop the review-nag / settings-detour clunk - (not useful). Confirmed. -- **Reverse preview:** open question. Initial recommendation in slice 03 is - to pre-render a reversed segment, cache it, and play the cached file in the - live preview (mirrors how reference Boomerang feels instant). Steven is - doing independent research before this slice's implementation begins. - -The sections below still capture the deep-dive architecture (storage model, -state machine, Media3 pipeline, design tokens) — they remain accurate. -Mismatches between this doc's UI section (§4) and the per-slice docs should -defer to the per-slice docs. - ---- - -## 1. Problem statement - -Today OpenLoop's capture flow ends in `LoopingPreview` — a fixed-speed preview of the raw -burst, saved verbatim to the gallery as `clip_.mp4`. There is no editor, no reverse, no -concat, no speed/repetition control, no trim. The Boomerang feature itself does not yet exist. - -We are also reverse-engineering the proprietary **Boomerang** app, which has the right -*editor* but the wrong *capture entry point* (forces the user to pre-record in another app) -and the wrong *business model* (ads/IAP per save). OpenLoop's wedge is: - -1. **Camera-first.** The app boots straight into the viewfinder so the user can react to what - they're witnessing without leaving for the system camera and coming back. -2. **Free and on-device.** Every operation is local; there are no ads, no IAP, no network - round-trip, no account. -3. **Open source.** The whole pipeline is auditable; users can fork the speed range, the modes, - the duration cap, anything. - -This document specifies the editor and the handoff from camera to editor. - ---- - -## 2. Scope - -### In scope - -- **Capture-to-editor handoff**: after the user stops recording in `CameraScreen`, route into - the new `BoomerangEditorScreen` with the raw clip loaded. -- **Edit-from-gallery entry point**: tapping a raw clip in `GalleryScreen` opens the same - editor; tapping a finished boomerang opens it in *re-edit* mode (if the source raw still - exists) or plays it back. -- **Editor UI**: video preview that updates live as settings change; trim handles; mode chips - (Forward / Reverse / Reverse→Forward / Forward→Reverse); Speed and Repetitions tab controls; - Save. -- **Media3 pipeline**: trim, reverse, concat, speed change, repetitions → single MP4 output. -- **Storage model**: keep the raw clip in the gallery *and* the finished boomerang, with a - visible distinction between the two (see [§7](#7-data-layer-changes)). -- **Post-save UX**: trigger the Android share sheet for the finished MP4, then return to the - camera viewfinder. - -### Out of scope (defer) - -- Audio handling beyond strip-or-keep (no pitch correction, no audio FX). -- Multi-clip composition (more than one source clip per boomerang). -- Cloud sync, sharing accounts, watermarks, captions, stickers, filters. -- Editing existing *boomerangs* by reverse-engineering their pipeline. Re-edit always works - from the original raw, never from a rendered boomerang. -- Auto-detection of "best loop point" (interesting heuristic; not v1). - ---- - -## 3. UX flow - -### 3.1 Capture-first path (primary) - -``` -ReadyToCapture (camera viewfinder) - │ tap shutter, record - ▼ -Recording - │ tap stop, OR auto-stop at 30 s (Decision D-1) - ▼ -[raw written to cacheDir/scratch/raw_.mp4 — survives process kill] - │ - ▼ -BoomerangEditor(source = ScratchClip(uuid)) - │ user trims, picks mode, sets speed + reps, taps Save - ▼ -Processing (Media3 Transformer renders → filesDir/boomerangs/boom_.mp4) - │ on success: scratch file is promoted to filesDir/videos/clip_.mp4 (raw kept) - ▼ -ShareSheet (Android ACTION_SEND chooser for the rendered boomerang MP4) - │ user dismisses (shares or cancels) - ▼ -ReadyToCapture ← back to viewfinder; gallery now shows the new boomerang and raw -``` - -If the user backs out of the editor before saving, the scratch file is **discarded** — no -half-finished raws clutter the gallery. - -### 3.2 Edit-from-gallery path (secondary) - -``` -Gallery - │ tap a RAW clip - ▼ -BoomerangEditor(source = GalleryClip(rawId)) - │ same editor, raw clip is not promoted again (already in gallery) - ▼ -Processing → ShareSheet → Gallery -``` - -For finished boomerangs in the gallery: tap → preview (existing `LoopingPreview` behavior). -Long-press a boomerang whose source raw still exists → **Re-edit** (opens the editor over the -*raw*, not the rendered output). Long-press a raw → **Make a boomerang**. - -### 3.3 Post-save share sheet (interpretation flag) - -The original Boomerang app pops the Android share sheet on save, then drops the user into a -review nag + settings page before they can get back to the gallery. **We lift the share sheet -(genuinely useful) and drop the nag chain.** On share-sheet dismissal we land back on the -camera viewfinder, with a toast: *"Saved — view in gallery"* (tap to jump to Gallery). - -> **Decision D-7** in [§12](#12-decisions) calls this out explicitly so Steven can override. - ---- - -## 4. Editor screen layout - -``` -┌────────────────────────────────────────────────┐ -│ ← back/discard Save → │ top bar (glassmorphic, DeepCharcoal 80%) -├────────────────────────────────────────────────┤ -│ │ -│ │ -│ ┌──────────────────────┐ │ -│ │ │ │ -│ │ live preview │ │ ExoPlayer, repeatMode = ALL, -│ │ (loops, reflects │ │ reflects current settings end-to-end -│ │ current settings) │ │ -│ │ │ │ -│ └──────────────────────┘ │ -│ │ -│ ◀──────[ trim window ]──────▶ │ trim handles (only if duration > 1.0 s) -│ 00:00 00:03.2 00:05 │ playhead + start/end markers -│ │ -│ [Fwd] [Rev] [Rev→Fwd] [Fwd→Rev] │ mode chips (single-select) -│ │ -│ ┌── Speed ──┬── Repetitions ──┐ │ tabbed controls -│ │ │ │ │ -│ │ slider │ [1] [2] [3] [4] │ │ speed: 0.25× – 3.0× (default 2.0×) -│ │ 0.25× 3× │ segmented │ │ reps: 1–4 -│ │ │ │ │ -│ └───────────┴──────────────────┘ │ -└────────────────────────────────────────────────┘ -``` - -**Behavioral notes:** - -- Every control mutates `EditorState` in the ViewModel; the preview re-binds the player to - the new settings (without re-rendering the file — see [§6](#6-media3-pipeline)). -- "Save" is disabled while the preview is mid-bind or while a previous render is in flight. -- Back/discard from a *scratch* source shows a confirm dialog ("Discard this clip?"). Back - from a *gallery* source needs no confirm (nothing is lost — raw is already in the gallery). -- The trim window enforces a **min 0.4 s** and **max = source duration** post-trim, and a - warning bar appears if projected output duration (see §6.3) would exceed **30 s**. - -**Design tokens** (from PRD §3 Design System Tokens): -- Background: `darkColorScheme` (DeepCharcoal-derived). -- Mode chip selected fill: `NeonCoral → NeonPurple` horizontal gradient. -- Tab indicator + slider track: `NeonPurple` 100%. -- Glass surfaces (top bar, control panel): `GlassWhite` 20% over `GlassWhiteBorder` 30%. -- All colors must be 8-hex-digit `Color(0xAARRGGBB)` literals (Lesson 001). - ---- - -## 5. State machine update - -Add to `OpenLoopUiState`: - -```kotlin -/** - * The boomerang editor is open over a raw clip (either a fresh scratch capture or an - * existing gallery raw). All editor controls mutate an inner [EditorState] held by the - * ViewModel; this object only carries the source identity so navigation is resumable. - */ -data class BoomerangEditor(val source: EditorSource) : OpenLoopUiState - -sealed interface EditorSource { - /** A just-captured clip living in cacheDir/scratch/raw_.mp4, not yet in the gallery. */ - data class ScratchClip(val uuid: String) : EditorSource - /** An existing raw clip in the gallery, addressed by its RecordedVideo.id. */ - data class GalleryClip(val rawId: Long) : EditorSource -} -``` - -Updated transitions (only the affected edges): - -``` -Recording ──▶ BoomerangEditor(ScratchClip) (was: LoopingPreview) -Gallery ──▶ BoomerangEditor(GalleryClip) (new: tap raw, or long-press boomerang→Re-edit) -BoomerangEditor ──▶ Processing (user taps Save) -Processing ──▶ ShareSheet (intent) ──▶ ReadyToCapture -BoomerangEditor ──▶ ReadyToCapture / Gallery (back/discard, depending on source) -``` - -`LoopingPreview` is **kept** for tap-a-finished-boomerang playback in the gallery. The editor -does not replace it. - -The `EditorState` lives inside the ViewModel and is **not** part of `OpenLoopUiState` — keeping -it out of the public state surface lets us mutate freely on every slider tick without producing -re-renders of the navigation host. The editor screen collects `editorState: StateFlow` -directly via `collectAsStateWithLifecycle()` (Lesson 002). - -```kotlin -data class EditorState( - val sourceDurationMs: Long, - val trimStartMs: Long, - val trimEndMs: Long, - val mode: BoomerangMode, // FORWARD | REVERSE | REVERSE_THEN_FORWARD | FORWARD_THEN_REVERSE - val speed: Float, // 0.25f .. 3.0f - val repetitions: Int, // 1 .. 4 - val isRendering: Boolean = false, -) -``` - ---- - -## 6. Media3 pipeline - -A new `media/VideoProcessor.kt` owns all rendering. The ViewModel calls a single suspend -function and awaits the rendered file. - -### 6.1 Render contract - -```kotlin -interface VideoProcessor { - /** - * Render a boomerang from [source] applying [trim], [mode], [speed], and [repetitions], - * writing the output to [outputFile] and reporting progress via [onProgress] (0f..1f). - * Throws on Media3 failure; cancellable via coroutine cancellation. - */ - suspend fun renderBoomerang( - source: File, - trimStartMs: Long, - trimEndMs: Long, - mode: BoomerangMode, - speed: Float, - repetitions: Int, - outputFile: File, - onProgress: (Float) -> Unit = {}, - ): File -} -``` - -### 6.2 Pipeline stages (single Transformer invocation) - -The render builds **one** `Composition` of `EditedMediaItem`s rather than chaining multiple -Transformer passes — chaining doubles I/O and re-encodes per pass. - -1. **Trim**: `MediaItem.ClippingConfiguration` with `setStartPositionMs(trimStartMs)` and - `setEndPositionMs(trimEndMs)` on the source. -2. **Reverse half** (only if `mode ∈ {REVERSE, REVERSE_THEN_FORWARD, FORWARD_THEN_REVERSE}`): - call `videoReverser.reverse(source, trimStart, trimEnd)` to obtain (or - cache-hit) a reversed `File`, then feed it as the second `EditedMediaItem` - in the Composition. The `VideoReverser` is the hand-rolled two-pass - MediaCodec implementation specified in - [`../boomerang-rollout/RESEARCH-reverse-video.md`](../boomerang-rollout/RESEARCH-reverse-video.md) - — Media3 itself has no reverse effect, and FFmpegKit is no longer - maintained. -3. **Assemble cycle** depending on mode: - - `FORWARD` → `[trimmed]` - - `REVERSE` → `[reversed]` - - `FORWARD_THEN_REVERSE` → `[trimmed, reversed]` - - `REVERSE_THEN_FORWARD` → `[reversed, trimmed]` -4. **Repeat cycle**: append the assembled cycle `repetitions` times to the `Composition`. -5. **Speed**: apply `SpeedChangeEffect(speed)` on the composition — the constant-speed video effect - in Media3 1.10.1 (**not** `SpeedChangingVideoEffect`, which isn't in this version). **Audio is - stripped on render** (Decision D-3), so no audio speed processor is applied. -6. **Output**: H.264 / AAC-stripped, MP4 container, matching source dimensions and orientation - (no normalization in v1). - -### 6.3 Output duration math (and the 30 s warning) - -``` -cycle_ms = trimDuration (FORWARD, REVERSE) - = 2 × trimDuration (FORWARD_THEN_REVERSE, REVERSE_THEN_FORWARD) -output_ms = (cycle_ms × repetitions) / speed -``` - -A 5 s trim at `FORWARD_THEN_REVERSE`, `reps=4`, `speed=0.5×` produces 80 s — way past what a -boomerang should be. The editor shows a non-blocking warning chip above Save when -`output_ms > 30_000`, and a hard error when `output_ms > 60_000`. (These thresholds are -Decision D-5; tune after first dogfood.) - -### 6.4 Seam handling - -When concatenating `trimmed + reversed` (or vice versa), the last frame of the first clip is -visually identical to the first frame of the second clip, producing a perceptible "freeze" at -the seam. Drop the first frame of the second clip via a 1-frame clip offset -(`setStartPositionMs(frameDurationMs)`). At 30 fps that's ~33 ms. - ---- - -## 7. Data layer changes - -### 7.1 `RecordedVideo` gains a kind - -```kotlin -data class RecordedVideo( - val id: Long, - val videoPath: String, - val thumbnailPath: String, - val kind: VideoKind, // NEW - val sourceRawId: Long? = null, // NEW — set on BOOMERANG, points back to its raw -) - -enum class VideoKind { RAW, BOOMERANG } -``` - -Determined at load time from the file's directory (raws in `filesDir/videos/`, boomerangs in -`filesDir/boomerangs/`). `sourceRawId` is encoded in the boomerang filename: -`boom__from_.mp4`. - -### 7.2 `VideoStorageRepository` additions - -```kotlin -interface VideoStorageRepository { - // existing: rawCaptureFile, saveFinalizedVideo, loadRecordedVideos, deleteVideo - - /** Create a fresh scratch capture file under cacheDir/scratch/raw_.mp4. */ - fun createScratchCapture(): ScratchCapture - - /** Promote a scratch capture into the gallery as a RAW; returns the persisted file. */ - fun promoteScratchToRaw(scratch: ScratchCapture): File? - - /** Delete a scratch capture (user backed out of the editor). */ - fun discardScratch(scratch: ScratchCapture) - - /** Allocate the output File for a rendered boomerang derived from [sourceRawId]. */ - fun allocateBoomerangFile(sourceRawId: Long): File - - /** Register a rendered boomerang (extract thumbnail, etc.) so loadRecordedVideos() sees it. */ - fun registerBoomerang(file: File, sourceRawId: Long): RecordedVideo? -} - -data class ScratchCapture(val uuid: String, val file: File) -``` - -Directory layout after this change: - -``` -cacheDir/ - scratch/raw_.mp4 ← in-flight scratch captures -filesDir/ - videos/clip_.mp4 ← RAW kind (unchanged path for back-compat) - thumbnails/clip_.jpg - boomerangs/boom__from_.mp4 ← BOOMERANG kind (new) - thumbnails/boom__from_.jpg -``` - -> **Back-compat:** existing `clip_.mp4` files (today's only output) are treated as `RAW` -> with no associated boomerang. The gallery will show them with the raw badge. This matches -> reality — they were never processed. - -### 7.3 Gallery visual distinction - -`GalleryScreen` adds: - -- A small badge overlay on raw thumbnails (e.g., a "•R" chip in the bottom-right corner using - `NeonPurple` 80%). Boomerangs get no badge (they're the default expected output). -- A filter row at the top: `All` · `Boomerangs` · `Raw`. Default `All`. -- Long-press menu: - - Raw → *Make a boomerang* (opens editor), *Delete*. - - Boomerang → *Re-edit from source* (only if source raw still exists), *Delete*. - -Deleting a raw that has linked boomerangs prompts: *"This will also break re-edit for N -boomerang(s). Delete anyway?"* The boomerangs themselves are unaffected — only the re-edit -path breaks. - ---- - -## 8. ViewModel surface - -New entry points and one removed transition. - -```kotlin -// New -fun startEditorFromScratch(scratch: ScratchCapture) -fun startEditorFromGallery(rawId: Long) -fun startEditorFromBoomerang(boomerangId: Long) // finds source raw; falls back to playback - -fun updateTrim(startMs: Long, endMs: Long) -fun updateMode(mode: BoomerangMode) -fun updateSpeed(speed: Float) -fun updateRepetitions(reps: Int) - -fun saveBoomerang() // launches Processing → ShareSheet -fun discardEditor() // confirms if source is ScratchClip - -// Removed: the auto-transition from Recording → LoopingPreview goes away. -// Recording → BoomerangEditor(ScratchClip) replaces it. -``` - -All writes are guarded with `try { ... } catch (e: IOException) { ... }` per Lesson 003. -No `Context` is passed into these functions — the `Factory` in `MainActivity` wires the -repository as before (Lesson 004). - ---- - -## 9. Camera capture changes - -`CameraManager.startRecording(file, onEvent)` is unchanged. The differences are at the call -site: - -1. `startBurstCapture` allocates a `ScratchCapture` via `videoStorage.createScratchCapture()` - instead of overwriting the singleton `rawCaptureFile`. -2. The 1.5 s self-stop timer is replaced with a **30 s cap** (Decision D-1); user-driven stop - is the expected path. -3. On `VideoRecordEvent.Finalize` success, transition to - `BoomerangEditor(ScratchClip(scratch.uuid))` instead of `LoopingPreview`. -4. On `Finalize` error, discard the scratch and return to `ReadyToCapture` (unchanged). - -`cacheDir/raw_capture.mp4` (the singleton scratch file) is **retired** — every capture now -gets its own UUID so a process kill mid-edit doesn't clobber the previous in-flight capture. - ---- - -## 10. Post-save flow - -After `Processing` succeeds: - -1. Launch `Intent.ACTION_SEND` with `EXTRA_STREAM = FileProvider URI for the rendered MP4`, - `type = "video/mp4"`. Wrap with `Intent.createChooser(...)` so the user picks the target - app. **No write to public storage is required** for this — the FileProvider points at the - app-private file. -2. Whether the user shares or dismisses, return to `ReadyToCapture`. -3. Show a `Snackbar` (preferred over `Toast` for accessibility and action affordance) with - text *"Saved — view in gallery"* and a `View` action that navigates to `Gallery`. -4. Auto-dismiss the snackbar at 4 s. - -We **do not** copy the boomerang into public DCIM/Pictures in v1 — that's a separate decision -(D-6) about media-store integration and scoped storage. The share sheet is sufficient for v1 -since FileProvider URIs are first-class share targets. - ---- - -## 11. Testing plan - -### 11.1 Unit tests (JVM) - -- `OpenLoopViewModelTest`: - - Recording → BoomerangEditor(ScratchClip) transition fires on finalize success. - - Recording → ReadyToCapture transition on finalize error; scratch is discarded. - - Editor mutators (`updateTrim`, `updateMode`, `updateSpeed`, `updateRepetitions`) push - onto `editorState` without changing `uiState`. - - `saveBoomerang()` flips `editorState.isRendering = true`, awaits the processor, and - transitions to `ReadyToCapture` on success. - - `discardEditor()` from a `ScratchClip` source calls `discardScratch()`; from a - `GalleryClip` it does not. - - Uses the patterns in Lesson 008: real `TemporaryFolder`, single shared `TestDispatcher`, - fake `VideoProcessor` instead of mocking the Media3 surface. -- `VideoStorageRepositoryImplTest`: - - `createScratchCapture` / `promoteScratchToRaw` / `discardScratch` round-trip. - - `allocateBoomerangFile` + `registerBoomerang` produce a `RecordedVideo` with - `kind = BOOMERANG` and correct `sourceRawId`. - - `loadRecordedVideos` returns mixed kinds, newest first. - -### 11.2 Instrumented tests (androidTest) - -- End-to-end: capture → editor opens with the scratch loaded → set mode + speed + reps → - Save → assert the boomerang file exists, has expected duration (within ±1 frame), and - appears in `loadRecordedVideos()`. -- `BoomerangEditorScreenTest` (Compose): mode chips are single-select; speed slider clamps - to `[0.25, 3.0]`; reps segmented control clamps to `[1, 4]`; Save is disabled while - rendering. -- Re-edit-from-gallery path: tap a `BOOMERANG` with a present source → opens editor over the - raw; tap a `BOOMERANG` whose raw is missing → falls back to playback. - -### 11.3 Manual QA checklist (post-build, per `DEFINITION_OF_DONE.md`) - -- Capture → editor opens with the captured clip; preview loops. -- All four modes produce visually correct previews. -- Speed slider: 0.25× plays slow-motion, 3.0× plays fast. -- Reps: 4 visibly repeats; 1 plays once. -- Save → share sheet appears; share to a file-saving target (Drive / Telegram) succeeds. -- Back from camera → app returns to viewfinder; gallery contains both the raw and the - boomerang, raw badged. -- Process kill during edit (`adb shell am force-stop com.OpenLoop.app`): on relaunch the - raw scratch is detected and offered for resume. *(Stretch — Decision D-8.)* -- Screenshot of the editor screen attached to the PR. - ---- - -## 12. Decisions - -Each decision below is the **default** for this spec. Override any of them on review and -I'll update the doc. - -| ID | Decision | Rationale | -|-----|----------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------| -| D-1 | Recording cap: **30 s**. Auto-stop at 30 s; user can stop earlier. (was 10 s in v1 of this doc) | More headroom so users don't have to start the camera at the "perfect" moment. Trim is the escape valve. | -| D-2 | Speed range: **0.25× – 3.0×**, default **2.0×**. | 2× matches original Boomerang feel. 0.25× enables hyperlapse-style slow loops. | -| D-3 | **Audio stripped on render.** Reversed audio sounds awful; speed-shifted audio is artifact-laden. | Boomerang strips audio. Add a "Keep audio" toggle later if requested. | -| D-4 | Modes: **FORWARD / REVERSE / FORWARD_THEN_REVERSE / REVERSE_THEN_FORWARD**. | Matches your description verbatim. | -| D-5 | Output duration warning at **>30 s**, hard error at **>60 s**. | Above 30 s a "boomerang" stops being one. Hard error protects against accidental 5-min renders. | -| D-6 | Boomerang lives in **app-private storage** + FileProvider share. No DCIM/MediaStore write in v1. | Avoids scoped-storage complexity. Share sheet covers "get it out of the app." | -| D-7 | Post-save flow: **share sheet → snackbar → camera viewfinder**. No review nag, no settings detour. | Drops the original Boomerang's clunk; keeps the useful piece (share sheet). | -| D-8 | **Resume after process kill** is stretch, not v1. Scratch files older than 24 h are auto-pruned at boot. | Keeps v1 scope tight. Resume is a nice-to-have once the core flow works. | - ---- - -## 13. Open questions - -| # | Question | -|---|-------------------------------------------------------------------------------------------------------------------| -| ~~Q-1~~ | ~~Default mode on editor open~~ — **resolved 2026-05-28: `FORWARD_THEN_REVERSE`** (classic boomerang). | -| Q-2 | Should the editor allow **0 reps** (preview only, no save)? Probably no; flag for sanity. | -| Q-3 | Front-camera vs back-camera capture: anything mode-specific (e.g., mirror flip)? Likely no; track for v1.5. | -| ~~Q-4~~ | ~~Media3 `ReverseVideoEffect` availability~~ — **resolved 2026-05-28: there is no such API in Media3 1.10.x**, and FFmpegKit is retired. Plan locked: hand-rolled two-pass MediaCodec via the new `media/VideoReverser.kt`. Full rationale + algorithm + alternatives in [`../boomerang-rollout/RESEARCH-reverse-video.md`](../boomerang-rollout/RESEARCH-reverse-video.md). | -| Q-5 | "Make a boomerang" from a raw imported via the system file picker (vs. captured by us): in-scope for v1, or v1.5? | - ---- - -## 14. Acceptance criteria - -A change is shippable for this feature when **all** of the following hold (per -`DEFINITION_OF_DONE.md`): - -- [ ] `assembleDebug` + `assembleRelease` are genuinely green (BUILD SUCCESSFUL, exit 0, zero `e:`). -- [ ] `testDebugUnitTest`: 0 failures; new tests in §11.1 present. -- [ ] `connectedDebugAndroidTest`: 0 failures; new tests in §11.2 present. -- [ ] `zipalign -c -P 16 -v 4 …` on the release APK shows `(OK)` (not `(OK - compressed)`) - for every `.so` (Lesson 011). -- [ ] App actually launched on an emulator AND on Steven's Pixel 10 Pro Fold; manual QA - checklist in §11.3 walked end-to-end; screenshot(s) attached to the PR. -- [ ] Honest coverage statement: list anything not exercised on real hardware (e.g., very long - clips, low-storage edge cases). -- [ ] No `Color(0x…)` literal in the new code violates the 8-hex-digit rule (Lesson 001). -- [ ] Every `Flow` collected in the editor screen uses `collectAsStateWithLifecycle()` - (Lesson 002). -- [ ] Every `DataStore` / repository write is wrapped in `try / catch (IOException)` - (Lesson 003). -- [ ] No `Context` parameter on any `OpenLoopViewModel` function (Lesson 004). - ---- - -## 15. Implementation order (suggested) - -1. **Data-layer plumbing**: `RecordedVideo.kind`, `VideoStorageRepository` additions, tests. -2. **State + ViewModel**: `BoomerangEditor` state, `EditorState`, editor mutators, tests - with a fake `VideoProcessor` that returns a stub file. -3. **`VideoProcessor` implementation**: trim → reverse → concat → speed in one Composition, - instrumented tests against a known input file. -4. **Capture handoff**: rewire `startBurstCapture` to scratch + transition to editor. -5. **`BoomerangEditorScreen`**: preview, trim, mode chips, tabs, Save. Compose tests. -6. **Gallery updates**: kind badge, filter row, long-press menu, re-edit entry point. -7. **Post-save share intent + snackbar**, FileProvider config in `AndroidManifest.xml`. -8. **Manual QA pass** on emulator + Pixel 10 Pro Fold; screenshot; PR. - -Each numbered step is a meaningful PR-sized chunk. Steps 1–4 are invisible to the user but -unblock 5; do not bundle them into one mega-PR. diff --git a/docs/active/boomerang-rollout/03-editor-direction-tab.md b/docs/active/boomerang-rollout/03-editor-direction-tab.md deleted file mode 100644 index d19eb08..0000000 --- a/docs/active/boomerang-rollout/03-editor-direction-tab.md +++ /dev/null @@ -1,204 +0,0 @@ -# Slice 03 — Tabbed Editor + Direction tab - -> **Branch:** `feature/boomerang-slice-03-direction-tab` (PR #25) -> **Depends on:** slice 02 (Trim screen + default-render Save). -> **Unblocks:** slices 04 (Speed) and 05 (Reps). -> -> **Status: built.** This doc has been reconciled to the **shipped** implementation (PR #25); where the -> original spec and the code diverged, the code wins and is described here. It moves to -> `docs/completed/boomerang-rollout/` when PR #25 merges. - ---- - -## Problem - -After slice 02 the user can capture, trim, and save a default boomerang — but it is *always* -`FORWARD_THEN_REVERSE` at 2× for 1 rep. There is no expression. This slice introduces the **tabbed -editor** and its first tab, **Direction**: after `NEXT` on the Trim screen the editor opens with the -trimmed boomerang already looping, and four chips let the user pick the loop direction before saving. - -The bottom tab bar shows **only the Direction icon** in this slice; its full height is reserved so -Speed (slice 04) and Reps (slice 05) drop in without reflowing the layout. - -## Scope - -### In scope (as shipped) -- New `BoomerangEditor(source)` routed state — a **slim discriminator** like `Trim` (the trim window - stays in the ViewModel's `editorState`; the editor's tab selections live in a new sibling - `editorTabState`). *(The original spec sketched `BoomerangEditor(source, trim)` + a `TrimWindow` type; - the shipped code keeps the established slim-discriminator-plus-sibling-flow pattern instead.)* -- The Trim screen's `NEXT` now opens `BoomerangEditor` (was: rendered the default boomerang and returned - to camera). The slice-02 default-render-on-`NEXT` path is removed; **all saves now go through the - editor's save checkmark** (`saveBoomerang()`). -- New `BoomerangEditorScreen`: preview (~75%), Direction tab content panel, one-icon tab bar (height - reserved ~56 dp). -- Direction tab: 4 single-select chips — Forward `▶▶`, Reverse `◀◀`, Forward→Reverse `▶◀`, - Reverse→Forward `◀▶`. Default `FORWARD_THEN_REVERSE`. Selected chip = `NeonCoral → NeonPurple` - gradient, white glyph; unselected = `GlassWhite` fill + `GlassWhiteBorder`, `NeonPurple` glyph. Hit - target ≥ 48 dp (Material / ANDROID_STANDARDS §7 accessibility minimum). -- Save checkmark (top-right, filled `NeonPurple` circle, white check): renders with the chosen direction - at the still-hard-wired `speed = 2.0×`, `reps = 1`. Disabled while the reversed clip is generating. -- Live preview reflects the chosen direction (see "Reverse preview" below). - -### Out of scope -- Speed control (slice 04), Reps control (slice 05), share sheet (slice 06), gallery edit (slice 07). -- More than one icon in the tab bar. - -## UX deltas - -### State transitions (as shipped) - -``` -Trim tap NEXT → BoomerangEditor(source) (was: render default → ReadyToCapture) -BoomerangEditor tap ← → Trim (preserves the trim selection) -BoomerangEditor tap ✓ → Processing -Processing success → ReadyToCapture (snackbar) (same as slice 02) -Processing failure → BoomerangEditor (direction selection preserved, "Failed" snackbar) -``` - -### Editor screen layout - -``` -┌────────────────────────────────────────────────┐ -│ ← [ ✓ ] │ top bar (back, save) -├────────────────────────────────────────────────┤ -│ ┌──────────────────┐ │ -│ │ preview │ │ ~75% — loops with chosen direction -│ │ (loops w/ dir.) │ │ -│ └──────────────────┘ │ -│ 3.2s │ live output-duration label -├────────────────────────────────────────────────┤ -│ Select video direction │ Direction tab content -│ [▶▶] [◀◀] [▶◀] [◀▶] │ -│ Fwd Rev F→R R→F │ -├────────────────────────────────────────────────┤ -│ [ ≫ ] │ tab bar — one icon, ~56 dp reserved -└────────────────────────────────────────────────┘ -``` - -- Output-duration label = `boomerangOutputDurationMs(mode, trim, speed=2×, reps=1)` (pure helper): - `trimDuration` for `FWD`/`REV`, `2 × trimDuration` for the two-part modes, divided by speed. A 5 s - trim in `F→R` shows `5.0s`. -- Back: a "Discard changes?" confirm dialog only when the user changed the direction off the default; - otherwise back returns silently to Trim (Lesson 015 — gate the `BackHandler`, don't always-intercept). - -### Reverse preview — as shipped - -ExoPlayer can't reverse natively, so reverse-containing previews need the pre-rendered reversed file. -The editor reuses the slice-02 `VideoReverser` **through the `VideoProcessor`** (see "shared cache" -below), then builds the looping preview as an **ExoPlayer playlist** — `setMediaItems(...)` + -`REPEAT_MODE_ALL`, rebuilt only when the direction / reversed-file / trim changes: - -- `FORWARD` → `[trimmed]` -- `REVERSE` → `[reversed]` -- `FORWARD_THEN_REVERSE` → `[trimmed, reversed]` (seam frame dropped off the 2nd clip) -- `REVERSE_THEN_FORWARD` → `[reversed, trimmed]` (seam frame dropped off the 2nd clip) - -*(The original spec left this open between `ConcatenatingMediaSource2` and a preview `Composition`/ -`CompositionPlayer`. The shipped code uses the plain ExoPlayer playlist — simplest, and the seam + -direction match the render for `reps = 1`. A constant ~33 ms preview seam offset is used; the export -computes the source's exact frame duration.)* - -On editor entry (and on any switch to a reverse-containing direction) the ViewModel eagerly calls -`ensureReversedSegment()`. While the reversed clip is generating, a **"Loopifying…"** glass overlay -covers the preview and Save is disabled. The generation is serialized — once the reversed file is ready -or in flight, repeated chip taps are no-ops (the trim is fixed for the session). - -## Technical deltas (as shipped) - -### `OpenLoopUiState.kt` - -```kotlin -data class BoomerangEditor(val source: EditorSource) : OpenLoopUiState - -data class EditorTabState( - val mode: BoomerangMode = BoomerangMode.FORWARD_THEN_REVERSE, - val reversedFile: File? = null, // the cached reversed clip the preview plays - val isReversedFileLoading: Boolean = false, -) -``` - -There is **no** `TrimWindow` type; the trim window is read from the existing `TrimState` in -`editorState`. - -### `OpenLoopViewModel.kt` - -- `editorTabState: StateFlow` (sibling to `editorState`). -- `onNextFromTrim()` — resets `editorTabState`, posts `BoomerangEditor(source)`, calls - `ensureReversedSegment()`. (No longer renders.) -- `updateMode(mode)` — sets the direction; triggers `ensureReversedSegment()` for reverse-containing modes. -- `ensureReversedSegment()` — serialized; calls `videoProcessor.ensureReversed(...)`, stores the result - in `editorTabState.reversedFile`, drives `isReversedFileLoading`. -- `saveBoomerang()` — promotes scratch→raw, renders the chosen `mode` (speed `DEFAULT_SPEED` = 2.0f, - reps `DEFAULT_REPS` = 1) **from the scratch path** (so the render hits the same reverse cache the - preview filled), registers the boomerang, discards the scratch, emits `Saved`. On failure → back to - `BoomerangEditor`, emit `Failed`, selection preserved. -- `backToTrim()` — returns to `Trim`, preserving the trim selection. - -### `media/VideoProcessor.kt` - -- **`ensureReversed(source, trimStartMs, trimEndMs)`** added to the interface — `Media3VideoProcessor` - delegates to its `VideoReverser`. The editor preview and the render both call through the **same - processor instance**, so the reversed clip for a given (source path, trim) is generated **once** and - reused (satisfies the "shared cache / no re-generation" goal by construction — they literally can't - diverge). -- **Seam fix (Lesson 018):** the 1-frame seam drop now follows **sequence position**, extracted into a - pure `boomerangSequence(mode, repetitions)` in `media/BoomerangSequence.kt`. Slice 02 already handled - all four modes; the real change here is fixing the latent seam bug that the identity-based drop ("drop - the reversed clip") had for `REVERSE` (lost a real frame) and `REVERSE_THEN_FORWARD` (dropped the - wrong clip). -- Speed effect is `SpeedChangeEffect(speed)` (Media3 1.10.1 — **not** `SpeedChangingVideoEffect`, which - isn't in this version). Audio stripped. - -### `ui/BoomerangEditorScreen.kt` (new) - -- `BoomerangEditorScreen(viewModel)` (thin) + `BoomerangEditorContent(...)` (hoisted, testable). -- Direction-aware ExoPlayer playlist preview; "Loopifying…" overlay while loading. -- 4 direction chips; one-icon reserved tab bar; gated discard `BackHandler`; save checkmark disabled - while loading. - -### `MainActivity.kt` - -Routes `BoomerangEditor` → `BoomerangEditorScreen` in the exhaustive `OpenLoopNavHost` `when` -(no `else` — Lesson 014). - -## Testing plan (as shipped) - -### Unit tests (JVM) -- `BoomerangSequenceTest` — the pure seam-by-position rule (all 4 modes × reps, incl. the two slice-02 - regression cases) **and** `boomerangOutputDurationMs`. -- `OpenLoopViewModelTest` — `onNextFromTrim` opens the editor + eagerly generates the reversed clip; - `updateMode(FORWARD)` generates nothing; the reversed clip is generated once and reused across - reverse-containing modes; `backToTrim` preserves the trim; `saveBoomerang` renders the chosen mode / - saves / emits `Saved`; render failure returns to the editor with the direction preserved. - -> Note: there is **no** JVM `VideoProcessorTest` with a `src/test/resources` fixture (the original spec -> asked for one). `Media3VideoProcessor` drives a `Transformer` (Looper + MediaCodec + GL) that does not -> run on a pure JVM — the JVM-testable piece is the extracted `boomerangSequence`/duration math; the -> encode is covered by the instrumented smoke path. (Lesson 008/017.) - -### Instrumented tests -- `BoomerangEditorScreenTest` — 4 chips render; tapping selects (single-select); selected chip reports - `selected` semantics; Save disabled while loading; "Loopifying…" shimmer shown; gated back dialog; - duration label per mode. (No mockk — Lesson 017.) - -### Manual QA (Pixel 10 Pro Fold — owner) -- All four directions produce visually correct, **seam-clean** previews (especially `REVERSE` — no lost - frame — and `REVERSE_THEN_FORWARD` — no freeze at the turn). -- **#1658 check:** `F→R` and `R→F` both halves play at 2× (the per-item-effect risk). -- Reverse correctness/orientation (slice-02 device debt): reversed half reads reversed and upright. -- First switch to a reverse-containing direction shimmers, then is instant on later switches. -- Save → file in `filesDir/boomerangs/` plays the chosen direction; back preserves the trim. - -## Acceptance criteria - -- [x] `assembleDebug` + `assembleRelease`: BUILD SUCCESSFUL, exit 0, zero `e:`. -- [x] `testDebugUnitTest`: 0 failures. -- [x] `lintDebug`: no new issues; `compileDebugAndroidTestKotlin` compiles. -- [ ] `connectedDebugAndroidTest`: 0 failures (owner, on device). -- [ ] `zipalign -c -P 16 -v 4 …` on release APK shows `(OK)` (Lesson 011). -- [ ] App launched on the Fold; all 4 directions + the #1658 check manually verified; screenshot attached. -- [x] Editor preview and the render share the **same** reverser via `VideoProcessor.ensureReversed` - (one processor instance → one trim-keyed cache; no re-generation by construction). -- [x] No `Color(0x…)` literal violates the 8-hex rule (Lesson 001); editor Flow collection uses - `collectAsStateWithLifecycle()` (Lesson 002); no `Context` on any ViewModel method (Lesson 004). diff --git a/docs/active/boomerang-rollout/04-editor-speed-tab.md b/docs/active/boomerang-rollout/04-editor-speed-tab.md deleted file mode 100644 index 0b5130e..0000000 --- a/docs/active/boomerang-rollout/04-editor-speed-tab.md +++ /dev/null @@ -1,292 +0,0 @@ -# Slice 04 — Speed tab - -> **Branch:** `feature/boomerang-slice-04-speed-tab` -> **Depends on:** slice 03 (tabbed editor with Direction tab). -> **Unblocks:** slice 05 (Reps tab). - ---- - -## Problem - -After slice 03 the editor has a Direction tab and a save checkmark — but the -boomerang's playback speed is still hard-coded at 2.0×. That's a fine default -but not a feature. Users want to dial in slow-motion boomerangs (a kid running, -played at 0.25×) and fast pings (a high-five, played at 3×). - -This slice introduces: - -1. **Speed tab** as the second tab in the bottom tab bar (icon = lightning bolt - `⚡`). The bar now shows **three** icons — Direction, Speed, and a **disabled - Reps stub** (`⏱`) that lands in slice 05 — so the tab bar reaches its final - width this slice and slice 05 only enables an existing icon (no reflow). -2. **Horizontal speed slider** in the tab's content panel — drag with finger, - range `0.25× – 3.0×`, default `2.0×`. **Minimalist visual** matching the - reference screenshot (comet / motion-trail thumb on a filled track) — *no* - end labels and *no* floating value label. Current speed is conveyed by the - live duration chip and by haptic detents (see Slider behavior). -3. **Live preview** that re-binds to the new playback speed instantly. -4. **Render pipeline** that honors the chosen speed instead of the hard-coded - 2.0×. - -> **Design deviations from the original draft (decided 2026-05-29).** The -> screenshot wins over the earlier written spec on two points, and haptics grew: -> (a) **slider is label-free** (comet thumb, no `0.25×/3.0×` end labels, no -> floating `"1.75×"` bubble) — feedback comes from the duration chip + haptics, -> not text; (b) **tab bar ships 3 icons** (Direction, Speed, disabled Reps stub) -> rather than 2; (c) **haptic detents fire at both 1.0× and 2.0×** (find "normal" -> *and* the default by feel) — this is the label-free slider's substitute for a -> value readout, so the user can return to known speeds. A `stateDescription` -> still announces the numeric speed to TalkBack (accessibility is not optional — -> it's just invisible to sighted users and doesn't affect the screenshot match). - -## Scope - -### In scope -- Tab bar grows from 1 icon (Direction) to 3 slots (Direction, Speed, **disabled - Reps stub**). Only Direction and Speed are interactive this slice; the Reps - `⏱` icon renders dimmed (~40% alpha), is non-clickable, and carries no active - pill — it exists so slice 05 enables it without a layout shift. -- New Speed tab content: horizontal slider, range 0.25× – 3.0×, default 2.0×. - **No floating value label and no end labels** — minimalist comet thumb per the - reference screenshot. A `stateDescription` exposes the numeric speed to - TalkBack only. -- `EditorTabState.speed: Float` and `EditorTabState.activeTab: EditorTab` added. -- ExoPlayer `setPlaybackSpeed(speed)` called on every slider change so the - preview reflects the new speed live (this is native and free — no - re-rendering). -- `saveBoomerang()` passes `speed = state.speed` to - `VideoProcessor.renderBoomerang(...)` — replaces the hard-coded `DEFAULT_SPEED`. - (The processor itself already applies the `speed` parameter; see the note below.) -- Output duration indicator above the tab bar updates live as speed changes - (`cycle_ms × reps / speed`). - -### Out of scope -- Pitch correction / audio handling beyond strip. Audio remains stripped on - render per parent doc D-3. -- Per-slice speed within a single boomerang (e.g., first rep at 1×, second rep - at 2×). Out of scope for the entire v1. -- Snap-to-preset speed values (e.g., 0.5× / 1× / 2×). Slider is continuous; - add snap if user testing finds the slider too fiddly. (Haptic detents at 1.0× - and 2.0× are the lighter-weight substitute shipped this slice — they let the - user *feel* the two key speeds without forcing them.) -- A visible speed readout (floating label, end labels, or a "1.75×" chip). The - slider is deliberately label-free to match the screenshot; revisit only if QA - finds users can't tell what speed they've set. -- Storing the user's last-used speed across captures. Each capture starts at - the default 2.0×. - -## UX deltas - -### Editor screen layout (additions) - -``` -┌────────────────────────────────────────────────┐ -│ ← [ ✓ ] │ top bar -├────────────────────────────────────────────────┤ -│ │ -│ ┌──────────────────┐ │ -│ │ preview │ │ ~75% -│ │ (now reflects │ │ -│ │ chosen speed) │ │ -│ └──────────────────┘ │ -│ 1.6s │ live (output ms) -├────────────────────────────────────────────────┤ -│ │ -│ Slow down or speed up the video │ tab title -│ │ -│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━☄ ─────── │ comet thumb, no labels -│ │ -├────────────────────────────────────────────────┤ -│ [ ≫ ] [ ⚡ ] ( ⏱ ) │ tab bar (~5%) -│ Dir. Spd Reps(off) │ 3 slots; Reps disabled -└────────────────────────────────────────────────┘ -``` - -### Tab bar behavior - -- Tab bar is centered horizontally; icons are 28 dp, spaced 32 dp apart, in - three slots: Direction (`≫`), Speed (`⚡`), Reps (`⏱`, **disabled this slice**). -- Active tab has a darker pill background (`DeepCharcoal` 60%) with `NeonPurple` - icon. Inactive interactive tabs use `GlassWhite` 20% pill background with white - icon. -- **Reps stub:** rendered at ~40% alpha, no pill, `enabled = false` (no - `clickable`), `selected = false`. It is *not* part of the pill rotation and - taps are ignored — it only reserves its slot so slice 05 enables it without - reflowing the bar. -- Tap an interactive inactive icon → switches the content panel above. - Animations: 200 ms fade-cross between tab content panels. - -### Slider behavior - -- Range: `0.25..3.0`, default `2.0`. -- **Minimalist visual, matching the reference screenshot:** a comet / - motion-trail thumb riding a 4 dp track — `NeonPurple` filled portion to the - left of the thumb, `GlassWhite` 30% to the right. **No** `0.25×/3.0×` end - labels and **no** floating value bubble. -- **No visible speed readout.** Current speed is conveyed by (a) the live - duration chip above the tab bar and (b) haptic detents. A `stateDescription` - on the slider semantics announces the numeric speed (e.g. `"1.75 times speed"`) - to TalkBack — required for an unlabeled continuous slider (ANDROID_STANDARDS - §7), invisible to sighted users. -- **Haptic detents at both 1.0× and 2.0×.** Fire a short tick when the dragged - value *crosses* either threshold (debounced so a single drag-through fires once - per crossing, not per frame). 1.0× = "normal speed"; 2.0× = the default / - classic-boomerang sweet spot — together they let the user return to the two - known speeds by feel, standing in for the omitted value label. Exact API - (`View.performHapticFeedback` vs Compose `LocalHapticFeedback`) verified - against `developer.android.com` at implementation time — do not assume - `CLOCK_TICK` is reachable from Compose's `HapticFeedbackType`. -- Tick marks (optional polish, not required): not shipped — the comet thumb keeps - the track clean. - -### Live preview behavior - -On every slider change (debounced to ~50 ms to avoid spamming `setPlaybackSpeed`): - -```text -player.setPlaybackSpeed(state.speed) -``` - -That's it for Forward / Forward→Reverse modes. For Reverse-containing modes, -the cached reversed file in `EditorTabState.reversedFile` already plays -forward (just with reversed frames), so `setPlaybackSpeed` works there too. -**The pre-rendered reversed file is reused across speed changes** — speed is -a player-side effect, not a render-side one. - -### Output duration indicator - -Below the preview, the "Xs" indicator now uses the speed: - -``` -output_ms = (cycle_ms × reps) / speed -``` - -For `mode = F→R, reps = 1, speed = 1.75×, trim = 2.0 s`: -- `cycle_ms = 2 × 2000 = 4000` -- `output_ms = 4000 × 1 / 1.75 = 2285 ms` → display `"2.3s"`. - -## Technical deltas - -### `OpenLoopViewModel.kt` - -```kotlin -data class EditorTabState( - val mode: BoomerangMode = BoomerangMode.FORWARD_THEN_REVERSE, - val speed: Float = 2.0f, // NEW - val activeTab: EditorTab = EditorTab.DIRECTION, // NEW - val reversedFile: File? = null, - val isReversedFileLoading: Boolean = false, -) - -enum class EditorTab { DIRECTION, SPEED } // REPS arrives in slice 05 - -fun updateSpeed(speed: Float) { - val clamped = speed.coerceIn(0.25f, 3.0f) - _editorTabState.value = _editorTabState.value.copy(speed = clamped) -} - -fun switchTab(tab: EditorTab) { - _editorTabState.value = _editorTabState.value.copy(activeTab = tab) -} -``` - -`saveBoomerang()` now passes `speed = state.speed` to -`VideoProcessor.renderBoomerang(...)`. - -### `media/VideoProcessor.kt` - -**No change needed.** `renderBoomerang(...)` already takes a `speed: Float` parameter -(since slice 02) and `Media3VideoProcessor` already applies it per clip via -`SpeedChangeEffect(speed)` — the constant-speed effect in Media3 1.10.1 (**not** -`SpeedChangingVideoEffect`, which isn't in this version). There is no `2.0f` -literal in the processor to replace. The only render-side change is in -`OpenLoopViewModel.saveBoomerang()` (pass `state.speed` instead of `DEFAULT_SPEED`). -Audio remains stripped. - -### `ui/BoomerangEditorScreen.kt` - -- Replace the single centered `Text("≫")` tab bar with a 3-slot row: Direction - (`≫`) and Speed (`⚡`) interactive with active-tab pill switching driven by - `editorTabState.activeTab`; Reps (`⏱`) a dimmed, non-clickable stub. -- `BoomerangEditorContent` stays stateless (mirrors `TrimScreenContent`): thread - new params `speed: Float`, `activeTab: EditorTab`, `onSpeedChange: (Float) -> Unit`, - `onSwitchTab: (EditorTab) -> Unit` from `BoomerangEditorScreen`. -- Add `SpeedTabContent` composable: hosts a custom-drawn slider (comet thumb, - `NeonPurple`-filled track, `GlassWhite` 30% remainder) — **no value label, no - end labels**. Bind to `speed`; on drag, call `onSpeedChange(it)`. Add a - `stateDescription` to the slider semantics announcing the numeric speed. -- Debounce slider emissions to ~50 ms before calling - `player.setPlaybackSpeed(...)`. (Use `snapshotFlow { speed }.debounce(50.milliseconds)` - inside a `LaunchedEffect`.) -- Haptic detents: track the previous speed and, on each change, fire a tick when - the value crosses 1.0× or 2.0×. Verify the haptics API against - `developer.android.com` before wiring (likely `LocalView.current.performHapticFeedback`). -- The duration chip now reads `boomerangOutputDurationMs(..., speed = speed, ...)` - (it already takes `speed`) so it updates live as the slider moves — this is the - user's only *visual* speed feedback. -- Animate tab content cross-fade: `AnimatedContent` with 200 ms `fadeIn` + - `fadeOut`. - -### `MainActivity.kt` - -No new routes — same `BoomerangEditor` destination, expanded content. - -## Testing plan - -### Unit tests - -- `OpenLoopViewModelTest`: - - `updateSpeed(1.5f)` updates `editorTabState.speed = 1.5f`. - - `updateSpeed(5.0f)` clamps to `3.0f`; `updateSpeed(0.1f)` clamps to `0.25f`. - - `switchTab(EditorTab.SPEED)` updates `editorTabState.activeTab`. - - `saveBoomerang()` passes the current `speed` to `VideoProcessor`. -- `VideoProcessorTest`: - - For each of `[0.25, 0.5, 1.0, 1.5, 2.0, 3.0]`, render produces output with - duration ≈ `cycle_ms / speed` (±1 frame). - -### Instrumented tests - -- `BoomerangEditorScreenTest`: - - Tab bar shows 3 slots; Speed is tappable and shows the slider; the Reps stub - is present, dimmed, and **not** clickable (tap is a no-op, panel unchanged). - - Slider thumb position reflects current speed; dragging updates - `editorTabState.speed` and the slider's `stateDescription` (no visible label - to assert). - - Preview's `playbackSpeed` matches `editorTabState.speed` after debounce - settles. - - Output duration indicator updates as speed changes. -- End-to-end: capture 3 s → trim to 2 s → editor → switch to Speed tab → drag - to 0.5× → preview plays slow → save → output file duration ≈ 8 s (2 s cycle - × 2 for F→R × 1 rep / 0.5 speed = 8 s). - -### Manual QA - -- Drag slider through full range; preview speed changes smoothly with no jank. -- At 0.25×: preview is clearly slow-motion. At 3.0×: preview is clearly fast. -- Haptic detents felt at **both** 1.0× and 2.0× on Pixel 10 Pro Fold; each fires - once per crossing (not repeatedly while dragging through). -- Save at three different speeds (0.5×, 1×, 2.5×); verify each output file - plays at the expected speed in the gallery's `LoopingPreview`. -- Switch tabs (Direction ↔ Speed) several times; selections persist on each - tab; tab content cross-fades cleanly. Tapping the disabled Reps stub does - nothing. -- TalkBack on the slider announces the numeric speed (`stateDescription`). -- Screenshot of the Speed tab with the slider mid-drag attached to the PR (no - value label — confirm the comet thumb + live duration chip read clearly). - -## Acceptance criteria - -- [ ] `assembleDebug` + `assembleRelease`: BUILD SUCCESSFUL, exit 0, zero `e:`. -- [ ] `testDebugUnitTest`: 0 failures; new tests above present. -- [ ] `connectedDebugAndroidTest`: 0 failures. -- [ ] `zipalign -c -P 16 -v 4 …` on release APK shows `(OK)` (Lesson 011). -- [ ] App launched on emulator AND Pixel 10 Pro Fold; manual QA walked; - screenshot attached. -- [ ] Speed slider clamps verified on both ends (no `0.1×`, no `5.0×` reaches - the player or the renderer). -- [ ] No `Color(0x…)` literal violates the 8-hex-digit rule (Lesson 001). -- [ ] All Flow collection in editor uses `collectAsStateWithLifecycle()` - (Lesson 002). -- [ ] No `Context` parameter on any `OpenLoopViewModel` method (Lesson 004). -- [ ] PR description states the longest output duration observed in QA (so we - know we're still under the 60 s hard cap from parent doc D-5). diff --git a/docs/active/boomerang-rollout/05-editor-looks-tab.md b/docs/active/boomerang-rollout/05-editor-looks-tab.md deleted file mode 100644 index 4647f0b..0000000 --- a/docs/active/boomerang-rollout/05-editor-looks-tab.md +++ /dev/null @@ -1,283 +0,0 @@ -# Slice 05 — Looks tab (filters) - -> **Branch:** `feature/boomerang-slice-05-looks-tab` -> **Depends on:** slice 04 (Speed tab). -> **Unblocks:** slice 06 (Share sheet). -> -> **Scope pivot (decided 2026-05-29):** this slice *replaces* the originally-planned -> Repetitions tab with a **Looks (filters) tab**. Reps did nothing visible inside the app -> (the preview and gallery both loop forever, so 1 rep and 4 reps look identical — reps only -> changes the exported file length), and it dragged in a 60-second-cap UI for a rare edge case. -> Filters are visible *live* in the preview, are the single most "social media" feature, and -> ride the exact effects pipeline our speed control already proved. Reps is deferred to a -> possible v1.5 (or folded into the Share slice if "loop N times for play-once players" earns -> its keep). The folder README's "what ships" list is updated to match. - ---- - -## ⚠️ Start here — cold-session reality check - -*Verified against the slice-04 branch + `developer.android.com` (the no-trust-training-data -rule). Read this before the rest of the doc.* - -**Feasibility is already proven green.** The headline risk — "can a filter show *live* in the -preview, not just in the saved file?" — is resolved: - -1. **Live preview works via `ExoPlayer.setVideoEffects(List)`.** The Media3 Transformer - docs explicitly support effects "for preview in ExoPlayer," using the **same `Effect` - objects** as the render: `player.setVideoEffects(listOf(effect)); player.prepare()`. ([Transformations guide](https://developer.android.com/media/media3/transformer/transformations)) -2. **The filter effects exist in `androidx.media3.effect`** and attach through the **same - `Effects(audioProcessors, videoEffects=[…])`** object our speed code already builds: - `RgbFilter` (grayscale/invert), `RgbAdjustment` (per-channel scale → warm/cool), - `HslAdjustment` (saturation → pop). ([effect package](https://developer.android.com/reference/androidx/media3/effect/package-summary)) -3. **Speed + filter compose cleanly.** In the preview, `setPlaybackSpeed` (a Player setting) - and `setVideoEffects` (the effect list) are independent. At render, both live in the - `videoEffects` list — speed is already there; we append the filter. - -**What's reused (don't rebuild):** -- The **third tab slot already exists** as a disabled stub (slice 04): `TabBarItem(Icons.Filled.Timer, - "Repetitions tab, coming soon", enabled=false, testTag="tab_reps")`. Repurpose it: enable it, - swap the icon to a sparkle/wand, retag, wire `onSwitchTab(EditorTab.LOOKS)`. -- `BoomerangEditorContent` is **stateless** (mirrors `TrimScreenContent`): add `filter` + - `onFilterChange` **with defaults** so slice-03/04 tests keep compiling. -- The `when (activeTab)` in `AnimatedContent` has **no `else`** (Lesson 014). Adding `LOOKS` - makes it non-exhaustive → the compiler forces the new branch. Good. -- Effects already flow through `Media3VideoProcessor.speedEffects(speed)` → extend it to - build `[speedEffect] + filterEffect(s)`. - -**Latent bug to fix while here (from slice 04):** the back-discard gate only fires on a changed -*direction* (`if (mode != FORWARD_THEN_REVERSE) showDiscardDialog`). A user who changes **speed** -or **filter** and hits back loses it **silently**. Extend the gate to "any non-default -selection" (`mode != F→R || speed != 2.0f || filter != ORIGINAL`). - -**Unknowns to close at build time (not blockers):** -- **Exact Media3 builder method names** (`RgbFilter.createGrayscaleFilter()`, - `RgbAdjustment.Builder().setRedScale(…)`, `HslAdjustment.Builder().adjustSaturation(…)`) — - the JS reference pages don't render via fetch; confirm signatures against the 1.10.1 source - when coding. (Compilation is the safety net — a wrong name fails the build.) -- **On-device live `setVideoEffects` behavior** on the Pixel with our `PlayerView` (it's - `@UnstableApi` — we already opt in). The implementation re-applies effects on the same - rebind path as the playlist so the look is guaranteed to show; verify smoothness on device. -- **Thumbnail-vs-effect color fidelity** (see Technical deltas) — the chip thumbnail uses a - Compose `ColorMatrix`; the preview/render use the Media3 effect. Derive both from the *same* - per-look parameters so they match (avoid a new "preview lies about export"). - -**Process reminders (from slice 04):** web-search `developer.android.com` before any API claim; -clear the full Definition-of-Done gate (debug **and** release `BUILD SUCCESSFUL` exit 0 zero -`e:`, unit + instrumented tests, `lintDebug` clean, `zipalign -c -P 16` `(OK)`, run app + -screenshot, honest "couldn't verify" list). Device reality: in the slice-04 session the emulator -sat `offline`; only the physical Pixel 10 Pro Fold (`adb` serial `58271FDCG000XC`) was usable and -the owner ran manual QA — expect to defer `connectedDebugAndroidTest` + screenshot to the owner. - ---- - -## Problem - -After slice 04 the editor has Direction and Speed tabs. The boomerang is fun but visually flat — -every clip looks like the raw camera feed. For a "make something fun to post" app, a one-tap -**color look** (B&W, warm, cool, vibrant) is the highest-impact, most-expected creative control, -and it shows instantly in the preview. - -This slice introduces: - -1. **Looks tab** as the third (and final) tab in the bottom bar (icon = sparkle / `AutoAwesome`). -2. **A horizontal strip of filter chips**, each a **live thumbnail** of the current clip frame in - that look. Five looks: **Original, B&W / Noir, Warm / Vintage, Cool / Moody, Vibrant Pop.** - Default = Original. -3. **Live preview** that applies the chosen look instantly via `ExoPlayer.setVideoEffects`. -4. **Render pipeline** that bakes the chosen look into the saved MP4 (same effect object). - -## Scope - -### In scope -- Tab bar: repurpose the disabled Reps stub into an enabled **Looks** tab (Direction, Speed, Looks). -- `LooksTabContent`: a horizontal, centered strip of 5 filter chips. Each chip = a still frame - from the trimmed clip rendered in that look (Compose `Image` + per-look `ColorFilter`/`ColorMatrix`) - + a short label + a selection ring (`NeonPurple`) on the active one. -- `EditorTabState.filter: VideoFilter` (default `ORIGINAL`) and `EditorTab.LOOKS` added. -- A single `VideoFilter` model where each look owns its parameters once, and both its **Media3 - effect** (preview + render) and its **Compose `ColorMatrix`** (thumbnail) derive from those - parameters so the chip can't lie about the result. -- Live preview: the player applies `filter.toMediaEffects()` via `setVideoEffects` on the same - rebind path as the playlist. -- Render: `saveBoomerang()` passes the filter; `renderBoomerang` appends the filter effect to its - `videoEffects` list next to the speed effect. -- One representative frame extracted from the trim (`MediaMetadataRetriever`, off the main thread, - cached) to drive the thumbnails. - -### Out of scope -- **Repetitions** — pivoted out of v1 (see scope-pivot note). Deferred / possibly folded into slice 06. -- Per-filter **intensity slider** (e.g., 0–100% warmth). v1 ships fixed-strength presets; add - intensity later if QA wants it. -- **Custom LUT / `.cube` file** import. Presets only. -- **Stickers, text, overlays, frames** (`OverlayEffect`) — a bigger, separate feature. -- **Per-clip filters** (e.g., forward warm, reverse cool). One look applies to the whole boomerang. -- **Animated / video-thumbnail chips** — chips show a *still* frame, not a playing loop. - -## Looks (the five presets) - -Each look is defined by a small parameter set; the Media3 effect and the thumbnail matrix are both -built from it (single source of truth → thumbnail matches export). - -| Look | Feel | Media3 effect (verify exact API) | Thumbnail `ColorMatrix` | -|------|------|----------------------------------|--------------------------| -| **Original** | unfiltered | *(none — empty effect list)* | identity | -| **B&W / Noir** | timeless, high-contrast | `RgbFilter.createGrayscaleFilter()` | `setToSaturation(0f)` | -| **Warm / Vintage** | cozy, golden-hour | `RgbAdjustment.Builder().setRedScale(1.15f).setBlueScale(0.85f).build()` | scale R×1.15, B×0.85 | -| **Cool / Moody** | cinematic, calm | `RgbAdjustment.Builder().setRedScale(0.85f).setBlueScale(1.15f).build()` | scale R×0.85, B×1.15 | -| **Vibrant Pop** | bright, energetic | `HslAdjustment.Builder().adjustSaturation(+40f).build()` | `setToSaturation(1.4f)` | - -> Exact builder method names (`setRedScale`, `adjustSaturation`, etc.) and value ranges are -> **to be verified against Media3 1.10.1** when coding. Tune the warm/cool/pop numbers on-device so -> the look is pleasant and the thumbnail visually matches the preview/render. - -## UX deltas - -### Editor screen layout (additions) - -``` -┌────────────────────────────────────────────────┐ -│ ← [ ✓ ] │ -├────────────────────────────────────────────────┤ -│ ┌──────────────────┐ │ -│ │ preview │ │ ~75% -│ │ (filter applied │ │ -│ │ LIVE) │ │ -│ └──────────────────┘ │ -│ 1.6s │ -├────────────────────────────────────────────────┤ -│ │ -│ Choose a look │ tab title -│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │ -│ │▦Or │ │▦B&W│ │▦Wrm│ │▦Cool│ │▦Pop│ │ live-thumbnail chips -│ └────┘ └════┘ └────┘ └────┘ └────┘ │ (selected = NeonPurple ring) -├────────────────────────────────────────────────┤ -│ [ ≫ ] [ ⚡ ] [ ✨ ] │ tab bar (Looks enabled) -│ Dir. Spd Looks │ -└────────────────────────────────────────────────┘ -``` - -### Looks strip - -- Horizontal row of 5 chips, centered, evenly spaced; horizontally scrollable so it never clips - on a narrow width. -- Each chip: a ~64 dp rounded thumbnail of the trim's representative frame in that look, with a - short label beneath (Original / B&W / Warm / Cool / Pop). -- Selected chip: 2 dp `NeonPurple` ring + label brightens. Unselected: subtle `GlassWhiteBorder`. -- Tap → immediate selection + the big preview re-binds its effect live. Optional: light - `HapticFeedbackType.SegmentTick` on selection (consistency with slice-04 detents). - -### Tab bar - -- Three slots: Direction (`≫`/`FastForward`), Speed (`⚡`/`Bolt`), **Looks** (`✨`/`AutoAwesome`). -- The Looks slot is the slice-04 Reps stub, now `enabled = true`, with the active-pill behavior the - other tabs already have. Retag `tab_reps` → `tab_looks`; drop the "coming soon" description. - -## Technical deltas - -### `OpenLoopUiState.kt` -```kotlin -enum class EditorTab { DIRECTION, SPEED, LOOKS } // add LOOKS - -data class EditorTabState( - val mode: BoomerangMode = BoomerangMode.FORWARD_THEN_REVERSE, - val speed: Float = 2.0f, - val filter: VideoFilter = VideoFilter.ORIGINAL, // NEW - val activeTab: EditorTab = EditorTab.DIRECTION, - val reversedFile: File? = null, - val isReversedFileLoading: Boolean = false, -) -``` - -### `VideoFilter` (new) — single source of truth per look -A `VideoFilter` enum (in `media/`, alongside `BoomerangMode`) carries each look's display label + -parameters (red/blue channel scale, saturation). Two derivations: -- **`toMediaEffects(): List`** (Media3-aware, `@UnstableApi`) — for preview + render. - `ORIGINAL` → `emptyList()`. -- **`colorMatrix(): ColorMatrix?`** (UI-side helper in the screen file, using - `androidx.compose.ui.graphics.ColorMatrix`) — for the chip thumbnail. `ORIGINAL` → `null`. - -Both built from the same per-look constants so they stay in sync (thumbnail ≈ preview ≈ export). - -### `OpenLoopViewModel.kt` -```kotlin -fun updateFilter(filter: VideoFilter) { // mirror updateSpeed/updateMode - val current = _editorTabState.value - if (current.filter == filter) return - _editorTabState.value = current.copy(filter = filter) -} -``` -`saveBoomerang()` passes `filter = tab.filter` to `renderBoomerang(...)` (it already reads -`val tab = _editorTabState.value`). No 60 s cap, no warning chip — filters don't change duration. - -### `media/VideoProcessor.kt` -- Add `filter: VideoFilter` to the `renderBoomerang(...)` interface signature (defaults to - `VideoFilter.ORIGINAL` so callers/fakes stay simple). -- Extend the effects builder: `videoEffects(speed, filter)` returning - `Effects(emptyList(), listOf(speedEffect) + filter.toMediaEffects())`. Order: speed then color is - fine (color is per-pixel). Audio still stripped. -- This is the only render-side change — small and localized (contrast with reps, which needed none). - -### `ui/BoomerangEditorScreen.kt` -- Enable the third `TabBarItem` as **Looks** (icon `Icons.Filled.AutoAwesome`, `testTag("tab_looks")`, - `onClick = { onSwitchTab(EditorTab.LOOKS) }`). -- Add `LOOKS` branch to the `AnimatedContent` `when (activeTab)` → `LooksTabContent(...)`. -- `BoomerangEditorContent` gains `filter: VideoFilter = VideoFilter.ORIGINAL` and - `onFilterChange: (VideoFilter) -> Unit = {}` (defaults keep older tests compiling). -- **Live preview:** apply `filter.toMediaEffects()` via `exoPlayer.setVideoEffects(...)` on the same - `LaunchedEffect` that rebinds the playlist (so a direction/trim/filter change always re-applies - the look and the effect is guaranteed visible after `prepare()`). -- **Thumbnails:** extract one representative `Bitmap` from the trim (midpoint) via - `MediaMetadataRetriever` on `Dispatchers.IO`, remembered/cached per trim. Each chip is a Compose - `Image(bitmap, colorFilter = look.colorMatrix()?.let { ColorFilter.colorMatrix(it) })`. Frame - extraction must be off the main thread (ANDROID_STANDARDS §9) and cached so switching tabs doesn't - re-decode. A graceful placeholder shows while the frame loads / if extraction fails. -- **Back-gate fix:** `handleBack` confirms when `mode != FORWARD_THEN_REVERSE || speed != 2.0f || - filter != VideoFilter.ORIGINAL`. - -### `MainActivity.kt` -No new routes — same `BoomerangEditor` destination. - -## Testing plan - -### Unit tests (`OpenLoopViewModelTest`) -- `updateFilter(WARM)` → `editorTabState.filter == WARM`. -- `updateFilter` is a no-op when already selected (no extra emission). -- `saveBoomerang()` passes the selected `filter` to the processor (add `lastRenderFilter` to - `FakeVideoProcessor`, mirroring slice-04's `lastRenderSpeed`). - -### Instrumented tests (`BoomerangEditorScreenTest`) -- Tab bar shows 3 slots; tapping **Looks** shows the chip strip (assert `tab_looks` + a chip tag). -- The strip shows all 5 looks; tapping a look calls `onFilterChange` with it and rings it selected. -- Selection persists when switching tabs (Direction ↔ Speed ↔ Looks) and back. -- (Thumbnail bitmaps may be absent with a dummy temp file — assert on chip presence/tags + the - `onFilterChange` callback + selected semantics, not pixel content. Mirror slice-04's - stateless-content approach.) - -### `VideoProcessorTest` -- Rendering with each non-Original filter produces an output file without error (the fake/real - processor accepts the filter param and the effect list builds). - -### Manual QA -- Each look visibly changes the **live preview** instantly on tap (B&W obviously grayscale, warm - warmer, cool cooler, pop more saturated). -- The selected chip's **thumbnail matches** what the big preview shows (no "thumbnail lies"). -- Save with a non-Original look → the **saved file** in the gallery shows the same look. -- Speed + filter stack: set 0.5× + B&W → preview is slow *and* grayscale; saved file matches. -- Switching tabs preserves direction + speed + filter. -- Screenshot of the Looks tab (a non-Original look selected, ring visible) attached to the PR. - -## Acceptance criteria -- [ ] `assembleDebug` + `assembleRelease`: BUILD SUCCESSFUL, exit 0, zero `e:`. -- [ ] `testDebugUnitTest`: 0 failures; new filter tests present. -- [ ] `connectedDebugAndroidTest`: 0 failures. -- [ ] `zipalign -c -P 16 -v 4 …` on release APK shows `(OK)` (Lesson 011). -- [ ] App launched on emulator AND Pixel 10 Pro Fold; manual QA walked; screenshot attached. -- [ ] Filter visibly applies **live** in the preview (not just in the saved file). -- [ ] Selected chip thumbnail matches the preview/exported look (fidelity check). -- [ ] Speed + filter verified stacking correctly in both preview and a saved file. -- [ ] No `Color(0x…)` literal violates the 8-hex-digit rule (Lesson 001). -- [ ] All Flow collection uses `collectAsStateWithLifecycle()` (Lesson 002). -- [ ] No `Context` parameter on any `OpenLoopViewModel` method (Lesson 004) — frame extraction runs - in the composable (it has a `Context`), not in the ViewModel. -- [ ] PR notes which Media3 effect classes/methods were used (with the verified 1.10.1 signatures). diff --git a/docs/active/boomerang-rollout/06-share-sheet-and-return.md b/docs/active/boomerang-rollout/06-share-sheet-and-return.md deleted file mode 100644 index 89bede6..0000000 --- a/docs/active/boomerang-rollout/06-share-sheet-and-return.md +++ /dev/null @@ -1,241 +0,0 @@ -# Slice 06 — Share sheet + return-to-camera - -> **Branch:** `feature/boomerang-slice-06-share-sheet` -> **Depends on:** slice 05 (Looks tab — editor is feature-complete). -> **Unblocks:** slice 07 (Gallery tap-to-edit). - ---- - -## Problem - -Through slice 05 the editor is feature-complete (direction, speed, looks, -trim) and saves rendered boomerangs to internal storage. But saving today -just shows a snackbar — the user has no fast path to **share** the -boomerang to messaging, social, or cloud apps without first navigating to -the gallery, finding the file, and using a separate share gesture. - -Reference Boomerang's post-save flow pops the **Android share sheet** -immediately on save. This is the genuinely useful piece of their post-save -chain (the review nag and settings detour are not). Lifting just the share -sheet gives users single-tap distribution. - -## Scope - -### In scope -- `FileProvider` configuration in `AndroidManifest.xml` + `res/xml/file_paths.xml`. -- On successful render in `saveBoomerang()`, launch `Intent.ACTION_SEND` - via `Intent.createChooser(...)` for the rendered MP4. -- After the chooser dismisses (shared, canceled, or backed out), route to - `ReadyToCapture` (the camera) and show a snackbar **"Saved — view in gallery"** - with a `View` action that navigates to `Gallery`. -- Snackbar auto-dismisses at 4 s. - -### Out of scope -- Saving the boomerang to public storage (DCIM / `MediaStore.Video`). Not - needed for v1 — the FileProvider URI is a first-class share target, and - most receivers (Drive, Telegram, Photos) save their own copy. Public - MediaStore write is a future decision (parent doc D-6). -- "Save to gallery" as a separate option distinct from share. Save and - in-gallery storage already happen — share is just the additional surfacing. -- Watermarks, share captions, deep links back to OpenLoop. -- Per-target customization (e.g., compress for SMS). Out of scope for v1. - -## UX deltas - -### Save flow - -``` -Editor tap ✓ → Processing -Processing render success → Intent.createChooser(ACTION_SEND) -Share sheet user shares or cancels → ReadyToCapture + snackbar -Snackbar auto-dismiss in 4 s → snackbar removed -Snackbar tap "View" → Gallery -``` - -If the render **fails**, no share sheet appears; instead the editor stays -visible with a snackbar **"Couldn't save boomerang. Try again."** (same as the -slice 02 failure path). - -### Snackbar copy and action - -- Default text: `"Saved — view in gallery"`. -- Action label: `"View"` (right-aligned, `NeonCoral` text). -- Action behavior: navigates to `Gallery` (currently routed via - `OpenLoopUiState.Gallery`). -- Type: Compose Material 3 `Snackbar` hosted by a `SnackbarHostState` in the - `Scaffold` of `MainActivity` (so it persists across state transitions, not - scoped to the editor screen). - -### Share sheet contents - -- `Intent.EXTRA_STREAM`: FileProvider URI for the rendered boomerang MP4. -- `Intent.EXTRA_SUBJECT`: `"OpenLoop boomerang"`. -- `intent.type`: `"video/mp4"`. -- `intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)`. -- `Intent.createChooser(intent, "Share boomerang")`. - -## Technical deltas - -### `AndroidManifest.xml` - -```xml - - - -``` - -### `res/xml/file_paths.xml` (new) - -```xml - - - -``` - -Only `filesDir/boomerangs/` is exposed — raws and scratch files are not -shareable through this provider. - -### `OpenLoopViewModel.kt` - -- Replace the snackbar-emitting success path in `saveBoomerang()` with a - `SharedFlow` emit: - ```kotlin - sealed interface UiEffect { - data class ShareBoomerang(val file: File) : UiEffect - data object Saved : UiEffect // collected by snackbar host - data object SaveFailed : UiEffect - } - - private val _uiEffects = MutableSharedFlow(extraBufferCapacity = 4) - val uiEffects: SharedFlow = _uiEffects.asSharedFlow() - ``` -- On render success, emit `ShareBoomerang(file)` first, then post `ReadyToCapture`. - The snackbar emit (`Saved`) is deferred until the share sheet returns control - (see `MainActivity` wiring below). - -### `MainActivity.kt` - -- Collect `viewModel.uiEffects` in a `LaunchedEffect` keyed to the lifecycle - (use `repeatOnLifecycle(Lifecycle.State.STARTED)`). -- On `ShareBoomerang(file)`: - ```kotlin - val uri = FileProvider.getUriForFile(this, "${packageName}.fileprovider", file) - val intent = Intent(Intent.ACTION_SEND).apply { - type = "video/mp4" - putExtra(Intent.EXTRA_STREAM, uri) - putExtra(Intent.EXTRA_SUBJECT, "OpenLoop boomerang") - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - } - val chooser = Intent.createChooser(intent, "Share boomerang") - startActivity(chooser) - // After startActivity returns, emit Saved so the snackbar appears once the user - // is back on the camera. There is no callback for chooser dismissal — that's - // fine; the snackbar is queued and displays as soon as the activity is resumed. - lifecycleScope.launch { - withResumed { viewModel.emitSavedSnackbar() } - } - ``` -- The `Scaffold` in `MainActivity` hosts a `SnackbarHostState`; on `UiEffect.Saved`, - call `snackbarHostState.showSnackbar(message = "Saved — view in gallery", actionLabel = "View")` - and on `SnackbarResult.ActionPerformed`, post `OpenLoopUiState.Gallery`. - -### Boomerang file location - -No changes from slice 02–05 — files continue to live at -`filesDir/boomerangs/boom__from_.mp4`. The FileProvider mapping -above grants share-time access without copying or moving them. - -## Testing plan - -### Unit tests - -- `OpenLoopViewModelTest`: - - `saveBoomerang()` emits `UiEffect.ShareBoomerang(file)` on render success, - then transitions to `ReadyToCapture`. - - `saveBoomerang()` emits `UiEffect.SaveFailed` on render error; stays in - `BoomerangEditor`. - - `UiEffect.Saved` is emitted only after `withResumed { ... }` resolves - (verifiable by collecting `uiEffects` and asserting ordering). - -### Instrumented tests - -- `MainActivityTest` (or `ShareIntentTest`): - - Intent capture using `Intents` (Espresso intents library): - `intended(allOf(hasAction(ACTION_SEND), hasType("video/mp4"), hasExtraWithKey(EXTRA_STREAM)))`. - - Snackbar text appears after returning from the chooser; tapping "View" - routes to `Gallery`. -- End-to-end: capture → trim → editor → save → share sheet appears → cancel → - snackbar visible on camera screen with "View" action. - -### Manual QA - -- Save a boomerang → Android share sheet appears with at least Drive, Messages, - and any installed video-receiving apps. -- Share to Drive (or Telegram if installed): file uploads successfully and - plays in the receiver app. -- Cancel the share sheet: snackbar "Saved — view in gallery" appears on the - camera screen. -- Tap "View" → gallery opens; the new boomerang is visible. -- Repeat the flow 3× in a row without restarting the app — no FileProvider - permission errors in logcat (`grep "FileUriExposedException\|SecurityException"`). -- Test on Pixel 10 Pro Fold + an emulator with the Android system share UI. -- Screenshot of the share sheet (top half) attached to the PR. - -## Implementation notes (as built) - -This doc was written before slices 04/05 merged; two prescriptions were superseded during build, -both deliberately: - -- **Reused the existing `BoomerangEvent` `Channel` instead of a new `UiEffect` `SharedFlow`.** By - slice 06 the one-shot event channel and the "Saved — view in gallery" snackbar (with the `View` - action) already existed and worked. Adding a parallel `SharedFlow` would have been churn for no - user-visible gain, and a `Channel` is the more robust choice for one-shot events (it buffers a - miss; a `SharedFlow` can drop one if nobody is collecting at that instant). Added - `BoomerangEvent.Share(file)` (render success hands the rendered MP4 to the sheet) and - `OpenLoopViewModel.onShareSheetClosed()` (emits the deferred `Saved`). -- **Deferred the snackbar via the activity's next `onResume()`, not `lifecycle.withResumed { }`.** - Called synchronously right after `startActivity(chooser)`, the activity is still `RESUMED` - (the chooser hasn't taken over yet), so `withResumed` would run its block *immediately* — firing - the snackbar before the chooser appears, the exact thing the deferral exists to prevent. - `MainActivity` instead sets `awaitingShareReturn` when launching the sheet and consumes it on the - next `onResume()`, so the snackbar shows when the user is genuinely back on the camera. -- **Snackbar duration set to `SnackbarDuration.Short`.** Material3 defaults a snackbar *with an - action label* to `Indefinite`; the explicit `Short` honors the "auto-dismiss at 4 s" requirement - while keeping the `View` action. -- **No new gradle dependency.** `FileProvider` comes from `androidx.core`; the share-intent shape - and provider scope are covered by an instrumented `ShareBoomerangTest` using the real - `FileProvider`/`Intent`, so `espresso-intents` was not needed. -- **`awaitingShareReturn` survives recreation.** The deferred-share flag is persisted in - `onSaveInstanceState` and restored in `onCreate`, so a rotation (orientation is unlocked at target - 36 — see ANDROID_STANDARDS §11) or process death while the chooser is on top doesn't drop the - "Saved" snackbar on the next `onResume()`. -- **User-facing strings extracted to `strings.xml`.** The chooser title (`share_chooser_title`), - share subject (`share_subject`), and the three post-save snackbar strings (`snackbar_saved`, - `snackbar_view_action`, `snackbar_save_failed`) are resources rather than literals, so they - localize. `buildBoomerangShareIntent(uri, subject)` takes the subject as a parameter to stay - Context-free (and unit-testable). - -## Acceptance criteria - -- [ ] `assembleDebug` + `assembleRelease`: BUILD SUCCESSFUL, exit 0, zero `e:`. -- [ ] `testDebugUnitTest`: 0 failures. -- [ ] `connectedDebugAndroidTest`: 0 failures. -- [ ] `zipalign -c -P 16 -v 4 …` on release APK shows `(OK)` (Lesson 011). -- [ ] App launched on emulator AND Pixel 10 Pro Fold; share to at least one - real app verified; screenshot attached. -- [ ] No `SecurityException` / `FileUriExposedException` in logcat across the - share flow. -- [ ] FileProvider scope verified — confirm via test that a raw file path is - **NOT** shareable through `getUriForFile` (only `boomerangs/` is exposed). -- [ ] Snackbar "View" action routes to Gallery correctly. -- [ ] No `Color(0x…)` literal violates the 8-hex-digit rule (Lesson 001). -- [ ] All Flow collection uses `collectAsStateWithLifecycle()` (Lesson 002). -- [ ] No `Context` parameter on any `OpenLoopViewModel` method (Lesson 004). -- [ ] PR description states what was not verified (e.g., specific apps that - crash on receipt of a video URI — Steven's call on how thorough to be). diff --git a/docs/active/boomerang-rollout/07-gallery-tap-to-edit.md b/docs/active/boomerang-rollout/07-gallery-tap-to-edit.md deleted file mode 100644 index a44a0c7..0000000 --- a/docs/active/boomerang-rollout/07-gallery-tap-to-edit.md +++ /dev/null @@ -1,295 +0,0 @@ -# Slice 07 — Gallery tap-to-edit + raw/boomerang distinction - -> ⚠️ **DEFERRED to v1.5 — not the v1 slice 07.** The owner re-scoped the final v1 slice to -> **library import** instead; see **[`07-import-from-library.md`](./07-import-from-library.md)**. -> This internal re-edit feature (tap a raw to edit, raw/boomerang badges, filter chips, re-edit a -> boomerang from its source) is a strong v1.5 candidate and the design below is preserved intact for -> when it's picked up. Do **not** implement this as v1's slice 07. - -> **Branch (if revived):** `feature/boomerang-slice-07-gallery-tap-to-edit` -> **Depends on:** slice 06 (share sheet). -> **Status:** deferred (v1.5). - ---- - -## Problem - -Through slice 06, the boomerang flow only works on **fresh captures** — the -user must record a new clip every time they want a new boomerang. But many -moments worth boomerang-ing are already in the user's gallery (clips captured -earlier in the session, or clips from a prior session). The gallery shows -these clips but offers no editing path; tapping one just plays it via -`LoopingPreview`. - -Two missing pieces: - -1. **Tap-to-edit from gallery** — tapping a *raw* clip routes to the same - Trim → Editor flow used after a fresh capture. The user can re-edit any - captured raw into a new boomerang at any time. -2. **Visual distinction** between raw clips and finished boomerangs — without - it, the gallery feels noisy. Steven's "keep raw alongside boomerang" - decision means both kinds coexist; this slice makes them legible. -3. **Re-edit from a boomerang** — long-pressing a boomerang opens the editor - over its **source raw** (not the rendered boomerang), enabling - "this boomerang is almost right, let me tweak it." - -After this slice ships, OpenLoop v1 is complete and ready for Play submission -(Issue #14 mechanics permitting). - -## Scope - -### In scope -- `RecordedVideo.kind` already added in slice 02 — now surfaced in the UI. -- Gallery thumbnail badge for `RAW` items (a small chip in the bottom-right - corner). Boomerangs are unbadged (the default kind). -- Filter chip row at the top of the gallery: **All · Boomerangs · Raw**. - Default `All`. Persists across navigation but not across app restarts - (in-memory only). -- Tap behavior: - - **Raw** → routes to `Trim(GalleryClip(rawId))` — same Trim screen, same - editor flow as fresh capture. - - **Boomerang** → routes to `LoopingPreview(boomerangPath)` — existing - playback (unchanged behavior). -- Long-press behavior: - - **Raw** → contextual menu: "Make a boomerang" (same as tap), "Delete". - - **Boomerang** → contextual menu: "Re-edit from source" (enabled only if - `sourceRawId` exists in `loadRecordedVideos()` results), "Delete". -- `EditorSource.GalleryClip(rawId)` plumbed through Trim and Editor. -- Scratch-resume on app launch: auto-prune `cacheDir/scratch/raw_*.mp4` files - older than **24 hours** at app start (parent doc D-8). - -### Out of scope -- Drag-to-rearrange in gallery. Out of v1. -- Multi-select for batch operations. Out of v1. -- Search / metadata-based filtering beyond the three-state chip. Out of v1. -- Share from gallery (long-press → Share). Possible v1.5 if requested — the - FileProvider config from slice 06 supports it. - -## UX deltas - -### Gallery layout - -``` -┌────────────────────────────────────────────────┐ -│ ← back Gallery │ top bar -├────────────────────────────────────────────────┤ -│ ( All ) ( Boomerangs ) ( Raw ) │ filter chip row -│ ● │ "All" selected by default -├────────────────────────────────────────────────┤ -│ │ -│ ┌────┐ ┌────┐ ┌────┐ │ -│ │ │ │ │ │ │ │ 3-col grid (existing) -│ │ R │ │ │ │ R │ │ "R" = raw badge bottom-right -│ └────┘ └────┘ └────┘ │ -│ │ -│ ┌────┐ ┌────┐ ┌────┐ │ -│ │ │ │ R │ │ │ │ -│ └────┘ └────┘ └────┘ │ -│ │ -└────────────────────────────────────────────────┘ -``` - -### Raw badge - -- 24 × 24 dp circular badge in the bottom-right corner of the thumbnail with - 4 dp inset from the edges. -- Background: `NeonPurple` 80%, 1 dp `GlassWhiteBorder` outline. -- Glyph: white capital "R" in caption typography, centered. -- Boomerangs intentionally have no badge — they're the default expected - output, and unbadged thumbnails read as "ready to share." - -### Filter chips - -- Three chips horizontally aligned, evenly spaced, top of grid below the - toolbar. -- Selected chip: `NeonCoral → NeonPurple` gradient fill, white text. -- Unselected: glass surface (`GlassWhite` 20% fill, `GlassWhiteBorder` 30% - border), `NeonPurple` text. -- Tapping a chip filters the grid; the filter state lives in - `GalleryUiState.activeFilter: GalleryFilter` (enum: `ALL`, `BOOMERANGS`, `RAW`). -- Smooth 200 ms cross-fade on chip switch. - -### Tap behavior - -The grid item's `Modifier.combinedClickable { ... }` handles: - -```kotlin -combinedClickable( - onClick = { - when (video.kind) { - RAW -> viewModel.startEditorFromGallery(video.id) - BOOMERANG -> viewModel.playBoomerang(video) - } - }, - onLongClick = { - when (video.kind) { - RAW -> showRawContextMenu(video) - BOOMERANG -> showBoomerangContextMenu(video) - } - }, -) -``` - -### Long-press context menus - -A `DropdownMenu` anchored to the long-pressed thumbnail. - -**Raw menu:** -- "Make a boomerang" → same as tap. -- "Delete" → confirm dialog ("Delete this clip? This can't be undone."), then - `videoStorage.deleteVideo(video)` (existing method). - -**Boomerang menu:** -- "Re-edit from source" → if the source raw exists, route to - `Trim(GalleryClip(sourceRawId))`. If the source has been deleted, item is - grayed out with tooltip "Source clip no longer available." -- "Delete" → confirm dialog, delete just the boomerang (source raw is - untouched). - -### Deleting a raw with linked boomerangs - -When the user deletes a raw that has boomerangs derived from it, the confirm -dialog reads: - -> "Delete this clip? This will also break re-edit for **N boomerang(s)** -> derived from it. The boomerangs themselves will be kept." - -After confirm: raw is deleted; the linked boomerangs lose their re-edit -affordance (the "Re-edit from source" menu item becomes grayed for them). - -## Technical deltas - -### `OpenLoopUiState.kt` - -`EditorSource` was added in slice 02 as a sealed interface with only -`ScratchClip`. Now add the second variant: - -```kotlin -sealed interface EditorSource { - data class ScratchClip(val uuid: String) : EditorSource - data class GalleryClip(val rawId: Long) : EditorSource // NEW -} -``` - -### `OpenLoopViewModel.kt` - -- Add `startEditorFromGallery(rawId: Long)`: - - Resolves the raw via `videoStorage.loadRecordedVideos().firstOrNull { it.id == rawId && it.kind == RAW }`. - - Posts `Trim(GalleryClip(rawId))`. -- Add `playBoomerang(video: RecordedVideo)` → posts `LoopingPreview(video.videoPath, 1.5f)` - (preserving existing playback behavior). -- Add `deleteVideoAndRefresh(video: RecordedVideo)`: - - Calls `videoStorage.deleteVideo(video)`. - - Re-emits `recordedVideos` from `videoStorage.loadRecordedVideos()`. - - Wrapped in `try / catch (IOException)` per Lesson 003. -- Add a `GalleryFilter` state field with mutator - `setGalleryFilter(filter: GalleryFilter)`. Filter is applied as a UI-side - derivation from `recordedVideos`. -- On app start, kick off `videoStorage.pruneStaleScratch(olderThanMs = 24.hours)` - in `viewModelScope` — orphan cleanup per parent D-8. - -### `VideoStorageRepository` - -Add: - -```kotlin -fun pruneStaleScratch(olderThanMs: Long) -``` - -Implementation: lists `cacheDir/scratch/`, deletes any file whose -`lastModified() < (now - olderThanMs)`. Logs the count of pruned files. - -### `Trim` and `BoomerangEditor` source resolution - -When the source is `GalleryClip(rawId)`, the Trim screen and editor resolve -the on-disk file via `videoStorage.loadRecordedVideos().first { it.id == rawId }`. -When source is `ScratchClip`, it uses `cacheDir/scratch/raw_.mp4` (existing -behavior). - -Saving from a `GalleryClip` source: the raw is already in the gallery, so the -"promote scratch to raw" step is **skipped**. Only the boomerang is registered. -The `boom__from_.mp4` filename's `` is the existing raw's id. - -### `ui/GalleryScreen.kt` - -- Add filter chip row above the grid. -- Add raw badge overlay on `BOOMERANG` `false` items (i.e., on raws). -- Wire `combinedClickable` for tap + long-press as above. -- Add `DropdownMenu` for context menus. -- On chip selection, filter the `recordedVideos` list via the active filter. - -### Performance note - -`loadRecordedVideos()` already runs synchronously and is fast for hundreds of -files. If we ever exceed ~1000 clips it'll need pagination — track but don't -build for v1. - -## Testing plan - -### Unit tests - -- `OpenLoopViewModelTest`: - - `startEditorFromGallery(rawId)` for an existing raw posts - `Trim(GalleryClip(rawId))`. - - `startEditorFromGallery(rawId)` for a non-existent raw is a no-op (does - not crash, does not change state). - - `playBoomerang(video)` posts `LoopingPreview(video.videoPath, 1.5f)`. - - `deleteVideoAndRefresh(video)` removes the file (verifiable via the fake) - and re-emits the list. - - `setGalleryFilter(BOOMERANGS)` filters the exposed list to boomerangs only. -- `VideoStorageRepositoryImplTest`: - - `pruneStaleScratch(olderThanMs)` deletes files older than the threshold - and leaves newer ones alone. Use `TemporaryFolder` + `File.setLastModified` - to control ages. - -### Instrumented tests - -- `GalleryScreenTest`: - - Filter chips switch the visible items. - - Raw badge renders on raws, not on boomerangs. - - Tap on a raw routes to `Trim(GalleryClip(...))`. - - Tap on a boomerang routes to `LoopingPreview`. - - Long-press menu shows correct items per kind. -- End-to-end: - - Capture 2 raws + render 1 boomerang → gallery shows 3 items, one badged. - - Filter "Raw" → 2 items visible. - - Tap a raw → Trim screen opens with the raw loaded. - - Long-press the boomerang → "Re-edit from source" → editor opens on the - source raw, not the rendered boomerang. - -### Manual QA - -- Mix of raws and boomerangs in gallery — chips filter correctly. -- Raw badge readable at thumbnail size on Pixel 10 Pro Fold (both folded and - unfolded screen). -- Delete a raw that has 2 linked boomerangs → confirm dialog states "2 - boomerang(s)" correctly; after delete, the boomerangs' "Re-edit from - source" menu items are grayed. -- Process kill, relaunch: any scratch files >24 h old are pruned (verify via - `adb shell ls /data/data/com.OpenLoop.app/cache/scratch/`). -- Screenshot of the gallery with mixed raws/boomerangs + filter chip selected - attached to the PR. - -## Acceptance criteria - -- [ ] `assembleDebug` + `assembleRelease`: BUILD SUCCESSFUL, exit 0, zero `e:`. -- [ ] `testDebugUnitTest`: 0 failures. -- [ ] `connectedDebugAndroidTest`: 0 failures. -- [ ] `zipalign -c -P 16 -v 4 …` on release APK shows `(OK)` (Lesson 011). -- [ ] App launched on emulator AND Pixel 10 Pro Fold (both folded and - unfolded states); gallery + tap-to-edit + long-press + filter chips - all verified; screenshots attached. -- [ ] Re-edit-from-source verified: a boomerang's re-edit opens its actual - source raw (not the rendered boomerang). -- [ ] Deleting a raw with linked boomerangs leaves the boomerangs intact. -- [ ] Scratch-prune at app start verified (logcat shows the prune count; - manually populated old scratch files are removed). -- [ ] No `Color(0x…)` literal violates the 8-hex-digit rule (Lesson 001). -- [ ] All Flow collection uses `collectAsStateWithLifecycle()` (Lesson 002). -- [ ] All repository writes wrapped in `try / catch (IOException)` (Lesson 003). -- [ ] No `Context` parameter on any `OpenLoopViewModel` method (Lesson 004). -- [ ] Unfolded large-screen (≥600 dp) layout sanity-checked on Pixel 10 Pro - Fold — the grid should breathe, not just stretch to 3-col fixed. - (May warrant a separate small slice if extensive; track as open - question for ship.) -- [ ] PR description includes screenshots from folded + unfolded screens. diff --git a/docs/active/boomerang-rollout/07-import-from-library.md b/docs/active/boomerang-rollout/07-import-from-library.md deleted file mode 100644 index 9729515..0000000 --- a/docs/active/boomerang-rollout/07-import-from-library.md +++ /dev/null @@ -1,392 +0,0 @@ -# Slice 07 — Import a video from the phone library - -> **Branch:** `feature/boomerang-slice-07-import-from-library` -> **Depends on:** slice 06 (share sheet) — the full capture→trim→editor→render→share flow is shipped. -> **Unblocks:** v1 ship. -> **Resolves:** parent IMPLEMENTATION.md open question **Q-5** ("Make a boomerang from a raw -> imported via the system file picker: in-scope for v1, or v1.5?") → **in v1, this slice.** -> **Supersedes:** the original [`07-gallery-tap-to-edit.md`](./07-gallery-tap-to-edit.md) (internal -> re-edit / raw-vs-boomerang badges / filter chips). That feature is **deferred to v1.5** — see the -> banner on that file. This slice is a *different* feature: pulling an **external** video the app -> never recorded into the existing flow. - ---- - -## Read first (for the implementing session) - -1. Root `CLAUDE.md` (always). Then **every** file in `docs/lessons_learned/` — this slice touches the - state router (**Lesson 014**), Flow collection in Compose (**Lesson 002**), repository writes - (**Lesson 003**), the ViewModel/Context boundary (**Lesson 004**), color literals (**Lesson 001**), - and — because imported clips have arbitrary rotation — the reverse-pipeline rotation handling - (**Lesson 019**). -2. Parent design: [`../boomerang-editor/IMPLEMENTATION.md`](../boomerang-editor/IMPLEMENTATION.md) - (storage model §, Media3 pipeline §, Decision Log D-3/D-6/D-8). -3. This doc is the PRD for the PR. Clear the **[`../../DEFINITION_OF_DONE.md`](../../DEFINITION_OF_DONE.md)** - gate before "done": debug **and** release build genuinely green, unit + instrumented tests 0 - failures, lint 0 new issues, 16 KB `zipalign` OK, **run the app on a device + screenshot**. - ---- - -## Problem - -Through slice 06 the boomerang flow only works on **fresh captures** — the user must record a new -clip every time. But the moment they want to loop often already lives in their phone's photo -library (a clip they shot earlier, a download, an AirDrop). OpenLoop offers no way in. - -We want: from the gallery, tap an **Import** button → the system **video** picker opens → pick a -video → the app drops the user on the **Trim screen exactly as if they had just finished recording**, -and the rest of the pipeline (Trim → Editor → Render → Share, slices 02–06) is **unchanged**. - -After this slice ships, OpenLoop v1 is feature-complete for submission (Issue #14 mechanics -permitting). - -## Owner decisions baked into this slice - -- **Videos only — never images.** The picker is launched `VideoOnly`, so images are not selectable at - the source. (Don't add an image branch "just in case"; there is no image path.) -- **Imports must be ≤ 30 s.** If the picked clip is **longer than 30 s**, show a **friendly warning** - and do **not** proceed — never a silent or cryptic failure. (This refines the earlier "30 s max - trim window" idea: rather than letting a long clip in and capping the window, we keep the whole - clip ≤ 30 s, which also means *no* extra trim-window cap is needed — the existing Trim screen - already can't select more than the clip's length.) We verify length **before** copying, so a long - clip is never copied just to be rejected. - -## Key design decision — ingest as a scratch capture (do NOT invent a new source type) - -The entire pipeline downstream of capture is **`File`-based and keyed on a "scratch" clip**: - -- Capture records into `cacheDir/scratch/raw_.mp4` (`VideoStorageRepository.createScratchCapture()`). -- `OpenLoopViewModel` sets `editorState = TrimState(sourceFile = scratch.file, …)` and routes to - `Trim(EditorSource.ScratchClip(uuid))`. -- On save, the scratch is **promoted** to a raw (`promoteScratchToRaw`), the boomerang is rendered - from it, and slice 06 shares the output. `durationOf`, the two-pass reverse (`VideoReverser`), and - the Media3 `Transformer` all consume **file paths** — none of them speak `content://`. - -Therefore the cheapest correct way to import is: **copy the picked `content://` video's bytes into a -fresh scratch file, then enter the existing `ScratchClip` flow.** No `EditorSource.GalleryClip`, no -save-path special-casing, no Trim/Editor changes for source resolution. An imported clip is just a -scratch that came from the picker instead of the camera — "as if they just finished recording," -verbatim to the owner's intent. - -Consequence (intended, call it out in the PR): saving an imported boomerang leaves **both** a raw -copy (in `filesDir/videos/`) and the rendered boomerang in the gallery — identical to a capture. The -original library video is never touched (we copy, never move). - -## Scope - -### In scope -- **Import button in the gallery top bar**, top-**right**, opposite the existing back-to-camera - button (the film-slate glyph, top-left). Also surfaced as a CTA in the empty state. -- **Android Photo Picker** (`ActivityResultContracts.PickVisualMedia`, **`VideoOnly`**) — **no - runtime storage permission**. Single-select, returns one `Uri?`. -- **Pre-copy duration check**: probe the picked clip's length; **> 30 s → friendly warning + abort** - (no copy, stay on gallery). -- **Copy the accepted (≤ 30 s) video into a scratch file** off the main thread, then route to - `Trim(ScratchClip)` — reusing the entire existing flow. -- A brief **`ImportingVideo` loading state** while the probe + copy run. -- **Graceful failure** for the non-length cases (unreadable URI, unreadable duration, copy I/O - error): a snackbar ("Couldn't import that video.") and return to the gallery; never a crash, never - a wedged state. -- **(Recommended, small) Stale-scratch prune at launch** (parent D-8): imports increase scratch churn - (abandoned large copies), so pruning `cacheDir/scratch/` files older than 24 h on app start is - worth folding in. Mark optional if it inflates the PR. - -### Out of scope (explicitly) -- The internal **re-edit** feature (tap an existing raw to edit, raw/boomerang badges, filter chips, - re-edit-a-boomerang-from-its-source). Deferred to v1.5 — see `07-gallery-tap-to-edit.md`. -- **Images** of any kind (picker is `VideoOnly`). -- **Multi-select** import. v1 is single-video. -- Auto-**trimming a 30 s window out of a longer clip**. We reject > 30 s instead (owner decision). -- Writing the boomerang to **public storage / MediaStore** (parent D-6 stands). -- **Persistable URI permission** / re-import history. We copy immediately; the default picker grant - (valid until process death) is plenty. Do **not** call `takePersistableUriPermission`. - -## Verified API facts (Android Photo Picker — checked against developer.android.com this slice) - -Source: - -- Contract: `androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia()` (single - item). `androidx.activity` is already on the classpath via `activity-compose 1.8.2`. -- Register: `registerForActivityResult(PickVisualMedia()) { uri: Uri? -> … }`. -- Launch **video-only** (this is also what excludes images): - `launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly))`. -- Result: a **single `Uri?`** — non-null on pick, `null` if the user backs out. -- **No runtime permission required.** -- SDK: native on API 33+; modular-system-component on 30–32; **backported via Google Play services on - API 19–29** *if* the manifest declares the backport `` (below). `minSdk` is **26**, so for - the proper picker UI on 26–29 devices the `` matters; without it the framework still - resolves to an `ACTION_OPEN_DOCUMENT` fallback, so it degrades gracefully. -- Read the bytes with `ContentResolver.openInputStream(uri)`. The grant is valid until the app's - process is restarted — ample, since we copy synchronously on pick. - -## UX deltas - -### Gallery top bar - -``` -┌────────────────────────────────────────────────┐ -│ (←film-slate) Gallery (+ import) │ ← new import button, top-RIGHT -├────────────────────────────────────────────────┤ -│ …existing 3-col grid (unchanged)… │ -└────────────────────────────────────────────────┘ -``` - -- New circular button mirroring the existing back button's style (size 64 dp, `GlassWhite` fill, - 2 dp `NeonPurple` border, `NeonPurple`-tinted icon), anchored `Alignment.CenterEnd`. -- Icon: a video-add / library glyph from `material-icons-extended` (already a dep) — e.g. - `Icons.Outlined.VideoLibrary` or `Icons.Rounded.Add` — rather than a new vector. - `contentDescription = "Import a video"`. -- **Empty state:** alongside "NO LOOPS YET / Record your first loop…", add a secondary affordance - "…or import one" that triggers the same import action. - -### "Video too long" warning (the nice warning) - -When the picked clip is longer than 30 s, show a friendly **dialog** (not an error toast) over the -gallery — acknowledgment-only, no destructive framing: - -> **Title:** "That clip's a bit long" -> **Body:** "OpenLoop makes loops from videos up to 30 seconds. Pick a shorter clip and we'll loop -> it." *(adjust copy to taste — keep it warm, not an error)* -> **Button:** "Got it" - -A snackbar is an acceptable lighter alternative, but a dialog reads as "guidance," which is the -intent. Either way the user stays on the gallery and nothing is copied. - -### Flow - -``` -Gallery tap Import → system Photo Picker (VideoOnly; images not selectable) -Picker back out (null) → stay on Gallery (no-op) -Picker pick a video → ImportingVideo (loader): probe duration -ImportingVideo duration > 30 s → Gallery + "That clip's a bit long" dialog (NO copy) -ImportingVideo duration ≤ 30 s → copy bytes to scratch -ImportingVideo copy OK → Trim(ScratchClip) ── identical to a fresh capture ──▶ … -ImportingVideo copy/probe failed → Gallery + snackbar "Couldn't import that video." -``` - -The Trim screen, editor tabs, render, and share sheet are **byte-for-byte the existing flow** — the -only difference the user can perceive is the entry point. - -## Technical deltas (file-level) - -### `data/VideoImporter.kt` (new) - -A `Context`-bearing helper kept **out** of the `Context`-free `VideoStorageRepository` (Lesson 004): -`ContentResolver` needs a `Context`, the repository must never hold one. - -```kotlin -interface VideoImporter { - /** Best-effort source duration in ms (MediaMetadataRetriever over the content URI), or 0 if it - * can't be read. Used to enforce the ≤30 s rule BEFORE copying. */ - suspend fun probeDurationMs(source: Uri): Long - - /** Copy the picked [source] content into [dest] off the main thread. Returns false on any I/O - * failure (unreadable URI, low storage) — never throws to the caller. */ - suspend fun importToFile(source: Uri, dest: File): Boolean -} - -class VideoImporterImpl(private val contentResolver: ContentResolver) : VideoImporter { - override suspend fun probeDurationMs(source: Uri): Long = withContext(Dispatchers.IO) { - val r = MediaMetadataRetriever() - try { - // openAssetFileDescriptor keeps this class Context-free (vs setDataSource(context, uri)). - contentResolver.openAssetFileDescriptor(source, "r")?.use { afd -> - r.setDataSource(afd.fileDescriptor) - r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() ?: 0L - } ?: 0L - } catch (e: IllegalArgumentException) { Log.e(TAG, "probe failed", e); 0L } - catch (e: java.io.IOException) { Log.e(TAG, "probe open failed", e); 0L } - catch (e: SecurityException) { Log.e(TAG, "probe not permitted", e); 0L } - finally { r.release() } - } - - override suspend fun importToFile(source: Uri, dest: File): Boolean = withContext(Dispatchers.IO) { - try { - contentResolver.openInputStream(source)?.use { input -> - dest.outputStream().use { output -> input.copyTo(output) } - } != null - } catch (e: java.io.IOException) { Log.e(TAG, "import copy failed", e); false } - catch (e: SecurityException) { Log.e(TAG, "import URI not readable", e); false } - } -} -``` - -> **Reviewer pre-empt:** `onVideoPicked(uri: Uri?)` passes an `android.net.Uri` into the ViewModel. -> That is **allowed** under Lesson 004 — `Uri` is a parcelable value type, not a `Context`/View/ -> Lifecycle. The ban is on framework objects that retain an Activity; `Uri` retains nothing. - -### `ui/OpenLoopUiState.kt` - -```kotlin -object ImportingVideo : OpenLoopUiState // loader while the picked clip is probed + copied -``` - -**No `TrimState` change** — because imports are ≤ 30 s, no max-trim-window field is needed; the Trim -screen already can't select more than the (≤30 s) clip length. - -### `ui/OpenLoopViewModel.kt` - -- Constructor gains `private val videoImporter: VideoImporter` (update the `Factory` too). -- Constant: `IMPORT_MAX_DURATION_MS = MAX_RECORDING_MS` (30 s). Apply a small grace so a clip the user - thinks is "30 seconds" (often 30.2–30.5 s) isn't rejected — reject only when - `durationMs > IMPORT_MAX_DURATION_MS + 1_000L`. -- `fun onVideoPicked(uri: Uri?)`: - ```kotlin - if (uri == null) return // user backed out - _uiState.value = OpenLoopUiState.ImportingVideo - viewModelScope.launch { - val durationMs = videoImporter.probeDurationMs(uri) - when { - durationMs <= 0L -> failImport() // unreadable → generic "couldn't import" - durationMs > IMPORT_MAX_DURATION_MS + 1_000L -> warnTooLong() // friendly dialog - else -> { - val scratch = videoStorage.createScratchCapture() - if (!videoImporter.importToFile(uri, scratch.file)) { failImport(); return@launch } - val dur = videoStorage.durationOf(scratch.file) - if (dur <= 0L) { videoStorage.discardScratch(scratch); failImport(); return@launch } - activeScratch = scratch - promotedRaw = null - _editorState.value = TrimState( - sourceFile = scratch.file, - sourceDurationMs = dur, - trimStartMs = 0L, - trimEndMs = dur, // whole clip ≤30 s; no window cap needed - ) - _uiState.value = OpenLoopUiState.Trim(EditorSource.ScratchClip(scratch.uuid)) - } - } - } - ``` - - `failImport()`: emit `BoomerangEvent.ImportFailed`; `_uiState.value = OpenLoopUiState.Gallery`. - - `warnTooLong()`: emit `BoomerangEvent.ImportTooLong`; `_uiState.value = OpenLoopUiState.Gallery`. -- **Events:** add `ImportFailed` (snackbar) and `ImportTooLong` (the friendly dialog) to the sealed - `BoomerangEvent`. Keep the MainActivity `when` exhaustive (no `else`). -- **No `updateTrim` change** (no window cap). -- **(Optional D-8)** on `init`, `viewModelScope.launch { videoStorage.pruneStaleScratch(24h) }` — add - `pruneStaleScratch(olderThanMs: Long)` to the repository (list `cacheDir/scratch/`, delete files - whose `lastModified() < now - olderThanMs`, log the count). - -### `MainActivity.kt` - -- Register the picker launcher next to `requestPermissionLauncher`: - ```kotlin - private val pickVideoLauncher = registerForActivityResult(PickVisualMedia()) { uri -> - viewModel.onVideoPicked(uri) // Uri? — null when the user backs out - } - private fun importVideo() = pickVideoLauncher.launch(PickVisualMediaRequest(PickVisualMedia.VideoOnly)) - ``` -- Factory bridge: construct `VideoImporterImpl(applicationContext.contentResolver)` and pass it into - `OpenLoopViewModel.Factory` (Context stays in the Activity, per Lesson 004). -- Thread an `onImportVideo = ::importVideo` lambda through `OpenLoopNavHost` to `GalleryScreen`. -- Route the new state in the exhaustive `when` (Lesson 014 — **no `else`**): - `is OpenLoopUiState.ImportingVideo -> InfinityLoadingScreen()`. -- Events collector: `ImportFailed -> showSnackbar("Couldn't import that video.")`; - `ImportTooLong -> ` show the friendly dialog (drive an `AlertDialog` from a small remembered - state, e.g. `var showTooLongDialog by remember { mutableStateOf(false) }` flipped true on the - event). Keep the existing `Share`/`Saved`/`Failed` branches (slice 06). - -### `AndroidManifest.xml` (Photo Picker backport for API 26–29) - -```xml - - - - - - - -``` - -Without it the picker degrades to `ACTION_OPEN_DOCUMENT` on old devices (still functional). - -### `ui/GalleryScreen.kt` - -- Add the top-right import button (style mirrors the existing back button) wired to `onImportVideo`. -- Add the empty-state import affordance. -- **No grid / badge / filter changes** — deferred (v1.5). - -### Pipeline files — `media/VideoReverser.kt`, `media/VideoProcessor.kt` - -**No changes.** They already consume the scratch `File`. But imports exercise codecs/rotations the -camera never produced (HEVC, HDR, portrait from other devices); the existing `BoomerangEvent.Failed` -render-failure path is the safety net, and **Lesson 019** rotation handling must hold for arbitrary -source rotation. - -## Risks & edge cases (handle or consciously accept) - -| Case | Handling | -|------|----------| -| User backs out of picker (`uri == null`) | `onVideoPicked` returns immediately; stay on Gallery. | -| Clip longer than 30 s | Caught by `probeDurationMs` **before copy** → friendly `ImportTooLong` dialog → Gallery. Nothing copied. | -| Unreadable / revoked URI, low storage during copy | `importToFile` false → `ImportFailed` snackbar → Gallery. | -| Duration unreadable (`probe` or `durationOf` == 0) | `ImportFailed` (we can't enforce the 30 s rule on a clip we can't read). | -| Unsupported codec / HDR | Reverse/transcode fails at render → existing `BoomerangEvent.Failed` → editor, selection preserved. | -| Portrait vs landscape rotation | Must round-trip correctly — **Lesson 019**. Test both orientations. | -| Audio in source | Stripped on render (D-3). No action. | -| Discard from Trim after import | `discardTrim()` deletes the scratch copy — no orphan; library original untouched. Already handled. | -| Images | Not selectable (`VideoOnly`). No image code path. | - -## Testing plan - -### Unit (`OpenLoopViewModelTest`, JVM — add a `FakeVideoImporter` with settable `probeMs` / `copyOk`) -- `onVideoPicked(null)` → no state change, no scratch created. -- `probeDurationMs` returns ≤ 30 s + `copyOk` → `ImportingVideo` → `Trim(ScratchClip)`; `editorState` - set (`trimEndMs == duration`); `activeScratch` non-null. -- `probeDurationMs` returns **> 31 s** → emits `ImportTooLong`, state returns to `Gallery`, **no** - `createScratchCapture` / no copy. -- `probeDurationMs` returns 0 → `ImportFailed` → `Gallery`. -- copy fails (`copyOk = false`) → `ImportFailed` → `Gallery`; scratch discarded. -- post-copy `durationOf == 0` → `ImportFailed`, scratch discarded. -- **Lesson 017**: sweep every ViewModel construction site for the new constructor param — the JVM - fakes block, and `OpenLoopNavHostTest`'s `Noop…` fakes. - -### `VideoStorageRepositoryImplTest` (if D-8 prune included) -- `pruneStaleScratch(olderThanMs)` deletes old files, keeps newer ones (`TemporaryFolder` + - `File.setLastModified`). - -### Instrumented -- `VideoImporterImplTest`: `ContentResolver.openInputStream`/`openAssetFileDescriptor` accept - `file://` URIs — create a temp source, `Uri.fromFile`, assert `importToFile` copies bytes exactly - and `probeDurationMs` reads a real bundled short test clip's length; a non-existent URI → copy - false / probe 0. -- `OpenLoopNavHostTest`: `ImportingVideo` renders the loader and **not** the camera-bound screen - (mirror the `Processing`/`Trim` guards — Lessons 012/014). -- `GalleryScreenTest`: import button exists (`onNodeWithContentDescription("Import a video")`) and - invokes the `onImportVideo` lambda (capture a flag). -- **Not automatable:** the system Photo Picker selection — manual QA. - -### Manual QA (real device — Pixel 10 Pro Fold, folded + unfolded) -- Tap Import → picker opens **VideoOnly** (confirm images are not shown/selectable), **no permission - prompt** → pick a **landscape** clip ≤ 30 s → lands on Trim as if just recorded → save → boomerang - + raw appear; share sheet pops (slice 06). -- Repeat with a **portrait** clip and an **HEVC/4K** clip — orientation correct; render succeeds or - fails gracefully to the editor (never a crash). -- Pick a **> 30 s** clip → friendly "That clip's a bit long" dialog; nothing imported; still on - gallery. Confirm no scratch file was written (`adb shell ls .../cache/scratch/`). -- Back out of the picker → no state change. -- Import then **discard** on Trim → returns to gallery; no orphan scratch; original library video - still present on device. -- Screenshots: gallery with the Import button; the "too long" dialog; an imported clip on Trim. - -## Acceptance criteria - -- [ ] `assembleDebug` + `assembleRelease`: BUILD SUCCESSFUL, exit 0, zero `e:`. -- [ ] `testDebugUnitTest`: 0 failures (incl. ≤30 s success, >30 s warning, copy-fail, probe-0 cases). -- [ ] `connectedDebugAndroidTest`: 0 failures (incl. `VideoImporterImplTest`, NavHost `ImportingVideo` - routing, Gallery import button). -- [ ] `zipalign -c -P 16 -v 4 …` on the release APK shows `(OK)` (Lesson 011). -- [ ] App run on a real device: import a **landscape** AND a **portrait** ≤30 s clip end-to-end; share - verified; screenshots attached. -- [ ] **Images are not selectable** in the picker (verified on device). -- [ ] A **> 30 s** import shows the friendly dialog and is **not** copied or entered into the editor. -- [ ] Import requires **no runtime storage permission** (verified on device). -- [ ] Unreadable/copy-failed imports fall to a snackbar + gallery — never a crash or wedged state. -- [ ] State router `when` stays exhaustive with **no `else`** after adding `ImportingVideo` - (Lesson 014). -- [ ] No `Context` parameter on any `OpenLoopViewModel` method; `VideoImporterImpl` is the only new - Context holder, constructed in the `MainActivity` Factory bridge (Lesson 004). -- [ ] New Flow collection uses `collectAsStateWithLifecycle()` (Lesson 002); importer/repository I/O - guarded for `IOException` (Lesson 003); no malformed `Color(0x…)` literal (Lesson 001). -- [ ] PR notes what could not be verified (e.g., API 26–29 backport picker on a real old device). -- [ ] On merge: move this folder's slice docs (active → completed) per convention and close parent Q-5. diff --git a/docs/active/boomerang-rollout/README.md b/docs/active/boomerang-rollout/README.md deleted file mode 100644 index 47157ed..0000000 --- a/docs/active/boomerang-rollout/README.md +++ /dev/null @@ -1,91 +0,0 @@ -# Boomerang Rollout — Delivery Plan - -Companion to [`../boomerang-editor/IMPLEMENTATION.md`](../boomerang-editor/IMPLEMENTATION.md). - -The parent doc answers **"what are we building?"** This folder answers **"in what -order do we ship it?"** — broken into 7 thin vertical slices, each one a single PR -that ships a user-visible improvement on top of the previous slice. - ---- - -## Why thin slices - -A change is only useful if a user can feel it after `adb install`. Slices like -"data-layer plumbing for X" are invisible and skipped here — within a slice, the -technical work is whatever is needed to deliver that one user-visible promise. - -The §15 ordering in the parent IMPLEMENTATION.md was a *technical* sequencing -(data → state → processor → screen). What follows is a **delivery** sequencing: -each slice is a self-contained PR that an outside contributor could pick up cold, -implement, prove with the Definition-of-Done gate, and merge — without needing -any other slice in flight. - -Today the app's user-facing surface is: - -- 3-step Onboarding. -- Live camera viewfinder with front/back lens toggle; 1.5 s auto-stop burst recording. -- Gallery view (currently empty in practice — captures save to the gallery, but the - 1.5 s default is too short for anyone to use it meaningfully yet). - -Every slice below builds on top of that baseline. - ---- - -## The 7 slices - -| # | Slice | After this ships, the user can… | -|---|-------|-----------------------------------| -| [01](../../completed/boomerang-rollout/01-capture-variable-length.md) | **Variable-length capture (≤30 s)** | …tap shutter to start, tap again to stop, auto-cap at 30 s. Clip lands in gallery. | -| [02](./02-auto-route-trim-and-default-save.md) | **Auto-route to Trim screen + default-render Save** | …after capture, app drops them on a Trim screen; drag start/end handles; tap `NEXT` and a default `fwd→rev` 2× 1-rep boomerang renders and saves. Back to camera. | -| [03](./03-editor-direction-tab.md) | **Tabbed Editor + Direction tab** | …after `NEXT` on the trim screen, the editor opens with a Direction tab (4 options); pick a direction and tap the save checkmark. | -| [04](./04-editor-speed-tab.md) | **Speed tab** | …drag a horizontal slider 0.25× – 3× (default 2×). Preview re-binds with new speed live. | -| [05](./05-editor-looks-tab.md) | **Looks tab (filters)** | …tap a color look (B&W / Warm / Cool / Vibrant). Preview re-tints live; the look bakes into the saved file. *(Replaced the planned Repetitions tab — see that doc's scope-pivot note.)* | -| [06](./06-share-sheet-and-return.md) | **Share sheet + return-to-camera** | …Save now pops the Android share sheet on the rendered MP4; on dismiss, snackbar "Saved — view in gallery" → back to camera. | -| [07](./07-import-from-library.md) | **Import a video from the phone library** | …tap an Import button in the gallery, pick a video (≤30 s) from the phone library, and land on the Trim screen as if they'd just recorded it — then the existing Trim → Editor → Render → Share flow. *(Re-scoped from the originally-planned "gallery tap-to-edit / raw-vs-boomerang" slice, now [deferred to v1.5](./07-gallery-tap-to-edit.md).)* | - -Each slice's doc is structured the same way: **Problem → Scope (this slice only) -→ UX deltas → Technical deltas (file-level) → Testing plan → Acceptance criteria.** - ---- - -## How to use this folder - -1. **Pick the lowest-numbered open slice.** Slices are ordered for a reason — slice - N+1 assumes everything in slice N is shipped and merged. -2. **Cut a branch:** `feature/boomerang-slice--` (e.g. `feature/boomerang-slice-03-direction-tab`). -3. **Read the slice doc end-to-end.** It is the PRD for that PR. -4. **Cross-reference the parent.** [`../boomerang-editor/IMPLEMENTATION.md`](../boomerang-editor/IMPLEMENTATION.md) - has the deep-dive architecture (storage model, state machine, Media3 pipeline, - design tokens) the slice docs reference but don't repeat. -5. **Apply the Definition-of-Done gate.** See [`../../DEFINITION_OF_DONE.md`](../../DEFINITION_OF_DONE.md) — - debug + release green, unit + instrumented tests, *actually run the app + screenshot*. -6. **Open the PR with the screenshot attached** and the slice doc's acceptance - checklist filled in. - ---- - -## What ships when the rollout is complete - -After slice 07 merges, OpenLoop has feature parity with the proprietary Boomerang -app's core editor (capture → trim → direction / speed / looks → save → share) — -minus the ads, IAP, watermark, and PRO-locked options. Out of scope for this -rollout (deliberate): audio handling beyond strip, multi-clip composition, cloud -sync, stickers / text overlays, multi-loop repetitions, auto-detection of best loop -point. Those become candidates for a v1.5 rollout once v1 is dogfooded. (Slice 05 -pivoted from a Repetitions tab to a **Looks/filters** tab — filters moved *into* v1 -because they're visible live and high-impact for sharing; reps moved out because it -only changes the exported file length, never the in-app loop.) - ---- - -## Reference: UI inspiration - -The structural patterns (separate trim screen with `NEXT`, tabbed editor with -icon-only bottom tab bar, duration indicator at bottom of the preview, back arrow -top-left + save checkmark top-right) are drawn from screenshots of the reference -Boomerang app that Steven shared. OpenLoop's visual language stays -project-native: dark theme only, `NeonCoral` / `NeonPurple` / `GlassWhite` / -`DeepCharcoal` tokens (see PRD §3 "Design System Tokens" and Lesson 001 for the -hex-literal rule). The reference app's white bottom sheets become glassmorphic -dark panels; its blue accent becomes our `NeonPurple` (with `NeonCoral` gradient -for primary actions). diff --git a/docs/active/crashlytics-reverse-preview/HANDOFF.md b/docs/active/crashlytics-reverse-preview/HANDOFF.md deleted file mode 100644 index 68c7399..0000000 --- a/docs/active/crashlytics-reverse-preview/HANDOFF.md +++ /dev/null @@ -1,203 +0,0 @@ -# HANDOFF — Firebase Crashlytics reverse-preview codec issues - -**Last updated:** 2026-06-03 (MCP verification session) -**Owner context:** Steven Gates · OpenLoop (`io.github.stozo04.openloop`) - -Use this doc to resume work in a **new Cursor session** without re-triaging from scratch. - ---- - -## Latest MCP verification (PR #62 · pre–1.0.18 rollout) - -**When:** 2026-06-03 · **Auth:** `gates.steven@gmail.com` · **Project:** `openloop-8c266` - -| Check | Result | -|-------|--------| -| `topVersions` (14d) | **No** `1.0.18 (18)` — fix build not yet reporting to Crashlytics | -| `3a506c4e` · `lastSeenVersion` | **1.0.9 (9)** only · 3 events · last `2026-06-03T18:14:54Z` · Samsung SM-S921B | -| `b09e527` · `lastSeenVersion` | **1.0.17 (17)** only · 1 event · last `2026-06-03T20:40:17Z` · Android 17 (OS) emulator | -| Events on **1.0.18 (18)** for either issue | **None** (expected until testers install PR #62 build) | -| Issue notes | Empty on both issues | - -**Interpretation:** Baseline is clean for the verification version line — historical noise is confined to pre-fix builds. **Field proof still blocked** until [PR #62](https://github.com/stozo04/OpenLoop/pull/62) is merged and **1.0.18+** is installed on Samsung + Android 17 (OS) emulator with `google-services.json`, then re-run `/crashlytics-triage` and the checklist in `docs/diagnostics/crashlytics-issue-3a506c4e-verification.md`. - -**Root cause (disease vs symptom):** PR #62 treats Samsung **throws**; ping-pong failing to apply / 120 s emulator timeout is **codec slot churn** (preview + 2-pass reverse). See [`../editor-codec-churn/IMPLEMENTATION.md`](../editor-codec-churn/IMPLEMENTATION.md). Follow-up: `playerEpoch` release on reverse loading + `PRE_REVERSE_CODEC_SETTLE_MS` on all devices. - ---- - -## Where we are now - -| Item | State | -|------|--------| -| **Branch** | `fix/crashlytics-3a506c4e-pass1-codec-lifecycle` (tracking `origin/main`) | -| **PR** | [#62](https://github.com/stozo04/OpenLoop/pull/62) — **OPEN**, ready for review/merge | -| **Ship version (on branch)** | **1.0.18** (`versionCode` **18**) — use this to filter Crashlytics after rollout | -| **Prior shipped versions with events** | 1.0.9 (`3a506c4e`), 1.0.17 (`b09e527`) | - -**Commits on branch (newest first):** - -1. `ba6b7f6` — `chore: bump version to 1.0.18 (18) for Crashlytics verification` -2. `11d5664` — `fix(media): harden reverse preview against codec contention (3a506c4e, b09e527)` -3. `6be4378` — `fix(media): treat pass-1 codec teardown as cooperative cancel` - -**Not done yet (post-merge):** - -- Merge PR #62 and ship a build **≥ 1.0.18** to testers/Play -- Device QA + Crashlytics field verification (7-day zero-new-events watch) -- Close issues in Firebase console only after verification doc checklist passes - ---- - -## Firebase project constants - -| Field | Value | -|-------|--------| -| Firebase project | `openloop-8c266` (`.firebaserc` default) | -| Android **appId** (required for MCP) | `1:95815153197:android:c30254bb713d1e6ae96aa4` | -| Package | `io.github.stozo04.openloop` | -| `google-services.json` | `app/google-services.json` (gitignored; required for Crashlytics in builds) | -| CLI auth | `npx firebase-tools@latest login` (same account as console) | - -**Cursor:** Firebase marketplace plugin enabled in `.cursor/settings.json`. Agent skill: `.claude/skills/crashlytics-triage/SKILL.md` — invoke with `/crashlytics-triage`. - -**MCP quick commands (new session):** - -``` -firebase_get_environment -crashlytics_get_report → topIssues (last 7–14 days) -crashlytics_get_issue → per issue id below -crashlytics_list_events → sample stacks + custom keys -``` - ---- - -## Primary issues (in scope for PR #62) - -### 1. `3a506c4ecc5bfeff0ab2b56d58f6e1d6` — pass-1 dequeue churn (Samsung) - -| | | -|--|--| -| **Title** | `VideoReverser.runDecodeEncodeLoop` | -| **Subtitle** | `java.lang.IllegalStateException` (often empty message at dequeue) | -| **Type** | NON_FATAL (`ReverseCrashlytics.reportPreviewFailure`) | -| **Last seen (pre-fix)** | **1.0.9 (9)** · Samsung SM-S921B · Android 16 | -| **Console** | [Issue 3a506c4e](https://console.firebase.google.com/project/openloop-8c266/crashlytics/app/android:io.github.stozo04.openloop/issues/3a506c4ecc5bfeff0ab2b56d58f6e1d6) | - -**Session pattern (2026-06-03, same user):** - -1. `Pending dequeue output buffer request cancelled` -2. `Invalid to call at Released state; only valid in executing state` -3. Empty `IllegalStateException` at `dequeueOutputBuffer` - -**Root cause (agreed):** MediaCodec used after cancel/release or ExoPlayer/Transformer contention — **not** the 500k-iteration watchdog string. - -**Fix on branch:** - -- `MediaCodecLifecycle.kt` — classify lifecycle failures; map to `CancellationException` when job inactive -- `runMediaCodecCancellable` around pass-1/pass-2 dequeue paths -- `OpenLoopViewModel` — skip `markReversePreviewFailed` on `CancellationException` -- `BoomerangEditorScreen` — `playerEpoch++` on reverse loading (`ExoPlayer.release()`, not `stop()` alone) -- `VideoReverser.reverse()` — Samsung retry pass 1+2 after `SAMSUNG_CODEC_CONTENTION_RETRY_MS` - ---- - -### 2. `b09e5277491a4d8935210b9914ca52c5` — surface released at decoder configure - -| | | -|--|--| -| **Title** | `VideoReverser.openAvcDecoderForReverse` | -| **Subtitle** | `IllegalArgumentException: The surface has been released` | -| **Type** | NON_FATAL | -| **Last seen (pre-fix)** | **1.0.17 (17)** · Google `sdk_gphone16k_x86_64` · Android 17 emulator | -| **Console** | [Issue b09e527](https://console.firebase.google.com/project/openloop-8c266/crashlytics/app/android:io.github.stozo04.openloop/issues/b09e5277491a4d8935210b9914ca52c5) | - -**Root cause:** Pass 1 configured decoder against encoder **input Surface** that was already invalid. Rotating to another **decoder name** with the same dead surface cannot work (documented Pixel RTL bug; same class on Android 17 (OS) emulator). - -**Fix on branch:** - -- `openSurfaceCodecPipeline()` — encoder + surface + decoder; retry once with fresh encoder pair -- `openAvcDecoderForReverse` — on surface-released IAE, **stop** decoder try-order and rethrow -- `shouldRetryMediaCodecContention` — surface-released retries on **non-Samsung** too -- Pass 1 and pass 2 both use `openSurfaceCodecPipeline` - ---- - -## Related issues (same area — watch after ship, not all proven fixed) - -From `topIssues` (7-day window, ~2026-06-04). PR #62 may reduce these via shared pipeline; **verify separately.** - -| Issue ID | Location | Message | Last version | -|----------|----------|---------|--------------| -| `3da25acc4fcc12975a4e122e8666c8b9` | `transcodeToAllKeyframes` | Surface has been released | 1.0.12 | -| `d848b6c35e6787392fb03eb09d665637` | `awaitEncoderOutputFormat` | AVC encoder did not publish output format in time | 1.0.11 | - -**Separate watch (not this PR):** editor OOM `ef2823cfeeb3f1f59ef7308c266a7110` — see `docs/active/editor-memory-oom/IMPLEMENTATION.md`. - ---- - -## Key code touched (PR #62) - -| File | Role | -|------|------| -| `app/.../media/MediaCodecLifecycle.kt` | Lifecycle detection, cancel mapping, retry policy | -| `app/.../media/VideoReverser.kt` | `openSurfaceCodecPipeline`, reverse retry loop, cancellable dequeue | -| `app/.../media/DeviceMediaHints.kt` | `SAMSUNG_CODEC_CONTENTION_RETRY_MS`, `SAMSUNG_REVERSE_PASS_MAX_ATTEMPTS` | -| `app/.../ui/EditorPlaylistBind.kt` | `requiresPlayerEpochBumpForReversePreview` | -| `app/.../ui/BoomerangEditorScreen.kt` | Immediate ExoPlayer teardown when reverse loading | -| `app/.../ui/OpenLoopViewModel.kt` | Ignore `CancellationException` in `ensureReversed` failure path | -| `app/.../diagnostics/ReverseCrashlytics.kt` | Non-fatals + custom keys (`reverse_outcome`, etc.) | - -**Tests:** `MediaCodecLifecycleTest` (7 cases, exact Crashlytics strings), `EditorPlaylistBindTest`. - -**Docs:** - -- `docs/diagnostics/crashlytics-issue-3a506c4e-verification.md` — field verification checklist -- `docs/diagnostics/firebase-crashlytics-trimming.md` — product diagnostics / custom keys - ---- - -## What “resolved” means (do not skip) - -Historical events on **1.0.9 / 1.0.17** stay in the console forever. - -**Proof of fix requires:** - -1. Merge PR #62 and distribute **1.0.18+** with `google-services.json` -2. Repro: editor → reverse mode on **Samsung** + **Android 17 (OS) emulator** (b09e527 path) -3. Logcat: no `reverse_preview_failure` for benign cancel/churn -4. Crashlytics MCP: `crashlytics_list_events` for `3a506c4e` and `b09e527` — **no events** with `versionDisplayName` **1.0.18 (18)** -5. **7+ days** zero new events → close issues in console - -**Local gates already green on branch:** - -```bash -./gradlew :app:testDebugUnitTest -./gradlew :app:lintDebug -./gradlew :app:assembleDebug -``` - -**Device (after merge):** - -```bash -./gradlew :app:installDebug -adb shell am instrument -w -e class io.github.stozo04.openloop.media.VideoReverserTest \ - io.github.stozo04.openloop.test/androidx.test.runner.AndroidJUnitRunner -``` - ---- - -## Suggested next actions (pick up here) - -1. **Review/merge** [PR #62](https://github.com/stozo04/OpenLoop/pull/62). -2. **Internal or Play build** with version **1.0.18**; install on Samsung S24-class device + Android 17 (OS) emulator. -3. Run checklist in `docs/diagnostics/crashlytics-issue-3a506c4e-verification.md`. -4. New session: `/crashlytics-triage` → confirm no new events on **1.0.18** for `3a506c4e` and `b09e527`. -5. If `3da25acc` / `d848b6c35` still fire on 1.0.18, open a follow-up issue/PR (encoder timeout is a different failure mode). - ---- - -## Conversation / references - -- Firebase MCP setup: [Crashlytics AI assistance](https://firebase.google.com/docs/crashlytics/ai-assistance-mcp) -- Prior trimming overlay work: `docs/active/editor-trimming-overlay-stuck/`, `trimming-loop.md` -- PR #62 body has test-plan checkboxes for GitHub review diff --git a/docs/active/editor-codec-churn/IMPLEMENTATION.md b/docs/active/editor-codec-churn/IMPLEMENTATION.md deleted file mode 100644 index cee9a66..0000000 --- a/docs/active/editor-codec-churn/IMPLEMENTATION.md +++ /dev/null @@ -1,128 +0,0 @@ -# Editor codec churn — root cause (reverse preview / ping-pong) - -**Status:** Active — disease behind PR #62 symptoms -**Related:** [`../crashlytics-reverse-preview/HANDOFF.md`](../crashlytics-reverse-preview/HANDOFF.md) (PR #62 treats Samsung throws), [`../editor-memory-oom/IMPLEMENTATION.md`](../editor-memory-oom/IMPLEMENTATION.md) - ---- - -## Problem statement - -Users see **ping-pong / reverse mode fail to apply**: the editor stays on forward playback, a **120 s timeout**, or (on Samsung) Crashlytics non-fatals `3a506c4e` / `b09e527`. The failure banner describes a setting failure; the underlying issue is **MediaCodec slot exhaustion** from editor preview churn competing with the 2-pass reverse transcode. - -PR #62 correctly maps Samsung **symptoms** (ISE at dequeue, surface released at configure) to cooperative cancel + retry. It does **not** eliminate contention when the preview player still holds a decoder or the editor recreates codecs faster than they release. - ---- - -## Architecture (who holds codecs) - -| Phase | Codec sessions (typical) | -|-------|---------------------------| -| Editor preview (ExoPlayer) | 1× video decoder (+ optional `DefaultVideoFrameProcessor` GL path when a look is applied) | -| Reverse pass 1 (`transcodeToAllKeyframes`) | 1× decoder + 1× encoder (surface pipeline) | -| Reverse pass 2 | 1× decoder + 1× encoder | - -Devices expose only a **small pool** of hardware/software codec instances. **Three sessions at once** (preview + pass 1) is enough to trigger reclaim pressure. - -Reverse pumping: `VideoReverser.runDecodeEncodeLoop` calls `dequeueOutputBuffer(timeoutUs)` in a loop. If the codec is wedged under reclaim, the call returns `INFO_TRY_AGAIN_LATER` forever — **no exception** — until `OpenLoopViewModel` cancels at **120 s** (`select { onTimeout }`). That is the emulator timeout path. - -Samsung hardware codecs often **throw** at dequeue or configure instead of spinning — same root cause, different symptom (`3a506c4e`, `b09e527`). - ---- - -## Logcat evidence (repro: trim churn + tab/direction toggles) - -**Confirmed 2026-06-03** via full UI E2E + `adb logcat` on build **1.0.18** (PR #62 branch), -**Android 17 (OS) emulator (API 37, x86_64, software `c2.*` codecs)**. Exact counts from a single -editor session that ended in a 120 s timeout: - -| Marker | Count | Meaning | -|--------|------:|---------| -| `Created component [c2.*]` (19 avc-dec / 5 avc-enc / 7 goldfish-dec) | **31** | Codecs recreated on nearly every editor interaction — far more than ~4–6 needed for a couple of reverses | -| `keep callback message for reclaim` | **77** | Resource manager repeatedly reclaiming codecs — slot pressure | -| `sending message to a Handler on a dead thread` (`MediaCodec.postEventFromNative`) | **22** | Codec released while native still posts events — lifecycle race | -| `frameIndex not found` / `received null buffer` / `discarded an unknown buffer` | **71** | Buffers on flushed or dead sessions — starvation | - -At pass-1 start on a failed run: - -- `Codec2Client: setOutputSurface -- failed to set consumer usage (6/BAD_INDEX)` -- `Discard frames from previous generation` -- `MediaCodec: keep callback message for reclaim` - -Pass 1 opens into a **churned, reclaim-pressured** subsystem and never gets serviced. - ---- - -## Why it feels random - -| Session (2026-06-03 E2E) | Clip | Behavior | -|---------|------|----------| -| `gen=7` — fresh clip, single Loop tap, left undisturbed | 2.4 s | `reverse.complete attempts=1` in **4.4 s** — one clean codec generation, slot available | -| `gen=2` — rapid direction toggles during overlay | 9.3 s | **120 s timeout** | -| `gen=10` — trim-handle drags + tab switches before reverse | 2.7 s | **120 s timeout** | - -Success correlates with **low editor churn before reverse**, not clip length and not luck -(a 2.4 s clip succeeded; a churned 2.7 s clip timed out). - ---- - -## Fix layers (ordered) - -### Shipped / in flight - -| Layer | Change | -|-------|--------| -| PR #62 | Map benign teardown to `CancellationException`; Samsung retry; `openSurfaceCodecPipeline` | -| **Fix #2 (this branch)** | Reverse loading bumps `playerEpoch` so `ExoPlayer.release()` (DisposableEffect `onDispose`) drops the preview decoder — replaces the old `stop()`-only `teardownPlayerForReversePreview`, which never freed the slot | -| **Fix #2 (this branch)** | `PRE_REVERSE_CODEC_SETTLE_MS` before pass 1 on **all** devices — covers the unobservable OS slot-reclaim window | -| **Finding 3 (this branch)** | Entry-bump guard: skip `playerEpoch++` when `exoPlayer.mediaItemCount == 0` (first reverse on entry holds no preview decoder) — removes one wasteful player recreation | -| **Finding 4 (this branch)** | Removed the unused deprecated `SAMSUNG_PRE_REVERSE_CODEC_SETTLE_MS` alias; fixed test import order | - -### Review findings — resolution status (2026-06-03) - -| # | Finding | Status | -|---|---------|--------| -| 1 | Doesn't address the broader rebind storm | **Partially.** Trim-drag rebinds already coalesce (the playlist `LaunchedEffect` re-keys on trim and `delay(PLAYLIST_DEBOUNCE_MS)` cancel-restarts, so a drag collapses to one rebind). The entry-bump guard (finding 3) removes another recreation. Much of the measured "31 codecs" was inflated by *failed* trim-handle drags (see Memory `project-trim-handle-touch-sensitivity-bug`). **Remaining:** serialized codec ownership (item A below) — larger, deferred. | -| 2 | 400 ms settle is heuristic, not a handshake | **Resolved as a documented tradeoff.** A true handshake can only observe `release()` returning, **not** OS slot reclaim (unobservable from app code), and would only help slow devices if its timeout were *longer* than today's settle — i.e. longer waits. Kept the settle (now all-devices) with explicit intent in `PRE_REVERSE_CODEC_SETTLE_MS`. See item B below for the opt-in handshake if longer slow-device waits are acceptable. | -| 3 | Double player-create on editor entry | **Fixed** — `mediaItemCount == 0` guard. | -| 4 | Unused deprecated alias + import order | **Fixed.** | - -### Still required (larger, deferred — needs design sign-off) - -- **A. Serialize codec ownership** — at most one of {preview, reverse pass 1, reverse pass 2} holding - video codecs at a time (single owner/mutex). The structural cure; biggest change. -- **B. (Optional) Release handshake** — have the reverse await an explicit "preview player released" - signal from the editor (with a generous bounded timeout) instead of the fixed settle. Only worth it - if longer waits on slow devices are acceptable; adds Compose↔ViewModel coupling + deadlock risk - (mitigate with timeout fallback). -- **C. User-facing copy + latency** — replace jargon (“ping-pong”) with actionable text (e.g. - *“Couldn't build the reverse — tap to retry”*) and lower the 120 s time-to-fallback; should be rare - once A lands. - ---- - -## Key files - -| File | Role | -|------|------| -| `BoomerangEditorScreen.kt` | `playerEpoch`, playlist `LaunchedEffect`, reverse-loading gate | -| `EditorPlaylistBind.kt` | Hold-playlist policy; reverse must use full player release | -| `VideoReverser.kt` | 2-pass reverse, `runDecodeEncodeLoop`, pre-reverse settle | -| `OpenLoopViewModel.kt` | `ensureReversedSegment`, 120 s timeout, fallback forward | -| `MediaCodecLifecycle.kt` | PR #62 symptom handling | - ---- - -## Acceptance criteria (disease fixed) - -- [ ] Logcat during reverse on emulator: **O(1)** `Created component` per reverse, not O(interactions) -- [ ] Reverse completes after moderate trim/tab churn without 120 s timeout -- [ ] Samsung: zero new `3a506c4e` / `b09e527` on fixed build line (see verification doc) -- [ ] No `reverse_preview_failure` for benign cancel after user abandons reverse mid-flight - ---- - -## Testing - -- Manual: repro table above on the Android 17 (OS) emulator (API 37) + Samsung S24-class (SM-S921B saw `3a506c4e`) -- Automated: `EditorPlaylistBindTest`, `MediaCodecLifecycleTest`, `VideoReverserTest` (device/emulator) -- Crashlytics: `/crashlytics-triage` on `1.0.18+` after ship diff --git a/docs/active/editor-memory-oom/IMPLEMENTATION.md b/docs/active/editor-memory-oom/IMPLEMENTATION.md deleted file mode 100644 index 007cf73..0000000 --- a/docs/active/editor-memory-oom/IMPLEMENTATION.md +++ /dev/null @@ -1,268 +0,0 @@ -# Editor memory pressure & OOM hardening - -> **Status:** Spec — sign-off pending. No code written yet. -> **Owner:** Steven Gates -> **Branch (when started):** `feature/editor-memory-oom` (suggested) -> **Crashlytics:** Issue `ef2823cfeeb3f1f59ef7308c266a7110` · App **1.0.8 (8)** · Samsung **SM-S926B** · Android **16** · 2026-06-03 - ---- - -## 1. Problem statement - -Production Crashlytics reports a fatal **`OutOfMemoryError`** on the **256 MB Java heap** after a long editor session. The fatal stack frame (`DisplayInfo.createFromParcel` on a system display callback) is a **last-straw allocation**, not the root defect. Session evidence points at **media pipeline retention** inside OpenLoop: - -| Signal | Interpretation | -|--------|----------------| -| Breadcrumb `reverse_preview_failure: Timed out after 120s` | Preview reverse hit [`REVERSE_PREVIEW_TIMEOUT_MS`](../../../app/src/main/java/io/github/stozo04/openloop/ui/OpenLoopViewModel.kt) and was cancelled. | -| Crash ~14 minutes later | Slow heap growth after timeout, not an instant failure at reverse start. | -| Dozens of `ExoPlayer:Loader:ProgressiveMediaPeriod` threads | Far more loaders than one editor playlist (1–2 items) should need — suggests **stacked or leaked** media periods. | -| Main thread in `MediaMetricsListener.reportTrackChangeEvent` | ExoPlayer preview active under extreme memory pressure. | -| Device class | Samsung Exynos — already special-cased for reverse timeout, codec settle, and preview resolution cap ([`DeviceMediaHints.kt`](../../../app/src/main/java/io/github/stozo04/openloop/media/DeviceMediaHints.kt)). | - -Today the editor can remain usable after reverse failure (Lesson 020), but **memory is not bounded** across: - -1. **Cancelled / timed-out reverse jobs** — coroutine cancel does not guarantee prompt MediaCodec teardown on some devices; pass-1 `_intermediate_*.mp4` files may linger if cancel lands mid-pass. -2. **ExoPlayer playlist churn** — [`BoomerangEditorScreen`](../../../app/src/main/java/io/github/stozo04/openloop/ui/BoomerangEditorScreen.kt) calls `setMediaItems` + `prepare()` on many state changes (mode, trim, seam, reverse readiness, speed debounce is separate). -3. **Video effects preview** — non-`ORIGINAL` filters call `setVideoEffects`, routing through `DefaultVideoFrameProcessor` (extra GL/native retention; documented HDR seam constraints). -4. **Gallery thumbnails** — each grid tile decodes a full bitmap via `BitmapFactory.decodeFile` with no size cap ([`GalleryScreen.kt`](../../../app/src/main/java/io/github/stozo04/openloop/ui/GalleryScreen.kt)). - -Generic Crashlytics guidance that blames **`DisplayInfo` parceling** or the package name `io.github.stozo04.openloop` as a third-party library is **incorrect** for this incident. Profiling should target ExoPlayer, MediaCodec, effects, and bitmaps. - ---- - -## 2. Goals - -1. **No OOM** during a realistic “bad day” editor session on a 256 MB–class heap device after reverse timeout (repro below). -2. **Deterministic teardown** when preview reverse is cancelled, times out, or the user leaves the editor. -3. **Bounded ExoPlayer churn** — at most one prepared playlist worth of loaders under normal editor use. -4. **Graceful degradation** — after reverse failure or on low-memory signals, preview stays usable without effects-heavy paths piling on. -5. **Observable regression guard** — tests and/or manual checklist so memory fixes do not rot. - ---- - -## 3. Non-goals - -- Raising `android:largeHeap` as the primary fix (masks leaks; may hurt Play vitals narrative). -- Rewriting reverse algorithm or replacing MediaCodec reverse with a cloud/off-device path. -- Full gallery redesign (Coil/Glide migration) — only **bounded thumbnail decode** in scope unless profiling proves gallery is the dominant leak in editor-only repro. -- Fixing unrelated system display bugs. - ---- - -## 4. Success criteria (acceptance) - -| # | Criterion | Verification | -|---|-----------|--------------| -| A1 | After **120s reverse timeout** on Samsung (or test harness), user can interact with editor (mode / speed / looks / trim) for **≥15 minutes** without process death | Manual on **SM-S926B** or equivalent; Memory Profiler heap stable or sawtooth without monotonic climb to cap | -| A2 | Leaving editor (`backToTrim`, discard, save → Processing) releases ExoPlayer; heap dump shows **no** retained `ExoPlayerImpl` from editor | LeakCanary (optional) or Android Studio heap diff after navigation | -| A3 | Cancelling reverse (timeout, mode → `FORWARD`, `cancelReverseJob`) deletes `_intermediate_*.mp4` under scratch within **30s** and does not leave >1 `_intermediate_` file | Instrumented or device test; `adb shell ls` on `cache/.../scratch/reversed/` | -| A4 | Rapid direction/trim changes do not create **>4** concurrent `ProgressiveMediaPeriod` threads per editor session (2-item playlist + margin) | Logcat thread dump or debugger during stress script | -| A5 | With `reverseFailed == true`, non-original looks do not enable `setVideoEffects` (forward preview only, or explicit user messaging) | UI test + manual | -| A6 | Gallery grid with **N ≥ 20** videos does not hold **N** full-resolution bitmaps in heap at once | Heap dump after scroll; optional `inSampleSize` / max dimension cap | - -All shipping work must pass [`docs/DEFINITION_OF_DONE.md`](../../DEFINITION_OF_DONE.md) (build, lint, tests, on-device run). - ---- - -## 5. Scope — four workstreams - -Workstreams are ordered by **impact on the reported crash** (P0 → P2). They may ship as one PR or thin follow-ups. - -### WS-1 (P0): Reverse cancel — codecs + scratch files - -**Problem:** [`OpenLoopViewModel.ensureReversedSegment`](../../../app/src/main/java/io/github/stozo04/openloop/ui/OpenLoopViewModel.kt) uses `select { worker.onAwait; onTimeout(120s) { worker.cancel() } }` because wedged MediaCodec on Samsung may never return from `withTimeoutOrNull`. Cancellation propagates to [`VideoReverser.reverse`](../../../app/src/main/java/io/github/stozo04/openloop/media/VideoReverser.kt), which deletes `_intermediate_` in `finally` **only when the coroutine unwinds**. A wedged native call may delay or prevent unwind, leaving intermediates and codec buffers alive while ExoPlayer restarts. - -**In scope:** - -- **Cooperative cancellation checkpoints** in `VideoReverser` (pass 1 & 2 loops): `coroutineContext.ensureActive()` between frames / muxer steps; bounded wait on codec dequeue with cancel awareness where safe. -- **Explicit cleanup on timeout path** in ViewModel (not only `worker.cancel()`): - - Increment `reverseGeneration` (already done in `cancelReverseJob`). - - Invoke a small **`ReverseScratchJanitor`** API: delete `_intermediate_*.mp4` in `scratchDir` for the active session (glob), without deleting completed cache-key `.mp4` outputs. -- **Optional:** track active reverse in `VideoReverser` / processor with an `AtomicReference` to current intermediates so janitor can delete by path even if the job is wedged. -- **Samsung settle:** keep [`SAMSUNG_POST_TRANSFORM_CODEC_SETTLE_MS`](../../../app/src/main/java/io/github/stozo04/openloop/media/DeviceMediaHints.kt) but consider **releasing** any Transformer-held codec before reverse retry after timeout (audit `Media3VideoProcessor.ensureReversed`). -- **Logging:** Crashlytics breadcrumb `reverse_preview_cleanup` with bytes deleted / intermediate count (no PII). - -**Out of scope:** Changing 120s timeout value (separate tuning issue). - -**Primary files:** - -- `app/src/main/java/.../media/VideoReverser.kt` -- `app/src/main/java/.../media/VideoProcessor.kt` / `Media3VideoProcessor` -- `app/src/main/java/.../ui/OpenLoopViewModel.kt` -- `app/src/androidTest/.../media/VideoReverserTest.kt` (extend intermediate cleanup assertions) - -**Pattern references:** Lesson 013 (cancellation), Lesson 020 (reverse failure must not wedge editor). - ---- - -### WS-2 (P0): ExoPlayer loader stacking — debounce, serial prepare, release - -**Problem:** Editor `LaunchedEffect(mode, reversedFile, trimStartMs, trimEndMs, seamMs, reversePreviewLoading)` rebinding can overlap `prepare()` calls if inputs change faster than ExoPlayer tears down prior `ProgressiveMediaPeriod` instances. Crash session showed **many** loader threads. - -**In scope:** - -- **Single-flight playlist update:** guard with a `Mutex` or monotonic `playlistGeneration` — only the latest bind runs `setMediaItems` + `prepare()`; superseded jobs call `stop()` / `clearMediaItems()` without preparing stale playlists. -- **Debounce** rapid trim/mode/seam changes (~100–200 ms, similar to [`SPEED_DEBOUNCE_MS`](../../../app/src/main/java/io/github/stozo04/openloop/ui/BoomerangEditorScreen.kt)) so slider drags do not N× prepare. -- **Before `prepare()`:** `exoPlayer.stop(); exoPlayer.clearMediaItems()` when replacing playlist (already partially done when empty; make consistent on every rebind). -- **Lifecycle:** confirm `exoPlayer.release()` in `DisposableEffect` on leave composition (already present); add **`onDispose` before re-entering editor** from Trim if composition is preserved — verify Trim does not hold a second player concurrently. -- **Trim screen:** [`TrimScreen`](../../../app/src/main/java/io/github/stozo04/openloop/ui/TrimScreen.kt) has its own `remember { ExoPlayer }` — ensure navigating Trim → Editor disposes Trim player **before** Editor prepares (composition order / `DisposableEffect` ordering). - -**Out of scope:** Replacing ExoPlayer with TextureView + single decoder (too large). - -**Primary files:** - -- `app/src/main/java/.../ui/BoomerangEditorScreen.kt` -- `app/src/main/java/.../ui/TrimScreen.kt` (audit concurrent players) - -**Detection:** Thread count / heap diff while scrubbing trim handles and toggling Direction tab for 2 minutes post-timeout. - ---- - -### WS-3 (P1): Video effects degradation after reverse failure / low memory - -**Problem:** `setVideoEffects` allocates a frame-processing pipeline. After reverse timeout, user may still apply Looks while forward-only preview runs — compounding memory from WS-1/2. - -**In scope:** - -- When [`EditorTabState.reverseFailed`](../../../app/src/main/java/io/github/stozo04/openloop/ui/OpenLoopUiState.kt) and mode still `needsReverse`: - - **Disable** non-`ORIGINAL` looks in UI **or** apply effects only at export (preview stays unfiltered) with one-line snackbar already used for forward fallback. -- **`ActivityManager.isLowMemory()` / `ComponentCallbacks2.onTrimMemory`:** optional hook in `MainActivity` or ViewModel to set `effectsPreviewEnabled = false` until editor reset. -- Do **not** call `setVideoEffects(emptyList())` for ORIGINAL (existing comment: empty list still spins `DefaultVideoFrameProcessor` — keep current “only call when non-empty” rule). - -**Out of scope:** New looks engine; export-time effects remain required for saved boomerang. - -**Primary files:** - -- `app/src/main/java/.../ui/BoomerangEditorScreen.kt` -- `app/src/main/java/.../ui/OpenLoopViewModel.kt` (flag on `EditorTabState` or derived) -- `app/src/main/java/.../media/VideoFilter.kt` - -**UX:** Looks tab shows dimmed presets + “Preview unavailable after reverse error” (copy TBD). - ---- - -### WS-4 (P2): Gallery thumbnail decode bounds - -**Problem:** `BitmapFactory.decodeFile` at full image resolution per visible tile; scrolling a large gallery retains many bitmaps in heap. - -**In scope (minimal):** - -- Decode with **`inSampleSize`** or `BitmapFactory.Options` targeting max edge **~220px** (2× 110dp grid cell) — compute sample size from intrinsic dimensions once per file. -- **`remember(video.thumbnailPath)`** already caches per tile; ensure bitmap is not re-decoded on recomposition beyond key change. -- **Optional:** small LRU (e.g. 24 entries) in `VideoStorageRepository` or composable-level — only if profiling shows repeated decode churn. - -**Out of scope:** Coil dependency (unless owner approves manifest/size tradeoff). - -**Primary files:** - -- `app/src/main/java/.../ui/GalleryScreen.kt` (`VideoThumbnailCard`) -- Optional helper in `app/src/main/java/.../media/` or `ui/` (e.g. `ThumbnailDecoder.kt`) - -**Note:** Lower priority for **editor-only** OOM repro; include if combined “gallery → import → editor stress” is in QA matrix. - ---- - -## 6. Architecture fit - -``` -┌─────────────────────────────────────────────────────────────┐ -│ OpenLoopViewModel │ -│ ensureReversedSegment ──timeout/cancel──► ReverseScratch │ -│ │ Janitor + generation │ -│ ▼ │ -│ VideoProcessor.ensureReversed ──► VideoReverser (IO) │ -└──────────────────────────┬──────────────────────────────────┘ - │ -┌──────────────────────────▼──────────────────────────────────┐ -│ BoomerangEditorScreen │ -│ single ExoPlayer (remember) │ -│ playlist bind: debounce + single-flight prepare │ -│ effects: gated on reverseFailed / lowMemory │ -└─────────────────────────────────────────────────────────────┘ -``` - -Aligns with [`docs/PRD-mission-control.md`](../../PRD-mission-control.md) media layer and existing Samsung hints. Does not change the boomerang state machine — only resource discipline on `Trim`, `BoomerangEditor`, and `Gallery`. - ---- - -## 7. Implementation plan (ordered) - -| Step | Workstream | Task | -|------|------------|------| -| 1 | WS-1 | Add `ReverseScratchJanitor` + unit tests for glob delete semantics (preserve cache-key outputs). | -| 2 | WS-1 | Wire timeout and `cancelReverseJob()` to janitor; add `ensureActive` in `VideoReverser` hot loops. | -| 3 | WS-2 | Introduce playlist bind debounce + generation token in `BoomerangEditorScreen`. | -| 4 | WS-2 | Audit Trim ↔ Editor ExoPlayer overlap; fix if both can be alive. | -| 5 | WS-3 | Add `reverseFailed` / low-memory gate for `setVideoEffects` + Looks tab UX. | -| 6 | WS-4 | Bounded thumbnail decode in `VideoThumbnailCard`. | -| 7 | All | Manual repro + Memory Profiler validation; optional `androidTest` stress (rapid tab changes). | -| 8 | Docs | Add Lesson **023** on OOM / ExoPlayer loader stacking after ship. | - ---- - -## 8. Testing plan - -### Manual (required — matches Crashlytics device class) - -1. Samsung or Exynos phone, **import** a 20–30s 1080p+ clip (or record). -2. Trim → Editor, mode **Forward then Reverse**; wait **120s** for timeout breadcrumb. -3. For **15+ minutes**: toggle direction, scrub speed, try looks, nudge trim handles, retry reverse once. -4. Memory Profiler: heap should not monotonically approach 256 MB; thread count should not grow `ProgressiveMediaPeriod` without bound. -5. Navigate **Editor → Trim → Editor** and **discard**; confirm heap drops editor/player retained size. - -### Automated - -| Test | Type | Asserts | -|------|------|---------| -| Reverse cancel deletes `_intermediate_` | `VideoReverserTest` / androidTest | Extend existing “no intermediate files” case to **cancel mid-pass-1** | -| Timeout triggers janitor | JVM ViewModel test with fake processor | Fake hangs; advance time; verify janitor called (inject interface) | -| `reverseFailed` disables effects | JVM or Compose UI test | `setVideoEffects` not invoked when flag set (spy listener / fake) | -| Thumbnail sample size | JVM pure function test | 4000×3000 JPEG path → decoded bitmap max edge ≤ cap | - -### Crashlytics - -- Custom keys on OOM-adjacent events: `reverse_failed`, `editor_duration_sec`, `playlist_rebind_count` (if instrumented). -- Watch issue `ef2823cfeeb3f1f59ef7308c266a7110` for 30 days post-release. - ---- - -## 9. Risks & mitigations - -| Risk | Mitigation | -|------|------------| -| Aggressive `clearMediaItems` causes preview flicker | Debounce + single-flight; only clear when URI/clip set actually changes | -| Janitor deletes wrong file | Only `_intermediate_*` prefix; never `sha1-*.mp4` cache outputs | -| Disabling looks after reverse failure feels punitive | Copy explains forward-only preview; export still applies look | -| Wedged codec still ignores cancel | Document as known Samsung limit; janitor + don’t start ExoPlayer until reverse loading clears (existing gate) | - ---- - -## 10. Open questions - -| ID | Question | Default if no answer | -|----|----------|-------------------| -| Q-1 | Ship WS-1+2 in one PR or split? | One PR if review size OK (~400 LOC) | -| Q-2 | Adopt LeakCanary in debug builds for this work? | Optional debug-only dependency | -| Q-3 | Exact thumbnail max edge — 220px vs 256px? | 256px long edge | -| Q-4 | Should retry reverse after timeout be blocked until user taps “Retry” (no auto re-entry on mode change)? | Keep current mode-change retry; monitor Crashlytics | -| Q-5 | Instrument `playlist_rebind_count` in production? | Yes via Crashlytics log on editor dispose | - ---- - -## 11. References - -- Crash session analysis (2026-06-03) — team chat / Crashlytics issue `ef2823cfeeb3f1f59ef7308c266a7110` -- [`docs/lessons_learned/020-imported-clips-hdr-codec-and-reverse-failure-recovery.md`](../../lessons_learned/020-imported-clips-hdr-codec-and-reverse-failure-recovery.md) -- [`docs/lessons_learned/013-media-start-failure-return-and-narrow-catch.md`](../../lessons_learned/013-media-start-failure-return-and-narrow-catch.md) -- [`docs/active/boomerang-rollout/RESEARCH-reverse-video.md`](../boomerang-rollout/RESEARCH-reverse-video.md) -- Android: [Manage memory](https://developer.android.com/topic/performance/memory-overview), [ExoPlayer debugging](https://developer.android.com/media/media3/exoplayer/debugging) - ---- - -## 12. Sign-off - -| Role | Name | Date | OK | -|------|------|------|-----| -| Owner | Steven Gates | | ☐ | - -Once signed off, implementation follows §7 and [`docs/DEFINITION_OF_DONE.md`](../../DEFINITION_OF_DONE.md). diff --git a/docs/active/editor-trimming-overlay-stuck/ENGINEERING-HANDOFF.md b/docs/active/editor-trimming-overlay-stuck/ENGINEERING-HANDOFF.md deleted file mode 100644 index 50c3577..0000000 --- a/docs/active/editor-trimming-overlay-stuck/ENGINEERING-HANDOFF.md +++ /dev/null @@ -1,321 +0,0 @@ -# Engineering handoff — “Trimming..” / preview reverse failures - -**Last updated:** June 2026 -**Audience:** Next engineer, agent, or release owner picking up this work -**Shorter postmortem:** [`trimming-loop.md`](../../../trimming-loop.md) (repo root) -**Crashlytics ops:** [`docs/diagnostics/firebase-crashlytics-trimming.md`](../../diagnostics/firebase-crashlytics-trimming.md) - ---- - -## Executive summary - -Users reported the editor stuck on **“Trimming..”** (full-screen overlay) after leaving Trim, with **Save disabled**. On **Samsung S24+** (e.g. Brazil testers) the overlay sometimes never cleared and **never showed “Couldn’t loop that clip”** even after many minutes. - -**“Trimming..” is not a trim operation.** It means the app is building a **reversed MP4** for the default boomerang mode (`FORWARD_THEN_REVERSE`) using a **two-pass `MediaCodec` pipeline** in `VideoReverser`. - -We fixed this in layers: - -1. **PR #50 (1.0.3)** — codec loop wedges, buffer lifecycle, encoder ranking, first 120s timeout (often **did not unblock UI**). -2. **PR #51 (1.0.5, merged to `main`)** — non-blocking timeout, HEVC/HDR pre-normalize, Media3 decoder fallback, **Send debug info**, Firebase Crashlytics non-fatals. -3. **Follow-up on `feature/firebase-analytics` (commit `f0e706f`, not necessarily on `main` yet)** — Samsung **480p preview** reverse cap, **automatic Forward fallback** + snackbar so users can still preview/save when ping-pong reverse fails. - -**Confirmed production signal (developer device, 1.0.5):** Samsung **SM-S926B**, **720p H.264**, ~5.3 s trim → **120 s timeout** (Crashlytics non-fatal + share sheet). So the issue is **not only HEVC/HDR**. - ---- - -## User-visible behavior (what to call the bug) - -| UI copy | `EditorLoadingKind` | Meaning | -|---------|---------------------|---------| -| **Trimming..** | `TRIMMING` | First entry Trim → editor; reverse generation started | -| **Loopifying..** | `LOOPIFYING` | Later reverse (mode change, return from Trim) | -| **Couldn’t loop that clip** | `reverseFailed` (older builds) or Forward fallback (newer) | Reverse failed or timed out | -| **Send debug info** | — | Plain-text diagnostic share (PR #51) | - -**Save is disabled** while: - -- `previewLoading` is set (overlay visible), or -- Mode needs reverse but `reversedFile == null` and failure not yet surfaced (`awaitingReverse` / `reverseUnavailable` in `BoomerangEditorScreen.kt`). - ---- - -## Architecture (read this first) - -```mermaid -flowchart TD - Trim[Trim → SAVE or toolbar tab] --> onNext["onNextFromTrim()"] - onNext --> Editor[BoomerangEditor] - onNext --> ensure["ensureReversedSegment(TRIMMING|LOOPIFYING)"] - ensure --> Set["previewLoading = TRIMMING|LOOPIFYING"] - ensure --> Cap{Samsung?} - Cap -->|yes preview only| Scale480["prepareReverseInput max 480p short side"] - Cap -->|no / export| Scale1080["up to 1080p + HEVC/HDR normalize"] - Scale480 --> Worker["IO: VideoProcessor.ensureReversed()"] - Scale1080 --> Worker - Worker --> VR["VideoReverser.reverse() 2-pass MediaCodec"] - Worker --> Race["select: result vs onTimeout 120s"] - Race -->|success| OK["reversedFile set, clear overlay, Save enables"] - Race -->|timeout / throw| Fail["markReversePreviewFailed"] - Fail --> Share["Send debug info text"] - Fail --> CL["Crashlytics recordException non-fatal"] - Fail --> Fwd["mode = FORWARD + snackbar (follow-up commit)"] -``` - -### Key files - -| File | Responsibility | -|------|----------------| -| `ui/OpenLoopViewModel.kt` | `ensureReversedSegment`, 120s `select`+`onTimeout`, failure + Crashlytics, Forward fallback | -| `ui/BoomerangEditorScreen.kt` | Overlay, ExoPlayer playlist, Save gate, Send debug info UI | -| `ui/OpenLoopUiState.kt` | `EditorTabState` — `previewLoading`, `reversedFile`, `reverseFailed`, `reverseSupportReport` | -| `media/VideoProcessor.kt` | `prepareReverseInput`, `scaleSourceForReverse`, Transformer **decoder fallback** | -| `media/VideoReverser.kt` | Two-pass reverse, pass-1 frame cap (30 fps), encoder preference (Exynos/SEC/QTI) | -| `media/MediaFormatUtils.kt` | `sourceNeedsReverseNormalize` — HEVC or HDR → Media3 before reverse | -| `media/DeviceMediaHints.kt` | `SAMSUNG_PREVIEW_REVERSE_MAX_SHORT_SIDE = 480`, `isSamsungDevice()` | -| `diagnostics/ReverseCrashlytics.kt` | `recordException` + custom keys for non-fatals | -| `diagnostics/ReverseDiagnostics.kt` | Share-sheet text + probe for mime/size/fps | - -**Export/save** uses `renderBoomerang()` → separate `ensureReversed` / reverse inside worker — **full quality** (1080p cap), not the Samsung 480p preview cap. - ---- - -## Root causes (stacked — all were real) - -### 1. Preview reverse is inherently heavy - -- **Pass 1:** Re-encode trim window with **every frame a keyframe** (`KEY_I_FRAME_INTERVAL = 0`) so pass 2 can seek frame-by-frame backwards. -- Library imports and phone exports: **HEVC, HDR, 60 fps metadata**, bad sync points → slow or fragile path. -- Symptom: **Trimming..** for 30–120+ seconds on slow encoders (e.g. `c2.google.avc.encoder`). - -See [`RESEARCH-reverse-video.md`](../boomerang-rollout/RESEARCH-reverse-video.md), [lesson 020](../../lessons_learned/020-imported-clips-hdr-codec-and-reverse-failure-recovery.md). - -### 2. `withTimeoutOrNull` did not unblock the UI (critical for “infinite Trimming”) - -Kotlin coroutine cancellation is **cooperative**. If `MediaCodec` blocks in native code, the timeout coroutine may **never return**: - -- `reverseFailed` never set → no **“Couldn’t loop that clip”** -- Overlay stays forever - -This matched testers waiting **much longer than 2 minutes** with no failure screen. - -**Fix (PR #51):** Use `select { worker.onAwait; onTimeout(120_000) }`, return immediately on deadline, call `markReversePreviewFailed`, **do not** wait for cancelled worker (avoid `coroutineScope` that waits on children). - -References: - -- [Kotlin `withTimeout`](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/with-timeout.html) -- [Android coroutines best practices](https://developer.android.com/kotlin/coroutines/coroutines-best-practices) - -### 3. Samsung / OEM codecs (HEVC/HDR and decoder fallback) - -- CameraX **HD** does not force H.264; Samsung often records **HEVC and/or HDR** at ≤1080p. -- Old logic only pre-scaled when **short side > 1080p**, so 720p HEVC went straight into `VideoReverser`. -- Media3 `Transformer` default **does not enable decoder fallback** ([androidx/media#2189](https://github.com/androidx/media/issues/2189), [#2751](https://github.com/androidx/media/issues/2751)). - -**Fix (PR #51):** - -- `sourceNeedsReverseNormalize()` — HEVC or HDR → `scaleSourceForReverse` via Transformer before reverse. -- `DefaultDecoderFactory.setEnableDecoderFallback(true)` on Transformer. -- `VideoReverser.selectAvcEncoder` — prefer Exynos/SEC/QTI over `c2.google.avc.encoder`. - -### 4. Pass-1 import wedge (PR #50) - -| Issue | Effect | -|--------|--------| -| Seek before trim start → zero frames | EOS / hung loop | -| Frame skip without draining decoder buffers | `CodecException` | -| Restarting active `reverseJob` | Lost progress | -| Stale `previewLoading` with no job | TRIMMING forever | - -Fixed in `VideoReverser` pass-1 loop and `OpenLoopViewModel` session/generation tokens. - -### 5. Product gap after timeout (1.0.5 on Play) - -Even with failure UI, **Save stayed disabled** when `reverseFailed && reversedFile == null`. Users on Samsung could not use the app except by manually picking **Forward** in the Loop tab. - -**Fix (follow-up `f0e706f` on `feature/firebase-analytics`):** - -- `markReversePreviewFailed` → set `mode = FORWARD`, clear `reverseFailed`, keep `reverseSupportReport` for share. -- Snackbar via `BoomerangEvent.ReversePreviewFallbackForward`. -- `retryReverseSegment()` restores ping-pong mode and re-runs reverse. - -### 6. Samsung 720p AVC still > 120s (production evidence) - -Crashlytics + share sheet on **SM-S926B**, **video/avc**, **1280×720**, **5.3 s trim**, `hevc_or_hdr_normalize=false` → timeout, not HEVC-only issue. - -**Fix (follow-up):** On Samsung, preview `ensureReversed` passes `maxReverseShortSide = 480` so pass 1/2 runs on a smaller surface. **Export unchanged.** - ---- - -## Release / PR timeline - -| Item | Version | What shipped | -|------|---------|----------------| -| [PR #50](https://github.com/stozo04/OpenLoop/pull/50) | 1.0.3 (`versionCode` 4) | Pass-1 seek fix, 30 fps pass-1 cap, MediaCodec buffers, encoder ranking, `withTimeoutOrNull` 120s | -| [PR #51](https://github.com/stozo04/OpenLoop/pull/51) | 1.0.5 (`versionCode` 5) | `select`+`onTimeout`, HEVC/HDR normalize, decoder fallback, Send debug info, Crashlytics | -| `feature/firebase-analytics` @ `f0e706f` | TBD | Samsung 480p preview + Forward fallback + snackbar (merge to `main` when ready) | - ---- - -## Code reference — PR #51 timeout (production path) - -```kotlin -// OpenLoopViewModel.ensureReversedSegment — simplified -val worker = async(Dispatchers.IO) { - runCatching { - videoProcessor.ensureReversed(..., maxReverseShortSide = previewReverseCap) - } -} -val outcome = select { - worker.onAwait { it } - onTimeout(REVERSE_PREVIEW_TIMEOUT_MS) { // 120_000L - worker.cancel() - Result.failure(PreviewReverseTimeoutException()) - } -} -``` - -On failure → `markReversePreviewFailed` → `ReverseCrashlytics.reportPreviewFailure` + UI state. - -**Constant:** `OpenLoopViewModel.REVERSE_PREVIEW_TIMEOUT_MS = 120_000L` - ---- - -## Remote diagnostics (no logcat from testers) - -### A. In-app “Send debug info” (immediate) - -After failure UI (~120s on 1.0.5+). Plain text: device, version, mime, resolution, trim window, outcome. - -Built by `ReverseDiagnostics.buildReverseSupportReport` / `ReverseCrashlytics.supportReportForShare`. - -### B. Firebase Crashlytics (aggregate) - -- Project: **openloop-8c266** -- Package: `io.github.stozo04.openloop` -- **Non-fatals** — e.g. `PreviewReverseTimeoutException` -- **Keys:** `reverse_outcome`, `video_mime`, `video_width`, `hevc_or_hdr_normalize`, `app_version`, … -- Upload: **next app launch** after failure (not instant) - -Direct link pattern: -`https://console.firebase.google.com/project/openloop-8c266/crashlytics` - -Requires `app/google-services.json` at build time (gitignored). See `app/google-services.json.README`. - -### C. System bug report (heavy) - -Developer options → Take bug report. For old builds or deep native codec traces. - -**Do not** ask non-technical testers to install Logcat Reader apps. - ---- - -## Play Console / policy notes - -- **Advertising ID declaration:** `firebase-analytics` adds `AD_ID` permission. Declare in Play Console (Analytics, not ads). **Contains ads** stays **No**. -- **Data safety:** Store listing still says “no analytics” in places — update when Crashlytics + Analytics ship to production. See `docs/play-store/data-safety.md`. -- **Privacy policy:** Claims no crash reporting — update before wide Crashlytics rollout. - ---- - -## Testing - -### Unit tests (no real MediaCodec) - -```bash -./gradlew :app:testDebugUnitTest --tests "io.github.stozo04.openloop.ui.OpenLoopViewModelTest.reverse*" -``` - -Important tests: - -- `reverse preview times out when ensureReversed never completes` — expects **FORWARD** fallback, not `reverseFailed` -- `retrying reverse after a failure clears the flag and succeeds` -- `ensureReversedSegment does not restart reverse while a job is already in flight` - -**Test hooks:** `OpenLoopViewModel.reversePreviewTimeoutDisabledForTests` (avoids virtual-time deadlock with `advanceUntilIdle`). Do not enable in production. - -**IO in tests:** `ensureReversed` runs on `Dispatchers.IO`; use `awaitEditorReverseReady()` helper in `OpenLoopViewModelTest` (poll + short `Thread.sleep`), not only `advanceUntilIdle()`. - -### On-device (mandatory) - -Unit tests use `FakeVideoProcessor` — they **do not** exercise `VideoReverser`. - -1. Camera + gallery import on **Samsung** (and one non-Samsung control). -2. Trim → editor → note **Trimming..** (up to ~2 min acceptable on 1.0.5). -3. **Success:** Preview loops, Save enables. -4. **Failure:** Within ~120s, failure UI or Forward fallback + snackbar (follow-up build); **Send debug info** works. -5. Force-stop → reopen app → Crashlytics non-fatal (if Firebase configured). - -### Lint (PR gate) - -```bash -./gradlew :app:lintDebug -``` - ---- - -## Regression checklist (copy for QA) - -| Step | Expected (1.0.5+) | Expected (with `f0e706f`) | -|------|---------------------|---------------------------| -| Enter editor after Trim | Trimming.. up to ~120s max | Same; Samsung may finish faster (480p) | -| Reverse succeeds | Overlay clears, Save enables | Same | -| Reverse times out | Failure UI + Send debug info | Forward preview + snackbar; Save enables | -| Retry ping-pong | Try again / Loop direction | `retryReverseSegment` → FORWARD_THEN_REVERSE | -| Crashlytics | Non-fatal after reopen app | Same | - ---- - -## Open work / next steps - -| Priority | Task | -|----------|------| -| P0 | Merge `feature/firebase-analytics` (or cherry-pick `f0e706f`) to `main`, bump version, ship to Play | -| P0 | Update **Data safety** + privacy policy for Crashlytics/Analytics | -| P1 | Validate **480p preview** on SM-S926B — does ping-pong complete under 120s? | -| P1 | If still slow: stronger fallback (forward preview while export reverse runs in background) | -| P2 | Issue #41 — progress % during reverse, further frame thinning for pass 1 | -| P2 | Upload R8 **mapping.txt** to Firebase for readable stacks | - ---- - -## Real-world Crashlytics example (template) - -Use this to recognize the same issue in console: - -| Key | Example value | -|-----|----------------| -| `reverse_outcome` | `Timed out after 120s` | -| `video_mime` | `video/avc` | -| `video_width` / `video_height` | `1280` / `720` | -| `hevc_or_hdr_normalize` | `false` | -| `trim_window_ms` | `5300` | -| Device | Samsung SM-S926B | - -Stack top: `PreviewReverseTimeoutException` in `ensureReversedSegment` — that is the **deadline**, not the native wedge (wedge stays on IO thread). - ---- - -## Related documentation - -| Doc | Purpose | -|-----|---------| -| [`trimming-loop.md`](../../../trimming-loop.md) | Short postmortem + links | -| [`firebase-crashlytics-trimming.md`](../../diagnostics/firebase-crashlytics-trimming.md) | Firebase setup + console | -| [`HANDOFF.md`](./HANDOFF.md) | Earlier investigation notes (June 2026, pre-1.0.5); superseded by this file for current state | -| [Lesson 020](../../lessons_learned/020-imported-clips-hdr-codec-and-reverse-failure-recovery.md) | HDR / failure flag | -| [Lesson 021](../../lessons_learned/021-reverse-downscale-surface-mismatch.md) | Scale in Media3 only | -| [Issue #41 kickoff](../../prompts/issue-41-loopifying-optimization-kickoff.md) | Performance track | - ---- - -## Agent / session context - -- Brazilian **S24+** testers: infinite Trimming on older builds; 1.0.5 surfaces timeout + diagnostics. -- Developer repro on **SM-S926B** with **720p AVC** confirmed timeout path works in Crashlytics. -- Stash `stash@{0}` on `main` was applied to `feature/firebase-analytics` (Samsung + analytics branch merge); may drop after merge: `git stash drop stash@{0}`. - ---- - -## One-sentence summary - -**Trimming..** is preview reverse; Samsung and imports stress MediaCodec; **`withTimeoutOrNull` could block the UI forever**; we fixed with a **hard 120s deadline**, **codec/normalize improvements**, **remote diagnostics**, and (follow-up) **480p Samsung preview + Forward fallback** so users are never blocked from saving. diff --git a/docs/active/editor-trimming-overlay-stuck/HANDOFF.md b/docs/active/editor-trimming-overlay-stuck/HANDOFF.md deleted file mode 100644 index c319e75..0000000 --- a/docs/active/editor-trimming-overlay-stuck/HANDOFF.md +++ /dev/null @@ -1,44 +0,0 @@ -# HANDOFF — Editor “Trimming..” overlay (pointer) - -**This folder’s canonical handoff is now:** - -## → [`ENGINEERING-HANDOFF.md`](./ENGINEERING-HANDOFF.md) - -That document consolidates: - -- Symptom and architecture -- Root causes (timeout, Samsung/HEVC, MediaCodec wedges) -- PR #50 / PR #51 / follow-up Samsung + Forward fallback -- Crashlytics and Send debug info -- Testing, QA checklist, Play Console notes -- Production repro (SM-S926B, 720p AVC, 120s timeout) - -**Also see:** [`trimming-loop.md`](../../../trimming-loop.md) (short postmortem at repo root). - ---- - -*The sections below are kept as historical context from the June 2026 library-import investigation (Pixel 10 Pro Fold logcat). Do not treat “Open questions” or “Definition of done” as current — see ENGINEERING-HANDOFF.md for shipped state.* - ---- - -## Historical — library import investigation (pre-1.0.5) - -
-Original HANDOFF content (collapsed) - -### Symptom (June 2026) - -Editor stuck on **Trimming..** after library import → Trim → editor; Save disabled. Repro on Pixel 10 Pro Fold with portrait H.264 import; log showed active pass-1 encoding without `reverse pass2:` before log ended. - -### Fixes attempted before PR #51 - -- ViewModel session hygiene (`editorSessionActive`, `reverseGeneration`, stale loading recovery) -- VideoReverser pass-1 seek/advance fix for samples before trim start - -### Hypotheses that led to PR #51 - -- Slow reverse vs wedged codec vs stale `previewLoading` -- Need bounded timeout → `reverseFailed` (implemented in PR #51 as `select`+`onTimeout`) -- HEVC/HDR on Samsung without normalize path - -
diff --git a/docs/active/firebase-analytics/IMPLEMENTATION.md b/docs/active/firebase-analytics/IMPLEMENTATION.md deleted file mode 100644 index 8f06dbe..0000000 --- a/docs/active/firebase-analytics/IMPLEMENTATION.md +++ /dev/null @@ -1,178 +0,0 @@ -# Firebase Analytics — Reporter Abstraction & Staged Rollout - -**Status:** Proposed — awaiting sign-off -**Owner:** Steven Gates -**Branch:** `feature/firebase-analytics` -**Related:** [`docs/diagnostics/firebase-crashlytics-trimming.md`](../../diagnostics/firebase-crashlytics-trimming.md), [`docs/lessons_learned/004-viewmodel-no-context-parameters.md`](../../lessons_learned/004-viewmodel-no-context-parameters.md) - ---- - -## 1. Problem statement - -`firebase-analytics` is declared in `app/build.gradle.kts` and pulls automatic events (`first_open`, `session_start`, `app_update`, …) into the Firebase console with no code. That's enough to give Crashlytics breadcrumb logs leading up to the non-fatals `ReverseCrashlytics` already records, and it's the reason the dependency exists per the Crashlytics get-started guide. - -What's missing is everything the get-started for **Analytics** asks for: - -- No `FirebaseAnalytics.getInstance(...)` call anywhere in `app/src/main/` (verified by grep — only the Gradle line references the SDK). -- No `logEvent(...)` calls. The state-machine transitions defined in `OpenLoopUiState` (Recording → Trim → BoomerangEditor → Processing → ReadyToCapture, plus Gallery/Import branches) produce zero analytics signal. -- No `screen_view` instrumentation against `OpenLoopNavHost`. The funnel cannot be reconstructed in the console. -- No mechanism to disable collection (Apache 2.0 app with a privacy policy at `docs/privacy-policy.html` will eventually want a user toggle). - -The PRD's stated goals (real-time speed adjustment, filter adoption, gallery usage) cannot be measured without instrumentation. This doc lays out a staged rollout so we can land the abstraction now and add events incrementally without a second refactor. - -## 2. Scope - -### In - -- A thin `AnalyticsReporter` interface in the `diagnostics` package mirroring the `UserPreferencesRepository` shape: interface + production impl + a no-op fallback for tests and when `google-services.json` is absent. -- A Firebase-backed implementation (`FirebaseAnalyticsReporterImpl`) wrapping `FirebaseAnalytics.getInstance(applicationContext)`. -- A 6th `OpenLoopViewModel` constructor parameter + matching `Factory` parameter (additive, defaultable). -- One new bridge call in `MainActivity`'s viewModels delegate, alongside the existing repositories — no `Context` leaks downstream (Lesson 004). -- Unit-testable surface: a fake reporter records calls in-memory so ViewModel tests can assert `boomerang_exported_success` was emitted with the right params. - -### Out - -- Native-crash NDK capture (separate concern — see Crashlytics audit). -- Server-side BigQuery export wiring (manageable from console later, no code). -- User-facing opt-out toggle UI (parked — see §7). -- Web/iOS — Android only. - -## 3. Architecture - -``` - FirebaseAnalytics SDK (auto-events: first_open, session_start) - │ - ▼ -MainActivity ──> FirebaseAnalyticsReporterImpl ──┐ - (or NoOpAnalyticsReporter) │ - ▼ - OpenLoopViewModel.Factory - │ - ▼ - OpenLoopViewModel - (calls reporter.screenView / logEvent) - │ - ▼ - OpenLoopNavHost (screen-view side-effect - per state-machine transition) -``` - -The reporter is constructed in `MainActivity` (the only place a `Context` exists per Lesson 004) and passed to the `Factory`, exactly like `UserPreferencesRepositoryImpl(applicationContext.dataStore)` is today. - -The interface surface stays small — three methods, all the get-started doc actually uses: - -```kotlin -interface AnalyticsReporter { - fun screenView(screenName: String, screenClass: String? = null) - fun logEvent(name: String, params: Map = emptyMap()) - fun setUserProperty(name: String, value: String?) -} -``` - -`FirebaseAnalyticsReporterImpl` translates `Map` into a `Bundle` (Long/Double/String/Boolean per Analytics' supported param types) and routes `screenView` through `FirebaseAnalytics.Event.SCREEN_VIEW`. `NoOpAnalyticsReporter` is a singleton object that silently drops every call — used in tests and as the fallback when `google-services.json` is absent (mirrors the `ReverseCrashlytics.crashlyticsOrNull()` pattern). - -## 4. Staged rollout — option 1, 2, 3 - -The same interface ships for all three; we move along the ladder as we add value, not as we refactor. - -### Option 1 — Ship the abstraction only - -Land the interface, the Firebase impl, and the wiring. **No `screenView` or `logEvent` calls anywhere yet.** This is reversible-friendly: zero behavior change vs today (auto-events still flow, Crashlytics breadcrumbs still benefit). The abstraction is now there for when we want it. Used in CI / fresh-clone builds (no `google-services.json`) the bridge falls back to `NoOpAnalyticsReporter`. - -**Cost:** ~150 LOC across 3 new files + 2 line additions in existing files. - -### Option 2 — Add screen tracking - -Wire `screenView` exactly once per `OpenLoopUiState` discriminator in `OpenLoopNavHost` (a `LaunchedEffect(state::class)` side-effect). Funnel reconstructs in the Firebase console: % of users who pass Onboarding → Camera → Trim → Editor → Processing → Gallery. - -**Mapping** (deliberately omits `Initializing` — too short-lived to be useful): - -| State | screen_name | -|---|---| -| `Onboarding` | `onboarding` | -| `CheckingPermissions` / `PermissionRationale` / `PermissionDenied` | `permission_` | -| `ReadyToCapture` | `camera` | -| `Recording` | `recording` | -| `Trim` | `trim` | -| `BoomerangEditor` | `editor` | -| `Processing` | `processing` | -| `ImportingVideo` | `import` | -| `Gallery` | `gallery` | - -**Cost:** ~15 LOC added to `OpenLoopNavHost`. - -### Option 3 — Custom events for the PRD's success metrics - -Layer in domain events at the moments that already matter to the state machine and PRD goals. Event names are snake_case ≤40 chars (Analytics constraint); params are Long/Double/String/Boolean only. - -| When | Event | Params | -|---|---|---| -| Burst capture finalizes successfully | `burst_captured` | `duration_ms: Long` | -| Trim NEXT pressed → BoomerangEditor | `trim_completed` | `window_ms: Long` | -| Editor direction chip tap | `editor_direction_changed` | `mode: String` | -| Editor speed slider settle (debounced) | `editor_speed_changed` | `speed: Double` | -| Editor filter chip tap | `editor_filter_applied` | `filter: String` | -| Export completes | `boomerang_exported_success` | `mode: String`, `speed: Double`, `filter: String`, `duration_ms: Long` | -| Export fails | `boomerang_exported_failure` | `outcome: String` (mirrors the Crashlytics key) | -| Gallery video opens | `gallery_loop_opened` | (none) | -| Gallery batch delete commits | `gallery_loops_deleted` | `count: Long` | -| Library import completes | `video_imported_success` | `duration_ms: Long` | -| Library import fails | `video_imported_failure` | `reason: String` | -| Onboarding final page "LET'S GO" tap | `onboarding_completed` | (none) | - -Each call site is one line in `OpenLoopViewModel` next to the existing `ReverseCrashlytics.reportPreviewFailure(...)` and `userPreferencesRepository.setOnboardingCompleted(...)` calls. They land alongside the feature work that introduces them — they don't all need to ship at once. - -**Cost:** ~12 one-line additions across `OpenLoopViewModel`, paid per-event as features ship. - -### Recommendation - -Land **Option 1 + Option 2 in this PR** (the abstraction is cheap and screen tracking gives an immediate funnel). Pick up Option 3 events incrementally as the boomerang-rollout slices ship — that way each slice's PR owns its event addition rather than this PR carrying all twelve. The interface supports it without further refactor. - -## 5. Implementation steps - -1. **New files** - - `app/src/main/java/io/github/stozo04/openloop/diagnostics/AnalyticsReporter.kt` — interface + `NoOpAnalyticsReporter` object. - - `app/src/main/java/io/github/stozo04/openloop/diagnostics/FirebaseAnalyticsReporterImpl.kt` — Firebase wrapper. Constructor takes `applicationContext: Context` (only place a Context lives downstream of MainActivity — already the pattern for `UserPreferencesRepositoryImpl` consumers). -2. **Existing-file edits (additive)** - - `OpenLoopViewModel.kt`: add `private val analytics: AnalyticsReporter` as the 6th constructor param. Add the matching parameter to the nested `Factory`. No call sites populated in this PR (option 1) — except the navhost side-effect for option 2. - - `MainActivity.kt`: construct `FirebaseAnalyticsReporterImpl(applicationContext)` in the `viewModels { … Factory(…) }` block, alongside the existing repository constructions. - - `OpenLoopNavHost` (option 2 only): a `LaunchedEffect(state::class)` block that calls `viewModel.onScreenShown(state)`; the VM forwards to `analytics.screenView(...)` using the mapping in §4. -3. **Tests** - - `app/src/test/java/io/github/stozo04/openloop/diagnostics/FakeAnalyticsReporter.kt` — records a `List` of every call for assertions. - - Add fake into the existing `OpenLoopViewModelTest` constructor (one line) so existing tests don't break. - - (Option 2) one new test: route to each state, assert one `screen_view` per transition with the mapped name. -4. **Verification (per DEFINITION_OF_DONE.md)** - - `:app:lintDebug` clean (no new errors). - - Unit tests green. - - Run on emulator with `google-services.json` present, navigate Onboarding → Camera → Trim → Editor → Gallery, confirm the events show in **Firebase Console → Analytics → DebugView** (requires `adb shell setprop debug.firebase.analytics.app io.github.stozo04.openloop`). - - Without `google-services.json`: build still succeeds; reporter silently no-ops (manually verified — no `FirebaseAnalytics.getInstance` crash). - -## 6. Testing plan - -- **Unit:** `FakeAnalyticsReporter` swapped in via the VM constructor. ViewModel tests added per option 3 as events land. -- **Integration:** Manually verified once with Firebase DebugView. Not a unit-testable boundary — Firebase SDK has no public test harness for `logEvent`. -- **Crashlytics interaction:** `ReverseCrashlytics` continues to call `FirebaseCrashlytics.getInstance()` directly (unchanged). Analytics events become breadcrumbs automatically — verify by triggering a reverse-preview failure and inspecting the Crashlytics issue's **Logs** tab for the preceding `screen_view` and any custom events. -- **Without `google-services.json`:** `FirebaseAnalytics.getInstance()` throws inside `Impl` — catch + degrade to `NoOpAnalyticsReporter` in the constructor (same defensive pattern as `crashlyticsOrNull()`). - -## 7. Open questions (parked) - -- **User-facing opt-out toggle.** Apache 2.0 + privacy policy means we should eventually surface a Settings switch that toggles `FirebaseAnalytics.setAnalyticsCollectionEnabled(false)` and `FirebaseCrashlytics.setCrashlyticsCollectionEnabled(false)`. Out of scope for this PR; tracked separately. -- **DebugView screenshot in PR.** Per DEFINITION_OF_DONE the change needs the app-running screenshot. DebugView from the Firebase console satisfies the "events flow end-to-end" check; include both an emulator screenshot of the running app and a DebugView screenshot in the PR. -- **One-time test fatal crash.** Independent of this PR but worth doing in the same release — `throw RuntimeException("Crashlytics test")` once on a debug build to confirm fatals land in the console. Flagged in the Crashlytics audit too. - -## 8. Acceptance criteria - -- `:app` builds clean both with and without `app/google-services.json`. -- `:app:lintDebug` reports zero new errors against the baseline. -- `OpenLoopViewModelTest` and any new tests are green. -- With `google-services.json` present: launching the app produces `screen_view` events in Firebase DebugView matching the §4 mapping for the screens visited. -- Without `google-services.json`: no crash, no Firebase initialization error in logcat, app runs identically to today. -- Screenshot of running app attached to PR per DEFINITION_OF_DONE. - -## 9. References - -- [Get started with Google Analytics for Android](https://firebase.google.com/docs/analytics/android/get-started) -- [Log events (Android)](https://firebase.google.com/docs/analytics/android/events) -- [Configure data collection and usage](https://firebase.google.com/docs/analytics/ios/configure-data-collection) — same opt-out APIs exist on Android -- `docs/lessons_learned/004-viewmodel-no-context-parameters.md` — pattern this PR follows for the bridge -- `docs/PRD-mission-control.md` — overall architecture this slots into diff --git a/docs/active/gallery-selection-delete/IMPLEMENTATION.md b/docs/active/gallery-selection-delete/IMPLEMENTATION.md deleted file mode 100644 index 8358222..0000000 --- a/docs/active/gallery-selection-delete/IMPLEMENTATION.md +++ /dev/null @@ -1,120 +0,0 @@ -# Gallery — long-press multi-select + Undo delete - -> Branch: `feature/gallery-selection-delete` · Issue: [#35](https://github.com/stozo04/OpenLoop/issues/35) ("delete is instant and irreversible") -> Architecture reference: `docs/PRD-mission-control.md` (Gallery spec + ViewModel API table). - -## Problem statement - -The gallery stamped a coral 🗑 emoji on every thumbnail. Tapping it deleted the loop **instantly -and irreversibly** — no confirmation, no Undo — and the emoji clashed with the app's Material/vector -icon language, cluttering every tile. This is the genuine bug behind Issue #35. - -## Scope - -**In:** -- Remove the per-thumbnail delete control entirely (grid is content-only at rest). -- Google-Photos-style **long-press multi-select**: long-press enters selection, tap toggles in-mode - / plays out-of-mode. -- A **contextual action bar** (exit ✕ · "N selected" · Delete) that replaces the back/import bar - while selecting. -- **Deferred deletion + Undo snackbar**: a delete optimistically hides the tiles and shows an Undo - snackbar; the real file delete is committed only when the snackbar is dismissed. -- Selection survives rotation; system Back exits selection before leaving the gallery. - -**Out (explicitly):** -- Type/shape token adoption across the gallery (separate pending task). -- Deleting the now-unused `ic_film_slate` drawable (separate cleanup — see Lint note below). -- A trash/recently-deleted bin (Undo is the only recovery affordance). - -## Architecture - -Fits the existing MVVM + sealed-state machine (`docs/PRD-mission-control.md`). No new layers. - -- **`OpenLoopViewModel`** — deferred-deletion model: - - `pendingDeletionIds: StateFlow>` + an in-memory `pendingBatch: List`. - - `visibleVideos: StateFlow>` = `combine(recordedVideos, pendingDeletionIds)` - filtering out pending ids, shared with `WhileSubscribed(5_000)` (Lesson 002). - - `requestDeleteVideos(videos)` → sets the batch + ids, emits `BoomerangEvent.LoopsDeleted(count)`; - supersedes any prior pending batch by committing it first. - - `undoPendingDeletion()` → clears the batch + ids (tiles reappear; nothing deleted). - - `commitPendingDeletion()` → `videoStorage.deleteVideo` per item off the main thread, then reload. - - **Safe-by-design:** the batch is in-memory only, so process death before commit leaves every file - intact (implicit Undo, never data loss). -- **`BoomerangEvent.LoopsDeleted(count: Int)`** — new one-shot event on the existing `Channel`. -- **`MainActivity`** — the events collector shows the Undo snackbar - (`R.plurals.gallery_loops_deleted`, action `R.string.undo`, `SnackbarDuration.Short`): on - `ActionPerformed → undoPendingDeletion()`, else `commitPendingDeletion()`. Mirrors the existing - "Saved → View" pattern. The single app `SnackbarHost` is now app-styled (see UI polish below). -- **`GalleryScreen`** — split into a stateful shell + a stateless **`GalleryContent`**: - - Shell collects `visibleVideos` (NOT `recordedVideos`), owns the tap-to-play `LoopingVideoOverlay`, - and passes `backEnabledWhenIdle = selectedVideo == null` down (the overlay's Dialog owns Back - while open — Lesson 015). - - `GalleryContent` holds selection as local UI state (`rememberSaveable` via a `Set`↔`LongArray` - `Saver`, surviving rotation), renders the resting top bar or the contextual action bar, and the - grid. Tiles use `Modifier.combinedClickable` (stable foundation API — verified on - developer.android.com) for tap + long-press. A selected tile gets a lime ring, dimming scrim, a - check badge, a slight scale-down, and announces `semantics { selected = … }` (ANDROID_STANDARDS §7). - - `BackHandler(enabled = backEnabledWhenIdle)`: in selection mode → clear selection; else → back out. - -### UI polish (folded in during review) - -- Post-save snackbar copy tightened: "Saved — view in gallery" + a "View" button was redundant → - message is now **"Loop saved"** with the **View** action; "Couldn't save boomerang" → "Couldn't - save loop" (consistent "loop" vocabulary). -- The single `SnackbarHost` is themed to the app's language: a rounded (16 dp) `SurfaceContainerHigh` - card, `TextPrimary` content, `ElectricLime` action accent, floating above the nav bar — replacing - the stock Material slab. Uses the `Snackbar(snackbarData = …)` overload so the action button's - accessibility wiring is preserved. - -## Implementation steps (as built) - -1. ViewModel: `pendingDeletionIds`, `pendingBatch`, `visibleVideos`, `requestDeleteVideos`, - `undoPendingDeletion`, `commitPendingDeletion`; `BoomerangEvent.LoopsDeleted`. Removed the old - instant `deleteVideo(video)` (superseded; the repository-level `videoStorage.deleteVideo` stays). -2. MainActivity: Undo branch in the events collector; app-styled `SnackbarHost`. -3. `res/values/strings.xml`: `undo`, `gallery_selection_count` (plural), `gallery_delete_selected`, - `gallery_exit_selection`, `gallery_loops_deleted` (plural); removed the dead `gallery_delete`; - tightened `snackbar_saved` / `snackbar_save_failed`. -4. `GalleryScreen`: stateless `GalleryContent` + selection model + contextual action bar; removed the - trash control, the `onDelete` param, and the now-unused `painterResource`/`CoralRed`-trash code. - -## Testing - -- **Unit (`OpenLoopViewModelTest`)** — added: `requestDeleteVideos` hides ids + emits `LoopsDeleted`; - `undoPendingDeletion` restores without deleting; `commitPendingDeletion` deletes per item + reloads; - a new delete supersedes (commits) the prior batch. Fake repo + `MainDispatcherRule` (Lesson 008). - Removed the obsolete instant-`deleteVideo` test. -- **Instrumented (`GalleryScreenTest`)** — drives the stateless `GalleryContent` (Lesson 017, no - mockk): long-press → action bar + "1 selected"; tap a second tile → "2 selected"; out-of-mode tap - plays (not selects); Delete → `onRequestDelete` invoked + tile removed optimistically + selection - exits; exit ✕ clears selection without deleting. - -## Acceptance criteria - -- [x] No per-thumbnail delete control; grid content-only at rest. -- [x] Long-press enters multi-select; tap toggles in-mode, plays out-of-mode. -- [x] Contextual action bar: count + Delete + exit; system Back exits selection first. -- [x] Delete shows an Undo snackbar; Undo restores instantly; dismiss commits the real delete. -- [x] Deleted tiles disappear immediately (optimistic) via `visibleVideos`. -- [x] No inline hex; theme tokens + `MaterialTheme` for new text/shape (Lesson 001). -- [x] A11y + Compose-perf preserved (≥48 dp targets, `selected` semantics, deferred reads). - -## Definition of Done — status - -- Debug **and** release builds: `BUILD SUCCESSFUL`, exit 0, zero `e:`. -- `./gradlew :app:lintDebug`: green; only remaining warning is the pre-existing unused - `ic_film_slate` drawable (out of scope — separate cleanup per the kickoff). -- Unit tests: pass. -- Instrumented tests (`GalleryScreenTest`) + on-device long-press → select → delete → undo - screenshot: **owner manual QA** (owner opted to test manually). Attach the screenshot to the PR. - -## Manual QA checklist - -- [ ] Long-press a loop → action bar shows "1 selected"; tap more tiles → count climbs. -- [ ] Tap a tile out of selection mode → it plays (looping overlay). -- [ ] Delete → tiles vanish + "N loops deleted" snackbar with Undo (app-styled, lime action). -- [ ] Undo → tiles reappear; file still on disk. -- [ ] Let the snackbar dismiss → files actually deleted; survive app restart. -- [ ] Rotate mid-selection → selection survives. -- [ ] System Back: exits selection first, then leaves the gallery. -- [ ] Record + process a loop → "Loop saved" snackbar reads tight + matches the app card/accent style. diff --git a/docs/active/issue-35-section-2/SESSION_PROMPT.md b/docs/active/issue-35-section-2/SESSION_PROMPT.md deleted file mode 100644 index 5004839..0000000 --- a/docs/active/issue-35-section-2/SESSION_PROMPT.md +++ /dev/null @@ -1,252 +0,0 @@ -# Session prompt — Issue #35 · Section 2: Component consistency - -Copy everything below the `---` line into a **new Cursor session** to implement GitHub Issue #35, section 2. - ---- - -## Your task - -Implement **Issue #35 · Section 2 — Component consistency (mostly extractions, not redesigns)** for the OpenLoop Android app. - -**GitHub:** https://github.com/stozo04/OpenLoop/issues/35 - -This is **not** a redesign. Extract shared components, wire every call site to them, and replace emoji/text glyphs with Material vector icons. Preserve behavior, accessibility, and existing test tags. - ---- - -## Required reading (do this first) - -1. Root `CLAUDE.md` -2. **Every file** in `docs/lessons_learned/` (especially 014 exhaustive `when`, 016 deferred reads, 017 androidTest/no mockk) -3. `docs/PRD-mission-control.md` (UI layer) -4. `docs/ANDROID_STANDARDS.md` (Compose, a11y §7, touch targets) -5. `docs/DEFINITION_OF_DONE.md` (build + lint + tests + on-device screenshot before "done") -6. `docs/STATIC_ANALYSIS.md` - -**Do not trust training data for Android APIs.** Web-search `developer.android.com` before claiming how Compose/Material3 APIs work. - ---- - -## Context — what's already done - -### Section 1 ✅ (commit `f3e5269`, branch `feature/gallery-selection-delete`) - -Design-system foundations are complete: - -| Asset | Location | -|-------|----------| -| Color tokens | `app/src/main/java/io/github/stozo04/openloop/ui/theme/Color.kt` | -| Typography (Space Grotesk / Inter / JetBrains Mono) | `ui/theme/Type.kt` → `OpenLoopTypography`, `TimerTextStyle` | -| Shape scale (8 / 12 / 16 / 24 / 32 dp) | `ui/theme/Shape.kt` → `OpenLoopShapes` | -| Theme | `ui/theme/Theme.kt` → `OpenLoopTheme`, `shutterGradient()` | -| Shared background | `ui/theme/Background.kt` → `OpenLoopBackground` | - -All post-onboarding screens now use `MaterialTheme.typography.*` and `MaterialTheme.shapes.*`. Trim handle readouts were revamped to `HandleValueBubble` (START/END tooltips) — **do not regress this**. - -### Section 3 partial ✅ (same branch, separate work) - -Gallery delete undo/snackbar and camera "30s" badge fix are already shipped on this branch. **Out of scope for section 2.** - ---- - -## Section 2 — three checkboxes (your deliverables) - -### 1. One shared back button - -**Goal:** Promote the editor's `CircleIconButton` pattern to a **shared, public** composable. One size, one icon (`Icons.AutoMirrored.Filled.ArrowBack`) everywhere a "back" affordance appears. - -**Current call sites (audit before coding):** - -| Screen | File | Current impl | Size | Icon | `contentDescription` | -|--------|------|--------------|------|------|----------------------| -| Trim | `TrimScreen.kt` | Inline `Box` + `clickable` | 48 dp (`HANDLE_SIZE`) | ArrowBack | `"Discard clip"` | -| Editor | `BoomerangEditorScreen.kt` | `private CircleIconButton` | 56 dp (`CONTROL_SIZE`) | ArrowBack | `"Back to trim"` | -| Gallery | `GalleryScreen.kt` → `GalleryTopBar` | Inline `Box` | 56 dp | ArrowBack | `"Back to camera"` | - -**Not in scope:** `CameraScreen.kt` → `HomeButton` (lime circle, **gallery** icon — navigation to gallery, not "back"). Leave it alone. - -**Implementation guidance:** - -- New file suggestion: `app/src/main/java/io/github/stozo04/openloop/ui/components/BackButton.kt` (or `ui/components/CircleIconButton.kt` if you want a generic glass circle + a thin `BackButton` wrapper). -- Pick **one** touch target — **56 dp** is the majority (editor + gallery) and exceeds the 48 dp minimum. Trim's 48 dp back should adopt the shared size. -- Glass styling is already unified: `OverlayWhite` fill + `OverlayWhiteBorder` border + white icon tint. Keep that. -- Preserve per-screen **`contentDescription`** strings (they describe different actions: discard vs back to trim vs back to camera). -- Preserve **`testTag`s**: `trim_back`, `editor_back`. Add one to gallery if missing. -- Use `Role.Button` + `clickable` (editor/gallery already do; trim should match). -- Hoist for testability (project pattern: `ShutterButton`, `GetStartedButton`, `TrimScreenContent`). - -**Acceptance:** A user sees the same back control on Trim, Editor, and Gallery (size, shape, glass, arrow icon). - ---- - -### 2. One shared `PrimaryButton` - -**Goal:** Single flat-lime CTA: one fill (`ElectricLime`), one radius, one height, one label style. Reuse for the three text CTAs listed in the issue. - -**Current call sites:** - -| Screen | File | Composable / block | Height | Shape | Label | Notes | -|--------|------|-------------------|--------|-------|-------|-------| -| Onboarding | `OnboardingScreen.kt` | `GetStartedButton` | 64 dp | `shapes.extraLarge` (32) | `"LET'S GO!"` | Has `testTag("onboarding_cta")`, tested in `OnboardingScreenTest` | -| Trim | `TrimScreen.kt` | Inline NEXT `Box` | 56 dp | `shapes.medium` (16) | `"NEXT"` | Disabled state: grey glass + dim text. `testTag("next_button")`, `Role.Button`, `indication = null` | -| Gallery | `GalleryScreen.kt` | `LoopingVideoOverlay` close pill | ~auto | `shapes.extraLarge` (32) | `"CLOSE PREVIEW ✕"` | Full-width-ish pill at bottom of dialog | - -**Explicitly OUT of scope for `PrimaryButton`:** - -- Editor **Save** checkmark (circle icon button — different control class) -- Gallery **Import** lime circle (icon-only primary) -- `MainActivity` permission / `ImportTooLongDialog` Material3 `Button`s (different surface; not listed in issue) -- Gallery empty-state import text link (section 3 empty-state modernization) - -**Implementation guidance:** - -- New file: `app/src/main/java/io/github/stozo04/openloop/ui/components/PrimaryButton.kt` -- **Decide and document one canonical spec** (suggested starting point — validate visually with owner): - - Height: **56 dp** (matches Trim NEXT; onboarding drops from 64 → 56 for consistency) - - Shape: `MaterialTheme.shapes.extraLarge` (32 dp pill) OR `medium` (16) — pick one; issue wants them identical - - Label: `MaterialTheme.typography.labelLarge` + `LimeInk` on enabled; disabled variant needed for Trim NEXT - - Width: `fillMaxWidth()` with optional `widthIn(max = 520.dp)` where screens already cap (Trim) -- Support **`enabled`**, **`onClick`**, **`modifier`**, **`testTag`**, optional **`text`** -- Trim NEXT disabled semantics must stay: non-clickable when `(endMs - startMs) < MIN_TRIM_MS` -- **`GetStartedButton`**: either becomes a thin wrapper over `PrimaryButton` or is deleted and call sites use `PrimaryButton` directly — keep `testTag("onboarding_cta")` either way -- **Do not add ripple/haptics yet** — that's section 3 ("tactile feedback"). Keep `indication = null` if that's what call sites have today, unless you need Material default for a11y (discuss in PR if changing) - -**Acceptance:** LET'S GO / NEXT / CLOSE PREVIEW look like the same button family (fill, radius, height, type). - ---- - -### 3. Replace emoji/text glyphs with vector icons - -**Goal:** No emoji as iconography. Uniform Material vector language. - -| Location | File | Current | Target | -|----------|------|---------|--------| -| Gallery thumbnail fallback | `GalleryScreen.kt` → `VideoThumbnailCard` | `"🎬"` Text 28 sp | `Icons.Outlined.Movie` (or `VideoLibrary`) | -| Gallery close preview | `GalleryScreen.kt` → `LoopingVideoOverlay` | `"CLOSE PREVIEW ✕"` | Label `"CLOSE PREVIEW"` + trailing `Icons.Filled.Close` **or** icon-only with `contentDescription` — use `PrimaryButton` from task 2 | -| Direction chips | `BoomerangEditorScreen.kt` → `DirectionChipButton` | Unicode `"▶▶" "◀◀" "▶◀" "◀▶"` | Drawn `ImageVector`s or composed Material icons so they match the editor's icon language | - -**Also clean up:** - -- Delete unused `app/src/main/res/drawable/ic_film_slate.xml` if still present (lint: `UnusedResources`). Gallery already uses `ArrowBack`; the slate drawable is dead. - -**Direction chip guidance (hardest item):** - -- Issue says "consider drawn glyphs" — acceptable approaches: - 1. Custom `ImageVector` builder (two chevrons / forward-reverse pairs) in `ui/components/` or `res/drawable/` - 2. Row of two small `Icon`s per chip (e.g. double `PlayArrow`) -- Preserve **`accessibilityLabel`** on each chip (`"Forward"`, `"Reverse"`, etc.) — TalkBack must not regress -- Preserve **`testTag("direction_chip_${chip.mode.name}")`** -- Selected/unselected colors stay: lime fill + `LimeInk` glyph vs glass + white glyph - -**Acceptance (epic-level):** No emoji used as iconography on the listed screens. - ---- - -## Constraints — do NOT regress - -- **Accessibility:** ≥ 48 dp touch targets, real `contentDescription`s, trim handle semantics, speed slider semantics -- **Compose perf:** deferred high-frequency reads (Lesson 016) — don't read `recordingElapsedMs` / `renderProgress` at screen root -- **Copy:** "Loopifying…" unchanged between editor shimmer and processing screen -- **State machine:** no changes to `OpenLoopUiState` routing unless strictly required -- **Tests:** existing androidTest tags must keep working (`onboarding_cta`, `next_button`, `trim_back`, `editor_back`, `direction_chip_*`, `gallery_*`) -- **Scope discipline:** extractions + icon swap only — no section 3 work (haptics, empty state, delete flow) - ---- - -## Suggested implementation order - -1. Create `ui/components/` package + shared **`BackButton`** (or promoted **`CircleIconButton`**) → migrate Trim, Editor, Gallery -2. Create **`PrimaryButton`** → migrate `GetStartedButton`, Trim NEXT, Gallery close preview (task 3 close label can land here) -3. **Vector icons** — gallery fallback + direction chips (+ remove `ic_film_slate` if unused) -4. Run verification gate (below) -5. Update Issue #35: check off section 2 boxes, add progress comment with commit hash - ---- - -## Verification gate (`docs/DEFINITION_OF_DONE.md`) - -Before calling done or opening a PR: - -```bash -./gradlew :app:assembleDebug :app:assembleRelease -./gradlew :app:testDebugUnitTest :app:connectedDebugAndroidTest # if emulator/device available -./gradlew :app:lintDebug -``` - -- Exit code 0, zero `e:` compile errors -- `:app:lintDebug` — no **new** errors (baseline policy in `lint-baseline.xml`) -- Run app on device/emulator; capture screenshot showing at least: Trim (back + NEXT), Editor (back + direction chips), Gallery (back + thumbnail grid) -- Manual QA checklist in PR description - ---- - -## Files you'll touch (starting list) - -``` -app/src/main/java/io/github/stozo04/openloop/ui/ -├── components/ ← NEW (BackButton, PrimaryButton, maybe DirectionChipIcons) -├── OnboardingScreen.kt ← GetStartedButton → PrimaryButton -├── TrimScreen.kt ← back + NEXT -├── BoomerangEditorScreen.kt ← extract CircleIconButton; direction chip glyphs -├── GalleryScreen.kt ← back, 🎬 fallback, CLOSE PREVIEW -└── CameraScreen.kt ← read only (HomeButton stays) - -app/src/androidTest/.../ -├── OnboardingScreenTest.kt -├── TrimScreenTest.kt -└── (add/update component tests if hoisted composables warrant it) - -app/src/main/res/drawable/ -└── ic_film_slate.xml ← delete if unused -``` - ---- - -## Branch / git - -- Current branch: **`feature/gallery-selection-delete`** -- Latest relevant commit: **`f3e5269`** (section 1) -- Commit section 2 on the same branch unless owner directs otherwise -- **Do not commit** unless explicitly asked; owner may want one PR for gallery work + section 2 or a split — ask if unclear - ---- - -## Pushback / decisions to surface before coding - -If anything below is ambiguous, **ask the owner** rather than guessing: - -1. **PrimaryButton height:** 56 dp (Trim) vs 64 dp (onboarding today) — issue implies normalize; confirm visual preference -2. **PrimaryButton radius:** `extraLarge` (32 pill) vs `medium` (16) — pick one and apply everywhere -3. **Direction chips:** custom vectors vs double Material icons — show options if unsure -4. **CLOSE PREVIEW:** text + icon vs icon-only with a11y label -5. Whether section 2 should be **its own PR** off `main` or stacked on `feature/gallery-selection-delete` - ---- - -## Success criteria (section 2 complete) - -- [ ] Shared back button — same size, glass, `ArrowBack` on Trim / Editor / Gallery -- [ ] Shared `PrimaryButton` — LET'S GO / NEXT / CLOSE PREVIEW unified -- [ ] No emoji iconography (gallery fallback, close preview, direction chips use vectors) -- [ ] All existing instrumented tests green; no new lint errors -- [ ] Issue #35 section 2 checkboxes checked + comment posted - ---- - -## Reference — theme tokens (use these, no inline brand hex) - -```kotlin -// Primary CTA -ElectricLime, LimeInk -MaterialTheme.typography.labelLarge -MaterialTheme.shapes.extraLarge // or .medium — pick one for PrimaryButton - -// Glass back button -OverlayWhite, OverlayWhiteBorder, Color.White (icon tint) -CircleShape - -// Disabled primary (Trim NEXT) -OverlayWhite fill, Color.White.copy(alpha = 0.4f) label -``` - -Good luck — this should be a focused extraction pass, not a rewrite. diff --git a/docs/active/issue-35-section-3/SESSION_PROMPT.md b/docs/active/issue-35-section-3/SESSION_PROMPT.md deleted file mode 100644 index e3d93d2..0000000 --- a/docs/active/issue-35-section-3/SESSION_PROMPT.md +++ /dev/null @@ -1,244 +0,0 @@ -# Session prompt — Issue #35 · Section 3: UX safety & feel (remaining) - -Copy everything below the `---` line into a **new Cursor session** to finish GitHub Issue #35, section 3. - ---- - -## Your task - -Implement the **remaining** items in **Issue #35 · Section 3 — UX safety & feel** for the OpenLoop Android app. - -**GitHub:** https://github.com/stozo04/OpenLoop/issues/35 - -This is **polish**, not a redesign. Add tactile feedback to primary capture/save/CTA controls and modernize the gallery empty state. Preserve behavior, accessibility, existing test tags, and everything already shipped in sections 1–2 and the gallery-delete work. - ---- - -## Required reading (do this first) - -1. Root `CLAUDE.md` -2. **Every file** in `docs/lessons_learned/` (especially 014 exhaustive `when`, 016 deferred reads, 017 androidTest/no mockk, 015 predictive back) -3. `docs/PRD-mission-control.md` (UI layer + Gallery spec) -4. `docs/ANDROID_STANDARDS.md` (Compose, a11y §7, touch targets, haptics) -5. `docs/DEFINITION_OF_DONE.md` (build + lint + tests + on-device screenshot before "done") -6. `docs/STATIC_ANALYSIS.md` - -**Do not trust training data for Android APIs.** Web-search `developer.android.com` before claiming how Compose indication, `HapticFeedbackType`, or `Snackbar` APIs work. - ---- - -## Context — what's already done - -### Section 1 ✅ (commit `f3e5269`) - -`ui/theme/` tokens, typography/shape adoption, `OpenLoopBackground`. - -### Section 2 ✅ (commit `096a6d1`) - -| Asset | Location | -|-------|----------| -| Glass back | `ui/components/CircleIconButton.kt`, `BackButton.kt` | -| Primary CTA | `ui/components/PrimaryButton.kt` (56 dp, `extraLarge` pill, `indication = null` today) | -| Direction glyphs | `ui/components/DirectionChipIcon.kt` | - -### Section 3 — already shipped on `feature/gallery-selection-delete` ✅ - -**Do not re-implement; verify and leave intact:** - -| Checkbox | What shipped | Where to look | -|----------|--------------|---------------| -| Delete confirm / undo | Deferred delete + Undo snackbar; no per-tile trash | `OpenLoopViewModel.requestDeleteVideos` / `undoPendingDeletion` / `commitPendingDeletion`; `MainActivity` `BoomerangEvent.LoopsDeleted`; `GalleryScreen` → `GalleryContent` + `visibleVideos` | -| Camera "30s" badge | Misleading idle badge removed or restyled (no fake button beside lens toggle) | `CameraScreen.kt` — confirm no non-interactive control mimics the 54 dp flip button | - -Reference doc: `docs/active/gallery-selection-delete/IMPLEMENTATION.md`. - -### Follow-up (out of scope here) - -- Camera preview teardown log noise → [#36](https://github.com/stozo04/OpenLoop/issues/36) - ---- - -## Section 3 — your deliverables (two checkboxes) - -### 1. Add tactile feedback to primary actions - -**Goal:** Primary CTAs and the main capture/save affordances should feel responsive: visible press feedback + light haptics where appropriate. Section 2 intentionally kept `indication = null` on `PrimaryButton` — this task adds polish. - -**In scope — audit and update:** - -| Control | File | Current press feedback | Haptics today | -|---------|------|------------------------|---------------| -| `PrimaryButton` | `ui/components/PrimaryButton.kt` | `indication = null` | None | -| Onboarding CTA | `OnboardingScreen.kt` → `GetStartedButton` | via `PrimaryButton` | None | -| Trim NEXT | `TrimScreen.kt` | via `PrimaryButton` | None | -| Gallery close preview | `GalleryScreen.kt` → `LoopingVideoOverlay` | via `PrimaryButton` | None | -| Shutter (record/stop) | `CameraScreen.kt` → `ShutterButton` | `indication = null` | None | -| Editor save | `BoomerangEditorScreen.kt` → `SaveCheckmark` | `indication = null` | None | - -**Existing pattern to reuse (do not reinvent):** - -- **Press scale:** `VideoThumbnailCard` in `GalleryScreen.kt` — `MutableInteractionSource` + `collectIsPressedAsState` + `animateFloatAsState` (0.93f when pressed, 0.92f when selected). -- **Haptics:** `BoomerangEditorScreen.kt` — `LocalHapticFeedback` + `HapticFeedbackType.SegmentTick` on speed detents and look-chip changes. Web-search which `HapticFeedbackType` fits confirm/tap on primary actions (e.g. `ContextClick` / `Confirm` — verify on current API). - -**Implementation guidance:** - -- Prefer **one shared approach** in `PrimaryButton` (optional `enablePressFeedback: Boolean = true`) so all three CTAs stay consistent. -- Press scale on `PrimaryButton` should respect `enabled = false` (Trim NEXT when window < `MIN_TRIM_MS` — no spurious haptic/scale). -- For **ShutterButton** and **SaveCheckmark**: add scale and/or ripple consistent with the design system; fire haptic on successful **start** record and on **save** tap when `enabled`. -- **Do not** add haptics to disabled controls or to every scroll/drag (slider/look chips already have their own detents). -- Keep **REC-1** on `CameraScreen`: shutter progress still uses `progressFraction: () -> Float` in draw scope; don't read `recordingElapsedMs` at screen root. -- Preserve all **`testTag`s**: `onboarding_cta`, `next_button`, `editor_save`, etc. - -**Explicitly OUT of scope:** - -- Gallery thumbnail long-press scale (already done) -- Editor speed slider / look-chip haptics (already done) -- `MainActivity` permission / `ImportTooLongDialog` Material3 `Button`s -- Replacing `HomeButton` / import lime circle styling - -**Acceptance:** LET'S GO, NEXT (enabled), CLOSE PREVIEW, shutter, and save show visible press feedback; capture and save give a light haptic on successful tap. - ---- - -### 2. Modernize the gallery empty state - -**Goal:** Replace the text-only empty state with an icon + a filled primary **"Record a loop"** CTA. Keep import as a secondary path. - -**Current implementation:** - -```347:381:app/src/main/java/io/github/stozo04/openloop/ui/GalleryScreen.kt -/** Empty state: no loops yet, with an import affordance (slice 07). */ -@Composable -private fun EmptyGalleryState(onImportVideo: () -> Unit) { - // "NO LOOPS YET" headline + body + lime text link "…or import one" -} -``` - -**Target UX (per epic):** - -- **Icon:** Material vector (e.g. `Icons.Outlined.Videocam`, `Icons.Outlined.Movie`, or `Icons.Outlined.VideoLibrary`) — no emoji. -- **Primary CTA:** `PrimaryButton` — label e.g. `"RECORD A LOOP"` (match existing ALL-CAPS CTA voice: LET'S GO / NEXT). -- **Action:** Navigate to camera — same as leaving gallery: `viewModel.navigateBackFromGallery()` wired from `MainActivity` as `onBackClick` today. Hoist a dedicated `onRecordLoop: () -> Unit` on `EmptyGalleryState` / `GalleryContent` if clearer than overloading `onBackClick`. -- **Secondary:** Keep import affordance (`R.string.gallery_import_empty_state` — "…or import one") below the primary button; can remain a text link or a subdued secondary control — **do not remove import**. -- **Typography:** `MaterialTheme.typography.*` only; use theme tokens for colors. - -**Implementation guidance:** - -- Hoist `EmptyGalleryState` for testability (project pattern: `GalleryContent`, `GetStartedButton`) or add `testTag("gallery_empty_record")` on the primary CTA. -- Update `GalleryScreenTest` (and any snapshot of empty copy) when strings/layout change. -- Empty state only shows when `videos.isEmpty()` in `GalleryContent` — no ViewModel change required unless you add analytics later. - -**Acceptance:** With zero loops, user sees icon + lime primary "Record a loop" → camera; import still available. - ---- - -## Constraints — do NOT regress - -- **Delete undo flow:** optimistic hide, Undo snackbar, `visibleVideos`, selection mode, `gallery_*` test tags. -- **Accessibility:** ≥ 48 dp touch targets; real `contentDescription`s; trim handle + speed slider semantics unchanged. -- **Compose perf:** Lesson 016 — no high-frequency reads at screen roots. -- **Copy:** "Loopifying…" unchanged. -- **State machine:** no new `OpenLoopUiState` values unless strictly required; keep `OpenLoopNavHost` `when` exhaustive (Lesson 014). -- **Shared components:** extend `PrimaryButton` rather than duplicating lime pills. -- **Scope discipline:** section 3 only — no section 1/2 rework, no #36 camera unbind unless owner expands scope. - ---- - -## Suggested implementation order - -1. **Extend `PrimaryButton`** — press scale (+ optional haptic callback or internal haptic on click when enabled). -2. **Wire haptics/scale** on `ShutterButton` + `SaveCheckmark`. -3. **Refactor `EmptyGalleryState`** — icon + `PrimaryButton` + `onRecordLoop`; thread lambda through `GalleryScreen` / `MainActivity`. -4. **Tests** — `GalleryScreenTest` empty state; spot-check `OnboardingScreenTest` / `TrimScreenTest` still green. -5. Run verification gate (below). -6. Update Issue #35: check off section 3 remaining boxes, comment with commit hash; close epic if all acceptance criteria met. - ---- - -## Verification gate (`docs/DEFINITION_OF_DONE.md`) - -Before calling done or opening a PR: - -```bash -./gradlew :app:assembleDebug :app:assembleRelease -./gradlew :app:testDebugUnitTest :app:connectedDebugAndroidTest # if emulator/device available -./gradlew :app:lintDebug -``` - -- Exit code 0, zero `e:` compile errors -- `:app:lintDebug` — no **new** errors (baseline policy) -- Run app on device/emulator; capture screenshot showing: - - Gallery **empty** state (icon + RECORD CTA + import link) - - At least one **primary CTA press** state if feasible (or describe manual QA) - - Camera shutter tap (optional haptic — note device) -- Manual QA checklist in PR description - ---- - -## Files you'll touch (starting list) - -``` -app/src/main/java/io/github/stozo04/openloop/ui/ -├── components/PrimaryButton.kt ← press scale + haptic hook -├── OnboardingScreen.kt ← (via PrimaryButton) -├── TrimScreen.kt ← (via PrimaryButton) -├── GalleryScreen.kt ← LoopingVideoOverlay + EmptyGalleryState (+ hoist?) -├── CameraScreen.kt ← ShutterButton -├── BoomerangEditorScreen.kt ← SaveCheckmark -└── MainActivity.kt ← wire onRecordLoop for empty state (if new lambda) - -app/src/main/res/values/strings.xml ← optional new copy for "Record a loop" -app/src/androidTest/.../GalleryScreenTest.kt -app/src/androidTest/.../OnboardingScreenTest.kt ← if CTA behavior changes -``` - ---- - -## Branch / git - -- Branch: **`feature/gallery-selection-delete`** (or `main` if owner merged gallery work — confirm with `git branch`) -- Prior commits: `f3e5269` (§1), `096a6d1` (§2) -- **Do not commit** unless explicitly asked; owner may want one PR for the full epic branch. - ---- - -## Pushback / decisions to surface before coding - -Ask the owner rather than guessing: - -1. **Press feedback style:** scale-only vs Material ripple vs both (epic mentions both; gallery cards use scale only today). -2. **Empty-state icon:** `Videocam` vs `Movie` vs `VideoLibrary`. -3. **"Record a loop" copy:** exact string and whether import stays a text link or becomes a second button. -4. **Haptic intensity:** same `SegmentTick` as editor chips vs `ContextClick` / `Confirm` for primary taps. -5. Whether section 3 should **close Issue #35** entirely after this commit or leave the epic open for #36. - ---- - -## Success criteria (section 3 complete → epic done) - -- [ ] Tactile feedback on primary actions (PrimaryButton + shutter + save) -- [ ] Gallery empty state modernized (icon + primary Record CTA + import retained) -- [ ] Section 3 checkboxes `[x]` on #35; acceptance criteria for delete + a11y + perf still true -- [ ] All existing instrumented tests green; no new lint errors -- [ ] Issue #35 comment with commit hash (and close epic if owner agrees) - ---- - -## Reference — reuse these tokens/components - -```kotlin -// Primary CTA (extend, don't duplicate) -PrimaryButton(text, onClick, enabled, testTag, trailingIcon) -ElectricLime, LimeInk, MaterialTheme.shapes.extraLarge - -// Press-scale reference -VideoThumbnailCard → animateFloatAsState + collectIsPressedAsState - -// Haptics reference -LocalHapticFeedback.current.performHapticFeedback(...) - -// Gallery → camera -viewModel.navigateBackFromGallery() // MainActivity onBackClick today -``` - -Good luck — this should be a focused polish pass that finishes the #35 epic. diff --git a/docs/active/onboarding-video/IMPLEMENTATION.md b/docs/active/onboarding-video/IMPLEMENTATION.md deleted file mode 100644 index 4767103..0000000 --- a/docs/active/onboarding-video/IMPLEMENTATION.md +++ /dev/null @@ -1,73 +0,0 @@ -# Onboarding page-1 video — Implementation - -> Branch: `feature/loop-1` · PR: [#34](https://github.com/stozo04/OpenLoop/pull/34) · Status: built, in review -> Belongs in `docs/active/` until merged, then moves to `docs/completed/`. - -## Problem statement - -Onboarding page 1 ("No Subscriptions & No Ads") showed a static drawable (`onboarding_skater`). The app -*is* a video-loop tool, so the first thing a new user sees should be the product itself — a looping -boomerang — not a still image. This was produced alongside the first real loop generated from an -imported clip (which also surfaced the trim-preview and reverse-corruption bugs fixed in the same PR). - -## Scope - -**In** -- Page 1 autoplays a muted, looping boomerang clip bundled at `app/src/main/res/raw/onboarding_loop_1.mp4` - (540×960, ~2.8 MB), cropped to fill the frosted card. -- Playback is via ExoPlayer in an `AndroidView` (`OnboardingVideoCard`), mirroring the gallery's - `LoopingVideoOverlay`, but inline and silent. -- Only the **settled** pager page decodes; adjacent composed-but-off-screen pages stay paused. -- Playback is **lifecycle-aware**: pauses on `ON_STOP`, resumes on `ON_START`, released on - leave-composition. - -**Out** -- Pages 2–3 stay static drawables. -- No per-device clip selection, no remote/streamed onboarding media, no audio. -- No runtime still-image fallback on decode failure (the dark frosted card is the failure state); the - drawable is only used as the `@Preview` / inspection-mode fallback. - -## Architecture - -Fits `OnboardingScreen.kt` (PRD §4.4) with no ViewModel or state-machine change — onboarding is a pure -UI screen. - -- `OnboardingPage` gains an optional `videoRawRes: Int?`. When non-null **and** not in inspection mode, - `OnboardingPageContent` renders `OnboardingVideoCard(rawResId, playing = isActivePage)`; otherwise the - existing `Image(drawableRes)`. -- `OnboardingVideoCard`: - - References the clip via the non-deprecated `android.resource:///` URI - (`ContentResolver.SCHEME_ANDROID_RESOURCE` + KTX `String.toUri()`), which Media3's - `DefaultDataSource` routes to its raw-resource reader (replaces the deprecated - `RawResourceDataSource.buildRawResourceUri`). - - `REPEAT_MODE_ALL`, `volume = 0f`, `RESIZE_MODE_ZOOM` (crop-to-fill). - - `LifecycleStartEffect(playing)` sets `playWhenReady = playing` on `ON_START` and `false` on - `ON_STOP` ([release/stop playback in `onStop` on API 24+](https://developer.android.com/media/implement/playback-app)). - - `DisposableEffect` releases the player on leave-composition. - - Carries a `contentDescription` so TalkBack labels the otherwise-bare `PlayerView` (the clip is a - decorative demo; the page title is the real label). - -## Implementation steps (as built) - -1. Bundle `res/raw/onboarding_loop_1.mp4`. -2. Add `videoRawRes` to `OnboardingPage`; set it on page 1. -3. Add `OnboardingVideoCard`; route page content through it when `videoRawRes != null && !LocalInspectionMode.current`. -4. Gate playback on the settled page (`pagerState.currentPage == page`). -5. Make playback lifecycle-aware (`LifecycleStartEffect`) and released on dispose. - -## Testing plan - -- Existing `OnboardingNavigationTest` / `OnboardingScreenTest` still green (no API change to navigation). -- `@Preview`s render the static drawable (inspection-mode fallback) — no ExoPlayer in preview. -- Manual / on-device: page 1 plays the looping clip; backgrounding pauses it; returning resumes; swiping - to pages 2–3 stops decode of page 1. -- Compose UI tests can't assert real decode; coverage here is the inspection-mode fallback + on-device QA. - -## Acceptance criteria - -- [x] Page 1 autoplays the muted looping boomerang on-device, cropped to the card. -- [x] Only the settled page decodes. -- [x] Playback pauses when the app is backgrounded and resumes on return. -- [x] Player is released (no leak) when onboarding leaves the composition. -- [x] `@Preview` shows the static drawable, not a blank `AndroidView`. -- [x] `:app:lintDebug` reports no new issues; no deprecated-API usage introduced. diff --git a/docs/active/reverse-output-validation/EXPLAINED-SIMPLY.md b/docs/active/reverse-output-validation/EXPLAINED-SIMPLY.md deleted file mode 100644 index 483dcae..0000000 --- a/docs/active/reverse-output-validation/EXPLAINED-SIMPLY.md +++ /dev/null @@ -1,142 +0,0 @@ -# What Broke, What We Did, and Why the Fix Works — The Simple Version - -> The grown-up versions live in [`IMPLEMENTATION.md`](./IMPLEMENTATION.md) (the plan) and -> [`RESEARCH.md`](./RESEARCH.md) (the evidence). This is the story in plain words. - ---- - -## How OpenLoop makes a backwards video - -To make a loop, the app needs your clip playing **forwards and then backwards**. -Phones don't have a "play it backwards" button for video files, so the app builds -the backwards part itself using two machines that live inside every phone: - -- **The Unpacker** (real name: *decoder*) — opens the video and turns it back into - individual pictures, one at a time. -- **The Packer** (real name: *encoder*) — takes pictures and squeezes them into a - new video file. - -The pictures travel from the Unpacker to the Packer on a **conveyor belt** -(real name: *Surface*). The app feeds the pictures in backwards order, the Packer -packs them, and out comes a backwards video. Done thousands of times a day on -millions of phones. Normally boring. - -## What went wrong on the Galaxy S23 - -On this one phone, in this one backwards-building mode, something weird happens: - -**The Unpacker puts pictures on the conveyor belt… and they never arrive at the -Packer.** Nobody knows exactly why — it's a quirk deep inside Samsung's Android 13 -software for that phone. The Packer just sits there, receives nothing, and when the -app says "that's everything!", the Packer shrugs and seals up a **completely empty -box** — a video file with a label on the outside and zero pictures inside. -(598 bytes. A real video that size would be a few hundred *thousand* bytes.) - -That quirk was the spark. But three bugs in **our** app turned a spark into a fire: - -1. **Nobody checked the box.** The app saw "a box exists" and declared victory. - The empty box got handed to the next machine down the line (the *movie - combiner*, real name: Media3 Transformer), which opened it 3 seconds later and - crashed with a confusing error — `"The asset loader has no audio or video track - to output"` — that pointed at the wrong machine entirely. That's the error you - spent days staring at. - -2. **The empty box got filed in the "already done" cabinet** (the *cache*). The - app's rule was "if a box exists and weighs more than zero, reuse it." So every - time you pressed Save again, the app grabbed the same empty box and failed - instantly. The phone could never recover, even by luck. - -3. **The crash slipped past the safety net.** The saving worker had a list of - error types it knew how to catch. This error wasn't on the list (a Java - technicality — `ExportException` isn't an `IOException` or a - `RuntimeException`), so it fell through, skipped the cleanup, and surfaced as a - generic failure. - -One bonus discovery: the **preview** in the editor had been failing the exact same -way all along — silently. The "reverse" you watched before saving was an empty -clip that played for 0 seconds. It just *looked* fine because the forward part -played normally. - -## How we found it - -- Your logcat was the security-camera footage. It showed the Packer's own diary: - *"Received 0 buffers and encoded 0 frames"* — the empty box being sealed — - 3 seconds before the crash everyone was looking at. -- We tested the **same app** on a Galaxy S25: worked perfectly. Same machines, - same conveyor belt. So it wasn't our recipe — it was this phone's software. -- We pulled the actual empty boxes off your S23 and turned them into **test - fixtures** — now our tests check against the real broken thing, forever. -- Best of all: we found the bug reproduces with a tiny 12-picture test video, in - an automated test, in seconds. That gave us a laboratory. - -## What we changed - -**1. Count the pictures.** The app now counts every picture it puts in the box. -Zero pictures = stop and raise an alarm. No more pretending. - -**2. Check boxes before trusting the cabinet.** Before reusing a cached backwards -video, the app opens it and checks there's at least one picture inside. Empty box? -Throw it away and build a fresh one. (Fun fact: our first version checked box -*weight* instead of contents — and a test on a Pixel immediately proved that wrong -by making a perfectly good video that weighed almost nothing. Checking contents is -the only honest check.) - -**3. Fix the safety net.** The saving worker now catches that missing error type, -cleans up properly, and fails gracefully. - -**4. Be honest with the user.** If something still fails, you get a clear message -and a **"Send debug report"** button instead of a mystery. - -**5. THE FIX: when the box comes out empty, switch Packers.** Phones have two -kinds of Packer: - -- a **hardware Packer** — a dedicated chip; super fast; this is the one whose - conveyor belt is broken on the S23 -- a **software Packer** — the phone's main processor doing it "by hand"; slower, - but it always receives its pictures - -Now, if a backwards build produces an empty box, the app immediately rebuilds it -with the software Packer — and **remembers** ("this phone's fast Packer is broken -today") so it doesn't waste time re-trying the broken one on every save. - -## Why this works - -We tested all three combinations live on your actual S23: - -| Unpacker | Packer | Result | -|---|---|---| -| software | hardware (fast chip) | Empty box — pictures vanish on the belt 📦💨 | -| hardware | hardware (fast chip) | Even worse — the machines crash outright 💥 | -| software | **software** | **Works. Every picture arrives. Real backwards video.** ✅ | - -The problem was never the pictures, the recipe, or the Unpacker — it was that this -phone's fast Packer never receives pictures in this specific mode. The software -Packer doesn't use that broken delivery path, so it just… works. Slightly slower, -only on phones that need it, and invisible to every phone that doesn't. - -And the proof: the very first successful Save this S23 has ever done — -*"Worker result SUCCESS"* — with the log showing the whole play-by-play: empty box -detected → alarm raised → Packers switched → real video built → loop saved. 🎉 - -## The two lessons worth keeping - -1. **A machine that can produce an empty box must count what it puts in the box.** - "The file exists" is not the same as "the work happened." -2. **When a phone misbehaves, test your guesses on the phone.** Two of our three - theories about the fix were wrong, and twenty minutes of on-device testing was - what found the right one. - ---- - -### Tiny glossary (kid word → real word) - -| In this doc | Real name | -|---|---| -| Unpacker | `MediaCodec` decoder (`c2.android.avc.decoder` etc.) | -| Packer | `MediaCodec` encoder (`c2.qti.avc.encoder` = hardware, `c2.android.avc.encoder` = software) | -| Conveyor belt | `Surface` (the decoder renders onto the encoder's input surface) | -| Empty box | the 598-byte moov-only MP4 with zero video samples | -| "Already done" cabinet | the reversed-clip cache in `cache/scratch/reversed/` | -| Movie combiner | Media3 `Transformer` (builds the final forward+backward loop) | -| Count the pictures | `ReverseOutputValidator` + the zero-sample throw in `VideoReverser` | -| Switch Packers + remember | the software-encoder fallback + `zeroFrameEncoderWedgeSticky` | diff --git a/docs/active/reverse-output-validation/IMPLEMENTATION.md b/docs/active/reverse-output-validation/IMPLEMENTATION.md deleted file mode 100644 index 19a74ea..0000000 --- a/docs/active/reverse-output-validation/IMPLEMENTATION.md +++ /dev/null @@ -1,558 +0,0 @@ -# Reverse output validation + friendly save failure with debug report - -> **Status:** Spec — ready for implementation (amended 2026-06-04 after full S23 logcat forensics) -> **Owner:** Steven Gates -> **Branch (suggested):** `fix/reverse-output-validation` -> **Root-cause evidence:** [`RESEARCH.md`](./RESEARCH.md) — line-level proof of the failure chain (read before implementing) -> **Related:** [`../editor-codec-churn/IMPLEMENTATION.md`](../editor-codec-churn/IMPLEMENTATION.md) (codec slot pressure), [`../crashlytics-reverse-preview/HANDOFF.md`](../crashlytics-reverse-preview/HANDOFF.md) (PR #62 Samsung throws), [`../boomerang-rollout/RESEARCH-reverse-video.md`](../boomerang-rollout/RESEARCH-reverse-video.md) (why reverse is hand-rolled) - ---- - -## 0. Agent bootstrap (read this first) - -You are implementing **loud failure** when the reverse pipeline produces an invalid file, plus **user-friendly messaging** and **Send debug report** on both preview and save paths, plus a **gated decoder-fallback retry** (§5.8) that makes the S23 Save actually succeed. You are **not** implementing the GL pass-2 rewrite in this slice — that remains a follow-up ([`../editor-codec-churn/IMPLEMENTATION.md`](../editor-codec-churn/IMPLEMENTATION.md)). Read [`RESEARCH.md`](./RESEARCH.md) first — it is the line-level evidence for every claim in this spec. - -### Read these files before editing code - -| File | Why | -|------|-----| -| [`app/src/main/java/io/github/stozo04/openloop/media/VideoReverser.kt`](../../../app/src/main/java/io/github/stozo04/openloop/media/VideoReverser.kt) | Pass 1/2 pipeline; `emitted` counter in pass 2; cache hit gate | -| [`app/src/main/java/io/github/stozo04/openloop/media/ReverseDiagnostics.kt`](../../../app/src/main/java/io/github/stozo04/openloop/media/ReverseDiagnostics.kt) | `buildReverseSupportReport`, `probeReversePreviewDiagnostics` | -| [`app/src/main/java/io/github/stozo04/openloop/diagnostics/ReverseCrashlytics.kt`](../../../app/src/main/java/io/github/stozo04/openloop/diagnostics/ReverseCrashlytics.kt) | Non-fatal Crashlytics + share report builder | -| [`app/src/main/java/io/github/stozo04/openloop/ui/OpenLoopViewModel.kt`](../../../app/src/main/java/io/github/stozo04/openloop/ui/OpenLoopViewModel.kt) | `ensureReversedSegment`, `markReversePreviewFailed`, `saveBoomerang`, `failBackToEditor` | -| [`app/src/main/java/io/github/stozo04/openloop/ui/BoomerangEditorScreen.kt`](../../../app/src/main/java/io/github/stozo04/openloop/ui/BoomerangEditorScreen.kt) | Reverse failure overlay + **SEND DEBUG INFO** button (copy this pattern) | -| [`app/src/main/java/io/github/stozo04/openloop/MainActivity.kt`](../../../app/src/main/java/io/github/stozo04/openloop/MainActivity.kt) | Snackbar handler for `ReversePreviewFallbackForward` with **Send report** action | -| [`app/src/main/res/values/strings.xml`](../../../app/src/main/res/values/strings.xml) | Existing user-facing strings | -| [`app/src/main/java/io/github/stozo04/openloop/work/BoomerangRenderWorker.kt`](../../../app/src/main/java/io/github/stozo04/openloop/work/BoomerangRenderWorker.kt) | Save/render worker — failures become `Result.failure()` | -| [`app/src/test/java/io/github/stozo04/openloop/ui/OpenLoopViewModelTest.kt`](../../../app/src/test/java/io/github/stozo04/openloop/ui/OpenLoopViewModelTest.kt) | Preview timeout/failure tests — extend for empty-reverse | - -### Repro reference (Samsung S23 Save bug) - -Log file: user-provided `openloop-reverse.txt` (SM-S911U, Android 13). **Full forensic chain with line -numbers: [`RESEARCH.md`](./RESEARCH.md).** Summary: - -| Step | Log evidence | Meaning | -|------|--------------|---------| -| Pass 1 | `reverse pass1: 1280x720, encoded=120` | Stream transcode OK | -| Pass 2 | `MPEG4Writer: encoded 0 frames`, file ~598 bytes | Empty moov-only MP4 — encoder's input surface never received a single frame (`GraphicBufferSource` never logs `got buffer`; decoder output-format registration never completes) | -| Save | `ExportException: The asset loader has no audio or video track to output` | Symptom, not root cause | - -**Root defect:** `VideoReverser.reverseAllKeyframeVideo()` completes without throwing when `emitted == 0`. Cache treats any `length() > 0` as valid. Preview treats return as success. Save feeds bad file to Media3 Transformer. - -**Also confirmed (RESEARCH.md §2/§6):** - -- The **preview** reverse on the S23 failed identically (118-frame pass 1, 0-frame pass 2) — the user's - preview "worked" only because the empty reverse clip plays as ~0-duration. 2-for-2 deterministic. -- `ExportException` **escapes the worker's catch blocks** (it extends `Exception`, not - `IOException`/`RuntimeException`) — see §5.5, now a required fix. -- The failing codec pairing is the Samsung carve-out's forced **software decoder** - (`c2.android.avc.decoder`) feeding the QTI HW encoder surface; the platform-default - `c2.qti.avc.decoder` demonstrably works on this device. HEAD's selection logic reproduces the pairing - (traced in RESEARCH.md §5) — see §5.8 for the gated fallback. -- The S23 was running a build **older than HEAD's `OpenLoopReverse` instrumentation** — rebuild from - HEAD before re-capturing logs. - ---- - -## 1. Problem statement - -OpenLoop’s boomerang flow depends on a **cached reversed MP4** produced by [`VideoReverser`](../../../app/src/main/java/io/github/stozo04/openloop/media/VideoReverser.kt). On some devices (notably **Samsung Galaxy S23 / Snapdragon**), pass 2 can finish with **zero encoded frames** while still writing a tiny (~598 byte) file. Today: - -1. **No exception** is thrown → preview thinks reverse succeeded. -2. **Cache poisons** → subsequent attempts may hit bad cache. -3. **Save reaches Transformer** with an empty reversed clip → cryptic `ExportException`. -4. **Save failure** shows only `Couldn't save loop. Try again.` with **no debug report** (unlike preview failure). - -Users need: - -- Plain-language explanation (not codec jargon). -- **Try again** without losing trim/direction. -- **Send debug report** (same capability as preview reverse failure). -- Automatic Crashlytics non-fatal for aggregate triage (already exists for preview — extend to save/validation). - ---- - -## 2. Goals - -| # | Goal | -|---|------| -| G1 | **Never treat an invalid reversed file as success** (preview or save). | -| G2 | **Never cache** a reversed file that fails validation. | -| G3 | **User-friendly copy** on preview and save failure; no stack traces in UI. | -| G4 | **Send debug report** on save failure — reuse the preview share-sheet pattern. | -| G5 | **Crashlytics non-fatal** with the same custom keys as preview failures. | -| G6 | **Zero regression** on Pixel emulators where pass 2 already works (validation is additive). | - ---- - -## 3. Non-goals (this slice) - -- GL-bridge pass 2 (definitive Samsung surface fix) — separate doc/PR. -- ~~Software encoder retry for pass 2~~ **Corrected by RESEARCH.md §5:** the S23 evidence points at the - forced software **decoder** pairing, not the encoder. A *decoder*-fallback retry is now **in scope - but gated** — see §5.8. (Validation alone leaves S23 users with a loud failure and still no working - Save.) -- Serialized codec ownership (editor-codec-churn item A). -- Changing reverse algorithm or replacing with FFmpeg. -- New analytics events beyond existing Crashlytics non-fatals. - ---- - -## 4. User-facing copy spec - -All strings live in [`strings.xml`](../../../app/src/main/res/values/strings.xml). **Do not hardcode** in Composables except via `stringResource`. - -### Tone rules - -- Second person, short sentences, **no** MediaCodec / Transformer / pass-2 terminology. -- Offer **Try again** (primary) and **Send debug report** (secondary). -- Mention automatic report upload once: *“Reopening the app also sends an automatic report if diagnostics are enabled.”* - -### Preview reverse failure (existing overlay — align copy) - -Already implemented in [`BoomerangEditorScreen.kt`](../../../app/src/main/java/io/github/stozo04/openloop/ui/BoomerangEditorScreen.kt) (~lines 480–533). **Keep structure;** optionally move hardcoded strings to `strings.xml` for consistency. - -| Element | Current text | Target (strings.xml) | -|---------|--------------|----------------------| -| Title | `Couldn't loop that clip` | `reverse_failure_title` | -| Body | `Try again, or pick the Forward direction.` | `reverse_failure_body` | -| Hint | `Send debug info below, or reopen…` | `reverse_failure_crashlytics_hint` | -| Primary CTA | `TRY AGAIN` | `reverse_failure_retry` | -| Secondary CTA | `SEND DEBUG INFO` | `reverse_failure_send_debug_report` | - -**Unify label:** Use **“Send debug report”** everywhere (overlay + snackbar action). Replace `snackbar_reverse_preview_report_action` value `"Send report"` → `"Send debug report"`. - -### Preview snackbar (forward fallback) - -When validation fails during preview, keep existing forward fallback via [`markReversePreviewFailed`](../../../app/src/main/java/io/github/stozo04/openloop/ui/OpenLoopViewModel.kt) + [`BoomerangEvent.ReversePreviewFallbackForward`](../../../app/src/main/java/io/github/stozo04/openloop/ui/OpenLoopViewModel.kt). - -| String key | Text | -|------------|------| -| `snackbar_reverse_preview_forward` | (keep) *We couldn't build the reverse for this clip, so it's playing forward only. You can retry from the Loop tab.* | -| `snackbar_reverse_preview_report_action` | **Send debug report** | - -Handler: [`MainActivity.kt`](../../../app/src/main/java/io/github/stozo04/openloop/MainActivity.kt) ~lines 263–280 — **copy this Intent.ACTION_SEND block** for save failure. - -### Save failure (new / extended) - -Replace bare [`BoomerangEvent.Failed`](../../../app/src/main/java/io/github/stozo04/openloop/ui/OpenLoopViewModel.kt) snackbar with report-capable event. - -| String key | Text | -|------------|------| -| `snackbar_save_failed` | *Couldn't save your loop. Try again, or send a debug report so we can fix device issues.* | -| `snackbar_save_failed_report_action` | **Send debug report** | - -Snackbar: `duration = SnackbarDuration.Long`, `actionLabel = …` when report non-null (same as preview). - ---- - -## 5. Technical design - -### 5.1 Validation primitive (single source of truth) - -Add **`ReverseOutputValidator`** (suggested path: `app/src/main/java/io/github/stozo04/openloop/media/ReverseOutputValidator.kt`). - -```kotlin -/** Result of validating a candidate reversed MP4. */ -data class ReverseOutputValidation( - val valid: Boolean, - val reason: String, // machine-readable, e.g. "zero_frames_pass2" - val fileBytes: Long, - val videoTrackCount: Int, - val estimatedFrameCount: Int, // from sample count or duration × fps — document choice -) - -fun validateReversedOutput(file: File): ReverseOutputValidation -``` - -**Validation rules (all must pass):** - -| Check | Rationale | -|-------|-----------| -| `file.exists()` | Missing file → invalid | -| `MediaExtractor` finds ≥1 `video/` track | Transformer requires a video track | -| ≥1 readable sample on that track (iteration capped at 500) | Catches zero-frame muxer success — the S23 shells have a parseable moov with zero samples | -| File parses at all | Unreadable → invalid, never a crash | - -> **Size-gate correction (2026-06-04, found by the instrumented test):** the originally suggested -> `MIN_REVERSED_BYTES = 4096` validity gate was **dropped** — a Pixel 10 Pro Fold encoded a -> legitimate 12-frame 320x240 clip to under 4 KiB, so size false-positives on small real clips -> while the zero-sample probe already rejects the 598 B shells. `fileBytes` is reported for -> diagnostics only. Validity is content-based. - -**Do not** use `length() > 0` alone anywhere for cache or success. - -Expose as `internal` + unit tests in `ReverseOutputValidatorTest.kt` (pure JVM, temp files or fixtures). - -### 5.2 VideoReverser changes - -File: [`VideoReverser.kt`](../../../app/src/main/java/io/github/stozo04/openloop/media/VideoReverser.kt) - -1. **Pass 2 — throw on zero output** - - At end of `reverseAllKeyframeVideo`, after loop completes: - - ```kotlin - require(emitted > 0) { - "Pass 2 encoded 0 frames (intermediate=${source.name}, dest=${dest.name})" - } - ``` - - Also log: `ReversePreviewLog.e("reverse.pass2.empty", "emitted=0 frames=$total …")` - -2. **Cache hit — validate before return** - - Replace: - - ```kotlin - if (output.exists() && output.length() > 0L) { … return } - ``` - - With: - - ```kotlin - if (output.exists()) { - val v = validateReversedOutput(output) - if (v.valid) { cache hit; return } - output.delete() // stale/poisoned cache - } - ``` - -3. **After pass 2 success — validate before return** - - ```kotlin - val validation = validateReversedOutput(output) - if (!validation.valid) { - output.delete() - throw IOException("Invalid reversed output: ${validation.reason}") - } - ``` - -4. **Include pass metadata in exception message** (for logs only): `pass1Encoder`, `emitted`, `outBytes` — already partially logged. - -5. **Custom exception (optional but helpful):** `ReverseOutputInvalidException(reason, validation)` — lets ViewModel map to friendly outcome strings without parsing `IOException` messages. - -### 5.3 Diagnostics + debug report extension - -File: [`ReverseDiagnostics.kt`](../../../app/src/main/java/io/github/stozo04/openloop/media/ReverseDiagnostics.kt) - -Extend `buildReverseSupportReport` **or** add `buildBoomerangSupportReport` with optional fields: - -| Field | Source | -|-------|--------| -| `phase` | `"preview_reverse"` \| `"save_render"` | -| `outcome` | Human + machine reason | -| `validation` | `ReverseOutputValidation.reason`, bytes, frame estimate | -| `pass1_encoder` | `VideoReverser.lastSurfaceEncoderName` (already `@Volatile`) | -| `mode` | `BoomerangMode` name at failure | -| Existing | app version, device, trim window, source probe | - -Footer line (update): - -``` -What you saw: [preview stuck / couldn't save / loop wouldn't play] -How to help: send this entire message to the developer. -Crashlytics: a non-fatal report is queued on next app open (if Firebase is configured). -``` - -File: [`ReverseCrashlytics.kt`](../../../app/src/main/java/io/github/stozo04/openloop/diagnostics/ReverseCrashlytics.kt) - -- Rename or generalize: `reportPreviewFailure` → keep; add **`reportReverseFailure(phase, …)`** or **`reportSaveFailure`** calling same `customKeys` + new keys: - - `reverse_phase` = `preview` | `save` - - `reverse_validation_reason` - - `reversed_file_bytes` - - `pass1_encoder` - -Use **`recordException(nonFatal)`** — never crash the app for user-visible failures. - -### 5.4 Preview path (ViewModel) - -File: [`OpenLoopViewModel.kt`](../../../app/src/main/java/io/github/stozo04/openloop/ui/OpenLoopViewModel.kt) - -`ensureReversedSegment` already calls `markReversePreviewFailed` on exception/timeout. **Ensure** `ReverseOutputInvalidException` / `IOException` from new validation routes to the same path: - -```kotlin -markReversePreviewFailed( - trim, - outcome = "Reverse file invalid (${validation.reason})", - cause = e, -) -``` - -Outcome strings for report (user-visible summary line inside report, not snackbar): - -| Failure | `outcome` field | -|---------|-----------------| -| Pass 2 zero frames | `Reverse export produced no video (device codec issue)` | -| Invalid cache deleted | `Cached reverse file was invalid; retrying` | -| Timeout (existing) | `Timed out after 120s` | - -No change to forward-fallback behavior: mode → `FORWARD`, `reverseSupportReport` populated, snackbar with **Send debug report**. - -### 5.5 Save path (ViewModel + Worker) - -**Worker** ([`BoomerangRenderWorker.kt`](../../../app/src/main/java/io/github/stozo04/openloop/work/BoomerangRenderWorker.kt)): no UI changes, but **one required fix** — the S23 log proves `androidx.media3.transformer.ExportException` currently **escapes** `doWork`'s catch list (`CancellationException` / `IOException` / `RuntimeException`): `ExportException` extends `Exception` directly, so the render failure bypassed `deletePartialOutput` and surfaced via WorkManager's generic *"failed because it threw an exception/error"* path (RESEARCH.md §6.2). Add an explicit catch: - -```kotlin -} catch (e: ExportException) { // before the RuntimeException branch - progressPublisher.cancel() - Log.e(TAG, "Boomerang render failed (export)", e) - deletePartialOutput(parsed.outputFile) - Result.failure() -} -``` - -(Per Lesson 013, keep catches narrow and documented — `ExportException` is the documented async failure type delivered by `Transformer.Listener.onError`, rethrown out of `runTransformer`.) - -**ViewModel** — extend failure event: - -```kotlin -// Replace object Failed OR extend: -data class SaveFailed(val supportReport: String?) : BoomerangEvent -``` - -Update [`failBackToEditor`](../../../app/src/main/java/io/github/stozo04/openloop/ui/OpenLoopViewModel.kt): - -1. Build report via `ReverseCrashlytics.supportReportForShare(…)` with `phase = save`, capture `mode`, trim, source file, outcome from worker/render exception. -2. Call `ReverseCrashlytics.reportSaveFailure(…)`. -3. Emit `BoomerangEvent.SaveFailed(supportReport)`. -4. Route to editor (unchanged). - -**Pre-Transformer guard** in [`Media3VideoProcessor.renderBoomerang`](../../../app/src/main/java/io/github/stozo04/openloop/media/VideoProcessor.kt): - -After `reverser.reverse(…)` when `needsReverse`: - -```kotlin -val v = validateReversedOutput(reversedFile) -if (!v.valid) throw IOException("Reversed clip invalid: ${v.reason}") -``` - -Defense in depth if cache/worker paths change later. - -### 5.6 MainActivity snackbar - -File: [`MainActivity.kt`](../../../app/src/main/java/io/github/stozo04/openloop/MainActivity.kt) - -Replace: - -```kotlin -BoomerangEvent.Failed -> snackbarHostState.showSnackbar(message = saveFailedMessage) -``` - -With handler mirroring `ReversePreviewFallbackForward`: - -```kotlin -is BoomerangEvent.SaveFailed -> { - val report = event.supportReport - val result = snackbarHostState.showSnackbar( - message = saveFailedMessage, - actionLabel = if (!report.isNullOrBlank()) saveFailedReportAction else null, - duration = SnackbarDuration.Long, - ) - if (result == SnackbarResult.ActionPerformed && !report.isNullOrBlank()) { - // Same Intent.ACTION_SEND as preview — extract to shared helper (see 5.7) - } -} -``` - -Remove or deprecate bare `BoomerangEvent.Failed` if fully replaced; update all `when` branches and tests. - -### 5.7 DRY — share debug report helper - -Both [`BoomerangEditorScreen`](../../../app/src/main/java/io/github/stozo04/openloop/ui/BoomerangEditorScreen.kt) and [`MainActivity`](../../../app/src/main/java/io/github/stozo04/openloop/MainActivity.kt) duplicate `Intent.ACTION_SEND`. Extract: - -```kotlin -// e.g. app/src/main/java/io/github/stozo04/openloop/diagnostics/DebugReportShare.kt -fun Context.shareDebugReport(text: String, subject: String, chooserTitle: String) -``` - -Use from overlay button, preview snackbar action, save snackbar action. - -### 5.8 Software-ENCODER fallback retry on zero-frame output (REVISED + verified on-device 2026-06-04) - -File: [`VideoReverser.kt`](../../../app/src/main/java/io/github/stozo04/openloop/media/VideoReverser.kt) - -> **Revision:** the original decoder-fallback hypothesis was **falsified on the S23 itself** -> (RESEARCH.md §7d): the platform HW decoder feeding the HW encoder surface dies with -> `CodecException 0xe` at `queueInputBuffer`. The pairing that works is keeping the carve-out's SW -> decoder and flipping the **encoder** to software (`c2.android.avc.encoder`). - -Shipped behavior: - -1. A `ReverseOutputInvalidException` (zero-frame pass, 5.2) makes the retry loop run one more - attempt with `preferSoftwareEncoder = true` — `openSurfaceAvcEncoder` fronts the installed SW - AVC encoders (`c2.android.avc.encoder`, `c2.google.avc.encoder`, `OMX.google.h264.encoder`) - ahead of the normal try-order. Decoder selection is unchanged. -2. A process-scoped sticky (`zeroFrameEncoderWedgeSticky`) starts subsequent reverses directly on - the software encoder, so a wedged device pays the doomed HW attempt + retry delay only once per - process. Not persisted (reboot/codec updates may fix the device). -3. Telemetry: `reverse.zero_frame_retry | preferSoftwareEncoder=true`, - `encoder.software_fallback_order`, and `reverse.complete | attempts=N`. -4. Never loops past `SAMSUNG_REVERSE_PASS_MAX_ATTEMPTS`; a second zero-frame run fails loudly - through the validation path (5.2) — the loud-failure floor holds. - -**Verification (gate cleared):** `ReverseOutputValidatorAndroidTest` + `VideoReverserTest` = -**11/11 on the S23 (SM-S911U / API 33, Samsung RTL)** — previously 6/11 failing with the -zero-frame exception — including cold-cache regenerate, warm-cache hit, poisoned-cache delete, and -luma-ramp reversal correctness. JVM 189/189. **A10 cleared manually (2026-06-04 15:15, RTL):** -cold-start capture → trim → loop preview → Save on the S23 UI succeeded end-to-end — preview reverse -hit the wedge and recovered (`attempts=2`), the save-path reverse used the sticky (`attempts=1`), -`BoomerangRenderWorker` returned SUCCESS, zero `ExportException` (log: `openloop-s23-savetest.txt`). -Exynos behavior is still an open question (RESEARCH.md §8) — do not widen the carve-out gating -(`Build.SOC_MANUFACTURER`) without an Exynos capture. - ---- - -## 6. Implementation steps (ordered) - -| Step | Task | Files | -|------|------|-------| -| 1 | Add `ReverseOutputValidator` + unit tests | `ReverseOutputValidator.kt`, `ReverseOutputValidatorTest.kt` | -| 2 | Wire validation into `VideoReverser` (pass 2 require, cache, post-write) | `VideoReverser.kt` | -| 3 | Extend diagnostics report + Crashlytics keys for save/validation | `ReverseDiagnostics.kt`, `ReverseCrashlytics.kt` | -| 4 | Add `strings.xml` entries; unify **Send debug report** label | `strings.xml` | -| 5 | Extract `shareDebugReport()` helper | new `DebugReportShare.kt`; refactor Editor + MainActivity | -| 6 | Map validation errors in `ensureReversedSegment` (should already flow to `markReversePreviewFailed`) | `OpenLoopViewModel.kt` | -| 7 | Pre-Transformer validate in `renderBoomerang` | `VideoProcessor.kt` | -| 8 | Replace `BoomerangEvent.Failed` with `SaveFailed(supportReport)`; update `failBackToEditor` | `OpenLoopViewModel.kt`, `MainActivity.kt` | -| 9 | Move overlay strings to resources (optional polish) | `BoomerangEditorScreen.kt`, `strings.xml` | -| 10 | **Catch `ExportException` in worker** (escapes current catch list — see §5.5) | `BoomerangRenderWorker.kt` | -| 11 | **Decoder-fallback retry on zero frames** (§5.8 — gated on physical-S23 verification) | `VideoReverser.kt` | -| 12 | Update tests (below) | `OpenLoopViewModelTest.kt`, new validator tests | - ---- - -## 7. Testing plan - -### Unit tests (required) - -| Test | Assert | -|------|--------| -| `validateReversedOutput_emptyFile` | invalid, reason documents zero bytes | -| `validateReversedOutput_moovOnly` | invalid — use minimal fixture or mock extractor if needed | -| `validateReversedOutput_validMinimalMp4` | valid — reuse test asset if present, or generate via MediaMuxer in test | -| `VideoReverser pass2 emitted=0` | throws (mock/delegate test via fake intermediate — may need extractor test double) | -| Cache poison | write 598-byte file at cache path → reverse deletes and re-encodes (integration-style with temp dir) | - -### ViewModel tests (extend existing) - -File: [`OpenLoopViewModelTest.kt`](../../../app/src/test/java/io/github/stozo04/openloop/ui/OpenLoopViewModelTest.kt) - -| Test | Setup | Assert | -|------|-------|--------| -| `reverse invalid output flags forward fallback` | `FakeVideoProcessor` throws or returns invalid file | `mode == FORWARD`, `reverseSupportReport != null` | -| `save failure emits SaveFailed with report` | `FakeBoomerangRenderScheduler` returns `Failure` or fake processor throws on invalid reverse | Event is `SaveFailed`, report non-blank | -| `save failure preserves editor` | same | Still `BoomerangEditor`, trim intact | - -Update `FakeVideoProcessor` to simulate `ReverseOutputInvalidException` if needed. - -### Manual device matrix - -| Device | Scenario | Expected | -|--------|----------|----------| -| Pixel emulator | Capture → Loop → Save | No change (success) | -| Galaxy S23 | **Rebuild from HEAD first** (installed APK predates `OpenLoopReverse` logging — RESEARCH.md §6.3), clear app storage (poisoned cache), then Capture → Loop → Save | **Validation only:** preview shows forward fallback OR save snackbar with **Send debug report**; never a silent empty file. **With §5.8 fallback:** Save succeeds; logcat shows `reverse.zero_frame_retry` + platform decoder name | -| Galaxy S23 | Repeat Save on the same clip (warm cache) | Cache hit only on a validated file; never replays the empty-file failure | -| Any | Force invalid cache (adb push 598B file to cache key path) | App deletes cache, retries or fails loudly with report | - -Logcat filters: `VideoReverser`, `ReversePreviewLog`, `MPEG4Writer`, `OpenLoopViewModel`, `BoomerangRenderWorker`. - ---- - -## 8. Acceptance criteria - -| # | Criterion | -|---|-----------| -| A1 | Pass 2 with `emitted == 0` **never** returns a file without throwing | -| A2 | Cache never returns a file failing `validateReversedOutput` | -| A3 | Preview validation failure → forward fallback + overlay/snackbar + **Send debug report** (existing UX, now triggered for empty output too) | -| A4 | Save validation/render failure → snackbar with friendly copy + **Send debug report** action | -| A5 | Share sheet report includes phase, validation reason, device, trim, encoder name | -| A6 | Crashlytics non-fatal recorded for preview **and** save validation failures | -| A7 | All unit + ViewModel tests pass; `:app:lintDebug` clean | -| A8 | Pixel 6/8/10 emulator E2E save still passes (no regression) | -| A9 | `ExportException` from the Transformer is caught in `BoomerangRenderWorker` → `deletePartialOutput` runs → `Result.failure()` (not WorkManager's generic thrown-exception path) | -| A10 | *(gated, §5.8)* Galaxy S23: Save succeeds via decoder-fallback retry, verified on device, cold + warm cache | - -Ship gate: [`docs/DEFINITION_OF_DONE.md`](../../DEFINITION_OF_DONE.md). - ---- - -## 9. Reference — existing debug report flow (copy this) - -### Preview overlay (editor) - -```511:532:app/src/main/java/io/github/stozo04/openloop/ui/BoomerangEditorScreen.kt - if (!reverseSupportReport.isNullOrBlank()) { - ... - Text(text = "SEND DEBUG INFO", ...) - .clickable { - val share = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, reverseSupportReport) - putExtra(Intent.EXTRA_SUBJECT, "OpenLoop loop debug") - } - context.startActivity(Intent.createChooser(share, "Send debug info")) - } - } -``` - -### Preview snackbar (activity) - -```263:280:app/src/main/java/io/github/stozo04/openloop/MainActivity.kt - is BoomerangEvent.ReversePreviewFallbackForward -> { - val report = event.supportReport - val result = snackbarHostState.showSnackbar( - message = reversePreviewForwardMessage, - actionLabel = if (!report.isNullOrBlank()) reversePreviewReportAction else null, - duration = SnackbarDuration.Long, - ) - if (result == SnackbarResult.ActionPerformed && !report.isNullOrBlank()) { - val share = Intent(Intent.ACTION_SEND).apply { ... } - startActivity(Intent.createChooser(share, reversePreviewReportAction)) - } - } -``` - -### Report builder - -```46:70:app/src/main/java/io/github/stozo04/openloop/media/ReverseDiagnostics.kt -internal fun buildReverseSupportReport(...): String = buildString { - appendLine("OpenLoop — loop preview diagnostic") - appendLine("Outcome: $outcome") - ... -} -``` - -### State fields - -```135:137:app/src/main/java/io/github/stozo04/openloop/ui/OpenLoopUiState.kt - val reverseFailed: Boolean = false, - /** Plain-text diagnostic for the "Send debug info" action when [reverseFailed] is true. */ - val reverseSupportReport: String? = null, -``` - -Save failure should **not** require new Compose overlay state unless you want a Loop-tab banner — snackbar + optional editor re-entry is enough for v1. - ---- - -## 10. Follow-up work (explicitly out of scope here) - -| Follow-up | Why separate | -|-----------|--------------| -| GL-bridge pass 2 | Fixes Samsung root cause; large change | -| ~~Software encoder retry on pass 2~~ → superseded by §5.8 decoder-fallback (in scope, gated) — RESEARCH.md §5 shows the *decoder* pairing is the defect, not the encoder | Corrected 2026-06-04 | -| Gate the Samsung SW-decoder carve-out on actual silicon (`Build.SOC_MANUFACTURER`, API 31+; fallback heuristic for 26–30) instead of brand | Needs an Exynos capture first (RESEARCH.md §8) — don't fix what we can't verify | -| `BoomerangEvent.Failed` → rich inline editor error on Save | Nice-to-have; snackbar sufficient for v1 | -| Serialize codec ownership | [`editor-codec-churn`](../editor-codec-churn/IMPLEMENTATION.md) item A | - ---- - -## 11. PR checklist (for reviewer) - -- [ ] No user-facing string mentions MediaCodec, Transformer, pass 1/2, or ExportException -- [ ] **Send debug report** label consistent in overlay + both snackbars -- [ ] Invalid cache file deleted, not reused -- [ ] `BoomerangEvent.Failed` usages migrated or documented -- [ ] Tests added for validator + save failure event -- [ ] Manual note in PR description: tested on Pixel emulator + (if available) Samsung S23 diff --git a/docs/active/reverse-output-validation/RESEARCH.md b/docs/active/reverse-output-validation/RESEARCH.md deleted file mode 100644 index d032945..0000000 --- a/docs/active/reverse-output-validation/RESEARCH.md +++ /dev/null @@ -1,231 +0,0 @@ -# RESEARCH — Galaxy S23 Save failure: pass-2 reverse emits zero frames - -> **Status:** Forensic analysis complete (2026-06-04) -> **Device:** Samsung Galaxy S23, SM-S911U (US / Snapdragon 8 Gen 2), Android 13 (API 33) -> **Log:** user-captured unfiltered logcat `openloop-reverse.txt` (47,994 lines), capture window 13:26:30–13:27:31 -> **Companion spec:** [`IMPLEMENTATION.md`](./IMPLEMENTATION.md) - -This document is the line-level evidence behind the spec. It exists so the next session (or PR reviewer) -does not have to re-derive the failure chain from a 5.7 MB logcat. - ---- - -## 1. Symptom - -User taps Save (green check) in the boomerang editor. Render fails; Pixel 6/8/10 emulators pass the same -flow end-to-end with zero errors. - -``` -13:27:23.254 E/WM-WorkerWrapper: Work [...BoomerangRenderWorker, boomerang_render] failed because it threw an exception/error -13:27:23.254 E/WM-WorkerWrapper: androidx.media3.transformer.ExportException: Asset loader error - at androidx.media3.transformer.ExoPlayerAssetLoader$PlayerListener.onTracksChanged(ExoPlayerAssetLoader.java:460) -Caused by: java.lang.IllegalStateException: The asset loader has no audio or video track to output. -``` - -[`ExoPlayerAssetLoader.onTracksChanged` (1.10.1)](https://github.com/androidx/media/blob/1.10.1/libraries/transformer/src/main/java/androidx/media3/transformer/ExoPlayerAssetLoader.java) -throws this when the item's ExoPlayer selects **neither** an audio nor a video track. Our -`EditedMediaItem`s strip audio by design, so this means: *no selectable video track*. The Transformer -error is the **symptom**; the broken input was produced 3 seconds earlier. - -## 2. The failure chain (timestamps from the log) - -| Time | Evidence (line) | Event | -|------|-----------------|-------| -| 13:27:16.74 | 35323 | `BoomerangRenderWorker` starts | -| 13:27:16.82 | 35589 | `VideoReverser` reverse start: 1280x720@30, trim 540..4559ms; codecs: `c2.qti.avc.encoder` (HW) + `c2.android.avc.decoder` (SW, Samsung carve-out) | -| 13:27:19.877 | 40174 | **Pass 1 OK** — `MPEG4Writer: Received total (120/0) buffers and encoded 120 frames` | -| 13:27:19.90 | 40225 | Pass 2 plan valid — `reverse pass2: 1280x720, frames=120, sync=120` (all-keyframe intermediate is good) | -| 13:27:21.09 | 41172 | Pass-2 decoder stalls — `CCodecBufferChannel [c2.android.avc.decoder#271] elapsed: n=12 [in=0 pipeline=0 out=8]` | -| 13:27:22.765 | 41513 | Pass-2 encoder at EOS — `QC2Comp [avcE_44] Returning empty EOS buffer as-is as the codec is not started yet` | -| 13:27:22.789 | 41617 | **Pass 2 "succeeds" with nothing** — `MPEG4Writer: Received total (0/0) buffers and encoded 0 frames`; file is 598 bytes (`ftruncate mOffset:598` — moov shell, zero samples) | -| 13:27:22.797 | 41651 | Transformer starts; forward clip's asset loader (`ExoPlayerImpl 3b4426c`) runs ~430ms | -| 13:27:23.235 | 44205 | Second asset loader (`de509a7`) opens the **empty reversed file** | -| 13:27:23.254 | 44275 | `ExportException` ← `IllegalStateException: no audio or video track to output` | - -**The editor preview reverse failed identically and earlier** (854x480 preview-res pass at -13:27:04–09): pass 1 encoded 118 frames; pass 2 wrote **0 frames** at 13:27:09.653 (line 32342, same -598-byte shell). The preview's "reverse half" the user saw before Save was already an empty clip — -silently. Pass-2 zero-frame output was **2-for-2 deterministic** in this session. - -## 3. Three independent confirmations that zero frames reached the pass-2 encoder - -1. **`GraphicBufferSource` (the encoder's input surface).** - Pass 1: `setting dataspace: 0x104` → `got buffer with new dataSpace #101` 93ms later → frames flow. - Pass 2 (both runs): `setting dataspace: 0x104` → **never a `got buffer` line** → at teardown, - `released unpopulated slots: [0..63]` — all 64 slots never held a buffer. - -2. **Worker progress telemetry.** `WM-WorkProgressUpdater` published 0 → 9 → 22 → 39% smoothly through - pass 1 (lines 35519–40030), then jumped **39 → 80** (line 41689) — 80% is `REVERSE_BUDGET`, set - *after* `reverse()` returns. Pass 2's `emitted` counter (`VideoReverser.kt` pass-2 loop) never - incremented even once, so `onProgress` never fired in `0.5..1.0` of the reverse budget. - -3. **CCodec buffer channel.** Pass-1 decoders log - `CCodecBuffers [...:Output] popFromStashAndRegister: ... output format changed` (lines 28119, 36488) - — the moment decoded output becomes deliverable to the app. The pass-2 decoders (`#960` preview, - `#271` export) **never log output-format registration**. Their decoded buffers sit stashed - (`out=8`), the app's `dequeueOutputBuffer` only ever sees `INFO_TRY_AGAIN_LATER`, and the only - buffer ever delivered is the empty EOS — so `render` is never true and - `encoder.signalEndOfInputStream()` is the encoder's first and only input. - -Timing corroborates: both pass-2 runs took ~2.7–2.8s despite a 2.25× pixel-count difference -(854x480 vs 1280x720) — wall clock is dominated by ~120 iterations of 10ms dequeue timeouts, not by -decoding. - -## 4. Why pass 2 exits "successfully" with zero frames - -In `reverseAllKeyframeVideo`, the encoder — having received only `signalEndOfInputStream()` — emits -`INFO_OUTPUT_FORMAT_CHANGED` followed by an empty EOS buffer. `drainToMuxer` dutifully starts the muxer -on the format change (13:27:22.768, `MP4WtrCtrlHlpLooper Started`) and sees the EOS 21ms later. The -loop's exit condition (`EOS flag && muxerStarted`) is satisfied; nothing throws; `reverse()` returns the -598-byte file. **There is no success criterion anywhere in the reverse pipeline** — that is the root -app-level defect (spec G1/A1). - -## 5. Codec-pairing analysis (why S23, why not Pixel, why not pass 1) - -- The Samsung carve-out (`openAvcDecoderForReverse` → `samsungSoftwareAvcDecoderTryOrder`) forces the - AOSP **software** decoder. It was built from **Exynos** RTL findings (S24/S921B); the US S23 is - **Snapdragon**. On this device the carve-out resolves to `c2.android.avc.decoder` (the preferred - `c2.google.h264.decoder` is not installed), paired with the `c2.qti.avc.encoder` HW surface encoder. -- Pass 1 uses the **same pairing on the same device and works** (120/118 frames). The deterministic - differentiator is pass 2's mode of operation: per-frame `seekTo` reverse feeding of the all-keyframe - intermediate. The vendor-level mechanism (why Samsung's CCodec stack never completes output-format - registration in this mode) is **not determinable from logcat** — treat as a device behavior to detect - and route around, not to fix. -- The platform-default **HW decoder works on this device**: this same log shows `c2.qti.avc.decoder` - decoding for the ExoPlayer editor preview and for the Transformer's own asset loaders throughout the - session. Media3 itself ships decoder fallback for Samsung-specific codec failures - ([androidx/media#2189](https://github.com/androidx/media/issues/2189), - [androidx/media#2362](https://github.com/androidx/media/issues/2362), - [androidx/media#2751](https://github.com/androidx/media/issues/2751)) — but `VideoReverser` bypasses - Media3 and hand-picks codecs, so it needs its own fallback. -- Pixels never enter the carve-out (`createDecoderByType` → platform default) — hence 0 errors there. -- The pass-2 loop itself follows the canonical CTS/bigflake pattern - (`render = info.size > 0`, EOS via `signalEndOfInputStream`): - [MediaCodec docs](https://developer.android.com/reference/android/media/MediaCodec), - [bigflake MediaCodec FAQ](https://bigflake.com/mediacodec/), - [CTS DecodeEditEncodeTest](https://github.com/PhilLab/Android-MediaCodec-Examples/blob/master/DecodeEditEncodeTest.java). - The app-side loop is not doing anything undocumented. - -**HEAD reproduces this.** Traced against `ReverseEncoderSelection.kt` at HEAD: on the S23 the -`c2.google.*` codecs don't exist, `isSamsungVendorAvcCodec` excludes `android.avc`/`exynos`/`c2.sec` -*encoders* but the decoder try-order still lands on `c2.android.avc.decoder`, and the encoder ranking -still picks `c2.qti.avc.encoder` — the exact observed pairing. - -## 6. Secondary defects confirmed in this session - -1. **Cache poisoning** — `VideoReverser.reverse()` accepts any cached file with `length() > 0`; the - 598-byte shell passes. Every subsequent Save of the same trim window is an instant cache-hit - failure until scratch is cleared. (Spec §5.1/§5.2; A2.) -2. **`ExportException` escapes `BoomerangRenderWorker`** — `doWork` catches `CancellationException`, - `IOException`, `RuntimeException`; `androidx.media3.transformer.ExportException` extends `Exception` - directly and matched **none of them**. The log shows WorkManager's generic - *"failed because it threw an exception/error"* path, meaning `deletePartialOutput` never ran and the - worker's designed failure path was bypassed. (Spec §5.5 — corrected; A9.) -3. **The installed APK predates branch instrumentation.** The log contains **zero `OpenLoopReverse` - lines** despite `ReversePreviewLog` (commit `7930600`) being an ancestor of HEAD and no log-stripping - in ProGuard rules — the S23 was running an older build. Codec-selection outcome is unchanged at HEAD - (see §5), but **rebuild from HEAD before re-testing** so `reverse.pass2.done outBytes=…` / - `encoder.try_order` telemetry is present in the next capture. - -## 7. What a fix must achieve (feeds the spec) - -| Layer | Requirement | Spec home | -|-------|-------------|-----------| -| Correctness | Pass 2 with 0 muxed samples must throw, never return | §5.2 (G1/A1) | -| Correctness | Cache must validate sample presence, not `length() > 0` | §5.1/§5.2 (G2/A2) | -| Correctness | Worker must catch `ExportException` so cleanup + failure path run | §5.5 (A9) | -| S23 functionality | Zero-frame failure should retry with the **platform-default decoder** (skip the SW carve-out) — validation alone leaves S23 users unable to save | §5.8 (gated step) | -| UX | Loud, friendly failure + Send debug report on save path | §4/§5.6 | - -## 7b. Counter-evidence: S25 succeeds with the same pairing (added 2026-06-04, 14:09 capture) - -Galaxy S25 (SM-S931B, **API 35**, Snapdragon 8 Elite/SM8750), running a **current-HEAD debug build** -(full `OpenLoopReverse` telemetry present), log `openloop-reverse-s25.txt`: - -| Run | Pairing | Pass 1 | Pass 2 | Result | -|-----|---------|--------|--------|--------| -| Preview (854x480) | `c2.qti.avc.encoder` + `c2.android.avc.decoder` | 115 frames | **113 frames, 384,937 B** ✓ | `ensureReversed.ok` | -| Save (1280x720) | same | 115 frames | **113 frames, 695,690 B** ✓ | `Worker result SUCCESS` | - -The **same codec pairing that wedged on the S23 works on the S25**. This narrows §5's conclusion: -the SW-decoder carve-out pairing is *not* family-wide broken on Snapdragon Samsungs. Two variables -differ from the failing S23 capture — device/OS (API 33 vs 35) **and** app build (the S23 ran a stale -build lacking the codec-settle delays and unified encoder path; the S25 log shows -`reverse.settle | preReverseDelayMs=400` and `ensureReversed.settle | delayMs=500`, absent from the -S23 log). Until the S23 is re-tested on a current build, both hypotheses are live: - -- **H1 — already fixed at HEAD:** the S23 zero-frame wedge was codec-slot contention that the - settle/unify commits (`4fed93a`, `7930600`, `11d5664`) resolved. §5.8 decoder fallback then becomes - unnecessary; validation ships as insurance. -- **H2 — S23/API-33-specific:** the older CCodec stack wedges regardless of build. §5.8 stands. - -**The deciding experiment is Phase 0 on the S23 with a current-HEAD build** (pull poisoned cache -first — it is still the only real-world invalid fixture). - -Minor new observation (not this bug): pass 2 emits 113 of 115 fed frames on both S25 runs — 2 tail -frames consistently lost at EOS drain. Candidate follow-up for the spec's §10 table. - -## 7c. VERDICT: H2 confirmed (2026-06-04, 14:19 capture) - -Re-ran the identical flow on the S23 with the **same current-HEAD debug build** that succeeded on the -S25 (log: `openloop-reverse-s23-head.txt`). Result: **reproduced exactly** — same codec pairing, same -zero-frame pass 2, same 598-byte shells, same `ExportException` escaping the worker: - -| Run | Pass 1 | Pass 2 | Telemetry | -|-----|--------|--------|-----------| -| Preview (854x480) | 69 frames (skipped=39) | **0 frames**, `outBytes=598` | `reverse.complete attempts=1`; `ensureReversed.ok bytes=598` | -| Save (1280x720) | 108 frames | **0 frames**, `outBytes=598` | → `ExportException: Asset loader error` | - -Conclusions locked in: - -1. **Device/OS-stack difference, not app build**: S23/API 33 wedges, S25/API 35 succeeds, byte-identical - APK and codec names. The settle/unify commits (H1) did **not** fix the S23. -2. **The existing retry never engages** — `attempts=1` on the zero-frame run; nothing throws. The - spec's zero-frame `require`/throw (§5.2) is the necessary hook for any retry to fire. -3. **Encoder exonerated with config proof**: `muxer.format_changed` fires at EOS with a fully-formed - encoder output format (preview run echoes `frame-rate=19` — pass-1's effective rate after - subsampling; export echoes 30). Configured, alive, starved. -4. **Fixtures secured** (pulled via `run-as` before any cleanup) to `C:\Users\gates\openloop-fixtures\s23\`: - `reversed-empty-1.mp4` + `reversed-empty-2.mp4` (the 598 B poisoned outputs), `scaled-480-input.mp4` - + `raw-capture.mp4` (the exact inputs). The empty shells become validator unit-test fixtures; the - inputs enable a faithful instrumented repro of the §5.8 fallback on-device. - -§5.8 (decoder-fallback retry, gated on S23 verification) **stands**. §8's Exynos question remains open; -the API-33-vs-35 split is now the stronger axis to investigate than Snapdragon-vs-Exynos. - -## 7d. Fallback pairing experiments + fix verification (2026-06-04, S23 via Samsung RTL) - -The zero-frame wedge reproduces in an **isolated instrumented test** with a 12-frame 320x240 -synthetic clip (`VideoReverserTest` via `am instrument`) — no camera, no editor, no codec churn. -That made the §5.8 fallback testable on-device in minutes. Pairing matrix measured on the S23: - -| Pass-2 pairing (decoder → encoder) | Result | -|------------------------------------|--------| -| `c2.android.avc.decoder` (SW, carve-out) → `c2.qti.avc.encoder` (HW) | **Wedge**: 0 samples, encoder surface never receives a frame | -| `c2.qti.avc.decoder` (HW, platform default) → `c2.qti.avc.encoder` (HW) | **Worse**: `CodecException: Error 0xe` at `queueInputBuffer`, ~130ms into pass 1 | -| `c2.android.avc.decoder` (SW) → **`c2.android.avc.encoder` (SW)** | **WORKS**: 12/12 samples muxed, luma-ramp reversal-correctness assertions pass | - -So §5.8's original hypothesis (flip the *decoder*) was **falsified on-device**; the shipped fallback -flips the **encoder** to software on the zero-frame retry. A process-scoped sticky -(`zeroFrameEncoderWedgeSticky`) skips the doomed HW attempt on subsequent reverses — telemetry from -the green run shows the first reverse at `attempts=2` and every later one at `attempts=1` on -`c2.android.avc.encoder`. Deliberately not persisted (reboot/codec updates may fix the device; -rediscovery costs one attempt). - -**Verification:** `ReverseOutputValidatorAndroidTest` + `VideoReverserTest` = **11/11 green on the -S23** (the same suite that was 5-passed/6-failed before the fallback), including the cache-poison -regression (`cache_invalid reason=no_video_track bytes=598 — deleting poisoned cache` observed) and -reversal correctness. JVM suite 189/189. Note: Gradle's `connectedDebugAndroidTest` UTP device -provider cannot drive RTL's localhost-tunnel adb — use `adb shell am instrument -w -e class …` -directly. - -Remaining manual check for A10: one real capture → loop → save through the app UI on the S23. - -## 8. Open questions - -- Did the S23 preview visibly play a reverse half at all, or did the loop look forward-only? (The - reversed preview clip was empty — ExoPlayer likely skipped it as a ~0-duration item.) Worth one - manual observation when re-testing on the rebuilt APK. -- Does the zero-frame wedge also occur on **Exynos** S23/S24 with the `c2.google.h264.decoder` pairing, - or is it Snapdragon-/`c2.android.avc.decoder`-specific? The decoder-fallback retry covers both, but - the answer decides whether the carve-out should be gated on `Build.SOC_MANUFACTURER` (API 31+). diff --git a/docs/android-16/README.md b/docs/android-16/README.md deleted file mode 100644 index 7b7760e..0000000 --- a/docs/android-16/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# Android 16 (API 36) — Upgrade Knowledge Hub - -Durable reference for OpenLoop's move to **`targetSdk 36` / Android 16**. This folder is the -single place an agent or contributor should look to understand what Android 16 changes, what it -means for OpenLoop specifically, and where the official Google source lives — without re-running a -Google search. - -This hub is **evergreen reference**: unlike a feature folder, it does **not** move to -`docs/completed/` when the upgrade ships. The actionable, ordered upgrade plan lives separately in -[`docs/completed/007-target-sdk-upgrade/IMPLEMENTATION.md`](../completed/007-target-sdk-upgrade/IMPLEMENTATION.md) -(GitHub Issue [#7](https://github.com/stozo04/OpenLoop/issues/7)). - -> **Verified 2026-05-28.** Google's pages change. Every file records the source URL and fetch date -> in its header. **Re-verify against the live page before acting** — see the dated note in each file. - -## How to read this hub - -Each document leads with: -1. **Source** — the exact `developer.android.com` URL it summarizes, plus the fetch date. -2. **Impact on OpenLoop** — an *Applies / Partially applies / N/A* verdict with reasons tied to - OpenLoop's actual surface (on-device, zero-network, no notifications/health/Bluetooth, single - Activity, Compose + CameraX + Media3). -3. **Summary** — a faithful, verbose summary of the page. - -## Documents - -| Document | What it covers | OpenLoop verdict | -|----------|----------------|------------------| -| [behavior-changes-targeting-16.md](./behavior-changes-targeting-16.md) | Behavior changes for apps **targeting** API 36 (verbatim Google page + provenance header) | **Applies** — edge-to-edge, predictive back, adaptive layouts | -| [summary.md](./summary.md) | The full Android 16 change catalog (all-apps vs targeted vs new APIs) | Partially applies | -| [migration.md](./migration.md) | Google's two-phase migration process (compat first, then re-target) | **Applies** — this is the Issue #7 workflow | -| [features.md](./features.md) | New developer features & APIs in Android 16 | Partially applies — Camera/Media/Graphics are in-domain (optional) | -| [progress-centric-notifications.md](./progress-centric-notifications.md) | `Notification.ProgressStyle` for tracked journeys | N/A today (no notifications) | -| [api-diff-36.md](./api-diff-36.md) | The framework API delta (API 35 → 36) — how to use it | Reference only — additive; AndroidX libs not listed here | - -## Source URLs (for quick access) - -- Behavior changes (targeting 16): https://developer.android.com/about/versions/16/behavior-changes-16 -- Behavior changes (all apps): https://developer.android.com/about/versions/16/behavior-changes-all -- Summary: https://developer.android.com/about/versions/16/summary -- Migration: https://developer.android.com/about/versions/16/migration -- Features: https://developer.android.com/about/versions/16/features -- Progress-centric notifications: https://developer.android.com/about/versions/16/features/progress-centric-notifications -- Framework API diff (35 → 36): https://developer.android.com/sdk/api_diff/36/changes -- Google Play target API requirement: https://developer.android.com/google/play/requirements/target-sdk - -## Related - -- **Evergreen rules:** [`docs/ANDROID_STANDARDS.md`](../ANDROID_STANDARDS.md) — the version-agnostic - "how we build" standards. Android-16-specific rules there carried a `Status: pending — Issue #7` - marker during docs-prep and are now marked **satisfied** — the app targets 36 (Issue #7). -- **Upgrade plan & checklist:** [`docs/completed/007-target-sdk-upgrade/IMPLEMENTATION.md`](../completed/007-target-sdk-upgrade/IMPLEMENTATION.md) -- **This docs-prep effort (archived with the upgrade):** [`docs/completed/007-target-sdk-upgrade/DOC-PREP.md`](../completed/007-target-sdk-upgrade/DOC-PREP.md) diff --git a/docs/android-16/api-diff-36.md b/docs/android-16/api-diff-36.md deleted file mode 100644 index 0dac95a..0000000 --- a/docs/android-16/api-diff-36.md +++ /dev/null @@ -1,60 +0,0 @@ -# Android 16 — Framework API Diff (API 35 → 36) - -> **Source:** https://developer.android.com/sdk/api_diff/36/changes · fetched 2026-05-28 -> **Part of:** [docs/android-16/ hub](./README.md) — Android 16 (API 36) upgrade knowledge for OpenLoop (Issue #7) -> This is a large auto-generated report. We link to it and surface only OpenLoop-relevant deltas rather than copying it. - -## Impact on OpenLoop - -**Verdict:** Reference only — no breaking framework deltas hit OpenLoop's surface; the 35→36 additions are net-new APIs (opt-in), not removals or signature changes to anything we call. - -- **Bookmark for after the compileSdk bump.** Use this report to resolve any new deprecation warnings the compiler emits once OpenLoop compiles against API 36. Nothing here forces a code change today. -- **AndroidX libraries do NOT appear here.** CameraX, Media3 (ExoPlayer/Transformer), Compose, and DataStore are AndroidX, not framework. Their changes ship in their own release notes, never in this `android.*` framework diff. Track those separately when bumping library versions. -- **Framework deltas worth a glance** (all additive): `MediaStore` gained cancellation-aware file-open overloads and photo-picker pre-selection extras; predictive back gained new constants on `OnBackInvokedDispatcher`/`BackEvent`; `camera2` classes changed but at the framework level OpenLoop does not call directly (CameraX wraps them). Details below. - -## How to use this report - -The report is JDiff-generated and split into three buckets — **Added** (new packages/classes/members), **Removed** (deleted, the ones that can break a build), and **Changed** (modified signatures, return types, or docs). It is grouped first by package (e.g. `android.view`, `android.provider`), then by class, then by member (constructors / methods / fields). Start at the [changes summary](https://developer.android.com/sdk/api_diff/36/changes/changes-summary) to see which packages moved, then click into a package and class only when the compiler flags a symbol you actually use. Consult it reactively during the upgrade — after raising `compileSdk` to 36 — rather than reading it front to back. - -## OpenLoop-relevant deltas - -All entries below were confirmed in the fetched per-class diff pages. Everything found is an **addition**; no removals or signature changes touch OpenLoop's call surface. - -### Window / WindowInsets / WindowManager (edge-to-edge) - -**No changes.** `android.view.Window`, `android.view.WindowInsets`, `android.view.WindowManager`, `WindowInsetsController`, and `WindowMetrics` are **not** in the changed-class list for `android.view` in this diff. The classes that did change in `android.view` are `AttachedSurfaceControl`, `Display`, `FrameMetrics`, `KeyEvent`, `Surface`, `SurfaceControl.Transaction`, `SurfaceView`, `View`, `ViewGroup`, `ViewStructure` (plus added `SurfaceControl.JankData` and listeners) — none of which OpenLoop touches for its already-enabled edge-to-edge setup. - -### Predictive back (`android.window`) - -Additive only: - -- `OnBackInvokedDispatcher` — added field `int PRIORITY_SYSTEM_NAVIGATION_OBSERVER`. -- `BackEvent` — added constructor `BackEvent(float, float, float, int, long)`, added method `long getFrameTimeMillis()`, added field `int EDGE_NONE`. -- New class `SystemOnBackInvokedCallbacks` added to the package. -- `OnBackInvokedCallback`, `OnBackAnimationCallback`, and `BackNavigationInfo` show **no** entries in this diff. - -OpenLoop handles back through Compose/AndroidX, not these framework symbols directly. None of these additions require a change; they are optional capabilities. - -### MediaStore (`android.provider`) - -`MediaStore`, `MediaStore.MediaColumns`, and `MediaStore.Audio.AudioColumns` are listed as changed; `MediaStore.Images` and `MediaStore.Video` show no entries. Confirmed additions on `MediaStore`: - -- Methods: `markIsFavoriteStatus(ContentResolver, Collection, boolean)`, `openAssetFileDescriptor(ContentResolver, Uri, String, CancellationSignal)`, `openFileDescriptor(ContentResolver, Uri, String, CancellationSignal)`, `openTypedAssetFileDescriptor(ContentResolver, Uri, String, Bundle, CancellationSignal)`. -- Fields/constants: `ACCESS_OEM_METADATA_PERMISSION`, `ACTION_MOTION_PHOTO_CAPTURE`, `ACTION_MOTION_PHOTO_CAPTURE_SECURE`, `EXTRA_PICKER_PRE_SELECTION_URIS`, `QUERY_ARG_MEDIA_STANDARD_SORT_ORDER`. - -The new `CancellationSignal` file-open overloads are the only items relevant to a save/scan path — they are opt-in conveniences, not replacements. The motion-photo and picker-preselection constants do not apply to OpenLoop's flow. - -### Permissions (`android.Manifest.permission`) - -`android.Manifest.permission` is listed as changed, but **none of OpenLoop's permissions are affected**. The added permissions are `APPLY_PICTURE_PROFILE`, `BIND_APP_FUNCTION_SERVICE`, `BIND_TV_AD_SERVICE`, `EXECUTE_APP_FUNCTIONS`, `MANAGE_DEVICE_POLICY_APP_FUNCTIONS`, `MANAGE_DEVICE_POLICY_THREAD_NETWORK`, `QUERY_ADVANCED_PROTECTION_MODE`, `RANGING`, `READ_COLOR_ZONES`, `READ_SYSTEM_PREFERENCES`, `REQUEST_OBSERVE_DEVICE_UUID_PRESENCE`, `TV_IMPLICIT_ENTER_PIP`, `WRITE_SYSTEM_PREFERENCES`. No change to `CAMERA`, `READ_MEDIA_IMAGES`, `READ_MEDIA_VIDEO`, `READ_MEDIA_VISUAL_USER_SELECTED`, or any photo-picker / storage permission. - -### Camera / media framework - -- `android.hardware.camera2` changed classes: `CameraCharacteristics`, `CameraMetadata`, `CaptureRequest`, `CaptureResult`, `MultiResolutionImageReader`. These are **framework** camera2 symbols. OpenLoop uses **CameraX (AndroidX)**, which wraps camera2 — so these deltas reach OpenLoop only through a CameraX version bump, not directly. The summary-level read is additive (new keys/metadata), with no removals indicated; verify only if a future CameraX release surfaces a new capability. -- `android.media` is listed as a changed package; OpenLoop's media path is **Media3 (AndroidX)**, which does not appear in this framework diff. - -> Reminder: CameraX, Media3, Compose, and DataStore are AndroidX libraries and will **not** appear in this framework API diff. Their breaking changes live in their own changelogs. - -## See also -- Evergreen rules: [ANDROID_STANDARDS.md](../ANDROID_STANDARDS.md) -- Upgrade plan: [007 IMPLEMENTATION.md](../completed/007-target-sdk-upgrade/IMPLEMENTATION.md) diff --git a/docs/android-16/behavior-changes-targeting-16.md b/docs/android-16/behavior-changes-targeting-16.md deleted file mode 100644 index 2201466..0000000 --- a/docs/android-16/behavior-changes-targeting-16.md +++ /dev/null @@ -1,605 +0,0 @@ -
- -Like previous releases, Android 16 includes behavior changes that might affect -your app. The following behavior changes apply exclusively to apps that are -targeting Android 16 or higher. If your app is targeting Android 16 or higher, -you should modify your app to support these behaviors, where applicable. - -Be sure to also review the list of [behavior changes that affect all apps -running on Android 16](https://developer.android.com/about/versions/16/behavior-changes-all) regardless of your app's [`targetSdkVersion`](https://developer.android.com/guide/topics/manifest/uses-sdk-element#target). - -## User experience and system UI - -Android 16 (API level 36) includes the following changes that are intended -to create a more consistent, intuitive user experience. - -### Edge to edge opt-out going away - -[Android 15 enforced edge-to-edge](https://developer.android.com/about/versions/15/behavior-changes-15#edge-to-edge) for apps targeting Android 15 (API -level 35), but your app could opt out by setting -[`R.attr#windowOptOutEdgeToEdgeEnforcement`](https://developer.android.com/reference/android/R.attr#windowOptOutEdgeToEdgeEnforcement) to `true`. For apps -targeting Android 16 (API level 36), -`R.attr#windowOptOutEdgeToEdgeEnforcement` is deprecated and disabled, and your -app can't opt out of going edge-to-edge. - -- If your app targets Android 16 (API level 36) and is running on an Android 15 device, `R.attr#windowOptOutEdgeToEdgeEnforcement` continues to work. -- If your app targets Android 16 (API level 36) and is running on an Android 16 device, `R.attr#windowOptOutEdgeToEdgeEnforcement` is disabled. - -For testing in Android 16, ensure your app supports edge-to-edge and -remove any use of `R.attr#windowOptOutEdgeToEdgeEnforcement` so that your app -also supports edge-to-edge on an Android 15 device. To support edge-to-edge, -see the [Compose](https://developer.android.com/develop/ui/compose/layouts/insets) and [Views](https://developer.android.com/develop/ui/views/layout/edge-to-edge) guidance. - -### Migration or opt-out required for predictive back - -For apps targeting Android 16 (API level 36) or higher and running on an -Android 16 or higher device, the predictive back system animations -(back-to-home, cross-task, and cross-activity) are enabled by default. -Additionally, `onBackPressed` is not called and -[`KeyEvent.KEYCODE_BACK`](https://developer.android.com/reference/android/view/KeyEvent#KEYCODE_BACK) is not dispatched anymore. - -If your app intercepts the back event, and you haven't migrated to predictive -back yet, [update your app to use supported back navigation APIs](https://developer.android.com/guide/navigation/custom-back/predictive-back-gesture#update-custom), or -temporarily opt out by setting the -[`android:enableOnBackInvokedCallback`](https://developer.android.com/guide/topics/manifest/activity-element#enableOnBackInvokedCallback) attribute to `false` in the -`` or `` tag of your app's `AndroidManifest.xml` file. -The predictive back-to-home animation. The predictive cross-activity animation. The predictive cross-task animation. - -### Elegant font APIs deprecated and disabled - -Apps targeting Android 15 (API level 35) have the -[`elegantTextHeight`](https://developer.android.com/reference/android/R.attr#elegantTextHeight) -[`TextView`](https://developer.android.com/reference/android/widget/TextView) attribute set to `true` by -default, replacing the compact font with one that is much more readable. You -could override this by setting the `elegantTextHeight` attribute to `false`. - -Android 16 deprecates the -[`elegantTextHeight`](https://developer.android.com/reference/android/R.attr#elegantTextHeight) attribute, -and the attribute will be ignored once your app targets Android 16. The "UI -fonts" controlled by these APIs are being discontinued, so you should adapt any -layouts to ensure consistent and future-proof text rendering in Arabic, Lao, -Myanmar, Tamil, Gujarati, Kannada, Malayalam, Odia, Telugu or Thai. -![](https://developer.android.com/static/about/versions/15/images/elegant-text-height-before.png) `elegantTextHeight` behavior for apps targeting Android 14 (API level 34) and lower, or for apps targeting Android 15 (API level 35) that overrode the default by setting the `elegantTextHeight` attribute to `false`. ![](https://developer.android.com/static/about/versions/15/images/elegant-text-height-after.png) `elegantTextHeight` behavior for apps targeting Android 16 (API level 36), or for apps targeting Android 15 (API level 35) that didn't override the default by setting the `elegantTextHeight` attribute to `false`. - -## Core functionality - -Android 16 (API level 36) includes the following changes that modify or -expand various core capabilities of the Android system. - -### Fixed rate work scheduling optimization - -Prior to targeting Android 16, when [`scheduleAtFixedRate`](https://developer.android.com/reference/java/util/concurrent/ScheduledExecutorService#scheduleAtFixedRate(java.lang.Runnable,%20long,%20long,%20java.util.concurrent.TimeUnit)) -missed a task execution due to being outside a valid -[process lifecycle](https://developer.android.com/guide/components/activities/process-lifecycle), **all** missed executions immediately -execute when the app returns to a valid lifecycle. - -When targeting Android 16, at most **one** missed execution of -[`scheduleAtFixedRate`](https://developer.android.com/reference/java/util/concurrent/ScheduledExecutorService#scheduleAtFixedRate(java.lang.Runnable,%20long,%20long,%20java.util.concurrent.TimeUnit)) is immediately executed when the app -returns to a valid lifecycle. This behavior change is expected to improve app -performance. Test this behavior in your app to check if your app is impacted. -You can also test by using the [app compatibility framework](https://developer.android.com/guide/app-compatibility/test-debug) -and enabling the `STPE_SKIP_MULTIPLE_MISSED_PERIODIC_TASKS` compat flag. - -## Device form factors - -Android 16 (API level 36) includes the following changes for apps when -displayed on large screen devices. - -### Adaptive layouts - -With Android apps now running on a variety of devices (such as phones, tablets, -foldables, desktops, cars, and TVs) and windowing modes on large screens (such -as split screen and desktop windowing), developers should build Android apps -that adapt to any screen and window size, regardless of device orientation. -Paradigms like restricting orientation and resizability are too restrictive in -today's multidevice world. - -#### Ignore orientation, resizability, and aspect ratio restrictions - -For apps targeting Android 16 (API level 36), orientation, resizability, -and aspect ratio restrictions no longer apply on displays with the smallest width \>= -600dp. Apps fill the entire display window, regardless of aspect ratio or a -user's preferred orientation, and pillarboxing isn't used. - -This change introduces a new standard platform behavior. [Android is moving -toward a model](https://android-developers.googleblog.com/2025/01/orientation-and-resizability-changes-in-android-16.html) where apps are expected to adapt to various -orientations, display sizes, and aspect ratios. Restrictions like fixed -orientation or limited resizability hinder app adaptability. [Make your app -adaptive](https://developer.android.com/adaptive-apps) to deliver the best possible user experience. - -You can also test this behavior by using the -[app compatibility framework](https://developer.android.com/guide/app-compatibility/test-debug) and enabling the -`UNIVERSAL_RESIZABLE_BY_DEFAULT` compat flag. - -#### Common breaking changes - -Ignoring orientation, resizability, and aspect ratio restrictions might impact -your app's UI on some devices, especially elements that were designed for small -layouts locked in portrait orientation: for example, issues like stretched -layouts and off-screen animations and components. Any assumptions about aspect -ratio or orientation can cause visual issues with your app. -[Learn more](https://developer.android.com/develop/ui/compose/layouts/adaptive) about how to avoid them and improve your app's adaptive -behavior. - -Allowing device rotation results in more activity re-creation, which can result -in losing user state if not properly preserved. Learn how to correctly save UI -state in [Save UI states](https://developer.android.com/topic/libraries/architecture/saving-states). - -#### Implementation details - -The following manifest attributes and runtime APIs are ignored across large -screen devices in full-screen and multi-window modes: - -- [`screenOrientation`](https://developer.android.com/guide/topics/manifest/activity-element#screen) -- [`resizableActivity`](https://developer.android.com/guide/topics/manifest/activity-element#resizeableActivity) -- [`minAspectRatio`](https://developer.android.com/guide/topics/manifest/activity-element#minaspectratio) -- [`maxAspectRatio`](https://developer.android.com/guide/topics/manifest/activity-element#maxaspectratio) -- [`setRequestedOrientation()`](https://developer.android.com/reference/android/app/Activity#setRequestedOrientation(int)) -- [`getRequestedOrientation()`](https://developer.android.com/reference/android/app/Activity#getRequestedOrientation()) - -The following values for `screenOrientation`, `setRequestedOrientation()`, and -`getRequestedOrientation()` are ignored: - -- `portrait` -- `reversePortrait` -- `sensorPortrait` -- `userPortrait` -- `landscape` -- `reverseLandscape` -- `sensorLandscape` -- `userLandscape` - -Regarding display resizability, `android:resizeableActivity="false"`, -`android:minAspectRatio`, and `android:maxAspectRatio` have no effect. - -For apps targeting Android 16 (API level 36), app orientation, -resizability, and aspect ratio constraints are ignored on large screens by -default, but every app that isn't fully ready can temporarily override this -behavior by opting out (which results in the previous behavior of being placed -in compatibility mode). - -#### Exceptions - -The Android 16 orientation, resizability, and aspect ratio restrictions don't -apply in the following situations: - -- Games (based on the [`android:appCategory`](https://developer.android.com/guide/topics/manifest/application-element#appCategory) flag) -- Users explicitly opting in to the app's default behavior in aspect ratio settings of the device -- Screens that are smaller than `sw600dp` - -#### Opt out temporarily - -To opt out a specific activity, declare the -`PROPERTY_COMPAT_ALLOW_RESTRICTED_RESIZABILITY` manifest property: - - - - ... - - -If too many parts of your app aren't ready for Android 16, you can opt out -completely by applying the same property at the application level: - - - - - -> [!IMPORTANT] -> **Important:** The opt-out is temporary and won't apply when targeting API level 37 in a future Android release. That is, for apps targeting API level 37, orientation, resizability, and aspect ratio restrictions are ignored on displays that are at least `sw600dp`. - -## Health and fitness - -Android 16 (API level 36) includes the following changes related to health -and fitness data. - -### Health and fitness permissions - -For apps targeting Android 16 (API level 36) or higher, -[`BODY_SENSORS`](https://developer.android.com/reference/android/Manifest.permission#BODY_SENSORS) permissions use more granular permissions -under `android.permissions.health`, which [Health Connect](https://developer.android.com/health-and-fitness/guides/health-connect) -also uses. As of Android 16, any API previously requiring `BODY_SENSORS` -or `BODY_SENSORS_BACKGROUND` requires the corresponding -`android.permissions.health` permission instead. This affects the following data -types, APIs, and foreground service types: - -- [`HEART_RATE_BPM`](https://developer.android.com/reference/kotlin/androidx/health/services/client/data/DataType#HEART_RATE_BPM()) from Health Services on Wear OS -- [`Sensor.TYPE_HEART_RATE`](https://developer.android.com/reference/android/hardware/Sensor#TYPE_HEART_RATE) from Android Sensor Manager -- [`heartRateAccuracy`](https://developer.android.com/reference/androidx/wear/protolayout/expression/PlatformHealthSources#heartRateAccuracy()) and [`heartRateBpm`](https://developer.android.com/reference/androidx/wear/protolayout/expression/PlatformHealthSources#heartRateBpm()) from `ProtoLayout` on Wear OS -- [`FOREGROUND_SERVICE_TYPE_HEALTH`](https://developer.android.com/develop/background-work/services/fgs/service-types#health) where the respective `android.permission.health` permission is needed in place of `BODY_SENSORS` - -If your app uses these APIs, it should request the respective granular -permissions: - -- For while-in-use monitoring of Heart Rate, SpO2, or Skin Temperature: request the granular permission under `android.permissions.health`, such as [`READ_HEART_RATE`](https://developer.android.com/reference/android/health/connect/HealthPermissions#READ_HEART_RATE) instead of [`BODY_SENSORS`](https://developer.android.com/reference/android/Manifest.permission#BODY_SENSORS). -- For background sensor access: request [`READ_HEALTH_DATA_IN_BACKGROUND`](https://developer.android.com/reference/android/health/connect/HealthPermissions#READ_HEALTH_DATA_IN_BACKGROUND) instead of [`BODY_SENSORS_BACKGROUND`](https://developer.android.com/reference/android/Manifest.permission#BODY_SENSORS_BACKGROUND). - -These permissions are the same as those that guard access to reading data from -[Health Connect](https://developer.android.com/health-and-fitness/guides/health-connect), the Android datastore for health, -fitness, and wellness data. - -#### Mobile apps - -Mobile apps migrating to use the `READ_HEART_RATE` and other granular -permissions must also [declare an activity](https://developer.android.com/health-and-fitness/guides/health-connect/develop/get-started#show-privacy-policy) to display -the app's privacy policy. This is the same requirement as Health Connect. - -> [!IMPORTANT] -> **Important:** Failure to provide the rationale for mobile apps will result in the permission being revoked. - -## Connectivity - -Android 16 (API level 36) includes the following changes in Bluetooth stack -to improve connectivity with peripheral devices. - -### New intents to handle bond loss and encryption changes - -As part of the [Improved bond loss handling](https://developer.android.com/about/versions/16/behavior-changes-all#improved-bond-loss-handling), Android 16 also -introduces 2 new intents to provide apps with greater awareness of bond loss and -encryption changes. - -Apps targeting Android 16 can now: - -- Receive an [`ACTION_KEY_MISSING`](https://developer.android.com/reference/android/bluetooth/BluetoothDevice#ACTION_KEY_MISSING) intent when remote bond loss is detected, allowing them to provide more informative user feedback and take appropriate actions. -- Receive an [`ACTION_ENCRYPTION_CHANGE`](https://developer.android.com/reference/android/bluetooth/BluetoothDevice#ACTION_ENCRYPTION_CHANGE) intent whenever encryption status of the link changes. This includes encryption status change, encryption algorithm change, and encryption key size change. Apps must consider the bond restored if the link is successfully encrypted upon receiving [`ACTION_ENCRYPTION_CHANGE`](https://developer.android.com/reference/android/bluetooth/BluetoothDevice#ACTION_ENCRYPTION_CHANGE) intent later. - -#### Adapting to varying OEM implementations - -While Android 16 introduces these new intents, their implementation and -broadcasting can vary across different device manufacturers (OEMs). To ensure -your app provides a consistent and reliable experience across all devices, -developers should design their bond loss handling to gracefully adapt to these -potential variations. - -We recommend the following app behaviors: - -- If the [`ACTION_KEY_MISSING`](https://developer.android.com/reference/android/bluetooth/BluetoothDevice#ACTION_KEY_MISSING) intent is broadcast: - - The ACL (Asynchronous Connection-Less) link will be disconnected by the - system, but the bond information for the device will be retained (as - described [here](https://developer.android.com/about/versions/16/behavior-changes-all#improved-bond-loss-handling)). - - Your app should use this intent as the primary signal for bond loss - detection and guiding the user to confirm the remote device is in range - before initiating device forgetting or re-pairing. - - If a device disconnects after [`ACTION_KEY_MISSING`](https://developer.android.com/reference/android/bluetooth/BluetoothDevice#ACTION_KEY_MISSING) is received, - your app should be cautious about reconnecting, as the device may no longer - be bonded with the system. -- If the [`ACTION_KEY_MISSING`](https://developer.android.com/reference/android/bluetooth/BluetoothDevice#ACTION_KEY_MISSING) intent is NOT broadcast: - - The ACL link will remain connected, and the bond information for the device - will be removed by the system, same to behavior in Android 15. - - In this scenario, your app should continue its existing bond loss handling - mechanisms as in previous Android releases, to detect and manage bond loss - events. - -### New way to remove bluetooth bond - -All apps targeting Android 16 are now able to unpair bluetooth devices using a -public API in [`CompanionDeviceManager`](https://developer.android.com/reference/android/companion/CompanionDeviceManager). If a companion device is -being managed as a CDM association, then the app can trigger -bluetooth bond removal by using the new [`removeBond(int)`](https://developer.android.com/reference/android/companion/CompanionDeviceManager#removeBond(int)) API -on the associated device. The app can monitor the bond state changes by -listening to the bluetooth device broadcast event -[`ACTION_BOND_STATE_CHANGED`](https://developer.android.com/reference/android/bluetooth/BluetoothDevice#ACTION_BOND_STATE_CHANGED). - -## Security - -Android 16 (API level 36) includes the following security changes. - -### MediaStore version lockdown - -For apps targeting Android 16 or higher, [`MediaStore#getVersion()`](https://developer.android.com/reference/android/provider/MediaStore#getVersion(android.content.Context)) will now -be unique to each app. This eliminates identifying properties from the version -string to prevent abuse and usage for fingerprinting techniques. Apps shouldn't -make any assumptions around the format of this version. Apps should already -handle version changes when using this API and in most cases shouldn't need to -change their current behavior, unless the developer has attempted to infer -additional information that is beyond the intended scope of this API. - -### Safer Intents - -The Safer Intents feature is a multiphase security initiative designed to -improve the security of Android's intent resolution mechanism. -The goal is to protect apps from malicious actions by adding checks during -intent processing and filtering intents that don't meet specific criteria. - -In [Android 15](https://developer.android.com/about/versions/15/behavior-changes-15#safer-intents) the feature focused on the sending app, now with Android 16, -shifts control to the receiving app, allowing developers to opt in to strict -intent resolution using their app manifest. - -Two key changes are being implemented: - -1. Explicit Intents Must Match the Target Component's Intent Filter: - If an intent explicitly targets a component, it should match that component's - intent filter. - -2. Intents Without an Action Cannot Match any Intent Filter: Intents that - don't have an action specified shouldn't be resolved to any intent filter. - -These changes only apply when multiple apps are involved and don't affect -intent handling within a single app. - -#### Impact - -The opt-in nature means that developers must explicitly enable it in their -app manifest for it to take effect. -As a result, the feature's impact will be limited to apps whose developers: - -- Are aware of the Safer Intents feature and its benefits. -- Actively choose to incorporate stricter intent handling practices into their apps. - -This opt-in approach minimizes the risk of breaking existing apps that may rely -on the current less-secure intent resolution behavior. - -While the initial impact in Android 16 may be limited, the Safer Intents -initiative has a roadmap for broader impact in future Android releases. -The plan is to eventually make strict intent resolution the default behavior. - -The Safer Intents feature has the potential to significantly enhance the -security of the Android ecosystem by making it more difficult for -malicious apps to exploit vulnerabilities in the intent resolution mechanism. - -However, the transition to opt-out and mandatory enforcement must be -carefully managed to address potential compatibility issues with existing apps. - -#### Implementation - -Developers need to explicitly enable stricter intent matching using the -`intentMatchingFlags` attribute in their app manifest. -Here is an example where the feature is opt-in for the entire app, -but disabled/opt-out on a receiver: - - - - - - - - - - - - -> [!NOTE] -> **Note:** The attribute can be specified on the `` tag as well as at the component tags such as ``, ``, ``, ``, `` and the attribute on the component can be used to override what's on the `` tag - -More on the supported flags: - -| Flag Name | Description | -|---|---| -| enforceIntentFilter | Enforces stricter matching for incoming intents | -| none | Disables all special matching rules for incoming intents. When specifying multiple flags, conflicting values are resolved by giving precedence to the "none" flag | -| allowNullAction | Relaxes the matching rules to allow intents without an action to match. This flag to be used in conjunction with "enforceIntentFilter" to achieve a specific behavior | - -#### Testing and Debugging - -When the enforcement is active, apps should function correctly if the intent -caller has properly populated the intent. -However, blocked intents will trigger warning log messages like -`"Intent does not match component's intent filter:"` and `"Access blocked:"` -with the tag `"PackageManager."` -This indicates a potential issue that could impact the app and requires -attention. - -Logcat filter: - - tag=:PackageManager & (message:"Intent does not match component's intent filter:" | message: "Access blocked:") - -### GPU syscall filtering - -To harden the Mali GPU surface, Mali GPU IOCTLs that have been deprecated or are -intended solely for GPU development have been blocked in production builds. -Additionally, IOCTLs used for GPU profiling have been restricted to the shell -process or debuggable applications. Refer to the SAC update for more details on -the platform-level policy. - -This change takes place on Pixel devices using the Mali GPU (Pixel 6-9). Arm -has provided official categorization of their IOCTLs in -`Documentation/ioctl-categories.rst` of their [r54p2 release](https://developer.android.com/about/versions/16/dynamic-sections/changes/16/%22https:/developer.arm.com/downloads/-/Mali%205th%20Gen%20GPU%20Architecture#%22). This -list will continue to be maintained in future driver releases. - -This change does not impact supported graphics APIs (including Vulkan and -OpenGL), and is not expected to impact developers or existing applications. -GPU profiling tools such as the Streamline Performance Analyzer and the Android -GPU Inspector won't be affected. - -#### Testing - -If you see a SELinux denial similar to the following, it is likely your -application has been impacted by this change: - - 06-30 10:47:18.617 20360 20360 W roidJUnitRunner: type=1400 audit(0.0:85): avc: denied { ioctl } - for path="/dev/mali0" dev="tmpfs" ino=1188 ioctlcmd=0x8023 - scontext=u:r:untrusted_app_25:s0:c512,c768 tcontext=u:object_r:gpu_device:s0 tclass=chr_file - permissive=0 app=com.google.android.selinux.pts - -If your application needs to use blocked IOCTLs, please file a bug and assign it -to android-partner-security@google.com. - -#### FAQ - -1. **Does this policy change apply to all OEMs?** - This change will be opt-in, but available to any OEMs who would like to use this - hardening method. Instructions for implementing the change can be found in the - implementation documentation. - -2. **Is it mandatory to make changes in the OEM codebase to implement this, or does it come with a new AOSP release by default?** - The platform-level change will come with a new AOSP release by default. Vendors - may opt in to this change in their codebase if they would like to apply it. - -3. **Are SoCs responsible for keeping the IOCTL list up to date? For example, if my device uses an ARM Mali GPU, would I need to reach out to ARM for any of the changes?** - Individual SoCs must update their IOCTL lists per device upon driver release. - For example, ARM will update their published IOCTL list upon driver updates. - However, OEMs should make sure that they incorporate the updates in their - SEPolicy, and add any selected custom IOCTLs to the lists as needed. - -4. **Does this change apply to all Pixel in-market devices automatically, or is a user action required to toggle something to apply this change?** - This change applies to all Pixel in-market devices using the Mali GPU - (Pixel 6-9). No user action is required to apply this change. - -5. **Will use of this policy impact the performance of the kernel driver?** - This policy was tested on the Mali GPU using GFXBench, and no measurable change - to GPU performance was observed. - -6. **Is it necessary for the IOCTL list to align with the current userspace and kernel driver versions?** - Yes, the list of allowed IOCTLs must be synchronized with the IOCTLs supported - by both the userspace and kernel drivers. If the IOCTLs in the user space or - kernel driver are updated, the SEPolicy IOCTL list must be updated to match. - -7. **ARM has categorized IOCTLs as 'restricted' / 'instrumentation', but we want to use some of them in production use-cases, and/or deny others.** - Individual OEMs/SoCs are responsible for deciding on how to categorize the - IOCTLs they use, based on the configuration of their userspace Mali libraries. - ARM's list can be used to help decide on these, but each OEM/SoC's use-case may - be different. - -## Privacy - -Android 16 (API level 36) includes the following privacy changes. - -### Local Network Permission - -Devices on the LAN can be accessed by any app that has the `INTERNET` permission. -This makes it easy for apps to connect to local devices, but it also has privacy -implications such as forming a fingerprint of the user, and being a proxy for -location. - -The Local Network Protections project aims to protect the user's privacy by -gating access to the local network behind a new runtime permission. - -#### Release plan - -This change will be deployed between two releases, **25Q2 and 26Q2** -respectively. -It is imperative that developers follow this guidance for **25Q2** and share -feedback because these protections **will be enforced at a later Android release**. -Moreover, they will need to update scenarios which depend on implicit local -network access by using the following guidance and prepare for user rejection -and revocation of the new permission. - -#### Impact - -At the current stage, LNP is an opt-in feature which means only the apps that -opt in will be affected. The goal of the opt-in phase is for app developers to -understand which parts of their app depend on implicit local network access -such that they can prepare to permission guard them for the next release. - -Apps will be affected if they access the user's local network using: - -- Direct or library use of raw sockets on local network addresses (e.g. mDNS or SSDP service discovery protocol) -- Use of framework level classes that access the local network (e.g. NsdManager) - -Traffic **to** and **from** a local network address requires local network -access permission. The following table lists some common cases: - -| App Low Level Network Operation | Local Network Permission Required | -|---|---| -| Making an outgoing TCP connection | yes | -| Accepting incoming TCP connections | yes | -| Sending a UDP unicast, multicast, broadcast | yes | -| Receiving an incoming UDP unicast, multicast, broadcast | yes | - -These restrictions are implemented deep in the networking stack, -and thus they apply to **all networking APIs**. This includes sockets created -in native or managed code, networking libraries like Cronet and OkHttp, -and any APIs implemented on top of those. Trying to resolve services on the -local network (i.e. those with a .local suffix) will require local network -permission. - -> [!NOTE] -> **Note:** Traffic originating from Android Webviews that require local network access will inherit permission state from the host app - -Exceptions to the rules above: - -- If a device's DNS server is on a local network, traffic to or from it (at port 53) doesn't require local network access permission. -- Applications using Output Switcher as their in-app picker won't need local network permissions (more guidance to come in 2025Q4). - -> [!NOTE] -> **Note:** Many media casting scenarios depend on access to the local network and will be impacted by this change. However, not all apps which offer casting will need to request the new permission. Future APIs and guidance for dealing with casting scenarios will be provided in 25Q4. - -#### Developer Guidance (Opt-in) - -To opt into local network restrictions, do the following: - -1. Flash the device to a build with 25Q2 Beta 3 or later. -2. Install the app to be tested. -3. Toggle the Appcompat flag in adb: - - adb shell am compat enable RESTRICT_LOCAL_NETWORK - -4. **Reboot The device** - -Now your app's access to the local network is restricted and any attempt to -access the local network will lead to socket errors. If you are using APIs that -perform local network operations outside your app process (ex: NsdManager), -**they won't be impacted during the opt-in phase.** - -To restore access, you must grant your app permission to `NEARBY_WIFI_DEVICES`. - -1. Ensure the app declares the `NEARBY_WIFI_DEVICES` permission in its manifest. -2. Go to **Settings \> Apps \> \[Application Name\] \> Permissions \> Nearby devices \> - Allow**. - -> [!NOTE] -> **Note:** In a future Android release, this feature will be guarded by a *new* permission in the Nearby devices permission group - -Now your app's access to the local network should be restored and all your -scenarios should work as they did prior to opting the app in. - -Once enforcement for local network protection begins, here is how the app -network traffic will be impacted. - -| Permission | Outbound LAN Request | Outbound/Inbound Internet Request | Inbound LAN Request | -|---|---|---|---| -| Granted | Works | Works | Works | -| Not Granted | Fails | Works | Fails | - -Use the following command to toggle-off the App-Compat flag - - adb shell am compat disable RESTRICT_LOCAL_NETWORK - -#### Errors - -Errors arising from these restrictions will be returned to the calling socket -whenever it invokes send or a send variant to a local network address. - -Example errors: - - sendto failed: EPERM (Operation not permitted) - - sendto failed: ECONNABORTED (Operation not permitted) - -#### Local Network Definition - -A local network in this project refers to an IP network that utilizes a -broadcast-capable network interface, such as Wi-Fi or Ethernet, but excludes -cellular (WWAN) or VPN connections. - -The following are considered local networks: - -**IPv4:** - -- 169.254.0.0/16 // Link Local -- 100.64.0.0/10 // CGNAT -- 10.0.0.0/8 // RFC1918 -- 172.16.0.0/12 // RFC1918 -- 192.168.0.0/16 // RFC1918 - -**IPv6:** - -- Link-local -- Directly-connected routes -- Stub networks like Thread -- Multiple-subnets (TBD) - -Additionally, both multicast addresses (224.0.0.0/4, ff00::/8) and the IPv4 -broadcast address (255.255.255.255) are classified as local network addresses. - -### App-owned photos - -When prompted for photo and video permissions by an app targeting SDK 36 or -higher on devices running Android 16 or higher, users who choose to limit access -to selected media will see any photos owned by the app pre-selected in the photo -picker. Users can deselect any of these pre-selected items, which will revoke -the app's access to those photos and videos. diff --git a/docs/android-16/features.md b/docs/android-16/features.md deleted file mode 100644 index bec8ec3..0000000 --- a/docs/android-16/features.md +++ /dev/null @@ -1,385 +0,0 @@ -# Android 16 — New Features & APIs - -> **Source:** https://developer.android.com/about/versions/16/features · fetched 2026-05-28 -> **Part of:** [docs/android-16/ hub](./README.md) — Android 16 (API 36) upgrade knowledge for OpenLoop (Issue #7) - -## Impact on OpenLoop - -**Verdict:** Partially applies — most of the surface (notifications, connectivity, health, TV, accessibility) is irrelevant to a 100% on-device camera/loop app, but the Camera, Media, and Graphics additions are directly in OpenLoop's domain and worth tracking. - -- **Camera (relevant, optional):** Hybrid auto-exposure (ISO / exposure-time priority), precise color temperature + tint (`COLOR_CORRECTION_MODE_CCT`), and night-mode scene detection are Camera2 features. OpenLoop uses CameraX today, so these only matter if it ever drops to Camera2 interop for manual capture controls. Not required for the SDK 36 upgrade. -- **Media (relevant, optional):** UltraHDR HEIC (`ImageFormat.HEIC_ULTRAHDR`) and the APV professional video codec (`MediaFormat.MIMETYPE_VIDEO_APV`) touch OpenLoop's video-processing/export domain (Media3 Transformer), but APV's multi-Gbps pro-codec target is far beyond a Boomerang app's needs. The embedded/cloud Photo Picker is more about picking media than exporting it. -- **Graphics (relevant, optional):** AGSL `RuntimeColorFilter` / `RuntimeXfermode` could power on-device video effects/filters in Compose-driven UI later; not needed now. -- **Adaptive UI (relevant):** Predictive-back updates and the accessibility improvements (outline high-contrast text, tri-state checkbox, expandable-element state) are general best-practice items that benefit any Compose Material 3 app, OpenLoop included. -- **Not relevant:** Progress-centric notifications, richer haptics, live-wallpaper content, all Connectivity (Wi-Fi ranging, UWB, Companion Device), Health Connect, Privacy Sandbox, Key Sharing, TV quality framework, and internationalization (vertical text, measurement system) — OpenLoop has zero network, no notifications, no accounts, and is not a Wear/TV/Auto target. -- **Versioning (relevant):** The new `SDK_INT_FULL` / `VERSION_CODES_FULL` constants and `Build.getMinorSdkVersion(int)` matter for the two-releases-per-year cadence and should be on the radar for runtime version checks during the SDK 36 work. - -## Summary - -The features page documents the new developer-facing capabilities and APIs introduced in Android 16 (API 36, codename **BAKLAVA**), grouped by category. The page also lists supporting references for full API diffs, behavior changes, and non-SDK restrictions. - -### Core Functionality - -#### Two Android API releases in 2025 - -Android 16 introduces a shift to two API releases per year: - -- **Q2 2025 major release** — carries new developer APIs and the behavior changes tied to `targetSdkVersion`. -- **Q4 2025 minor release** — feature updates, optimizations, and bug fixes with no app-impacting behavior changes. -- **Q1 and Q3 updates** — incremental improvements between the API releases. - -To support checking both the major and the new minor versions, Android 16 adds: - -- `Build.Version.SDK_INT` — continue using for major-version checks. -- `Build.Version.SDK_INT_FULL` — new constant covering both major and minor versions. -- `Build.VERSION_CODES` — existing major-version enumeration. -- `Build.VERSION_CODES_FULL` — new enumeration for major/minor version codes. -- `Build.getMinorSdkVersion(int)` — retrieves the minor SDK version. - -```kotlin -// Major version check (existing approach) -if (SDK_INT >= VERSION_CODES.BAKLAVA) { - // Use APIs introduced in Android 16 -} - -// Major or minor version check (new approach) -if (SDK_INT_FULL >= VERSION_CODES_FULL.BAKLAVA) { - // Use APIs introduced in Android 16 -} - -// Get minor SDK version -val minorSdkVersion = Build.getMinorSdkVersion(VERSION_CODES_FULL.BAKLAVA) -``` - -### User experience and system UI - -#### Progress-centric notifications - -A new `Notification.ProgressStyle` notification style lets apps track user-initiated, start-to-end journeys with visual progress indicators. Supporting nested classes: - -- `Notification.ProgressStyle.Point` — denotes states and milestones in a journey. -- `Notification.ProgressStyle.Segment` — defines journey segments. - -Intended use cases include rideshare tracking, delivery notifications, and navigation progress. (See OpenLoop's dedicated `progress-centric-notifications.md` note for detail.) - -#### Predictive back updates - -Apps can register callbacks for predictive-back system animations in gesture navigation (for example, back-to-home). Key APIs: - -- `OnBackInvokedDispatcher.registerOnBackInvokedCallback(int, OnBackInvokedCallback)` — register back callbacks. -- `OnBackInvokedDispatcher.PRIORITY_SYSTEM_NAVIGATION_OBSERVER` — new priority constant for observing system navigation. -- `OnBackInvokedCallback.onBackInvoked()` — regular back-invocation callback. -- `SystemOnBackInvokedCallbacks.finishAndRemoveTaskCallback(Activity)` — finish-and-remove-task behavior. -- `SystemOnBackInvokedCallbacks.moveTaskToBackCallback(Activity)` — move-task-to-back behavior. - -Apps can receive `onBackInvoked()` when the system handles back navigation (without disrupting normal flow), trigger specific behaviors, and play ahead-of-time animations when a back gesture is invoked. - -#### Richer haptics - -New APIs in the `android.os.vibrator` package let apps define the amplitude and frequency curves of haptic effects while abstracting away device-capability differences. This builds on Android 11's `VibrationEffect.Composition`, which exposed device-defined semantic primitives. - -### Developer productivity and tools - -#### Content handling for live wallpapers - -New content APIs let developers build dynamic, user-driven live wallpapers: - -- `WallpaperDescription` — identifies distinct instances of live wallpapers produced by the same service. -- `WallpaperInstance` — manages an individual wallpaper instance. -- `WallpaperManager` — uses the metadata to present wallpapers. - -A representative use case is showing unique content on the home screen and lock screen at the same time. - -### Performance and battery - -#### System-triggered profiling - -Building on `ProfilingManager` (introduced in Android 15), Android 16 lets the system automatically collect Perfetto profiling data for critical app flows without manual initiation. Apps register interest in triggers such as: - -- Cold start events. -- `Activity.reportFullyDrawn()` completion. -- ANR (Application Not Responding) events. - -The system starts and stops traces on the app's behalf and delivers results to the app's data directory. - -#### Start component in ApplicationStartInfo - -`ApplicationStartInfo.getStartComponent()` returns the component type that triggered the app start, helping developers optimize the startup flow. This extends the Android 15 `ApplicationStartInfo` API, which already provided start reasons, start type, start times, and throttling info. - -#### Better job introspection - -Improved insight into job-scheduling constraints and pending states: - -- `JobScheduler.getPendingJobReason(int jobId)` — returns a single reason (existing, limited API). -- `JobScheduler.getPendingJobReasons(int jobId)` — **new**; returns multiple reasons a job is pending. -- `JobScheduler.getPendingJobReasonsHistory(int jobId)` — **new**; returns a list of recent constraint changes. - -Reasons distinguish explicit constraints set by the developer from implicit constraints set by the system, helping debug why jobs aren't executing, diagnose reduced success rates, and identify latency. - -#### Adaptive refresh rate (ARR) enhancements - -Extends Android 15's ARR with new display APIs: - -- `Display.hasArrSupport()` — check whether the display supports ARR. -- `Display.getSuggestedFrameRate(int)` — get a suggested frame rate. -- `Display.getSupportedRefreshRates()` — restored, to query available refresh rates. - -Jetpack support includes `RecyclerView 1.4`, which adds built-in ARR support for fling and smooth-scroll settling, with more Jetpack libraries gaining ARR support. ARR reduces power consumption by adapting the display refresh rate to the content frame rate without jank-inducing mode switching. - -#### Headroom APIs in ADPF (Android Dynamic Performance Framework) - -Provides estimates of available CPU and GPU resources for games and resource-intensive apps: - -- `SystemHealthManager.getCpuHeadroom(CpuHeadroomParams)` — available CPU resources. -- `SystemHealthManager.getGpuHeadroom(GpuHeadroomParams)` — available GPU resources. -- `CpuHeadroomParams` / `GpuHeadroomParams` — customize the computation window and select average vs. minimum availability. - -These complement existing ADPF thermal-throttling detection and help reduce CPU/GPU usage for better UX and battery life. - -### Accessibility - -#### Improved accessibility APIs - -A cluster of additions to the accessibility framework: - -- **Outline text for maximum text contrast** — replaces high-contrast text with outline text for low-vision users. `AccessibilityManager.isHighContrastTextEnabled()` checks the mode; `AccessibilityManager.addHighContrastTextStateChangeListener(Executor, HighContrastTextStateChangeListener)` registers for changes. Aimed at UI-toolkit libraries (e.g., Jetpack Compose) and apps doing custom text rendering that bypasses `android.text.Layout`. -- **Duration added to TtsSpan** — new `TtsSpan.TYPE_DURATION` with `ARG_HOURS`, `ARG_MINUTES`, and `ARG_SECONDS` arguments, for accurate text-to-speech of time durations via TalkBack and other TTS services. -- **Support elements with multiple labels** — `AccessibilityNodeInfo.setLabeledBy(View)` and `getLabeledBy()` are deprecated in favor of list-based `addLabeledBy(View)`, `removeLabeledBy(View)`, and `getLabeledByList()` (common in web content). -- **Improved support for expandable elements** — `AccessibilityNodeInfo.setExpandedState(int)` conveys expanded/collapsed state; dispatch `AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED` with `CONTENT_CHANGE_TYPE_EXPANDED`. Applies to menus, expandable lists, and similar elements so screen readers announce state changes. -- **Indeterminate ProgressBars** — `AccessibilityNodeInfo.RangeInfo.RANGE_TYPE_INDETERMINATE` extends `RangeInfo` to cover indeterminate progress, giving TalkBack consistent feedback for all progress indicators. -- **Tri-state CheckBox** — `getChecked()` / `setChecked(int)` support checked, unchecked, and partially-checked states; the boolean `isChecked()` and `setChecked(boolean)` are deprecated. -- **Supplemental descriptions** — `View.setSupplementalDescription(CharSequence)` labels a `ViewGroup` (e.g., "Font Family") without overriding child descriptions (e.g., "Roboto"). -- **Required form fields** — `AccessibilityNodeInfo.setFieldRequired(boolean)` informs accessibility services that input is required, so users can reliably identify and navigate required fields. - -#### Phone as microphone input for voice calls with LEA hearing aids - -LE Audio hearing-aid users can switch the microphone source during voice calls between the hearing-aid mics and the phone mic, improving performance in environments such as noisy settings. - -#### Ambient volume controls for LEA hearing aids - -LE Audio hearing-aid users can adjust the ambient sound volume picked up by the hearing-aid microphones, for better handling of background noise that is too loud or too quiet. - -### Camera - -#### Hybrid auto-exposure - -Allows manual control of specific exposure aspects while the auto-exposure (AE) algorithm handles the rest, via: - -- `CameraMetadata.CONTROL_AE_PRIORITY_MODE_SENSOR_SENSITIVITY_PRIORITY` — control ISO with AE. -- `CameraMetadata.CONTROL_AE_PRIORITY_MODE_SENSOR_EXPOSURE_TIME_PRIORITY` — control exposure time with AE. - -Supporting keys: `CameraCharacteristics.CONTROL_AE_AVAILABLE_PRIORITY_MODES`, `CaptureRequest.CONTROL_AE_MODE`, `CaptureRequest.CONTROL_AE_PRIORITY_MODE`, and `CaptureRequest.SENSOR_SENSITIVITY`. - -```kotlin -fun setISOPriority() { - val availablePriorityModes = mStaticInfo.characteristics.get( - CameraCharacteristics.CONTROL_AE_AVAILABLE_PRIORITY_MODES - ) - - reqBuilder.set( - CaptureRequest.CONTROL_AE_MODE, - CameraMetadata.CONTROL_AE_MODE_ON - ) - reqBuilder.set( - CaptureRequest.CONTROL_AE_PRIORITY_MODE, - CameraMetadata.CONTROL_AE_PRIORITY_MODE_SENSOR_SENSITIVITY_PRIORITY - ) - reqBuilder.set( - CaptureRequest.SENSOR_SENSITIVITY, - TEST_SENSITIVITY_VALUE - ) - val request: CaptureRequest = reqBuilder.build() -} -``` - -#### Precise color temperature and tint adjustments - -Enables fine color-temperature and tint control for professional video, replacing the previous `CONTROL_AWB_MODE` preset-only options (Incandescent, Cloudy, Twilight). Key APIs: - -- `CameraMetadata.COLOR_CORRECTION_MODE_CCT` — enable color-temperature control. -- `CameraCharacteristics.COLOR_CORRECTION_COLOR_TEMPERATURE_RANGE` — query the supported temperature range. -- `CaptureRequest.CONTROL_AWB_MODE` — set to OFF for manual mode. -- `CaptureRequest.COLOR_CORRECTION_COLOR_TEMPERATURE` — set a specific temperature. -- `CaptureRequest.COLOR_CORRECTION_COLOR_TINT` — apply a tint adjustment. - -```kotlin -fun setCCT() { - val colorTemperatureRange: Range = - mStaticInfo.characteristics[CameraCharacteristics.COLOR_CORRECTION_COLOR_TEMPERATURE_RANGE] - - reqBuilder[CaptureRequest.CONTROL_AWB_MODE] = CameraMetadata.CONTROL_AWB_MODE_OFF - reqBuilder[CaptureRequest.COLOR_CORRECTION_MODE] = CameraMetadata.COLOR_CORRECTION_MODE_CCT - reqBuilder[CaptureRequest.COLOR_CORRECTION_COLOR_TEMPERATURE] = 5000 - reqBuilder[CaptureRequest.COLOR_CORRECTION_COLOR_TINT] = 30 - - val request: CaptureRequest = reqBuilder.build() -} -``` - -#### Camera night mode scene detection - -`CaptureResult.EXTENSION_NIGHT_MODE_INDICATOR` (in Camera2's `CaptureResult`, when supported) helps apps detect when to switch between normal and night-mode camera sessions. - -#### Motion photo capture intent actions - -Standard Intent actions to request motion-photo capture: - -- `MediaStore.ACTION_MOTION_PHOTO_CAPTURE` -- `MediaStore.ACTION_MOTION_PHOTO_CAPTURE_SECURE` - -Provide the output Uri via `MediaStore.EXTRA_OUTPUT` or via `Intent.setClipData(ClipData)`. If ClipData is not set, it is populated when calling `Context.startActivity(Intent)`. - -#### UltraHDR image enhancements - -Extends UltraHDR with new formats and ISO standard parameters: - -- `ImageFormat.HEIC_ULTRAHDR` — UltraHDR in HEIC format with an embedded gainmap (similar to the existing UltraHDR JPEG). AVIF support is in development. -- New **ISO 21496-1** parameters — colorspace specification for gainmap math, and support for HDR-encoded base images with SDR gainmaps. - -### Graphics - -#### Custom graphical effects with AGSL - -Authoring of complex graphical effects with AGSL (Android Graphics Shading Language) for color filtering and blending: - -- `RuntimeColorFilter` — AGSL-powered color filter extending `ColorFilter`. -- `RuntimeXfermode` — AGSL-based compositing/blending extending `Xfermode`. - -These join the Android 13+ `RuntimeShader` (an AGSL-based `Shader`). Example effects include threshold, sepia, and hue/saturation. - -```kotlin -private val thresholdEffectString = """ - uniform half threshold; - - half4 main(half4 c) { - half luminosity = dot(c.rgb, half3(0.2126, 0.7152, 0.0722)); - half bw = step(threshold, luminosity); - return bw.xxx1 * c.a; - }""" - -fun setCustomColorFilter(paint: Paint) { - val filter = RuntimeColorFilter(thresholdEffectString) - filter.setFloatUniform(0.5); - paint.colorFilter = filter -} -``` - -### Connectivity - -#### Ranging with enhanced security - -Adds robust security for Wi-Fi location using Wi-Fi 6's 802.11az standard via `SecureRangingConfig`. Security features include AES-256-based encryption, protection against man-in-the-middle (MITM) attacks, higher accuracy and scalability than previous standards, and dynamic scheduling. Use cases include proximity-based unlocking of laptops and vehicle doors. - -#### Generic ranging APIs - -`RangingManager` provides unified APIs to determine distance and angle between local and remote devices across multiple technologies: BLE channel sounding, BLE RSSI-based ranging, Ultra Wideband (UWB), and Wi-Fi Round Trip Time (RTT). Apps can query the ranging supported by device hardware. - -#### Companion device manager device presence - -Automatic service binding based on a companion device's BLE/Bluetooth connectivity state: - -- `CompanionDeviceManager.startObservingDevicePresence(ObservingDevicePresenceRequest)` — start presence observation. -- `CompanionDeviceService.onDevicePresenceEvent(DevicePresenceEvent)` — callback for presence events. -- `DevicePresenceEvent` — event data with presence states. - -The service is bound when BLE is in range and Bluetooth is connected, and unbound when BLE is out of range or Bluetooth is disconnected. - -### Media - -#### Photo picker improvements - -Enhancements to the built-in photo picker (supported since Android 4.4 / API 19 via Modular System Components and Google Play Services) for safer media selection: - -- **Embedded photo picker** — new APIs in the `android.widget.photopicker` package let apps embed the picker in their own view hierarchy, giving an integrated feel while maintaining process isolation (no broad permission grants). A Jetpack library for cross-version compatibility is forthcoming. -- **Cloud search in photo picker** — new APIs enable searching cloud media providers, with full search functionality coming soon. - -#### Advanced Professional Video (APV) codec support - -Support for the APV codec, designed for professional-grade recording and post-production. Characteristics include perceptually lossless quality, low-complexity high-throughput intra-frame-only coding (no pixel-domain prediction), high bitrate up to several Gbps for 2K/4K/8K, frame tiling for immersive content and parallel processing, multiple chroma sampling formats and bit-depths, multiple encode/decode cycles without severe quality loss, multi-view and auxiliary video (depth, alpha, preview), and HDR10/10+ plus custom metadata. - -Android 16 implementation: - -- `MediaFormat.MIMETYPE_VIDEO_APV` — the APV MIME type. -- Profile: **APV 422-10** (YUV 422 color sampling, 10-bit encoding). -- Target bitrates: up to 2 Gbps. - -### Privacy - -#### Health Connect updates - -- New `ACTIVITY_INTENSITY` data type — tracks moderate/vigorous activity per WHO guidelines, requiring start time, end time, and intensity level (moderate or vigorous). -- Medical records support — updated APIs to read/write medical records in FHIR (Fast Healthcare Interoperability Resources) format, requiring explicit user consent. - -#### Privacy Sandbox on Android - -Continued development of privacy-protective technologies, incorporating the newest Privacy Sandbox on Android protocols. The SDK Runtime lets SDKs run in a dedicated runtime environment separate from the host app, providing stronger safeguards around user-data collection and sharing. A developer beta program is available. - -### Security - -#### Key Sharing API - -Apps can share access to Android Keystore keys with other apps: - -- `KeyStoreManager.grantKeyAccess(String uid, int keyId)` — grant key access to another app. -- `KeyStoreManager.revokeKeyAccess(String uid, int keyId)` — revoke key access. -- Additional methods to access shared keys. - -Access is specified by app UID (e.g., via `Process.myUid()`). - -### Device form factors - -#### Standardized picture and audio quality framework for TVs - -The new `android.media.quality` package exposes standardized APIs for TV audio/picture profiles and hardware settings, enabling dynamic profile switching. Profile types include color-accuracy preference (wide-dynamic-range movies), brightness preference (narrow-dynamic-range live sports), and game mode (minimal latency, higher frame rates). Apps can query available hardware profiles and apply them dynamically; users can tune TV profiles for optimal viewing. Use cases include streaming, live-sports, and gaming apps. - -### Internationalization - -#### Vertical text - -Low-level rendering and measurement support for vertical text (notably for languages such as Japanese): - -- `Paint.VERTICAL_TEXT_FLAG` — new flag for vertical text rendering. -- `Paint.setFlags(int)` — apply the vertical flag. -- `Canvas` — renders text vertically when the flag is set; Paint text-measurement APIs then report vertical advances instead of horizontal. - -High-level text APIs (Jetpack Compose `Text`, `TextView`, `Layout` classes) do **not** currently support vertical writing systems or `VERTICAL_TEXT_FLAG`. - -```kotlin -val text = "「春は、曙。」" -Box( - Modifier.padding(innerPadding).background(Color.White).fillMaxSize().drawWithContent { - drawIntoCanvas { canvas -> - val paint = Paint().apply { textSize = 64.sp.toPx() } - // Draw text vertically - paint.flags = paint.flags or VERTICAL_TEXT_FLAG - val height = paint.measureText(text) - canvas.nativeCanvas.drawText( - text, - 0, - text.length, - size.width / 2, - (size.height - height) / 2, - paint - ) - } - } -) {} -``` - -#### Measurement system customization - -Users can customize measurement systems (metric, imperial, etc.) under **Settings > System > Languages & region**. The preference is included in the locale code. Apps register a `BroadcastReceiver` on `Intent.ACTION_LOCALE_CHANGED` to handle changes and use `Formatter` to match the local experience. Example: "0.5 in" for English (US) versus "12,7 mm" for English (Denmark) or US with a metric preference. - -### Additional references - -The page links to supplementary resources: - -- Full API diff — Beta 2 → API 36. -- Full API diff — API 35 → API 36. -- Behavior changes — all apps. -- Behavior changes — apps targeting Android 16. -- Non-SDK interface restrictions. - -## See also -- Evergreen rules: [ANDROID_STANDARDS.md](../ANDROID_STANDARDS.md) -- Upgrade plan: [007 IMPLEMENTATION.md](../completed/007-target-sdk-upgrade/IMPLEMENTATION.md) diff --git a/docs/android-16/migration.md b/docs/android-16/migration.md deleted file mode 100644 index b6969b7..0000000 --- a/docs/android-16/migration.md +++ /dev/null @@ -1,107 +0,0 @@ -# Android 16 — Migration Guide - -> **Source:** https://developer.android.com/about/versions/16/migration · fetched 2026-05-28 -> **Part of:** [docs/android-16/ hub](./README.md) — Android 16 (API 36) upgrade knowledge for OpenLoop (Issue #7) - -## Impact on OpenLoop - -**Verdict:** Applies — OpenLoop must run both migration phases the page describes (compatibility first, then re-target to 36), even though its tiny surface keeps the work small. - -- **Phase 1 (compatibility, no `targetSdkVersion` change):** Run OpenLoop's existing published build against an Android 16 device/emulator, work through every flow — camera capture, looping preview, gallery, delete, onboarding — and fix anything the all-apps behavior changes break. Per the page, this phase needs no change to `targetSdkVersion` or `compileSdkVersion`. -- **Phase 2 (re-target to 36):** Update `targetSdkVersion` and `compileSdkVersion` to 36 in the Gradle config, recompile, then re-test focusing on the changes that apply *only* when targeting Android 16. This is the substance of Issue #7. -- **Non-SDK interface check applies even to us:** Audit OpenLoop and its libraries (CameraX, Media3, DataStore, Compose) for restricted non-SDK interface usage. Watch logcat warnings and consider wiring `StrictMode.VmPolicy.Builder.detectNonSdkApiUsage()` into a debug build during testing. -- **Third-party SDK testing matters here:** OpenLoop ships CameraX, Media3 (ExoPlayer + Transformer), DataStore, and Compose. The page requires fully testing every library on Android 16 and updating to the latest versions if any misbehaves. -- **Already done:** `enableEdgeToEdge()` is already called in `MainActivity` — relevant to the edge-to-edge enforcement that lands when targeting 36, but that specific change lives in the behavior-changes docs, not this migration page. -- **Largely N/A for us:** No notifications, foreground services, health/Bluetooth/LAN, Wear/TV/Auto, accounts, network, or exported intents beyond LAUNCHER — so the privacy and targeted-change surface to review (Phase 2 Step 3) is narrow. - -## Summary - -This page gives a high-level view of the typical development and testing phases for getting an app ready for Android 16, aligned with the platform release timeline. Android 16 introduces new features and behavior changes intended to make the platform more helpful, secure, and performant. Because users can receive the new platform as soon as its source is released to AOSP, apps should be ready — and ideally take advantage of the new features and APIs. - -The page frames the work as two phases that can run concurrently: - -- **Phase 1 — Ensuring app compatibility**, targeted for the Android 16 final release. -- **Phase 2 — Targeting new platform features and APIs**, done as soon as possible after the final release. - -### Ensure compatibility with Android 16 - -The goal of this phase is to confirm that your existing app keeps working on Android 16. You can usually adjust the app and publish it **without changing `targetSdkVersion`**, and you shouldn't need to use new APIs or change `compileSdkVersion` (whether you do depend on your build approach and the platform functionality your app uses). - -Before testing, familiarize yourself with the **behavior changes for all apps**. These changes can affect your app even if you don't change `targetSdkVersion`. - -The compatibility testing workflow: - -1. **Get Android 16.** Flash an Android 16 system image onto a device, or download a system image for the Android emulator. -2. **Review changes.** Review the system behavior changes to identify areas where your app might be affected. -3. **Test.** Install your current published app on a device or emulator running Android 16, run your tests, focus on the system behavior changes, and work through all your app flows. -4. **Update.** Make only the code changes required to adapt to behavior changes or resolve issues, and **recompile with the same API level the app originally targeted** — you do not need to target Android 16 in this phase. -5. **Publish.** Sign, upload, and publish the updated Android App Bundle or APK. - -#### Perform compatibility testing - -Compatibility testing is much like ordinary app testing — a good time to review the [core app quality guidelines](https://developer.android.com/docs/quality-guidelines/core-app-quality) and the [best practices for testing](https://developer.android.com/training/testing). Install your current published app on a device running Android 16 and work through all flows and functionality, looking for issues. - -Focus areas: - -1. **Behavior changes for all apps.** Review the behavior changes for all apps — they can affect how your app functions or even cause it to crash. -2. **Restricted non-SDK interfaces.** Review and test for any use of restricted non-SDK interfaces; replace each one with a public SDK or NDK equivalent. Watch for logcat warnings that highlight these accesses, and use the `StrictMode` method `detectNonSdkApiUsage()` (on `StrictMode.VmPolicy.Builder`) to catch them programmatically. -3. **Third-party libraries and SDKs.** Fully test every library and SDK in your app to make sure they work as expected on Android 16 and follow best practices for privacy, performance, UX, data handling, and permissions. If you find an issue, try updating to the latest SDK version or reach out to the SDK developer. - -Publish the compatible app right away after testing and updates. Doing so lets users test it early and helps ensure a smooth transition as they update to Android 16. - -### Update the app's targeting and build with new APIs - -This is the next step after publishing a compatible version. You can make these updates whenever you're ready, keeping in mind the [Google Play requirements](https://developer.android.com/distribute/play-policies) for targeting a new platform. - -Review the **behavior changes that affect apps targeting Android 16**. These targeted behavior changes might cause functional issues that require significant development work, so the page recommends learning about and addressing them as early as possible. To identify which targeted changes affect you, use the **compatibility toggles** (described in the next section) to test your app with selected changes enabled. - -The full support workflow: - -1. **Get the Android 16 SDK.** Install the latest Android Studio preview to build with Android 16, make sure you have an Android 16 device or emulator, and update `targetSdkVersion` and other build configurations. -2. **Review behavior changes.** Review the behavior changes that apply to apps targeting Android 16, identify affected areas, and plan how to support them. -3. **Check against new privacy changes.** Make the code and architecture changes needed to support Android 16's user privacy changes. -4. **Adopt Android 16 features.** Take advantage of Android 16 APIs to bring new features and capabilities to your app, and recompile for Android 16. -5. **Test.** Test on an Android 16 device or emulator, focusing on areas where behavior changes might affect the app and on functionality that uses new APIs. Provide platform and API feedback and report any platform, API, or third-party SDK issues. -6. **Final update.** Once the Android 16 APIs are final, update `targetSdkVersion` and other build configurations again, make any additional updates, and test the app. -7. **Publish.** Sign, upload, and publish the updated Android App Bundle or APK. - -#### Get the SDK, change targeting, build with new APIs - -Use the latest preview version of Android Studio to download the Android 16 SDK and other tools, update the app's `targetSdkVersion` and `compileSdkVersion`, and re-compile the app. See the [SDK setup guide](https://developer.android.com/about/versions/16/setup-sdk) for details. - -#### Test your Android 16 app - -Some behavior changes apply **only** when your app targets the new platform, so review the **behavior changes for apps targeting Android 16** before testing, then work through all flows and functionality looking for issues. - -Focus areas: - -1. **Behavior changes for apps targeting Android 16** — the primary focus. -2. **Core app quality guidelines** — check your app against them. -3. **Testing best practices** — follow the recommended approach. - -Also review and test for uses of **restricted non-SDK interfaces** that may apply. Watch for logcat warnings highlighting these accesses, and use the `StrictMode` method `detectNonSdkApiUsage()` to catch them programmatically. As in Phase 1, fully test all libraries and SDKs to ensure they work on Android 16 and follow best practices for privacy, performance, UX, data handling, and permissions; update to the latest SDK version or contact the developer if you find an issue. - -### Test using app compatibility toggles - -App compatibility toggles make it easier to test a debuggable app against the targeted behavior changes. They provide four capabilities: - -1. **Test targeted changes without changing `targetSdkVersion`** — force-enable specific targeted behavior changes to evaluate their impact on your existing app. -2. **Focus testing on specific changes only** — rather than addressing all targeted changes at once, disable every targeted change except the ones you want to test. -3. **Manage toggles through adb** — use adb commands to enable and disable toggleable changes, which works in an automated test environment. -4. **Debug faster using standard change IDs** — each toggleable change has a unique ID and name, letting you quickly debug a root cause in the log output. - -For detailed information, see the [Compatibility framework changes (Android 16)](https://developer.android.com/about/versions/16/reference/compat-framework-changes) reference. - -### API and tooling references mentioned - -- **`StrictMode`** / **`StrictMode.VmPolicy.Builder`** with `detectNonSdkApiUsage()` — programmatic detection of restricted non-SDK API usage. -- **`targetSdkVersion`** and **`compileSdkVersion`** — the build configuration properties changed (or deliberately left unchanged) across the two phases. -- **Android Studio** (latest preview), the **Android 16 SDK**, an **Android 16 device or emulator**, **adb** (toggle management), and **logcat** (non-SDK warnings). -- Output artifacts: **Android App Bundle** and **APK**. -- Two behavior-change categories are implied by the page structure: **all-apps behavior changes** (apply regardless of `targetSdkVersion`, tested in Phase 1) and **targeted behavior changes** (apply only when targeting Android 16, toggleable for testing). - -_Page last updated 2026-05-18 UTC per the source._ - -## See also -- Evergreen rules: [ANDROID_STANDARDS.md](../ANDROID_STANDARDS.md) -- Upgrade plan: [007 IMPLEMENTATION.md](../completed/007-target-sdk-upgrade/IMPLEMENTATION.md) diff --git a/docs/android-16/progress-centric-notifications.md b/docs/android-16/progress-centric-notifications.md deleted file mode 100644 index 00d4f17..0000000 --- a/docs/android-16/progress-centric-notifications.md +++ /dev/null @@ -1,87 +0,0 @@ -# Android 16 — Progress-centric Notifications - -> **Source:** https://developer.android.com/about/versions/16/features/progress-centric-notifications · fetched 2026-05-28 -> **Part of:** [docs/android-16/ hub](./README.md) — Android 16 (API 36) upgrade knowledge for OpenLoop (Issue #7) - -## Impact on OpenLoop - -**Verdict:** N/A today — OpenLoop posts no notifications and runs no user-initiated, start-to-end background journeys, which is the only thing this style is for (revisit if background video export ever needs a progress UI). - -- This style targets user-initiated journeys like rideshare, delivery, and navigation; OpenLoop has nothing of that shape today. -- The app is 100% on-device with no background or long-running user-visible jobs, so there is no progress to surface. -- The one realistic future fit: if loop generation / video export ever moves to a long-running background job, a `Notification.ProgressStyle` notification could show export progress with segments and a tracker icon. - -## Summary - -### What progress-centric notifications are - -Progress-centric notifications are a new notification style introduced in Android 16 that help users track user-initiated, start-to-end journeys. They visually represent progress through a journey using customizable visual elements: segments, points, and a tracker icon. - -### Key APIs and classes - -The primary classes are: - -- `Notification.ProgressStyle` — the main notification style class. -- `Notification.ProgressStyle.Segment` — denotes states with duration. -- `Notification.ProgressStyle.Point` — denotes states and milestones. - -Core `Notification.Builder` methods used alongside `ProgressStyle`: - -- `setSubText()` — header subtext. -- `setWhen()` — header time. -- `setContentTitle()` — content title. -- `setContentText()` — content text. -- `addAction()` — action buttons. -- `setLargeIcon()` — vehicle/tracker image (via `NotificationCompat.Builder`). - -`ProgressStyle`-specific methods: - -- `setStyledByProgress(boolean)` — control styling behavior. -- `setProgress(int)` — set the current progress value. -- `setProgressTrackerIcon(Icon)` — set the tracker/vehicle icon image. -- `setProgressSegments(List)` — define colored segments. -- `setProgressPoints(List)` — define milestone points. - -### Typical use cases - -Google lists these primary use cases: - -- Rideshare -- Delivery -- Navigation - -### Constraints and best practices - -- **Promoted visibility** — set the right fields to meet promoted visibility requirements. -- **Visual elements** — use vehicle images and accurate colors for the journey type. -- **Text clarity** — use concise language; critical information includes time of arrival, driver name, and journey state. -- **Actionable items** — provide relevant actions (e.g., tip options, adding items to an order). -- **Segment/point usage** — use segments for states with duration (e.g., traffic conditions with colors), points for milestones. -- **Frequent updates** — update progress frequently and accurately to reflect actual journey progression. - -### Example - -```kotlin -var ps = Notification.ProgressStyle() - .setStyledByProgress(false) - .setProgress(456) - .setProgressTrackerIcon(Icon.createWithResource(appContext, R.drawable.ic_car_red)) - .setProgressSegments( - listOf( - Notification.ProgressStyle.Segment(41).setColor(Color.BLACK), - Notification.ProgressStyle.Segment(552).setColor(Color.YELLOW), - Notification.ProgressStyle.Segment(253).setColor(Color.WHITE), - Notification.ProgressStyle.Segment(94).setColor(Color.BLUE) - ) - ) - .setProgressPoints( - listOf( - Notification.ProgressStyle.Point(60).setColor(Color.RED), - Notification.ProgressStyle.Point(560).setColor(Color.GREEN) - ) - ) -``` - -## See also -- Evergreen rules: [ANDROID_STANDARDS.md](../ANDROID_STANDARDS.md) -- Upgrade plan: [007 IMPLEMENTATION.md](../completed/007-target-sdk-upgrade/IMPLEMENTATION.md) diff --git a/docs/android-16/summary.md b/docs/android-16/summary.md deleted file mode 100644 index c7855c9..0000000 --- a/docs/android-16/summary.md +++ /dev/null @@ -1,192 +0,0 @@ -# Android 16 — Release Summary - -> **Source:** https://developer.android.com/about/versions/16/summary · fetched 2026-05-28 -> **Part of:** [docs/android-16/ hub](./README.md) — Android 16 (API 36) upgrade knowledge for OpenLoop (Issue #7) - -## Impact on OpenLoop - -**Verdict:** Partially applies — only a handful of the targetSdk-36 behavior changes touch OpenLoop's actual surface (edge-to-edge, predictive back, adaptive layouts, photo picker), and a few "all apps" changes are worth knowing; the large bulk of Android 16 (health, Bluetooth, notifications, TV, ranging, haptics, etc.) is N/A. - -- **Edge-to-edge opt-out is removed for targetSdk 36** (`R.attr#windowOptOutEdgeToEdgeEnforcement` gone). OpenLoop already calls `enableEdgeToEdge()` in `MainActivity`, so the job is to *verify* every screen (CameraScreen, OnboardingScreen, PreviewScreen, GalleryScreen) consumes window insets correctly under enforcement — nothing left behind the system bars or display cutout. -- **Predictive back becomes default-on for targetSdk 36** (`android:enableOnBackInvokedCallback` defaults to `true`; legacy `onBackPressed` / `KEYCODE_BACK` paths get ignored). OpenLoop's back navigation is a sealed-interface state machine routed in `MainActivity` — confirm back gestures between states (Gallery to ReadyToCapture, LoopingPreview to ReadyToCapture) flow through the predictive-back/`OnBackInvokedCallback` path, not a swallowed key event. -- **Adaptive layouts: orientation/aspect-ratio/resizability manifest restrictions are ignored for targetSdk 36.** If OpenLoop locks the camera screen to portrait via manifest/`setRequestedOrientation`, that lock stops being honored on large screens — must verify the viewfinder and gallery behave under forced resizability rather than relying on a portrait lock. -- **App-owned photos in the photo picker (targetSdk 36):** N/A today — OpenLoop writes loops to storage but does not currently launch the system photo picker. Flag for the future if an "import a clip" flow is ever added. -- **Local Network Permission (targetSdk 36):** N/A — OpenLoop makes zero network calls and has no LAN features, so the new declaration requirement does not apply. -- **N/A categories:** Health and fitness (`android.permissions.health`, Health Connect/FHIR), Bluetooth bond-loss intents, progress-centric and all notifications, ranging/Wi-Fi 802.11az, TV `MediaQuality`, richer haptics, live wallpapers, key sharing, Privacy Sandbox — none map to OpenLoop's on-device, no-network, no-notification, single-Activity camera surface. Camera2/Media items below are *opt-in* APIs, not forced changes. - -## Summary - -The Android 16 summary page (API level **36**, last updated 2026-03-03 UTC) catalogs every feature and behavior change in a single filterable table. Each row carries a **Category**, a **Type**, and a **Name**. The three Types are the load-bearing distinction: - -- **Change (all apps)** — applies on Android 16 devices regardless of your `targetSdkVersion`. -- **Change (apps targeting Android 16 or higher)** — gated behind `targetSdk` 36; only triggers once you raise the target. -- **New features and APIs** — opt-in additions available to use; they do not force any behavior on you. - -The page also notes that 2025 has **two** Android API releases: a **Q2 2025 major release** (the only 2025 release carrying planned behavior changes tied to `targetSdkVersion`) and a **Q4 2025 release** that adds new developer APIs without those behavior changes. - -The categories on the page are: Accessibility, Camera, Connectivity, Core functionality, Graphics, Health and fitness, Internationalization, Device form factors, Media, Performance and battery, Privacy, Security, and User experience and system UI. - -### Core functionality - -**Change (all apps)** - -- **ART internal changes.** Android Runtime updates improve performance and add Java feature support, delivered to Android 12 (API 31+) devices via Google Play system updates. Apps or libraries that rely on ART internal structures may break on Android 16 (or earlier devices that receive the updated ART module). -- **JobScheduler quota optimizations.** `JobScheduler` adjusts regular and expedited job runtime quota based on the app's standby bucket, whether the job starts while the app is in the top state, and whether it runs while a foreground service is active. -- **Abandoned empty jobs stop reason.** The system adds a new stop reason, `STOP_REASON_TIMEOUT_ABANDONED`, to detect and reduce abandoned jobs (it is assigned in place of `STOP_REASON_TIMEOUT` in those cases). -- **Ordered broadcast priority scope is no longer global.** Broadcast delivery order set via `android:priority` on an `` (or `IntentFilter#setPriority(int)`) is now only respected for ordered broadcasts within the same application process, not across processes. -- **16 KB page size compatibility mode.** Android 15 introduced 16 KB memory pages; Android 16 adds a compatibility mode so apps built for 4 KB pages can still run on 16 KB-page devices. - -**Change (apps targeting Android 16 or higher)** - -- **Fixed-rate work scheduling optimization.** For `ScheduledExecutorService#scheduleAtFixedRate(...)`, at most one missed execution is run immediately when the app returns to a valid lifecycle state (rather than firing all the backlog). - -**New features and APIs** - -- **Two Android API releases in 2025**, as described above — Q2 (behavior changes) and Q4 (new APIs). - -### User experience and system UI - -**Change (all apps)** - -- **Deprecating disruptive accessibility announcements.** `View#announceForAccessibility(CharSequence)` and the `AccessibilityEvent#TYPE_ANNOUNCEMENT` event are deprecated. -- **Support for 3-button navigation.** Predictive back support is extended to 3-button navigation for apps that have properly migrated to predictive back. -- **Automatic themed app icons.** Android 16 can automatically theme app icons for a cohesive home-screen appearance. - -**Change (apps targeting Android 16 or higher)** - -- **Elegant font APIs deprecated and disabled.** The `elegantTextHeight` attribute is deprecated and ignored for apps targeting Android 16+. -- **Edge-to-edge opt-out going away.** The `R.attr#windowOptOutEdgeToEdgeEnforcement` attribute is removed; apps targeting Android 16+ must handle window insets themselves rather than opting out of edge-to-edge. -- **Migration or opt-out required for predictive back.** System back animations (back-to-home, cross-task, cross-activity) appear by default because `android:enableOnBackInvokedCallback` now defaults to `true`. As a result, `onBackPressed` and `KeyEvent.KEYCODE_BACK` handling is ignored — apps must migrate to predictive back or explicitly opt out. - -**New features and APIs** - -- **Predictive back updates.** New APIs to drive predictive back system animations in gesture navigation, including `SystemOnBackInvokedCallbacks#finishAndRemoveTaskCallback(Activity)` and `SystemOnBackInvokedCallbacks#moveTaskToBackCallback(Activity)`. -- **Richer haptics.** New haptic APIs (in `android.os.vibrator.*`) let apps define amplitude and frequency curves while abstracting away device capability differences. -- **Progress-centric notifications.** A new notification type for tracking user-initiated, start-to-end journeys, with upgraded visibility on system surfaces and top ranking in the notification drawer. (See the hub's dedicated page on this.) -- **Content handling for live wallpapers.** A new content API for the live wallpaper framework, addressing the challenges of dynamic, user-driven wallpapers. - -### Security - -**Change (all apps)** - -- **Improved security against Intent redirection attacks.** By-default hardening against Intent redirection exploits. -- **Companion apps no longer notified of discovery timeouts.** The Companion Device Manager (CDM) no longer notifies the app when a device is not found. - -**Change (apps targeting Android 16 or higher)** - -- **MediaStore version lockdown.** `MediaStore#getVersion()` now returns a value unique to each app. -- **Safer Intents.** Security improvements to Android's Intent resolution mechanism. -- **GPU syscall filtering.** A high-level SEPolicy provides fine-grained IOCTL control for GPU access. - -**New features and APIs** - -- **Key sharing API.** APIs to share access to Android Keystore keys with other apps. - -### Device form factors - -**Change (all apps)** - -- **Virtual device owner overrides.** Virtual device owners (limited to select trusted/privileged apps) can override app settings on managed devices. - -**Change (apps targeting Android 16 or higher)** - -- **Adaptive layouts.** The platform ignores manifest attributes and runtime APIs that restrict screen orientation, aspect ratio, and resizability — apps targeting Android 16+ are treated as resizable/adaptive across form factors. - -**New features and APIs** - -- **Standardized picture and audio quality framework for TVs.** A new `android.media.quality.*` (`MediaQuality`) package with standardized APIs for accessing audio/picture profiles and hardware settings, so streaming apps can query profiles and apply them dynamically. - -### Connectivity - -**Change (all apps)** - -- **Improved bond loss handling.** Better handling of Bluetooth bond-loss events. - -**Change (apps targeting Android 16 or higher)** - -- **New intents to handle bond loss and encryption changes.** Two new intents for bond-loss events and for encryption changes. -- **New way to remove a Bluetooth bond.** A new `removeBond` API for removing Bluetooth bonds. - -**New features and APIs** - -- **Ranging with enhanced security.** Support for robust security in Wi-Fi location (Wi-Fi 6 802.11az on supported devices): higher accuracy, greater scalability, dynamic scheduling, AES-256-based encryption, and protection against man-in-the-middle attacks — via `SecureRangingConfig`. -- **Companion device manager device presence.** New APIs to bind a companion app service; the service is bound when BLE is in range and Bluetooth is connected, and unbound when BLE goes out of range or Bluetooth disconnects. -- **Generic ranging APIs.** A new `RangingManager` for determining distance and angle between local and remote devices. - -### Health and fitness - -**Change (apps targeting Android 16 or higher)** - -- **Health and fitness permissions.** A transition to a more granular permission set under the `android.permissions.health` namespace (used by Health Connect) for apps targeting Android 16+ that use health/fitness data. - -**New features and APIs** - -- **Health Connect updates.** A new `ACTIVITY_INTENSITY` data type aligned with WHO guidelines for moderate/vigorous activity, plus new APIs for reading/writing health records in HL7 FHIR format with explicit user consent (early access program available via sign-up). - -### Privacy - -**Change (apps targeting Android 16 or higher)** - -- **Local Network Permission.** The platform requires apps targeting Android 16+ to declare a permission to access the local network. -- **App-owned photos.** The photo picker pre-selects an app's own photos/videos; users can deselect to revoke the app's future access to them. - -**New features and APIs** - -- **Health Connect updates.** APIs for reading and writing medical records in FHIR format with explicit user consent (early access program). -- **Privacy Sandbox on Android.** The latest version of Privacy Sandbox is incorporated, advancing technologies that protect user privacy. - -### Performance and battery - -**New features and APIs** - -- **Start component in ApplicationStartInfo.** `ApplicationStartInfo#getStartComponent()` distinguishes which component type triggered the app start, helping optimize startup flow. -- **Adaptive refresh rate.** New APIs for Adaptive Refresh Rate (ARR): `Display#hasArrSupport()`, `Display#getSuggestedFrameRate(int)`, and the restored `Display#getSupportedRefreshRates()`. -- **Better job introspection.** `JobScheduler#getPendingJobReasons()` returns multiple reasons a job is pending (both explicit developer constraints and implicit system constraints), and `JobScheduler#getPendingJobReasonsHistory()` returns recent changes to those pending reasons. -- **System-triggered profiling.** `ProfilingManager` can be triggered by the system on events such as cold start (`Activity#reportFullyDrawn()`) and ANRs; the system starts/stops the trace on the app's behalf and delivers results to the app's data directory. -- **Headroom APIs in ADPF.** `SystemHealthManager#getCpuHeadroom(CpuHeadroomParams)` and `SystemHealthManager#getGpuHeadroom(GpuHeadroomParams)` estimate available CPU/GPU resources for games and resource-intensive apps. - -### Media - -**New features and APIs** - -- **Photo picker improvements.** New APIs to embed the photo picker directly into the view hierarchy, plus APIs for cloud media provider searching. -- **Advanced Professional Video (APV) codec support.** A new codec for professional-grade, high-quality video recording and post-production. - -### Camera - -**New features and APIs** - -- **Precise color temperature and tint adjustments.** Fine color temperature and tint control for professional video recording (Camera2). -- **Hybrid auto-exposure.** New hybrid AE modes let an app manually control specific exposure aspects while the AE algorithm handles the rest (Camera2). -- **Motion photo capture intent actions.** Standard Intent actions `MediaStore#ACTION_MOTION_PHOTO_CAPTURE` and `MediaStore#ACTION_MOTION_PHOTO_CAPTURE_SECURE` — a camera app captures a motion photo and returns it. -- **Camera night mode scene detection.** A new `CaptureResult#EXTENSION_NIGHT_MODE_INDICATOR` constant helps an app decide when to switch to or from a night mode camera session (Camera2). -- **UltraHDR image enhancements.** Support for UltraHDR images in the HEIC file format. - -### Internationalization - -**New features and APIs** - -- **Vertical text.** Low-level support for rendering and measuring text vertically — foundational support for libraries handling vertical writing systems. -- **Measurement system customization.** Users can customize the measurement system under regional preferences in Settings. - -### Accessibility - -**New features and APIs** - -- **Improved accessibility APIs.** Additional APIs to enhance UI semantics for consistency across accessibility services such as TalkBack. -- **Phone as microphone input for voice calls with LEA hearing aids.** Users of LE Audio hearing aids can switch between the hearing aids' built-in microphones and the phone microphone for voice calls. -- **Ambient volume controls for LEA hearing aids.** Users of LE Audio hearing aids can adjust the volume of ambient sound picked up by the hearing aid microphones. - -### Graphics - -**New features and APIs** - -- **Custom graphical effects with AGSL.** New classes `RuntimeColorFilter` and `RuntimeXfermode` let apps author complex graphical effects (for example Threshold, Sepia, Hue/Saturation) and apply them to draw calls. - -### Additional resources - -The page also links to API diffs (Beta 2 to 36 incremental, and API 35 to API 36), compatibility framework change documentation, the app compatibility guides, the restrictions on non-SDK interfaces, the platform dashboards, QPR (Quarterly Platform Release) information, migration guides, SDK setup, factory/OTA images, and Generic System Images (GSI). - -## See also -- Evergreen rules: [ANDROID_STANDARDS.md](../ANDROID_STANDARDS.md) -- Upgrade plan: [007 IMPLEMENTATION.md](../completed/007-target-sdk-upgrade/IMPLEMENTATION.md) diff --git a/docs/completed/007-target-sdk-upgrade/DOC-PREP.md b/docs/completed/007-target-sdk-upgrade/DOC-PREP.md deleted file mode 100644 index b27eb24..0000000 --- a/docs/completed/007-target-sdk-upgrade/DOC-PREP.md +++ /dev/null @@ -1,75 +0,0 @@ -# Android 16 Doc-Prep — Get the docs ready for the `targetSdk 36` upgrade - -**GitHub Issue:** none of its own — this is **preparation for [Issue #7](https://github.com/stozo04/OpenLoop/issues/7)** (the actual SDK upgrade). Unnumbered folder on purpose: the `NNN-` prefix maps a folder to a GitHub issue number, and this work has no separate issue. -**Branch:** `feature/update-api` (merged in PR #13) -**Status:** SHIPPED — archived here. Originally `docs/active/android-16-doc-prep/IMPLEMENTATION.md`; combined into the `007-target-sdk-upgrade/` folder alongside the implementation record when Issue #7 shipped (PR #15). -**Last updated:** 2026-05-28 - ---- - -## Problem statement - -OpenLoop must raise `targetSdk` from **34 → 36** to publish on Google Play (Issue #7). Before any build file changes, the project's documentation needed to: - -1. Capture Android 16's behavior changes, features, and migration process **in OpenLoop terms** — so an agent or contributor knows exactly which changes apply to a zero-network, on-device camera app and which don't. -2. Reconcile that new, version-specific material with the **evergreen** `docs/ANDROID_STANDARDS.md` without conflating the two. -3. Make the **current vs. target SDK state explicit** in `CLAUDE.md` and `README.md`, with links to the authoritative Google sources. - -This branch is **documentation only**. It changes no Kotlin and no Gradle config. - -## Scope - -**In scope (this branch):** - -- `docs/android-16/` — a durable Android 16 (API 36) knowledge hub: a README index plus a summary of each Google source page (behavior changes, summary, migration, features, progress-centric notifications, framework API diff). Each file leads with its source URL and an *Impact on OpenLoop* verdict. -- `docs/ANDROID_STANDARDS.md` — §8 dates corrected against verified Google data; a new **§11 Android Version Targeting** section stating the target-36 rules, each marked `Status: pending — Issue #7`. -- `CLAUDE.md` + `README.md` — explicit SDK-state notes (34 today → 36 in progress) and references to the hub. -- This implementation record. - -**Out of scope (belongs to Issue #7, a later branch):** - -- Any `app/build.gradle.kts` change — the `compileSdk` / `targetSdk` bump, AGP / Gradle bumps, CameraX / Media3 bumps, the 16 KB page-size work. -- Any code change — the edge-to-edge inset audit, predictive-back migration, large-screen adaptive layout. -- Play Store release mechanics (signing, `.aab`, Data Safety form). - -## Architecture — how the docs fit together - -| Doc | Role | Lifespan | -|-----|------|----------| -| [`docs/ANDROID_STANDARDS.md`](../../ANDROID_STANDARDS.md) | Evergreen "how we build" rules (all SDK versions) | Permanent, at `docs/` root | -| [`docs/android-16/`](../../android-16/README.md) | Version-specific Android 16 reference (verbatim + summarized Google pages) | Durable hub — does **not** move to `completed/` | -| [`IMPLEMENTATION.md`](./IMPLEMENTATION.md) (this folder) | The actual upgrade plan & checklist (code + build) | Now in `docs/completed/007-target-sdk-upgrade/` | -| `DOC-PREP.md` (this file) | Tracks the doc-prep deliverable | Now in `docs/completed/007-target-sdk-upgrade/` | - -**Lesson 007 boundary.** Because this branch lets docs run *ahead* of code, every Android-16 rule that the code (API 34) does not yet satisfy is marked `Status: pending — Issue #7`. No doc asserts OpenLoop already targets 36, is edge-to-edge, or handles predictive back. A dedicated phase in the [007 plan](./IMPLEMENTATION.md) flips those markers and bumps the API numbers when the corresponding code merges, keeping doc and code convergent. - -## Implementation steps (done on this branch) - -1. **Verified** Google Play target-SDK requirements (2026-05-28): floor is **API 35** since Aug 31 2025; **API 36** expected ~Aug 2026 but with no published date yet. Corrected `ANDROID_STANDARDS.md` §8, which had asserted the Aug-31-2026 / API-36 date as fact. -2. **Built `docs/android-16/`** — README index + six reference docs, each fetched live from `developer.android.com` (not from model memory) and given an OpenLoop impact verdict. -3. **Updated `ANDROID_STANDARDS.md`** — verified §8 dates; new §11 with target-36 rules (edge-to-edge, predictive back, adaptive layouts), each `pending — Issue #7`; added Android 16 links. -4. **Updated `CLAUDE.md` + `README.md`** — SDK-state notes and hub references. -5. **Wrote this record.** -6. **Updated the [007 plan](./IMPLEMENTATION.md)** — added a doc-sync phase, clarified that implementation lands on a later branch, cross-linked the hub, and fixed a stale Lesson-002 citation. - -## Testing plan - -No code changes ⇒ no unit or UI tests. Validation is documentation-level: - -- IDE reports **zero Markdown "General Errors"** for the new/edited docs (Lesson 010 — code fences parse). -- All internal relative links resolve. -- **Lesson 007 pass:** no doc claims compliance the code lacks; every target-36 rule is marked pending. -- Date-sensitive claims carry a "verified 2026-05-28 / re-verify before release" stamp. - -## Acceptance criteria - -- [ ] `docs/android-16/` hub complete; each file has a source URL and an OpenLoop verdict. -- [ ] `ANDROID_STANDARDS.md` §8 dates match verified Google data; §11 rules all marked `pending — Issue #7`. -- [ ] `CLAUDE.md` + `README.md` state current SDK (34) and in-progress target (36) with links. -- [ ] No doc asserts the code already targets 36 / is edge-to-edge / handles predictive back. -- [ ] The 007 plan has a phase to flip the doc markers + bump API numbers when code lands. -- [ ] Branch contains no build or code changes. - -## Reversibility note - -All changes are documentation, version-controlled. Revert = `git restore` / branch reset. Nothing here is externally visible or irreversible. diff --git a/docs/completed/007-target-sdk-upgrade/IMPLEMENTATION.md b/docs/completed/007-target-sdk-upgrade/IMPLEMENTATION.md deleted file mode 100644 index 8a55c2f..0000000 --- a/docs/completed/007-target-sdk-upgrade/IMPLEMENTATION.md +++ /dev/null @@ -1,202 +0,0 @@ -# 007 — Target SDK Upgrade (Android 14 → Android 16) for Google Play - -**GitHub Issue:** [#7 — Upgrade targetSdk to meet Google Play requirements](https://github.com/stozo04/OpenLoop/issues/7) -**Branch:** implementation landed on `feature/target-sdk-36` (PR #15). The docs-prep that preceded it shipped on `feature/update-api` (PR #13) — see the [doc-prep record](./DOC-PREP.md), now archived in this same folder. -**Status:** IMPLEMENTED on branch `feature/target-sdk-36` — ready for review. Build (debug + release/R8) is green, unit (24) + instrumented (10) tests pass, and native libs verified 16 KB-aligned. On-device ≥600dp large-screen visual pass still recommended (see "Implementation outcome" below). Behavior-change research is captured in the [Android 16 hub](../../android-16/README.md). -**Last updated:** 2026-05-28 - ---- - -## Goal - -Raise OpenLoop from `targetSdk 34` (Android 14) to `targetSdk 36` (Android 16) so the app -can be submitted to / updated on Google Play, and meet the related **16 KB page size** -requirement, while handling every behavior change the bump introduces — verified against -`developer.android.com`, not training data. - -## Why now (verified this session against Google) - -| Fact | Source | -|------|--------| -| Since **Aug 31, 2025**, new apps + updates must target **API 35 (Android 15)** or higher. | developer.android.com/google/play/requirements/target-sdk | -| The extension allowing API 34 closed **Nov 1, 2025**. | same | -| Today is **2026-05-28** → both dates passed. At `targetSdk 34` we **cannot publish**. | — | -| Since **Nov 1, 2025**, apps targeting API 35+ **with native libraries** must support **16 KB page sizes**. | developer.android.com/guide/practices/page-sizes | -| OpenLoop bundles native `.so` libs via **CameraX** and **Media3** → 16 KB rule applies. | dependency inspection | - -**Decision (confirmed with owner):** go straight to **API 36 (Android 16)**, the latest, to -avoid repeating this when Google raises the floor to 36 (expected ~Aug 2026). This means we -absorb both the 34→35 and 35→36 behavior changes in one pass. - ---- - -## Current → Target state - -| Item | Current (verified in repo) | Target | -|------|----------------------------|--------| -| `compileSdk` | 34 | **36** | -| `targetSdk` | 34 | **36** | -| `minSdk` | 26 (Android 8.0) | **26** (unchanged) | -| AGP | 8.3.2 | **8.9.0+** (latest stable verified: **8.13.0**) | -| Gradle wrapper | (verify) | **8.13+** (min for AGP 8.13) | -| Kotlin | 1.9.22 | **1.9.22** (keep — see Decision D2) | -| Compose BOM | 2024.02.02 | **keep** (see Decision D2) | -| CameraX | 1.3.1 | **1.4.2** (16 KB-aligned; latest stable verified) | -| Media3 | 1.3.0 | **1.7.1** (16 KB-aligned; latest stable verified) | -| JDK | 17 | 17 (unchanged — already correct) | - -> Version numbers are the latest **stable** confirmed via Google's Maven metadata this -> session. At implementation time, re-check Maven for any newer stable and pin that. - ---- - -## Decisions / scope boundaries - -- **D1 — Target 36, not 35.** One upgrade instead of two. (Owner-confirmed.) -- **D2 — Do NOT bump Kotlin or Compose BOM unless the build forces it.** Compose has no - native libraries, so it is irrelevant to the 16 KB rule and not required by Play. Keeping - Kotlin 1.9.22 + Compose compiler 1.5.8 avoids a Kotlin 2.0 + Compose-compiler-plugin - migration, shrinking blast radius. Revisit only if AGP 8.13 rejects Kotlin 1.9.22. -- **D3 — minSdk stays 26.** No reason to drop older-device support. -- **D4 — Play Store release mechanics** (signing, .aab, Data Safety form, privacy policy) - are tracked in Phase 6 but are arguably a *separate* deliverable from the SDK bump. Flag - to owner whether #7 should include them or spin off a new issue. - ---- - -## Behavior changes to handle (verified against Google) - -### From targeting API 35 (Android 15) -- [ ] **Edge-to-edge displayed by default.** App already calls `enableEdgeToEdge()` in - `MainActivity.onCreate` — good, but every screen must consume insets correctly. -- [ ] **Foreground service timeouts / new `mediaProcessing` type.** **N/A today** — manifest - declares no foreground services. ⚠️ If the upcoming "loop generation" / Media3 Transformer - work runs as a foreground service, it must use the `mediaProcessing` FGS type. Note for later. - -### From targeting API 36 (Android 16) -- [ ] **Edge-to-edge is now MANDATORY** — the opt-out (`windowOptOutEdgeToEdgeEnforcement` / - `setDecorFitsSystemWindows`) is removed. We cannot fall back; insets must be handled in code. -- [ ] **Large-screen resizability/orientation restrictions ignored** (smallest width ≥ 600dp): - the system forces portrait+landscape+resizable. A camera UI that assumes fixed portrait may - break on tablets/foldables. Temporary opt-out exists (`PROPERTY_COMPAT_ALLOW_RESTRICTED_RESIZABILITY`) - but is explicitly temporary — prefer making the UI adaptive. -- [ ] **Predictive back default-on.** Verify back navigation. Back must route through the - `OpenLoopUiState` state machine (per `ANDROID_STANDARDS.md` §10/§11) — do **not** add an ad-hoc back boolean. -- [ ] **Intent redirect hardening, JobScheduler quotas, health permissions** — reviewed, **N/A** - for OpenLoop's current surface (no exported intents beyond LAUNCHER, no jobs, no health perms). - ---- - -## Checklist (work top to bottom) - -### Phase 0 — Pre-flight -- [ ] Confirm on branch `feature/update-api`. -- [ ] Record a baseline: `./gradlew clean assembleDebug` succeeds **before** changes. -- [ ] Confirm local Android Studio + SDK Platform 36 + an Android 16 emulator image are installed. -- [ ] Install/locate a **16 KB page-size emulator** image and a **large-screen (≥600dp)** emulator - for later testing. - -### Phase 1 — Build tooling (do first; SDK 36 needs newer AGP/Gradle) -- [ ] Bump Gradle wrapper to **8.13+** (`gradle/wrapper/gradle-wrapper.properties`). -- [ ] Bump AGP `8.3.2 → 8.13.0` in root `build.gradle.kts`. -- [ ] Confirm Kotlin `1.9.22` is still accepted by AGP 8.13. If rejected → escalate to owner - (triggers the D2 Kotlin/Compose migration sub-task). -- [ ] `./gradlew clean` then sync. (Lesson 003: always clean after Gradle changes.) - -### Phase 2 — SDK levels -- [ ] `app/build.gradle.kts`: `compileSdk = 36`. -- [ ] `app/build.gradle.kts`: `targetSdk = 36`. -- [ ] Leave `minSdk = 26`. - -### Phase 3 — Dependency upgrades for the 16 KB requirement -- [ ] CameraX `1.3.1 → 1.4.2` (all four artifacts: core, camera2, lifecycle, view). -- [ ] Media3 `1.3.0 → 1.7.1` (exoplayer, ui, transformer). -- [ ] *(Optional, recommended)* bump `androidx.core:core-ktx`, `activity-compose`, `lifecycle-*` - to current stable to silence compileSdk-36 compat warnings. Mark optional — not required. -- [ ] Re-check Maven for newer stable than the pinned numbers; update if present. -- [ ] `./gradlew clean assembleDebug` — fix any API-removal/deprecation compile errors from the - CameraX/Media3 jumps (review their release notes for breaking changes across 1.3→1.4 / 1.3→1.7). - -### Phase 4 — Behavior-change code work -- [ ] **Edge-to-edge:** audit `CameraScreen`, `OnboardingScreen`, `PreviewScreen`, `GalleryScreen`. - Ensure interactive elements (shutter, home, gallery delete, onboarding arrows) use - `WindowInsets` padding so nothing sits under the status/navigation bars. Camera viewfinder may - stay full-bleed intentionally. -- [ ] **Predictive back:** set `android:enableOnBackInvokedCallback="true"` on ``; - verify back gestures animate and route through the state machine (Lesson 002). -- [ ] **Large screen:** launch on a ≥600dp emulator; confirm camera preview + controls survive - landscape/resizing. If broken and out-of-scope to fix now, apply the temporary opt-out property - and file a follow-up issue (note it here). - -### Phase 5 — 16 KB verification -- [ ] Build a release `.aab`/`.apk` and verify native libs are 16 KB-aligned - (`zipalign -c -P 16 -v ` or Google's `check_elf_alignment.sh`). -- [ ] Run the app on the **16 KB emulator** image; smoke-test camera capture + preview playback. - -### Phase 6 — Play Store release prep (confirm scope — see D4) -- [ ] Decide `versionCode`/`versionName` bump (currently `versionCode = 1`). -- [ ] Configure Play App Signing / upload key. -- [ ] Build signed **App Bundle (.aab)**, not APK. -- [ ] Data Safety form + privacy policy (camera usage). -- [ ] Review `release` buildType: `isMinifyEnabled = false` — decide if shrinking is wanted. - -### Phase 7 — Cleanup spotted during audit (low risk, fix while here) -- [ ] **Malformed `proguardFiles` block** in `app/build.gradle.kts:26-32` — `proguardFiles(...)` - is nested inside another `proguardFiles(...)`. Collapse to a single correct call. -- [ ] **Manifest `tools:` namespace:** `app/src/main/AndroidManifest.xml` uses `tools:targetApi="31"` - but the `` tag declares only `xmlns:android`. Verify it builds; add `xmlns:tools` (or - remove the attribute) as needed. - -### Phase 8 — Test & validate -- [ ] `./gradlew clean` then full build. -- [ ] Unit tests + instrumented tests pass. -- [ ] Manual: onboarding → permission → capture → preview → gallery → delete, on **Android 16**, - **16 KB**, and **large-screen** emulators. -- [ ] Update `docs/TEST_COVERAGE.md` if test surface changed. - -### Phase 9 — Wrap-up -- [ ] Add a `docs/lessons_learned/NNN-*.md` entry if the upgrade surfaced a reusable pattern - (e.g., the 16 KB native-lib gotcha). -- [ ] **Re-verify `docs/android-16/`:** confirm each page summary still matches Google's live page; re-fetch and refresh the fetch/verification dates if stale. -- [ ] Update this file's **Status** line; on merge to `main`, move folder to `docs/completed/`. -- [ ] Close issue #7. -- [ ] **Final step — reconcile the docs with the shipped code.** Once the build actually targets 36, sort every affected doc reference into one of two buckets: - - **Flip (normative — these state *current* status):** `docs/ANDROID_STANDARDS.md` §8 OpenLoop status note + the four §11 `Status: pending — Issue #7` markers → mark satisfied or remove; `CLAUDE.md` (Tech Stack table + SDK status note) and `README.md` (SDK levels) → change 34 → 36. This is the convergence step that keeps docs and code in agreement ([Lesson 007](../../lessons_learned/007-standards-doc-must-match-code.md)). - - **Keep (provenance — these record what was *planned/done*):** this file, [`DOC-PREP.md`](./DOC-PREP.md), and the [`docs/android-16/`](../../android-16/README.md) hub. Leave their Issue #7 / "pending" history intact — that's correct linkage, not stale debt to scrub. - - The source of truth for the version numbers stays `app/build.gradle.kts`; the docs only echo it, so there are just a handful of echoes to update — all listed above. - ---- - -## Open questions for owner -1. Does #7's scope include **Phase 6 (Play release mechanics)**, or should that be a separate issue? -2. Acceptable to ship without R8/minify (`isMinifyEnabled = false`) for the first release? -3. If a large-screen tablet/foldable layout is non-trivial, OK to use the temporary - resizability opt-out + follow-up issue, rather than block this upgrade? - -## Reversibility note -All changes here are local and version-controlled; revert = `git restore` / branch reset. The -only externally-visible, hard-to-reverse step is **submitting to Google Play (Phase 6)** — that -will get an explicit "proceed" before execution per the project reversibility protocol. - -## Sources (verified 2026-05-28) -- Play target API requirement — developer.android.com/google/play/requirements/target-sdk -- Android 16 behavior changes — developer.android.com/about/versions/16/behavior-changes-16 -- Android 15 behavior changes — developer.android.com/about/versions/15/behavior-changes-15 -- 16 KB page sizes — developer.android.com/guide/practices/page-sizes -- Versions — Google Maven metadata (AGP 8.13.0, CameraX 1.4.2, Media3 1.7.1, Compose BOM 2025.06.01) -- **Internal:** OpenLoop Android 16 knowledge hub — [`docs/android-16/`](../../android-16/README.md) - ---- - -## Implementation outcome (what actually shipped, vs. this plan) - -Verified against `developer.android.com` + Google Maven on 2026-05-28 at implementation time: - -- **Build tooling was already current.** AGP `8.13.2` and Gradle `9.0.0` were already on `main` (bumped in earlier PRs), and AGP 8.13 supports max API 36.1 — so no AGP/Gradle change was needed. The plan's "current state" table (AGP 8.3.2 / Gradle 8.x) was stale. -- **Dependencies went to latest stable, not the plan's numbers.** CameraX `1.3.1 → 1.6.1`, Media3 `1.3.0 → 1.10.1` (the plan named 1.4.2 / 1.7.1, which had been superseded). -- **D2 escalation fired — Kotlin 2.x migration (owner-approved).** The latest CameraX/Media3 are compiled with Kotlin 2.1 metadata, which the Kotlin 1.9.22 compiler cannot read. Resolved by bumping Kotlin `1.9.22 → 2.3.21`, moving the Compose compiler to the `org.jetbrains.kotlin.plugin.compose` Gradle plugin (removing `kotlinCompilerExtensionVersion`), bumping Compose BOM `2024.02.02 → 2026.05.01`, and migrating `kotlinOptions` → the `compilerOptions` DSL. -- **16 KB packaging.** `jniLibs.useLegacyPackaging` flipped `true → false` so native libs are stored uncompressed and 16 KB page-aligned; `zipalign -c -P 16` then reports real `(OK)` alignment (with legacy packaging it was a vacuous "compressed" pass). See [Lesson 011](../../lessons_learned/011-16kb-uncompressed-native-libs.md). -- **R8/shrinking enabled (owner-approved).** `isMinifyEnabled` + `isShrinkResources` = true; added `app/proguard-rules.pro` (relies on CameraX/Media3 consumer rules). Building release surfaced that the three `onboarding_*.png` drawables were actually JPEGs — renamed to `.jpg` (resource refs are extension-agnostic). -- **Phase 7 cleanup was stale.** The `proguardFiles` block was already a single correct call and the manifest had no `tools:targetApi` — nothing to fix. -- **Open questions resolved by owner:** Phase 6 Play release spun off to its own issue (with full prep detail); deps pinned to latest stable; R8 on now; large screens made adaptive (no opt-out). -- **Verification done:** `assembleDebug` + `assembleRelease` green; 24 unit + 10 instrumented tests pass; 16 KB alignment confirmed; onboarding render confirmed on an emulator. **Not yet done:** on-device walkthrough on an API-36/16 KB device and a ≥600dp large-screen visual pass (the only local AVD boots as API 34 on a phone). diff --git a/docs/completed/boomerang-rollout/01-capture-variable-length.md b/docs/completed/boomerang-rollout/01-capture-variable-length.md deleted file mode 100644 index 4b5a86c..0000000 --- a/docs/completed/boomerang-rollout/01-capture-variable-length.md +++ /dev/null @@ -1,171 +0,0 @@ -# Slice 01 — Variable-length capture (≤30 s) - -> **Branch:** `feature/boomerang-slice-01-variable-length` -> **Depends on:** nothing (first slice). -> **Unblocks:** every other slice in this rollout. - ---- - -## Problem - -Today `startBurstCapture` records exactly 1.5 s of video before auto-stopping. That -length is too short to make a meaningful boomerang loop and gives the user no time -to react to what they're filming. The whole point of OpenLoop's camera-first wedge -("react to what you're witnessing without leaving for the system camera") falls -flat when the recording is shorter than a sneeze. - -Variable-length capture is the foundation every other slice in this rollout -assumes. Trim only matters when the source is long enough to need trimming; -direction / speed / reps only matter on top of a clip the user actually likes. - -## Scope - -### In scope -- Replace the 1.5 s self-stop timer with a **user-controlled stop** and a **30 s - hard cap** auto-stop. -- Shutter button becomes **tap-to-start / tap-to-stop**. -- Visible **progress ring** around the shutter showing elapsed time toward the - 30 s cap. -- 30 s cap behavior: recording auto-finalizes at 30.0 s exactly (treat as the - same finalize path as a user tap). -- Unit and instrumented tests covering both stop paths (user tap, auto-cap). - -### Out of scope (deferred) -- Anything boomerang-related (no editor, no render, no trim). -- Showing the captured clip in a new screen — the existing `LoopingPreview` - transition stays as-is for this slice. The next slice replaces it. -- Visual countdown / haptic at the 5 s and 1 s "about to auto-stop" marks. Nice - to have, but adds polish work that isn't required to ship the capability. -- Pause/resume mid-capture. Not in the rollout at all. - -## UX deltas - -### Shutter behavior - -Today: tap shutter → records for 1.5 s → auto-stop. - -After this slice: - -``` -ReadyToCapture tap shutter → Recording (progress ring starts) -Recording tap shutter again → finalize -Recording elapsed ≥ 30.0 s → finalize (auto-cap) -``` - -The shutter button's visual state communicates the mode: - -- **Idle:** filled circle, `NeonCoral → NeonPurple` gradient (current). -- **Recording:** thin progress ring overlaid on the button, sweeping clockwise - from 12 o'clock; ring color `NeonCoral` 100%; button interior dimmed to - indicate "tap to stop"; small square glyph (vs. dot) in the center. - -### Capture-screen overlays - -A small `00:00 / 00:30` countdown chip appears top-center while recording (glass -surface, monospaced digits, `DeepCharcoal` 80% over `GlassWhite` 20%). Hidden in -idle state. - -### After-finalize routing - -Unchanged for this slice — finalize still transitions to `LoopingPreview` so the -user can verify the longer clip played back. Slice 02 replaces this transition -with the auto-route to the Trim screen. - -## Technical deltas - -### `OpenLoopViewModel.kt` - -- Remove the 1.5 s `delay()` self-stop in `startBurstCapture`. The - `viewModelScope.launch { delay(1500); ... }` block goes away. -- Add `stopBurstCapture(cameraManager: CameraManager)`: - ```text - // pseudocode — actual signature mirrors startBurstCapture's idempotency guards - if (_uiState.value != Recording) return - cameraManager.stopRecording() - // VideoRecordEvent.Finalize callback already wired in startBurstCapture - // continues to drive the state to LoopingPreview as today. - ``` -- Add a coroutine that posts a `recordingElapsedMs: StateFlow` for the UI - progress ring (re-emit every ~33 ms while in `Recording`; cancel on finalize). -- Add the 30 s auto-cap: another coroutine launched alongside the elapsed-time - flow that calls `stopBurstCapture()` when elapsed crosses 30 000 ms. - -### `CameraManager.kt` - -`stopRecording()` already exists per the existing code. Confirm no signature -changes needed. Add a Kotlin-doc note that the function is now called from both -the user-tap path and the auto-cap path. - -### `CameraScreen.kt` - -- Shutter button: read `uiState` and `recordingElapsedMs`; switch glyph and - draw the progress ring when `uiState == Recording`. Use `Canvas` for the - ring (Compose `drawArc` with `startAngle = -90f`, `sweepAngle = elapsed/30000 * 360f`). -- Collect both flows via `collectAsStateWithLifecycle()` (Lesson 002). -- Wire shutter `onClick`: if idle, `viewModel.startBurstCapture(cameraManager)`; - if recording, `viewModel.stopBurstCapture(cameraManager)`. -- Add the `00:00 / 00:30` countdown chip; format with - `"%02d:%02d".format(elapsed / 60_000, (elapsed / 1000) % 60)`. - -### `OpenLoopUiState.kt` - -No additions. `Recording` and `ReadyToCapture` cover this slice's flow. - -### Storage - -No changes to `VideoStorageRepository`. The same `rawCaptureFile` (cacheDir -singleton) is used; the resulting clip is still copied to -`filesDir/videos/clip_.mp4` via the existing `saveFinalizedVideo`. (Per-UUID -scratch files arrive in slice 02.) - -## Testing plan - -### Unit tests (`app/src/test/`) - -- `OpenLoopViewModelTest`: - - `startBurstCapture` puts the state into `Recording` and starts emitting on - `recordingElapsedMs`. - - `stopBurstCapture` while in `Recording` calls `cameraManager.stopRecording()` - exactly once and is a no-op outside `Recording`. - - Virtual-time test (`runTest(mainDispatcherRule.testDispatcher)` per Lesson - 008): advancing past 30 000 ms triggers the auto-cap which calls - `cameraManager.stopRecording()` exactly once even if no user tap occurs. - - The double-tap race: a user tap arriving at the same scheduler tick as the - auto-cap results in only one `stopRecording()` call. - -### Instrumented tests (`app/src/androidTest/`) - -- `CameraScreenTest` (Compose UI): shutter button toggles between idle and - recording glyph on click; progress ring is visible only while recording; - countdown chip text matches elapsed time within ±100 ms. -- End-to-end on emulator: tap shutter, wait 5 s, tap again → finalize, file - exists, duration is 5 s ± 100 ms. -- End-to-end auto-cap: tap shutter, wait 30 s+, no second tap → finalize fires - on the 30 s mark, file duration is 30 s ± 100 ms. - -### Manual QA (per `DEFINITION_OF_DONE.md`) - -- Tap shutter, immediately tap again → short clip (< 1 s) saves cleanly. -- Tap shutter, wait 30 s untouched → auto-stops; the resulting clip plays back - in `LoopingPreview` without freezing. -- Front camera + back camera both honor the new behavior. -- App backgrounded mid-recording → recording finalizes (CameraX default - behavior; just confirm no crash and the partial clip is saved). -- Screenshot of the recording state (shutter mid-progress, countdown chip - visible) attached to the PR. - -## Acceptance criteria - -- [ ] `assembleDebug` + `assembleRelease`: BUILD SUCCESSFUL, exit 0, zero `e:`. -- [ ] `testDebugUnitTest`: 0 failures; new tests above are present and passing. -- [ ] `connectedDebugAndroidTest`: 0 failures. -- [ ] `zipalign -c -P 16 -v 4 …` on release APK shows `(OK)` per native lib - (Lesson 011) — no regressions from baseline. -- [ ] App launched on an emulator AND on Pixel 10 Pro Fold; manual QA checklist - walked end-to-end; screenshot attached. -- [ ] No `Color(0x…)` literal violates the 8-hex-digit rule (Lesson 001). -- [ ] Every `Flow` collected in `CameraScreen` uses - `collectAsStateWithLifecycle()` (Lesson 002). -- [ ] No `Context` parameter introduced on `OpenLoopViewModel` methods (Lesson 004). -- [ ] PR description states what was NOT verified (e.g., "did not test recording - across screen rotation mid-record"). diff --git a/docs/completed/boomerang-rollout/02-auto-route-trim-and-default-save.md b/docs/completed/boomerang-rollout/02-auto-route-trim-and-default-save.md deleted file mode 100644 index e9d5840..0000000 --- a/docs/completed/boomerang-rollout/02-auto-route-trim-and-default-save.md +++ /dev/null @@ -1,402 +0,0 @@ -# Slice 02 — Auto-route to Trim screen + default-render Save - -> **Branch:** `feature/boomerang-slice-02-trim-and-default-save` -> **Depends on:** slice 01 (variable-length capture). -> **Unblocks:** slice 03 (tabbed editor). - ---- - -## Problem - -A captured clip today drops into `LoopingPreview` — a dead-end screen that just -plays the raw clip. There is no way for the user to make a boomerang. This slice -introduces: - -1. **Auto-route**: after capture finalizes, the user is dropped onto a dedicated - **Trim screen** with their clip loaded — no button to press, no decision to - make. This is what reference Boomerang does and matches Steven's "remove - hurdles between witnessing and creating" framing. Image reference (`docs\current-boomerang-ui\editor-tab-3.png`) -2. **First boomerang output**: the Trim screen's `NEXT` button renders a - **default** boomerang (`fwd→rev`, 2× speed, 1 rep, no extra trim if the user - left the handles alone) and saves it. The user gets a real boomerang from a - real capture, without any tab UI existing yet. - -The tabbed editor itself is **out of scope** for this slice — it arrives in -slice 03. Slice 02 proves the full pipeline: capture → trim → render → save, -end-to-end, with the simplest possible config. - -## Scope - -### In scope -- New `BoomerangUiState.Trim(source)` state. -- New `TrimScreen` composable: preview at top (~75%), trim bar + `NEXT` button - at bottom (~25%). -- Auto-transition from `Recording → Finalize → Trim(ScratchClip)` (replaces - today's `Finalize → LoopingPreview`). -- New `media/VideoProcessor.kt` with a single `renderBoomerang(...)` entry point - hard-wired to `mode=FORWARD_THEN_REVERSE, speed=2.0f, reps=1`. Trim values - come from the Trim screen. -- New `media/VideoReverser.kt` — the two-pass MediaCodec reverser that - `VideoProcessor` depends on for any reverse-containing mode (which includes - the default `FORWARD_THEN_REVERSE`, so this slice must ship it). See - [`RESEARCH-reverse-video.md`](./RESEARCH-reverse-video.md) for the verified - rationale and algorithm specification. -- New `VideoStorageRepository.createScratchCapture()` / - `promoteScratchToRaw()` / `allocateBoomerangFile()` / - `registerBoomerang()` — per-UUID scratch files, separate `boomerangs/` dir, - raws kept (per Steven's "raw alongside boomerang" decision). -- `RecordedVideo.kind` field (`RAW` | `BOOMERANG`); inferred from the file's - directory. Gallery still displays both without distinction for this slice - (badges + filter come in slice 07). -- A `Processing` state with a spinner during the render (Media3 is fast for - short clips but not instantaneous on older hardware). - -### Out of scope -- Any tab UI. The editor screen does not exist yet. -- Picking direction / speed / reps. All three are hard-coded. -- Share sheet on save. Save just snackbars "Saved" and returns to camera. Share - arrives in slice 06. -- Gallery filter / badge / tap-to-edit. Arrives in slice 07. -- Mid-render cancel ("oops" button). Renders are fast enough that v1 doesn't - need this; flag as a stretch for the v1.5 backlog. - -## UX deltas - -### Auto-route - -``` -Recording tap shutter again → finalize (slice 01) -Finalize (success) _no LoopingPreview!_ → Trim(ScratchClip(uuid)) -Finalize (error) → ReadyToCapture (unchanged) -``` - -`LoopingPreview` is **not deleted** — it remains as the playback target for -finished boomerangs tapped from the gallery (slice 07 wires this up). Its just -stops being the post-capture landing pad. - -### Trim screen layout - -``` -┌────────────────────────────────────────────────┐ -│ ← [ ✓ ] │ top bar -│ back (save: nyi) │ ✓ is HIDDEN in slice 02 -├────────────────────────────────────────────────┤ -│ │ -│ │ -│ ┌──────────────────┐ │ -│ │ │ │ -│ │ preview (loops │ │ -│ │ trimmed range) │ │ ~75% screen height -│ │ │ │ -│ └──────────────────┘ │ -│ │ -│ 3.2s │ live duration indicator -│ │ -├────────────────────────────────────────────────┤ -│ ◀══════[ trimmed range ]══════▶ │ trim bar -│ (drag handles) │ ~25% screen height -│ │ -│ [ NEXT (full-width) ] │ primary action -└────────────────────────────────────────────────┘ -``` - -**Behavioral notes:** - -- The back arrow (top-left) discards the scratch clip with a confirm dialog - ("Discard this clip?") and routes back to `ReadyToCapture`. -- The save checkmark (top-right) is **hidden** in this slice — `NEXT` is the - only primary action. (Slice 03 brings the checkmark online when the editor - exists.) -- The trim bar has two draggable handles (left = start, right = end). Min - trimmed duration: **400 ms**. Max trimmed duration: equal to source duration. -- Dragging either handle scrubs the preview to that handle's frame momentarily, - then resumes looping the trimmed range when the user lifts. -- The "3.2s" indicator below the preview shows the **trimmed range duration**, - updating live as handles drag. -- `NEXT` is enabled whenever `trimEnd - trimStart >= 400 ms`. Disabled - otherwise (gray pill, no ripple). - -### After NEXT - -A full-screen `Processing` state appears with a centered spinner and -"Creating boomerang…" caption. On success: snackbar **"Saved — view in gallery"** -with a `View` action, route to `ReadyToCapture`. On failure: snackbar -**"Couldn't save boomerang. Try again."**, route back to `Trim` with the user's -trim selection preserved. - -## Technical deltas - -### `OpenLoopUiState.kt` - -```kotlin -data class Trim(val source: EditorSource) : OpenLoopUiState -object Processing : OpenLoopUiState // already declared in current state file; keep - -sealed interface EditorSource { - data class ScratchClip(val uuid: String) : EditorSource - // GalleryClip arrives in slice 07 -} -``` - -### `OpenLoopViewModel.kt` - -- On `VideoRecordEvent.Finalize` success: instead of transitioning to - `LoopingPreview`, build a `ScratchClip(uuid)` from the just-finalized - scratch file and post `Trim(ScratchClip(uuid))`. -- Add a sibling `editorState: StateFlow` that the Trim screen reads: - ```kotlin - data class TrimState( - val sourceFile: File, - val sourceDurationMs: Long, - val trimStartMs: Long = 0L, - val trimEndMs: Long = sourceDurationMs, - ) - ``` -- Add mutators: `updateTrim(startMs, endMs)`, `discardTrim()`, - `saveBoomerangDefault()` — the last one calls `VideoProcessor.renderBoomerang` - with hard-coded `mode=FORWARD_THEN_REVERSE, speed=2.0f, reps=1`. -- Wrap every repository write in `try { ... } catch (e: IOException) { ... }` - per Lesson 003. - -### `VideoStorageRepository` (interface + impl) - -Add the five methods listed in the parent IMPLEMENTATION.md §7.2: - -```kotlin -fun createScratchCapture(): ScratchCapture -fun promoteScratchToRaw(scratch: ScratchCapture): File? -fun discardScratch(scratch: ScratchCapture) -fun allocateBoomerangFile(sourceRawId: Long): File -fun registerBoomerang(file: File, sourceRawId: Long): RecordedVideo? - -data class ScratchCapture(val uuid: String, val file: File) -``` - -Directory additions: - -``` -cacheDir/scratch/raw_.mp4 -filesDir/boomerangs/boom__from_.mp4 -filesDir/thumbnails/boom__from_.jpg -``` - -Also extend `RecordedVideo` with `kind: VideoKind` and `sourceRawId: Long?` -(see parent doc §7.1). `loadRecordedVideos()` infers `kind` from the file's -parent directory. - -### `media/VideoReverser.kt` (new) - -The two-pass MediaCodec reverser, fully specified in -[`RESEARCH-reverse-video.md`](./RESEARCH-reverse-video.md) §3 and §5. Surface: - -```kotlin -class VideoReverser( - private val scratchDir: File, // cacheDir/scratch/reversed/ -) { - /** - * Produce a reversed version of [source] over the trim window - * [[trimStartMs, trimEndMs]]. Suspending, cancellable, idempotent — - * a cache key of "__" is checked - * first; if a reversed file already exists in [scratchDir], it is - * returned without re-processing. Strips audio. Throws on MediaCodec - * failure. - */ - suspend fun reverse( - source: File, - trimStartMs: Long, - trimEndMs: Long, - onProgress: (Float) -> Unit = {}, - ): File = withContext(Dispatchers.IO) { /* ... */ } -} -``` - -Internals (mirrors the sisik.eu reference at -[github.com/sixo/reverse-video](https://github.com/sixo/reverse-video), -MIT licensed): - -1. **Pass 1:** transcode the trimmed source to an intermediate MP4 with - `KEY_I_FRAME_INTERVAL = 0` (every frame is an I-frame). Lives in - `cacheDir/scratch/reversed/_intermediate_.mp4`; deleted at end of - pass 2 regardless of success. -2. **Pass 2:** walk the intermediate's sync samples backwards via a `Stack` - of presentation times, feed each sample to a `MediaCodec` decoder whose - output Surface is the encoder's input Surface (single-Surface path; no EGL - needed since we're not adding visual effects), remap presentation time - to `endTimeUs - extractor.sampleTime`, mux to the final reversed file. -3. **Output path:** `cacheDir/scratch/reversed/.mp4`. -4. **Lifecycle:** decoder, encoder, muxer all released in a `finally` block; - `CancellationException` propagates cleanly so coroutine cancellation - tears down the pipeline. - -This slice must ship `VideoReverser` because the default mode -`FORWARD_THEN_REVERSE` needs reverse to work — there is no "ship reverse -later" path. - -### `media/VideoProcessor.kt` (new) - -A single-class file implementing the interface in the parent doc §6.1. For -this slice the implementation: - -1. Builds a `MediaItem` from the source file with - `ClippingConfiguration(start=trimStartMs, end=trimEndMs)` — this is the - "forward" half. -2. Calls `videoReverser.reverse(source, trimStartMs, trimEndMs)` to obtain - (or retrieve from cache) the reversed half as a `File`. Reports its - `onProgress` fraction back to the caller, scaled into the first 80% of - the overall progress budget — the Composition encode is the remaining 20%. -3. Constructs a Media3 `Composition` containing: - - `EditedMediaItem` for the trimmed forward half. - - `EditedMediaItem` for the reversed half (built from the file returned - in step 2), with the seam-handling 1-frame `setStartPositionMs` offset - described in parent doc §6.4. -4. Applies `SpeedChangingVideoEffect(2.0f)` to the composition and strips - audio (no `SpeedChangingAudioProcessor`). -5. Outputs to `videoStorage.allocateBoomerangFile(sourceRawId)`. -6. Reports `onProgress(0f..1f)` via `Transformer.Listener.onCompleted` / - `onError` / progress polling. - -Suspend-cancellable: the function returns a `withContext(Dispatchers.IO) { ... }` -that catches `CancellationException` and aborts both the in-flight -`VideoReverser` (if running) and the Transformer. - -> **Decision locked.** The earlier "Open question — verify -> `ReverseVideoEffect`" callout is resolved in -> [`RESEARCH-reverse-video.md`](./RESEARCH-reverse-video.md): Media3 1.10.x -> does **not** ship a reverse effect, and FFmpegKit is retired and unsuitable -> as a dependency. The two-pass MediaCodec approach via the new -> `VideoReverser` class is the chosen path. No further web-verification -> required before implementation begins. - -### `ui/TrimScreen.kt` (new) - -- Compose layout following the ASCII mock above. -- Hosts ExoPlayer in an `AndroidView`. Player binds the source with - `ClippingConfiguration` and `repeatMode = Player.REPEAT_MODE_ALL`. On trim - changes, rebuild the `MediaItem` with the new clipping and re-prepare the - player. -- Trim bar: a `Canvas`-based custom composable with two draggable handles. - Hit-targets ≥ 44 dp per Material accessibility guidance. -- `NEXT` button: enabled state bound to `(trimEnd - trimStart) >= 400`. -- Collects `editorState` via `collectAsStateWithLifecycle()` (Lesson 002). - -### `MainActivity.kt` - -Add the `Trim` and `Processing` branches to the conditional routing. `Trim` → -`TrimScreen(...)`. `Processing` → existing or new `ProcessingScreen` with a -centered spinner + caption (small composable). - -## Testing plan - -### Unit tests - -- `OpenLoopViewModelTest`: - - On `Finalize` success, `uiState` transitions to `Trim(ScratchClip(uuid))` - (NOT `LoopingPreview` anymore). - - `editorState.trimStart / trimEnd` initialize to `0 / sourceDuration`. - - `updateTrim(...)` clamps to `[0, sourceDuration]` and enforces - `trimEnd - trimStart >= 400`. - - `saveBoomerangDefault()` flips `uiState` to `Processing`, awaits the - `VideoProcessor` (fake returning a stub file), promotes the scratch to a - raw, registers the boomerang, transitions to `ReadyToCapture`, and emits a - "Saved" event (collected via a `SharedFlow`). - - `saveBoomerangDefault()` on `VideoProcessor` failure transitions back to - `Trim` with the prior trim selection intact and emits a "Failed" event. - - `discardTrim()` calls `videoStorage.discardScratch(...)` and transitions to - `ReadyToCapture`. -- `VideoStorageRepositoryImplTest`: - - `createScratchCapture` → returns a `ScratchCapture` whose `file` is in - `cacheDir/scratch/` and does not yet exist on disk. - - `promoteScratchToRaw` copies the scratch file into `filesDir/videos/` with - the expected `clip_.mp4` name and extracts a thumbnail. - - `discardScratch` deletes the scratch file (if present); idempotent. - - `allocateBoomerangFile` + `registerBoomerang` produce a `RecordedVideo` - with `kind = BOOMERANG` and correct `sourceRawId`. - - Use `TemporaryFolder` per Lesson 008 — do **not** mock `File`. -- `VideoReverserTest` (instrumented — needs real `MediaCodec`, can't run on - pure JVM): - - Reversing a short fixture MP4 produces a file with duration matching the - trim window within ±1 frame. - - First frame of the reversed output ≈ last frame of the source trim window - (sample via `MediaMetadataRetriever.getFrameAtTime`, compare via histogram - distance — exact pixel equality is unreliable across codec round-trips). - - Calling `reverse(...)` twice with the same source + trim returns the - cached file path without invoking MediaCodec again (verify by snapshotting - file `lastModified`). - - Intermediate keyframe-only file is deleted from `cacheDir/scratch/reversed/` - after pass 2 (success or failure). - - Coroutine cancellation mid-reverse releases codec resources and removes - partial output (assert no `_intermediate_*.mp4` and no partial output - file linger). - -### Instrumented tests - -- `TrimScreenTest`: handles drag updates `editorState`; `NEXT` is disabled when - trim duration < 400 ms; preview re-binds with new clipping when handles change. -- End-to-end (slowest test): record a 3 s capture on an emulator, drag handles - to a 1.5 s range, tap `NEXT`, assert that: - - A file appears under `filesDir/boomerangs/`. - - The file's duration is `≈ 1.5 s` for a 1.5 s trim: the fwd+rev cycle is - `2 × 1.5 s = 3.0 s` at 1×, divided by the `2.0` speed = `≈ 1.5 s` - (i.e. `cycle_ms × reps / speed`). - - `loadRecordedVideos()` returns the new boomerang with `kind = BOOMERANG`. - - A raw also exists with `kind = RAW`. - - The `cacheDir/scratch/reversed/` cache contains exactly one file (the - reversed half), and no `_intermediate_*.mp4` files remain. - -### Manual QA - -- Record 5 s clip → lands on Trim screen, preview loops the full 5 s. -- Drag start handle to ~1 s, end handle to ~4 s → preview loops 3 s range, - duration indicator reads "3.0s". -- Tap `NEXT` → spinner with "Creating boomerang…", then snackbar "Saved — view - in gallery". **Note the elapsed time** and record it in the PR description — - per [`RESEARCH-reverse-video.md`](./RESEARCH-reverse-video.md) §5 perf - estimates, a 3 s trimmed source on Pixel 10 Pro Fold should produce a - reversed half in ~1 s plus the Composition encode (~0.5 s) for a total of - ~1.5 s end-to-end. Anything > 4 s warrants investigation. -- Inspect `cacheDir/scratch/reversed/` via `adb shell run-as com.OpenLoop.app ls`: - exactly one reversed `.mp4` should exist; no `_intermediate_*.mp4` should - remain. -- Save a second boomerang from a *different* trim window of the same raw → a - second reversed file appears in the cache (different cache key because trim - is part of the key). -- Save a second boomerang from the *same* trim window of the same raw → no - new reversed file is created (cache hit); render is noticeably faster - (just the Composition encode). -- Tap `View` on snackbar → gallery shows the new boomerang (no badge yet). -- Discard from Trim screen → confirm dialog → "Yes" returns to camera, scratch - cleaned up. -- Process kill on the Trim screen (`adb shell am force-stop com.OpenLoop.app`) - → relaunch returns to `ReadyToCapture` (scratch is orphaned; pruning is - slice-07 territory). Document this is expected. -- Process kill *during* an in-flight render → on relaunch, no zombie - intermediate / partial output files in the cache directory; codecs released - (verify via `adb shell dumpsys media.codec | grep com.OpenLoop.app` shows - no held instances). -- Screenshot of the Trim screen attached to the PR. - -## Acceptance criteria - -- [ ] `assembleDebug` + `assembleRelease`: BUILD SUCCESSFUL, exit 0, zero `e:`. -- [ ] `testDebugUnitTest`: 0 failures; all unit tests above present. -- [ ] `connectedDebugAndroidTest`: 0 failures. -- [ ] `zipalign -c -P 16 -v 4 …` on release APK shows `(OK)` (Lesson 011). -- [ ] App launched on emulator AND Pixel 10 Pro Fold; end-to-end walked; - screenshot attached. -- [ ] No `Color(0x…)` literal violates the 8-hex-digit rule (Lesson 001). -- [ ] All Flow collection in TrimScreen uses `collectAsStateWithLifecycle()` - (Lesson 002). -- [ ] All repository writes wrapped in `try / catch (IOException)` (Lesson 003). -- [ ] No `Context` parameter on any `OpenLoopViewModel` method (Lesson 004). -- [ ] FileProvider not yet configured (that's slice 06); confirm no - `Intent.ACTION_SEND` shipped prematurely. -- [ ] `VideoReverser` cache behavior verified: cache hit on identical - source + trim is instant (no MediaCodec invocation); cache miss on - different trim produces a new reversed file. -- [ ] `VideoReverser` cleanup verified: no `_intermediate_*.mp4` files in - `cacheDir/scratch/reversed/` after success OR failure OR cancellation. -- [ ] Reverse-generation latency for a 3 s trimmed source on Pixel 10 Pro Fold - recorded in the PR description (target: ≤ 1.5 s end-to-end including - Composition encode; investigate if higher). -- [ ] PR description states what was NOT verified (especially: process-kill - scratch resume is out of scope for this slice). diff --git a/docs/completed/boomerang-rollout/assets/slice-01-recording-state.png b/docs/completed/boomerang-rollout/assets/slice-01-recording-state.png deleted file mode 100644 index 0e680dd..0000000 Binary files a/docs/completed/boomerang-rollout/assets/slice-01-recording-state.png and /dev/null differ diff --git a/docs/completed/datastore-preferences/IMPLEMENTATION.md b/docs/completed/datastore-preferences/IMPLEMENTATION.md deleted file mode 100644 index dc7e9f9..0000000 --- a/docs/completed/datastore-preferences/IMPLEMENTATION.md +++ /dev/null @@ -1,97 +0,0 @@ -# Jetpack DataStore — Onboarding Persistence - -**Branch:** `feature/datastore-preferences` -**PR:** #5 -**Status:** In review - ---- - -## Problem - -The app shows the 3-page onboarding carousel on every launch. Returning users are forced through it repeatedly. Decision Log #11 in `docs/PRD-mission-control.md` intentionally deferred persistence during early development — we are now ready to persist it. - -## Solution - -Implement Jetpack DataStore (Preferences) to persist a `has_completed_onboarding` boolean. On launch, the ViewModel reads DataStore before deciding the first screen. Returning users skip straight to the camera. - -## Scope - -**In scope:** -- `has_completed_onboarding` flag persisted via Preferences DataStore -- Repository pattern (`UserPreferencesRepository` interface + impl) for testability -- `Initializing` state added to sealed interface for async DataStore read at startup -- ViewModel constructor injection via `ViewModelProvider.Factory` -- `LaunchedEffect` in MainActivity auto-triggers permission check -- Unit tests with `FakeUserPreferencesRepository` - -**Out of scope:** -- Settings UI screen -- Other preferences (capture duration, speed, audio toggle) — infrastructure supports them but no values defined yet -- Migration from SharedPreferences (none exists) - -## Architecture - -### Data Layer - -``` -Context.dataStore (top-level singleton delegate) - ↓ -UserPreferencesRepositoryImpl (wraps DataStore, catches IOException) - ↓ implements -UserPreferencesRepository (interface — ViewModel depends on this) - ↓ injected via Factory -OpenLoopViewModel (reads in init, writes on onboarding completion) -``` - -### Startup Flow - -**First-time user:** -`Initializing` (spinner) → DataStore returns `false` → `Onboarding` → 3 pages → `CheckingPermissions` → permissions → `ReadyToCapture` - -**Returning user:** -`Initializing` (spinner) → DataStore returns `true` → `CheckingPermissions` → permissions → `ReadyToCapture` - -## Files Changed - -**New (3):** -- `app/src/main/java/com/OpenLoop/app/data/UserPreferencesRepository.kt` -- `app/src/main/java/com/OpenLoop/app/data/UserPreferencesRepositoryImpl.kt` -- `docs/ANDROID_STANDARDS.md` - -**Modified (9):** -- `gradle/libs.versions.toml` — DataStore dependency -- `app/build.gradle.kts` — DataStore dependency -- `OpenLoopUiState.kt` — `Initializing` state -- `OpenLoopViewModel.kt` — constructor injection, init block, Factory -- `MainActivity.kt` — factory creation, LaunchedEffect, Initializing handling -- `OpenLoopViewModelTest.kt` — FakeRepo, 3 new tests (19 total) -- `CLAUDE.md` — architecture updates -- `docs/PRD-mission-control.md` — Decision Log #11, architecture diagram -- `README.md` — rewrite + merge policy - -## Testing - -| Test | Validates | -|------|----------| -| `first-time user resolves to Onboarding after init` | DataStore `false` → Onboarding | -| `returning user resolves to CheckingPermissions after init` | DataStore `true` → CheckingPermissions | -| `onOnboardingCompleted persists true to repository` | Write actually happens | -| All 16 existing tests | Unchanged behavior with new constructor | - -## Acceptance Criteria - -- [x] First-time users see onboarding, then never again after completing it -- [x] Returning users skip straight to permission check / camera -- [x] DataStore file created at `files/datastore/OpenLoop_preferences.preferences_pb` -- [x] IOException on corrupted DataStore falls back to showing onboarding (safe degradation) -- [x] All 19 unit tests pass -- [x] Future preferences can be added with zero restructuring - -## Google Standards Compliance - -Follows all rules in `docs/ANDROID_STANDARDS.md` Section 4 (DataStore): -- Top-level `Context.dataStore` singleton delegate -- Repository interface between ViewModel and DataStore -- `IOException` caught with `emptyPreferences()` fallback -- Atomic writes via `dataStore.edit { }` -- No SharedPreferences, no `runBlocking` diff --git a/docs/completed/permission-rationale/IMPLEMENTATION.md b/docs/completed/permission-rationale/IMPLEMENTATION.md deleted file mode 100644 index 8307d93..0000000 --- a/docs/completed/permission-rationale/IMPLEMENTATION.md +++ /dev/null @@ -1,80 +0,0 @@ -# Permission Rationale Flow (Issue #11) - -## Problem statement - -`MainActivity.checkPermissions()` was a 2-state flow: granted → proceed, otherwise → launch the -system dialog. The middle state from Google's recommended permission flow — show an educational -rationale before re-asking — was missing. Consequences: users who denied once get re-prompted with -no context (more likely to deny again), and users who permanently denied see a button that appears -broken because the launcher returns immediately with `granted = false`. - -Origin: WARNING #5 from PR #5 review; captured in -[`docs/lessons_learned/006-permission-rationale-flow.md`](../../lessons_learned/006-permission-rationale-flow.md). - -## Scope - -**In:** -- New `OpenLoopUiState.PermissionRationale` state. -- ViewModel `showPermissionRationale()` and `onRationaleAcknowledged()`. -- `MainActivity.checkPermissions()` rewritten as a 3-state `when` (granted / rationale / request). -- `PermissionDeniedScreen` generalized into `PermissionExplanationScreen`, reused for both the - rationale and permanent-denial UIs via a parameterized secondary action. -- "Not now" cancel affordance on the rationale screen (`onRationaleDeclined()` → `PermissionDenied`), - per Google's "always provide the option to cancel an educational UI flow" guidance. -- Unit tests for the new transitions, plus a Compose UI test for `PermissionExplanationScreen`. - -**Out (tracked separately):** DataStore-backed permission UX metadata (#6); `targetSdk` bump (#7); -removing `Context` params from the ViewModel (#10). - -## Architecture - -OS-only state — `shouldShowRequestPermissionRationale()` is tracked by Android, so no DataStore is -needed. The rationale "Grant" action launches the system dialog **directly** (bypassing -`checkPermissions()`); routing back through `CheckingPermissions` alone would re-enter the rationale -branch and loop, because `shouldShowRequestPermissionRationale()` stays `true` until the user -actually responds. See `docs/PRD-mission-control.md` §3 (State Machine) and §4.7 (MainActivity). - -## Implementation steps - -1. `OpenLoopUiState.kt` — add `object PermissionRationale`. -2. `OpenLoopViewModel.kt` — add `showPermissionRationale()` → `PermissionRationale` and - `onRationaleAcknowledged()` → `CheckingPermissions`. -3. `MainActivity.kt` — `requiredPermissions` array; 3-state `checkPermissions()`; - `onRationaleAcknowledged()` (acknowledge + direct launch); route `PermissionRationale` and - `PermissionDenied` through `PermissionExplanationScreen`. -4. `PermissionExplanationScreen` — parameterized `title` / `body` / `primaryActionLabel` / - `onPrimaryAction` / nullable `onOpenSettings`. - -## Testing plan - -Unit tests (`OpenLoopViewModelTest.kt`): -- `showPermissionRationale transitions to PermissionRationale` -- `onRationaleAcknowledged transitions to CheckingPermissions` -- `onRationaleDeclined transitions to PermissionDenied` -- `rationale flow ending in grant reaches ReadyToCapture` -- `rationale flow ending in denial reaches PermissionDenied` - -Compose UI tests (`PermissionExplanationScreenTest.kt`): rationale variant shows "Grant"/"Not now" -and hides Settings; denial variant shows "Try Again"/"Open Device Settings"; primary-only variant -hides the secondary button; both buttons fire their callbacks. - -The Activity-level `checkPermissions()` branching (framework calls) is verified by manual emulator -passes per the issue's acceptance criteria — Activities are out of scope for local unit tests -(`docs/TEST_COVERAGE.md`). - -Note: while adding coverage, the committed `OpenLoopViewModelTest.kt` was found to never compile or -run green; the fixes and the general pattern are recorded in -[`docs/lessons_learned/008-jvm-test-file-and-dispatcher-pitfalls.md`](../../lessons_learned/008-jvm-test-file-and-dispatcher-pitfalls.md). - -## Acceptance criteria - -- [x] `PermissionRationale` added to the sealed interface. -- [x] `showPermissionRationale()` / `onRationaleAcknowledged()` added. -- [x] `checkPermissions()` rewritten as a 3-state `when`. -- [x] `PermissionExplanationScreen` serves both rationale and denial. -- [x] Rationale screen has a "Not now" cancel affordance (`onRationaleDeclined()` → `PermissionDenied`). -- [x] Routing wired for both states. -- [x] Unit tests for the new transitions; full suite green (24 unit tests). -- [x] Compose UI test for `PermissionExplanationScreen` (rationale vs denial variants). -- [x] PRD state-machine table updated with the `PermissionRationale` row. -- [ ] Manual emulator pass (fresh install / deny-once / grant-from-rationale / permanent-deny). diff --git a/docs/completed/video-storage-repository/IMPLEMENTATION.md b/docs/completed/video-storage-repository/IMPLEMENTATION.md deleted file mode 100644 index baf0e89..0000000 --- a/docs/completed/video-storage-repository/IMPLEMENTATION.md +++ /dev/null @@ -1,54 +0,0 @@ -# VideoStorageRepository — Remove Context from OpenLoopViewModel - -**Issue:** [#10](https://github.com/stozo04/OpenLoop/issues/10) · **Branch:** `feature/video-storage-repository` · **Shipped via:** PR #17 · **Labels:** architecture, refactor, tech-debt - -## Problem statement - -`OpenLoopViewModel` accepted `android.content.Context` as a method parameter in four places (`startBurstCapture`, `loadRecordedVideos`, `deleteVideo`, `navigateToGallery`) to reach `cacheDir` / `filesDir`. This violates Google's explicit ViewModel rule — *"A ViewModel must never reference a view, Lifecycle, or any class that may hold a reference to the activity context"* ([ViewModel Overview](https://developer.android.com/topic/libraries/architecture/viewmodel)) — forces every ViewModel test to `mockk`, and would compound as Phase 3 (`VideoProcessor`) adds more filesystem work. Captured as [lesson 004](../../lessons_learned/004-viewmodel-no-context-parameters.md); flagged as FAIL #3 in the PR #5 review. - -## Scope - -**In:** -- New `VideoStorageRepository` interface + `VideoStorageRepositoryImpl` (data layer), mirroring `UserPreferencesRepository`. -- Move `RecordedVideo` from the `ui` package to the `data` package (it is a data-layer model). -- `OpenLoopViewModel` constructor takes `VideoStorageRepository`; every `Context` parameter removed. -- `Factory` takes both repositories (kept Context-free — consistent with how `UserPreferencesRepository` is already wired). `MainActivity` builds `VideoStorageRepositoryImpl(applicationContext.cacheDir, applicationContext.filesDir)`. -- Update call sites: `MainActivity`, `CameraScreen`, `GalleryScreen`. -- Replace `mockk` in `OpenLoopViewModelTest` with a `FakeVideoStorageRepository`; add `VideoStorageRepositoryImplTest` using `TemporaryFolder`. - -**Out:** -- `CameraManager`'s Context-like construction (CameraX lifecycle binding — a separate, sanctioned concern). -- Phase 3 `VideoProcessor` itself. This refactor is the structural prerequisite; the repository is the seam it will plug into. - -## Architecture - -``` -MainActivity (only Context bridge) - └─ OpenLoopViewModel.Factory(userPrefs, videoStorage) ← repositories, no Context - └─ OpenLoopViewModel(userPrefs, videoStorage) ← zero Context references - └─ VideoStorageRepository (interface) - ├─ VideoStorageRepositoryImpl(cacheDir, filesDir) [prod] - └─ FakeVideoStorageRepository (in-memory) [test] -``` - -Deviation from the issue's Step 4: the issue showed `Factory(context: Context)`. The existing code already keeps `Factory` repo-only and bridges Context in `MainActivity` (for DataStore). We matched that established pattern instead of re-introducing Context into the Factory — confirmed with the owner. See PRD §4.2 / §4.8 and decision-log entry 13. - -## Implementation steps (done) - -1. `data/VideoStorageRepository.kt` — interface + `RecordedVideo` data class. -2. `data/VideoStorageRepositoryImpl.kt` — holds `cacheDir`/`filesDir`; `saveFinalizedVideo` / `loadRecordedVideos` / `deleteVideo` logic moved verbatim from the ViewModel. -3. `ui/OpenLoopViewModel.kt` — inject `videoStorage`; drop all `Context` params; `deleteVideo` now reloads in the ViewModel (repository no longer self-refreshes); `Factory` takes both repos. -4. `MainActivity.kt` — construct `VideoStorageRepositoryImpl` from `applicationContext` and pass to `Factory`. -5. `CameraScreen.kt` / `GalleryScreen.kt` — drop the `context` argument; import `RecordedVideo` from `data`. -6. Tests — `FakeVideoStorageRepository` + refactored `OpenLoopViewModelTest`; new `VideoStorageRepositoryImplTest`. -7. Docs — PRD §3/§4.2/§4.8/§6/§7 + decision log; this folder. - -## Testing plan - -- **Unit (JVM):** `OpenLoopViewModelTest` (26) with the fake — no Context/File mocks. `VideoStorageRepositoryImplTest` (8) against `TemporaryFolder`; `MediaMetadataRetriever` mocked via `mockkConstructor`. -- **Instrumented (UI):** the 10 onboarding/permission regression tests. On API 36 these initially crashed in Espresso (`InputManager.getInstance()` removed in Android 16) — a pre-existing tooling gap fixed by the `androidx.test` bump in **PR #16**. After rebasing this branch on the merged #16, all **10/10 instrumented tests pass** on the Pixel_10 AVD (API 36). -- **Manual QA:** see PR checklist — record a burst → preview → gallery → delete, on an emulator. - -## Acceptance criteria - -Tracks the Issue #10 checklist. Key gate: `grep "Context"` in `OpenLoopViewModel.kt` returns **only comment lines** (zero in any signature), and the ViewModel test compiles and passes without `mockk`. diff --git a/docs/current-boomerang-ui/editor-tab-1.png b/docs/current-boomerang-ui/editor-tab-1.png deleted file mode 100644 index 93e94fb..0000000 Binary files a/docs/current-boomerang-ui/editor-tab-1.png and /dev/null differ diff --git a/docs/current-boomerang-ui/editor-tab-2.png b/docs/current-boomerang-ui/editor-tab-2.png deleted file mode 100644 index f003c1e..0000000 Binary files a/docs/current-boomerang-ui/editor-tab-2.png and /dev/null differ diff --git a/docs/current-boomerang-ui/editor-tab-3.png b/docs/current-boomerang-ui/editor-tab-3.png deleted file mode 100644 index 35d98e0..0000000 Binary files a/docs/current-boomerang-ui/editor-tab-3.png and /dev/null differ diff --git a/docs/current-boomerang-ui/time-stamp-selector.png b/docs/current-boomerang-ui/time-stamp-selector.png deleted file mode 100644 index 2403c84..0000000 Binary files a/docs/current-boomerang-ui/time-stamp-selector.png and /dev/null differ diff --git a/docs/diagnostics/crashlytics-issue-3a506c4e-verification.md b/docs/diagnostics/crashlytics-issue-3a506c4e-verification.md deleted file mode 100644 index b4ca399..0000000 --- a/docs/diagnostics/crashlytics-issue-3a506c4e-verification.md +++ /dev/null @@ -1,76 +0,0 @@ -# Crashlytics issue `3a506c4e` — verification checklist - -**Issue:** `VideoReverser.runDecodeEncodeLoop` · `IllegalStateException` (NON_FATAL) -**Firebase:** [openloop-8c266 / issue 3a506c4ecc5bfeff0ab2b56d58f6e1d6](https://console.firebase.google.com/project/openloop-8c266/crashlytics/app/android:io.github.stozo04.openloop/issues/3a506c4ecc5bfeff0ab2b56d58f6e1d6) -**Branch:** `fix/crashlytics-3a506c4e-pass1-codec-lifecycle` · **Ship:** 1.0.18 (18) · **Handoff:** [`docs/active/crashlytics-reverse-preview/HANDOFF.md`](../active/crashlytics-reverse-preview/HANDOFF.md) - -Historical events (e.g. **1.0.9**) will remain in the console. Success means **no new events** after a build that includes this fix ships to testers/Play. - ---- - -## What the fix does - -| Layer | Behavior | -|-------|----------| -| **Cancel / teardown** | `MediaCodecLifecycle` maps Released/cancelled dequeue failures to `CancellationException` when the reverse job is no longer active — no `reverse_preview_failure` non-fatal, no forward fallback snackbar. | -| **Active Samsung contention** | `playerEpoch++` on reverse loading so preview decoders are `release()`d before pass 1; one retry of pass 1+2 after `SAMSUNG_CODEC_CONTENTION_RETRY_MS` when dequeue fails with lifecycle errors while the job is still active. | -| **Mainline (already on `main`)** | `EditorPlaylistBind.shouldHoldPlaylist`, Samsung pre/post-transform settle delays, software AVC codec pairing, `ReverseScratchJanitor`, preview 480p short-side cap on Samsung. | - ---- - -## Pre-ship (developer) - -1. Build **release** with `app/google-services.json` present. -2. `./gradlew :app:testDebugUnitTest` — includes `MediaCodecLifecycleTest`, `EditorPlaylistBindTest`. -3. `./gradlew :app:lintDebug` — no new errors. - ---- - -## Device QA (Samsung strongly recommended) - -Use the same class of repro as the Crashlytics session (camera AVC trim ~8s, ping-pong / reverse mode). - -1. **Enter editor from Trim** — overlay shows Trimming/Loopifying; confirm preview does **not** flash forward playback before reverse completes. -2. **Rapid churn** — toggle direction or nudge trim handles twice while overlay is visible; expect overlay to settle or retry, **not** forced forward-only snackbar every time. -3. **Background** — start reverse, press Home, return; no crash; cancel path should not spam failures. -4. **Success path** — reverse completes, ping-pong preview plays, Save works. - ---- - -## Field verification (Crashlytics MCP or console) - -After the fixed build is on devices (note **versionName** / **versionCode**): - -1. `crashlytics_get_report` · `topIssues` · filter last 14 days · app `1:95815153197:android:c30254bb713d1e6ae96aa4`. -2. Confirm issue `3a506c4ecc5bfeff0ab2b56d58f6e1d6` has **no new events** on builds ≥ the fix version. -3. Optional: `crashlytics_list_events` on that issue — last event timestamp should be **before** the fixed rollout. -4. Watch `reverse_preview_failure` custom keys — Released/cancelled/empty `IllegalStateException` should drop; timeouts (`Timed out after 120s`) are a separate bucket. - -Use skill `/crashlytics-triage` for the full prioritize → investigate flow. - -### Baseline MCP snapshot (2026-06-03, before 1.0.18 rollout) - -| Issue | Last version | Last event (UTC) | Events on 1.0.18 (18) | -|-------|--------------|------------------|------------------------| -| `3a506c4e` | 1.0.9 (9) | 2026-06-03T18:14:54Z | 0 | -| `b09e527` | 1.0.17 (17) | 2026-06-03T20:40:17Z | 0 | - -`topVersions` (14d) did not list `1.0.18 (18)` — no Crashlytics traffic from the PR #62 ship line yet. - ---- - -## Related: issue `b09e527` (surface released at decoder configure) - -**Issue:** `VideoReverser.openAvcDecoderForReverse` · `IllegalArgumentException: The surface has been released` -**Console:** [b09e5277491a4d8935210b9914ca52c5](https://console.firebase.google.com/project/openloop-8c266/crashlytics/app/android:io.github.stozo04.openloop/issues/b09e5277491a4d8935210b9914ca52c5) - -Sample (1.0.17): pass 1 on **Google Android 17 (OS) emulator** (`sdk_gphone16k_x86_64`; not API level 17 — `minSdk` is 26) — encoder input [Surface] invalid before `decoder.configure`. Fix: `openSurfaceCodecPipeline` recreates encoder+surface once; do not rotate decoder names on a dead surface; outer `reverse()` retry when `isMediaCodecSurfaceReleasedFailure`. - ---- - -## Close criteria - -- [ ] Fixed build in Play internal / production track used by repro devices -- [ ] 7+ days with zero new events on `3a506c4e` for that build line -- [ ] 7+ days with zero new events on `b09e527` for that build line -- [ ] No regression in editor OOM issue `ef2823cf` (separate watch) diff --git a/docs/diagnostics/firebase-crashlytics-trimming.md b/docs/diagnostics/firebase-crashlytics-trimming.md deleted file mode 100644 index 1364b70..0000000 --- a/docs/diagnostics/firebase-crashlytics-trimming.md +++ /dev/null @@ -1,153 +0,0 @@ -# Firebase Crashlytics — preview reverse failures (“Trimming..”) - -This doc explains how OpenLoop reports **stuck or failed preview reverse** (the “Trimming..” / “Couldn’t loop that clip” path) to Firebase Crashlytics, how **you** view reports, and how **testers** can help without Android Studio. - -Official references: - -- [Get started with Crashlytics for Android](https://firebase.google.com/docs/crashlytics/android/get-started) -- [Customize crash reports (custom keys, `recordException`)](https://firebase.google.com/docs/crashlytics/android/customize-crash-reports) -- [Capture bug reports from devices](https://developer.android.com/studio/debug/bug-report) (system bug report — backup path) - ---- - -## What the app sends - -When preview reverse **times out** (120 s) or **throws**, `OpenLoopViewModel` calls `ReverseCrashlytics.reportPreviewFailure()`, which: - -1. Logs a Crashlytics breadcrumb: `reverse_preview_failure: ` -2. Calls **`FirebaseCrashlytics.recordException(cause, customKeys)`** as a **non-fatal** event - -Custom keys (searchable in the Firebase console) include: - -| Key | Example | -|-----|---------| -| `reverse_outcome` | `Timed out after 120s` or `CodecException: …` | -| `app_version` / `app_version_code` | `1.0.5` / `5` | -| `video_mime` | `video/hevc` | -| `video_width` / `video_height` / `video_fps` | `1920` / `1080` / `60` | -| `hevc_or_hdr_normalize` | `true` if pre-normalize ran | -| `trim_window_ms` | length of trim selection | -| `source_bytes` | scratch file size | - -Implementation: `app/src/main/java/io/github/stozo04/openloop/diagnostics/ReverseCrashlytics.kt`. - -**Upload timing:** Non-fatal events are stored on-device and sent on the **next app launch** (or with the next fatal crash), not instantly. That is normal Crashlytics behavior. - ---- - -## One-time setup (developer) - -### 1. Create / open a Firebase project - -1. Go to [Firebase console](https://console.firebase.google.com/) -2. Create a project (or use an existing one) for OpenLoop -3. Add an **Android app** with package name: `io.github.stozo04.openloop` - -### 2. Download `google-services.json` - -1. Firebase console → **Project settings** → **Your apps** → Android app -2. Download **`google-services.json`** -3. Place it at: **`app/google-services.json`** (repo root is `OpenLoop/`, file lives under `app/`) - -The file is **gitignored**. See `app/google-services.json.README` for the path. - -**This repo’s Firebase project:** `openloop-8c266` (console display name: OpenLoop). Re-download config: - -```bash -npx -y firebase-tools@latest apps:sdkconfig ANDROID \ - 1:95815153197:android:c30254bb713d1e6ae96aa4 \ - --project openloop-8c266 > app/google-services.json -``` - -(Strip any CLI status lines before the opening `{` if your shell mixes stderr into the file.) - -### 3. Build - -Gradle applies the Google Services and Crashlytics plugins **only when** `app/google-services.json` exists: - -```kotlin -// app/build.gradle.kts (bottom) -if (file("google-services.json").exists()) { - apply(plugin = "com.google.gms.google-services") - apply(plugin = "com.google.firebase.crashlytics") -} -``` - -Without the JSON file, the app still **compiles**; Crashlytics simply does not initialize (`ReverseCrashlytics` no-ops safely). - -### 4. Release builds - -Ship **release** AABs built **with** `google-services.json` present so Play testers report to your Firebase project. Play App Signing does not block Crashlytics. - -Enable **deobfuscation** in Firebase if you use R8 (mapping files from Play Console or local `mapping.txt` upload). - ---- - -## How you view data (Firebase console) - -1. Open [Firebase console](https://console.firebase.google.com/) → your project -2. Go to **Crashlytics** (left nav: Build → Crashlytics, or “Run” / DevOps depending on console layout) -3. Look under **Non-fatals** (or filter issues by non-fatal) -4. Open an issue → event detail shows: - - Stack trace (`PreviewReverseTimeoutException` for timeouts, or the real codec error) - - **Keys** tab — custom keys listed above - - **Logs** tab — breadcrumb `reverse_preview_failure: …` -5. Filter examples: - - Search keys: `reverse_outcome`, `video_mime`, `app_version` - - Filter by device model (e.g. Samsung SM-S926*) or Android version - -**Tip:** Group by `reverse_outcome` and `video_mime` to see whether Brazil/S24+ reports are HEVC timeouts vs codec exceptions. - -Crashlytics does **not** replace logcat for deep codec debugging, but it aggregates **how many** users hit which outcome on which devices. - ---- - -## How testers send data (no SDK) - -Testers have **three** paths, from easiest to heaviest: - -### A. In-app “Send debug info” (immediate, recommended) - -After the fix in PR [#51](https://github.com/stozo04/OpenLoop/pull/51): - -1. Reproduce: record or import → Trim → enter editor (**Trimming..**) -2. Wait up to **~2 minutes** — overlay should change to **“Couldn’t loop that clip”** -3. Tap **SEND DEBUG INFO** -4. Share via WhatsApp, email, etc. - -You receive plain text (device, app version, codec, trim window). No PC required. - -### B. Firebase Crashlytics (automatic, no tester action) - -If the build was compiled with `google-services.json`: - -1. Tester uses the app normally; reverse fails or times out -2. Tester **fully closes and reopens** the app (or uses it until the next sync) -3. You see a **non-fatal** in Firebase console (section above) - -Tell testers: *“If the app says it couldn’t loop, close the app completely and open it again once — that helps send the automatic report.”* - -### C. Android system bug report (heavy backup) - -For **infinite Trimming..** on an old build, or when Firebase is not configured: - -1. Settings → About phone → tap **Build number** 7× -2. Settings → **Developer options** → **Take bug report** -3. Wait → share the zip when prompted - -You can also use [SDK platform-tools](https://developer.android.com/tools/releases/platform-tools) only (`adb bugreport`) — no full Android Studio install required. - -**Avoid** telling non-technical users to install “Logcat Reader” apps; most require `adb tcpip` setup first. - ---- - -## Privacy note for testers - -Crashlytics events include **device model**, **Android version**, **app version**, and **video format metadata** — not the video file itself. The in-app share sheet sends the same class of data in text form. - ---- - -## Related - -- [`trimming-loop.md`](../../trimming-loop.md) — full postmortem and fix history -- [`docs/lessons_learned/020-imported-clips-hdr-codec-and-reverse-failure-recovery.md`](../lessons_learned/020-imported-clips-hdr-codec-and-reverse-failure-recovery.md) diff --git a/docs/e2e/.gitkeep b/docs/e2e/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/e2e/2026-06-04_154500.md b/docs/e2e/2026-06-04_154500.md deleted file mode 100644 index 85341ee..0000000 --- a/docs/e2e/2026-06-04_154500.md +++ /dev/null @@ -1,76 +0,0 @@ -# E2E run — 2026-06-04 (G6 no-regression gate for reverse-output-validation) - -| | | -|---|---| -| **Branch / build** | `fix/crashlytics-3a506c4e-pass1-codec-lifecycle` @ `f981f2b` (+e2e), debug 1.0.18 (18) | -| **Device** | `emulator-5556` — Pixel_6 AVD, `sdk_gphone16k_x86_64`, **API 37 (Android 17, 16 KB pages)**, **cold boot** (`-no-snapshot-load`) | -| **Purpose** | Prove the S23 zero-frame work (validator, zero-frame throw, cache guard, SW-encoder fallback, SaveFailed UX) did **not** regress a known-good device family; investigate this morning's 120 s preview timeout on this same image | -| **Driver** | adb input + uiautomator dumps (no screenshots), full unfiltered logcat captured throughout | - -## Verdict: ✅ PASS — no regression; the morning's 120 s timeout did not reproduce after a cold boot - -The full capture → trim → speed → loop → filter → save → gallery-playback flow succeeded. -The reverse pipeline ran **once, first attempt, no fallback**, exactly as a healthy device should: -`reverse.complete | pass1Encoder=c2.android.avc.encoder … attempts=1`, 467/467 samples validated. -`zero_frame_retry` never fired (as required — the S23 fallback must not engage here). -`BoomerangRenderWorker → Worker result SUCCESS`. - -## Flow - -| Step | Action | Result | -|------|--------|--------| -| Onboarding | fresh install → LET'S GO | ✓ camera screen | -| Record | shutter start/stop (emulated camera) | ✓ clip captured (22.3 s — see Finding 3) | -| Trim | slow drag (1300 ms) of end handle | ✓ first try: `00:22.3 → 00:15.7` | -| Speed | tap seek-track near 1x | ✓ `2x → 0.9x` ("Current speed" updated) | -| Loop | "Reverse then forward" (non-default, reverse-needing) | ✓ reverse completed in ~24 s for the 15.7 s 720p window; cached reuse on direction switch | -| Filter | B&W | ✓ no "Preview unavailable" banner | -| Save | "Save boomerang" | ✓ Transformer ran ~18 s → `Worker result SUCCESS` → share sheet shown, dismissed | -| Gallery | open → tap newest → plays | ✓ fresh `ExoPlayerImpl Init eb1b32c` (confirmed via logcat) | - -## Logcat scan (`scan-logcat.ps1`) - -| Class | Signature | Count | -|-------|-----------|-------| -| CRASH | FATAL EXCEPTION / ANR / process died | **0 / 0 / 0** | -| CRASH | `surface has been released` (b09e527) | **0** | -| CRASH | `dequeueOutputBuffer` native (3a506c4e) | **0** | -| CRASH | MediaCodec CodecException | **0** | -| TIMEOUT | reverse preview 120 s / non-fatal failure | **0 / 0** | -| CHURN | codec reclaim pressure / dead-thread race / buffer starvation / c2.* created | 33 / 40 / 69 / 31 (advisory — emulator-typical; no user-visible effect this run) | - -Reverse terminal events: single clean chain (`pass1.done 7,168,755 B` → `pass2.done 7,033,418 B, -samples=467` → `attempts=1` → `ensureReversed.ok`). The non-OpenLoop `Worker result FAILURE` -lines in the worker scan are AOSP Dialer voicemail workers (no SIM on emulator) — unrelated. - -## Findings - -1. **(Resolved as environmental) The morning's 120 s preview timeout did not reproduce.** Same - AVD image, same code (HEAD), real 720p clip, longer trim window — completed in 24 s. The failing - run's logcat showed pass 1 silently stalled right after `c2.goldfish.h264.decoder` selection on a - **snapshot-restored** instance whose buffer still contained the previous day's process. Combined - with HEAD passing `VideoReverserTest` 7/7 (synthetic) and 6/6 (real 720p fixture) on this image, - the timeout is attributed to the **pre-existing codec-slot-pressure / stale-snapshot disease** - (b09e527 / `editor-codec-churn`), not to the reverse-output-validation changes. Next step: the - serialized-codec-ownership work already specced in `editor-codec-churn/IMPLEMENTATION.md` (item - A); consider recommending cold-boot (`-no-snapshot-load`) for emulator test sessions in - TEST_COVERAGE.md. -2. **(Positive signal worth recording) When the timeout *did* happen this morning, the new failure - UX worked**: 120 s timeout → forward fallback → phase-aware diagnostic report (the user pasted - it verbatim). Before this branch, that scenario wedged the editor. -3. **(Smell, low) Recorded clip length vs. expectation**: shutter was stopped ~4–5 s after start by - wall clock of the driving script, but the clip was 22.3 s. Most plausibly automation latency - (uiautomator dumps take seconds each and the stop tap is issued after a dump); the on-screen - counter read `00:05 / 00:30` mid-run, consistent with recording continuing during dumps. Not - reproducible as a defect from this run; if a human sees the same discrepancy, investigate - `CameraManager` stop latency. -4. **(Friction, known) Trim handle** grabbed on the first slow drag this time — no new evidence for - the touch-sensitivity bug, but one sample proves nothing; the known issue stands. - -## Not verified in this run - -- Visual quality of the played loop (logcat-confirmed playback only — screenshots avoided per skill). -- The Samsung-only paths (`zero_frame_retry`, SW-encoder fallback) — intentionally: they must NOT - engage here, and didn't. Their positive verification is the S23 RTL session (RESEARCH.md §7d). -- Release build behavior (debug APK only). -- Import flow, repeated-save cache-hit timing, multi-loop gallery interactions. diff --git a/docs/e2e/2026-06-04_instrumented-suite-triage.md b/docs/e2e/2026-06-04_instrumented-suite-triage.md deleted file mode 100644 index f2fc9e5..0000000 --- a/docs/e2e/2026-06-04_instrumented-suite-triage.md +++ /dev/null @@ -1,157 +0,0 @@ -# Instrumented-suite triage — 38 failures → root causes → fixes (2026-06-04) - -**Context.** Running the full `connectedDebugAndroidTest` suite (82 tests) on the reference -emulator (`Pixel_6` AVD, Android 17 / API 37 16k image, booted `-memory 4096`) as part of -PR #65's Definition-of-Done gate produced **38 failures**. None were caused by the PR branch: -`git diff main..HEAD` over every implicated source/test file was empty — the branch's only -production change is `pass1SampleAction` arithmetic in `VideoReverser.kt`, which appears in no -failing stack. The suite simply had not been run end-to-end on this image since several -latent problems accumulated on `main`. Branch with fixes: `fix/instrumented-editor-fixture`. - -**Result after fixes: 82 tests, 0 failures, 1 skipped** (the Samsung-gated -`reverse_pass1SurvivesOnSamsung_afterPostTransformSettle`, correctly skipped on a Pixel image). - -| # | Failures | Tests | Root cause | Fix | -|---|---|---|---|---| -| 1 | 35 | `BoomerangEditorScreenTest` (25), `TrimScreenTest` (10) | `MediaMetadataRetriever.setDataSource` throws a **bare `RuntimeException`** on unreadable input; two call sites only caught IAE/ISE | catch `RuntimeException` → placeholder (production fix) | -| 2 | 2 | `PermissionExplanationScreenTest` | test-internal label typo: sets "Grant Permission**s**", asserts "Grant Permission" | align the two `setContent` labels (test fix) | -| 3 | 1 | `BoomerangRenderWorkerTest` | asserts `progress` on a **terminal** `WorkInfo`, which WorkManager wipes | sample progress from in-flight polls (test fix) | -| 4 | 1* | `LoopifyingScreenshotTest` | fixed filename in `/sdcard/Download` orphaned by APK reinstall → `EACCES` | unique per-run filename (test fix) | -| 5 | 2* | `speedTab_showsCurrentSpeedInPill`, `rangePill_reflectsTheTrimmedWindow` | trailing space in UI string / **shadowed duplicate `testTag`** | string + tag hygiene (production fixes) | - -\* #4 and the visibility form of #5 only became observable on the second run, after #1 stopped -masking them (a fixture crash in setup fails the test before its real assertions execute). - ---- - -## 1. The big one: 35 tests — `RuntimeException: setDataSource failed: status = 0xFFFFFFEA` - -**Why the tests failed.** Both UI test classes deliberately drive their screens with an empty -temp file (`File.createTempFile(...)` — "Lesson 017: plain lambdas + a temp File for the -(non-playing) source"). Composition then runs the screens' best-effort frame decoders: - -- `BoomerangEditorScreen.kt` → `extractRepresentativeFrame` (the Looks-tab thumbnail, - launched from a content-level `produceState` — i.e. on **every** editor composition, - whatever tab a test exercises, which is why all 25 editor tests failed), -- `TrimFrameExtractor.extractTrimFilmstripFrames` (the Trim filmstrip). - -`MediaMetadataRetriever._setDataSource` (native) rejects the empty file with **`RuntimeException: -setDataSource failed: status = 0xFFFFFFEA`** (−22, `EINVAL`). Both functions were written as -best-effort ("returns null on a decode failure") but caught only `IllegalArgumentException` and -`IllegalStateException` — the bare `RuntimeException` sailed through, killed the `produceState` -coroutine, and crashed the test. The codebase already knew this API quirk — `VideoImporter.kt` -and `VideoStorageRepositoryImpl.kt` both catch `RuntimeException` with the comment -"MediaMetadataRetriever surfaces decode failures as bare RuntimeExceptions" — and -`TrimFrameExtractor` itself caught it around `getFrameAtTime`, three lines below the -`setDataSource` call it didn't protect. The two fixed call sites were the only unguarded -**screen-side** decoders — but the PR #66 review applied the take-away grep below and found two -more in the save path: `VideoStorageRepositoryImpl.promoteScratchToRaw` and `registerBoomerang` -caught only `IOException`/`IllegalArgumentException` around `extractThumbnail` (which has no -catch of its own; the gallery-scan caller in `loadFrom` was already guarded). Both are now fixed -in the same pattern in this PR. - -**Why this matters beyond tests:** this is a **production crash path**. A source file that -becomes unreadable behind the Trim/Editor screens (truncated scratch, cleared cache, bad -import) would have crashed the app instead of degrading to the documented placeholder UI. The -tests were doing exactly their job. - -**The fix and why it works.** Append `catch (e: RuntimeException)` (returning the same -null/placeholder results) to both functions, mirroring the house pattern. The functions' -contract is explicitly best-effort; the catch converts the undocumented throw into the -already-designed degraded path (glass placeholder chips / empty filmstrip slots). The fix is -ordered after the existing IAE/ISE catches, so behavior for those documented throws is -unchanged. Verified: the 35 failures drop to 0 with no production-behavior change for valid -files (the full pixel-sweep E2E flow still passes on real video). - -## 2. `PermissionExplanationScreenTest` — 2 tests, label typo inside the test - -**Why they failed.** `noSecondaryAction_rendersPrimaryOnly` and -`primaryAndSecondaryClicks_invokeTheirCallbacks` pass `primaryActionLabel = "Grant Permissions"` -(plural) into the composable under test, then assert/click `onNodeWithText("Grant Permission")` -(singular). `onNodeWithText` is an exact matcher — no node carries the singular text, so the -assert fails ("is not displayed") and the click fails ("Failed to inject touch input"). The -test's other two methods already use the singular label (matching the production copy in -`MainActivity.kt:568`) — a label rename was applied to half the file. - -**The fix and why it works.** Change the two `setContent` labels to the singular -"Grant Permission". Setter and assertion now refer to the same string, and the whole file is -consistent with production copy. (The component under test renders whatever label it is given, -so this is purely test-internal consistency.) - -## 3. `BoomerangRenderWorkerTest` — progress asserted on a terminal `WorkInfo` - -**Why it failed.** `enqueueTinyForwardClip_succeedsAndDiscardsScratch` waited for the worker to -finish, then read `finished.progress`. Per the official docs (developer.android.com, "Observe -intermediate worker progress"): *"Progress information can only be observed and updated while -the ListenableWorker is running"* — WorkManager wipes progress at a terminal state. So the -terminal read returns `Data.EMPTY` → `-1` → assert fails. Any past pass of this assertion was a -race against WorkManager's internal progress cleanup, i.e. the test was flaky by design. - -**The fix and why it works.** `awaitTerminalWorkInfo` gained an `onInFlightPoll` callback; the -test records the maximum `PROGRESS_PERCENT` observed across polls **while the worker is -running**, and asserts on that. This matches the documented visibility window of the API. It is -reliable in practice because `BoomerangRenderWorker` publishes 0% immediately on start and -joins its progress publisher before returning success, and the poll interval was tightened -500 ms → 250 ms so even a fast tiny-clip render is sampled mid-run. - -## 4. `LoopifyingScreenshotTest` — orphaned file in shared Downloads - -**Why it failed (second run only).** The test writes its PR-proof screenshot to a **fixed** -filename in public `Download/`. Run 1 creates the file and passes. AGP then uninstalls the test -APK, orphaning the file's ownership. Run 2's fresh install cannot open another owner's file -under scoped storage → `EACCES` (the granted `WRITE_EXTERNAL_STORAGE` is a no-op at this API -level). The failure is invisible until you run the suite twice on one emulator — which the DoD -re-runs did. - -**The fix and why it works.** Unique per-run filename -(`openloop_loopifying-processing-42pct_.png`): each install only ever creates files it -owns, so no collision with orphans is possible. The screenshot still lands in Downloads -(surviving APK teardown, which is the point of the test); the KDoc documents pulling the newest. - -## 5. Two "pill" nodes unfindable — string and semantics hygiene - -These surfaced only after #1 unmasked them (their setups previously crashed first). - -**5a. `speedTab_showsCurrentSpeedInPill`.** Production rendered the label as -`Text("Current speed ")` — trailing space as ad-hoc spacing between the label and the value. -The test's exact matcher `onNodeWithText("Current speed")` therefore found nothing. -**Fix:** drop the trailing space and express the 4 dp gap as `padding(end = 4.dp)`. The node's -text now equals the asserted string; spacing is unchanged visually and the accessibility text -no longer carries a stray space. - -**5b. `rangePill_reflectsTheTrimmedWindow`.** The pill had **two `testTag`s on one node**: the -call site passed `Modifier.testTag("trim_range_pill")` and `TrimRangePill` appended -`.testTag("trim_range_label")` to that same modifier chain. For duplicate semantics keys, the -first value in the chain wins — the node's effective tag was `trim_range_pill`, and the test's -`trim_range_label` lookup found nothing. `trim_range_pill` is referenced by zero tests. -**Fix:** remove the unused caller-side tag (with a comment explaining the shadowing) so the -component's own documented tag is the node's tag again. - ---- - -## Verification - -- Full `connectedDebugAndroidTest` on the Pixel 6 reference image: **82 tests / 0 failures / - 1 skipped** (Samsung-gated, expected) — see XML under `app/build/outputs/androidTest-results/`. -- JVM suite `:app:testDebugUnitTest`: 191 / 0 (unchanged — none of these fixes touch JVM-tested code). -- `:app:lintDebug`: zero new errors. -- Production-behavior sanity: the scripted pixel-sweep flow (import → … → save → quality gate) - passes on the same build, confirming the two production fixes (RuntimeException catches, speed - pill text, removed duplicate tag) changed no real-video behavior. - -## Take-aways (candidate lessons) - -1. **A "best-effort" media helper must catch what the API actually throws, not what it - documents.** `MediaMetadataRetriever` throws bare `RuntimeException`s from native; the repo - knew this in three places and missed it in **four** — two screen-side decoders fixed first, - then the PR #66 review applied this very grep and caught two more (`promoteScratchToRaw` / - `registerBoomerang` around the uncaught `extractThumbnail`). Grep candidate, now satisfied: - every `MediaMetadataRetriever` use must have a `RuntimeException` catch on its path. -2. **WorkManager progress is only observable while RUNNING** — asserting it on a terminal - `WorkInfo` is a race you sometimes win. Observe in-flight. -3. **One node, one `testTag`** — a second tag earlier in the modifier chain silently shadows - the inner one. -4. **Don't encode layout spacing in string literals** — trailing spaces break exact-match - test lookups and TalkBack phrasing. -5. **Fixed filenames in shared storage + APK reinstall cycles = EACCES on the orphan.** - Unique names per run, or write app-owned storage. diff --git a/docs/e2e/2026-06-21_150054.md b/docs/e2e/2026-06-21_150054.md deleted file mode 100644 index b5f2c9b..0000000 --- a/docs/e2e/2026-06-21_150054.md +++ /dev/null @@ -1,66 +0,0 @@ -# E2E sweep — Pixel 6 / Pixel 8 / Pixel 10 Pro Fold - -- **Date:** 2026-06-21 -- **App:** `io.github.stozo04.openloop` **1.0.23 (23)** — built from the **working tree** (includes the uncommitted `VideoReverser` software-codec fallback fix + worker Crashlytics reporting from this session). -- **Flow per device:** record a clip → modify **every** editor tab (Trim, Speed, Loop, Filter) → Save → confirm the share modal → open the library → confirm the clip is present → tap it → confirm it plays. -- **Loop direction:** `Reverse then forward` on every device (per request — exercises `VideoReverser`, the path behind Crashlytics 47233ad7). -- **Scoring:** strict — any reverse-preview timeout / "forward only" fallback counts as a device failure (per request). - -## Verdict: ✅ PASS on all 3 devices - -| Device | AVD | Display | Reverse preview | Render worker | Library + playback | Crashes | Verdict | -|---|---|---|---|---|---|---|---| -| Pixel 6 | `Pixel_6` | 1080×2400 | OK ~23s, `attempts=1` | SUCCESS | clip present, plays (B&W, visually confirmed) | 0 | ✅ PASS | -| Pixel 8 | `Pixel_8` | 1080×2400 | OK ~5s, `attempts=1` | SUCCESS (~7s) | clip present, plays (B&W, visually confirmed) | 0 | ✅ PASS | -| Pixel 10 Pro Fold | `Pixel_10_Pro_Fold` | 2076×2152 | OK ~4s, `attempts=1` | SUCCESS (~10s) | clip present, plays (overlay + `c2.goldfish.h264.decoder` rendering; PNG unreadable by viewer, confirmed via logcat + UI dump) | 0 | ✅ PASS | - -## Per-step flow - -| Step | Pixel 6 | Pixel 8 | Pixel 10 Pro Fold | -|---|---|---|---| -| Onboarding → "LET'S GO!" | ✓ | ✓ | ✓ | -| Record clip | ✓ (~5s) | ✓ (~4s) | ✓ (~4s) | -| **Trim** (drag end handle) | 21.5s → 14.7s | 7.4s → 4.9s | 7.3s → 4.8s | -| **Speed** (tap track) | 2x → 0.9x | 2x → 0.9x | 2x → 1x | -| **Loop** (Reverse then forward) | reverse OK | reverse OK | reverse OK | -| **Filter** (B&W) | ✓, no "preview unavailable" | ✓ | ✓ | -| **Save boomerang** | worker SUCCESS | worker SUCCESS | worker SUCCESS | -| Share modal (system Quick Share) | ✓ | ✓ | ✓ | -| Library shows clip | ✓ | ✓ | ✓ | -| Tap → plays | ✓ (ExoPlayer Init) | ✓ (ExoPlayer Init) | ✓ (ExoPlayer Init + decoder render) | - -## Logcat scan (CRASH / TIMEOUT / CHURN) - -| Signature | Class | P6 | P8 | P10 Fold | -|---|---|---|---|---| -| FATAL EXCEPTION | CRASH | 0 | 0 | 0 | -| ANR | CRASH | 0 | 0 | 0 | -| process died | CRASH | 0 | 0 | 0 | -| surface has been released (b09e527) | CRASH | 0 | 0 | 0 | -| dequeueOutputBuffer native (3a506c4e) | CRASH | 0 | 0 | 0 | -| MediaCodec CodecException | CRASH | 0 | 0 | 0 | -| reverse preview timeout (120s) | TIMEOUT | 0 | 0 | 0 | -| reverse preview failure (non-fatal) | TIMEOUT | 0 | 0 | 0 | -| codec reclaim pressure | CHURN | 34 | 39 | 52 | -| dead-thread handler race | CHURN | 20 | 22 | 14 | -| codec buffer starvation | CHURN | 67 | 84 | 103 | -| codec components created (c2.*) | CHURN | 31 | 29 | 35 | - -**All CRASH and TIMEOUT rows are 0 on every device.** CHURN rows are advisory only — expected resource churn from the emulator's software codec (`c2.android.avc.encoder` + `c2.goldfish.h264.decoder`) running the two-pass reverse plus the Transformer export. No crash correlated with them. - -## Findings - -1. **No hard findings.** Record → 4-tab-edit → reverse → save → share → library → playback completed cleanly on all three devices, with the B&W look correctly baked into the saved boomerang (verified in the playback frame on P6 and P8). -2. **Smell — share-sheet `FileProvider` permission-denial warnings (P6).** The system chooser (`com.android.intentresolver`) logged `Permission Denial: opening provider androidx.core.content.FileProvider … not exported` while building its preview. The share to a chosen target still works (the share Intent grants per-target URI read permission); this is the chooser-*preview* process being denied — a known Android quirk, not a save/share failure. Worth a glance if share-target previews look blank in the field. - -## What could NOT be verified (honest gaps) - -- **The new software-codec fallback branch is NOT exercised by *this happy-path sweep*** — every reverse completed with `attempts=1` because the emulator already selects the *software* AVC encoder (`c2.android.avc.encoder`) + goldfish decoder by default, so nothing rejects `start()`. **This gap is now closed by a dedicated fault-injection test** (added 2026-06-21): `VideoReverserTest.reverse_recoversFromCodecStartFailure_viaSoftwareFallback` injects `IllegalArgumentException("start failed")` on attempt 0 via the `VideoReverser.attemptHook` test seam, then asserts the reverse retries on the software **encoder + decoder** and still produces a valid, playable clip. It **passed on Pixel 6** (`tests=1 failures=0 errors=0`, 2.53s) — exercising the exact 47233ad7 recovery path on-device. The only thing still unverified is that path on the *literal* LG LM-X540 hardware (Test Lab has no LG devices; would need the physical phone). -- **Strict-scoring caveat:** the "fail on any reverse timeout" rule never triggered — no device timed out. On slower hardware or a busier host this path can still hit the 120s budget. -- **Pixel 10 Fold playback screenshot** could not be rendered by the image viewer (unusual PNG from the 2076×2152 capture); playback was confirmed via `ExoPlayerImpl: Init`, the active `c2.goldfish.h264.decoder` output, and the "Close preview" overlay rather than a viewed frame. -- Camera capture is the emulator's synthetic scene (the checkerboard-TV living room), not a real camera sensor. -- Only the inner (unfolded) display of the Fold was tested; the cover display was not. - -## Artifacts - -Screenshots + per-device logcat captured under `…/Temp/openloop_e2e/` (`p6_share.png`, `p6_playback.png`, `p8_playback.png`, `p10_playback.png`, `logcat_p6_*.txt`, `logcat_p8_*.txt`, `logcat_p10_*.txt`). diff --git a/docs/e2e/2026-06-22_082330-api34-fgs-fix.md b/docs/e2e/2026-06-22_082330-api34-fgs-fix.md deleted file mode 100644 index 6d5ee43..0000000 --- a/docs/e2e/2026-06-22_082330-api34-fgs-fix.md +++ /dev/null @@ -1,55 +0,0 @@ -# OpenLoop E2E run — 2026-06-22 08:23 (API 34 FGS fix verification) - -**Build:** versionName 1.0.23 (versionCode 23) · debug APK (with FGS type fix + Robolectric wiring, uncommitted on `main`) -**Device:** Google sdk_gphone64_x86_64 (Pixel 8 profile) · Android 14 (API **34**) · x86_64 · serial `emulator-5556` · AVD `Pixel_8_API34` -**Branch / commit:** `main` @ `82f3dca` (+ local crash fix) -**Driver:** Manual capture→save flow on new API-34 AVD (Crashlytics 9663c743 regression) -**Logcat:** `C:\Users\gates\AppData\Local\Temp\openloop_api34_fgs\logcat.txt` -**Screenshots:** `docs/e2e/api34-fgs-fix/01_share_sheet_api34_fix.png`, `docs/e2e/api34-fgs-fix/02_gallery_playback_api34_fix.png` - -## Verdict - -**PASS** — Loopify save completes on Android 14 (API 34) with no `InvalidForegroundServiceTypeException`. The share sheet shows `boom_*.mp4` and gallery playback opens; this is the exact OS/API combination that fatally crashed in Crashlytics 9663c743 on v1.0.23 before the FGS-type gate fix. - -## Flow coverage - -| Step | Action | Result | Evidence (logcat line / UI text) | -|------|--------|--------|----------------------------------| -| Launch | cold boot `Pixel_8_API34`, install, onboarding | ✅ | `MainActivity` launched | -| Record | capture ~11 s clip (virtualscene camera) | ✅ | Trim screen with `00:00.0 — 00:27.6` | -| Speed tab | tap seekbar → **2x** | ✅ | `Current speed` chip shows **2x** | -| Loop tab | **Forward loop** | ✅ | direction selected | -| Filter tab | **B&W** | ✅ | B&W chip selected | -| Create/Save | render boomerang under FGS | ✅ | `Worker result SUCCESS … BoomerangRenderWorker` @ 08:22:33; share sheet `boom_1782134538690_from_1782134538484.mp4` | -| View loop | gallery thumbnail → playback overlay | ✅ | screenshot `02_gallery_playback_api34_fix.png` | - -## FGS regression check (the point of this run) - -| Check | Result | -|-------|--------| -| `InvalidForegroundServiceTypeException` / `Starting FGS with type unknown` | **0 occurrences** | -| `SystemForegroundService` started | ✅ `Started foreground service … SystemForegroundService` @ 08:22:19 | -| `BoomerangRenderWorker` outcome | ✅ `Worker result SUCCESS` @ 08:22:33 | -| Share sheet after save | ✅ screenshot `01_share_sheet_api34_fix.png` | - -On API 34 the fix requests `dataSync` (type `1`) instead of the API-35-only `mediaProcessing` (8192) that v1.0.23 passed and that the Galaxy A55 rejected. - -## Concerning logs - -| Class | Signature | Count | Notes | -|-------|-----------|------:|-------| -| CRASH | FATAL EXCEPTION (app crash) | 0 | | -| CRASH | ANR (app not responding) | 2 | system/other packages during cold boot — **none** mention `io.github.stozo04.openloop` | -| CRASH | process died | 0 | | -| TIMEOUT | reverse preview timeout | 0 | | -| CHURN | codec components created (c2.*) | 27 | expected for capture + reverse + render on emulator | - -## Findings & issues to research deeper - -1. **None (hard)** — the FGS crash path is fixed on API 34 in this run. - -## What could not be verified - -- **Pre-fix repro on this AVD:** we did not reinstall the broken v1.0.23 binary without the fix; Robolectric + the failing unit test already demonstrated the old logic produces type 8192 on API 34. -- **Physical Samsung Galaxy A55 (SM-A556E):** emulator confirms the FGS contract; device-specific codec behavior still differs (see pixel-sweep honesty rules). -- **Trim handle drag:** skipped — landed on editor with full trim window; speed/loop/filter/save still exercised the render worker FGS path. diff --git a/docs/e2e/api34-fgs-fix/01_share_sheet_api34_fix.png b/docs/e2e/api34-fgs-fix/01_share_sheet_api34_fix.png deleted file mode 100644 index 33a218b..0000000 Binary files a/docs/e2e/api34-fgs-fix/01_share_sheet_api34_fix.png and /dev/null differ diff --git a/docs/e2e/api34-fgs-fix/02_gallery_playback_api34_fix.png b/docs/e2e/api34-fgs-fix/02_gallery_playback_api34_fix.png deleted file mode 100644 index 416c363..0000000 Binary files a/docs/e2e/api34-fgs-fix/02_gallery_playback_api34_fix.png and /dev/null differ diff --git a/docs/e2e/fgs-guard-launch-proof.png b/docs/e2e/fgs-guard-launch-proof.png deleted file mode 100644 index 12c0549..0000000 Binary files a/docs/e2e/fgs-guard-launch-proof.png and /dev/null differ diff --git a/docs/e2e/fold-loop/2026-06-04_160734-iter-1.md b/docs/e2e/fold-loop/2026-06-04_160734-iter-1.md deleted file mode 100644 index 949b245..0000000 --- a/docs/e2e/fold-loop/2026-06-04_160734-iter-1.md +++ /dev/null @@ -1,129 +0,0 @@ -# E2E Fold Loop — Iteration 1 (2026-06-04) - -**Verdict: all three devices PASS goal items 1–8 on the post-fix APK — but this iteration -required a mid-run code fix (BUG-1), so it does NOT count as the clean sweep. Iteration 2 must -confirm.** - -- Branch: `feature/e2e-fold-loop-fixes` · app 1.0.18 (versionCode 18) + fix commit `731b26b` -- Source video: `google-pro-fold-video.mp4` — 2,602,743 bytes, 1280×720 H.264 8-bit SDR (bt709), - 30 fps (real frame spacing 33,222 µs), 5.447 s, AAC audio + data track -- Build: `:app:assembleDebug` BUILD SUCCESSFUL, exit 0, zero `e:` lines (verified twice — before - and after the fix) -- Tests after fix: `:app:testDebugUnitTest` **191 passed / 0 failed / 0 skipped** · - `:app:lintDebug` zero new errors (4 stale baseline entries noted by lint as "fixed", baseline - untouched) -- Emulators (all cold-booted `-no-snapshot-load`, run sequentially): `Pixel_6`, `Pixel_8`, - `Pixel_10_Pro_Fold` — all Android 17 (API 37) 16k x86_64 images, host software codecs - -## Per-device flow results (post-fix APK) - -| Step | Pixel 6 | Pixel 8 | Fold | -|---|---|---|---| -| 1. Import → Trim screen | PASS | PASS | PASS | -| 2. Trim drag (slow ~1300 ms) | PASS — 5.4→3.8 s, first try | PASS — first try | PASS — first try | -| 3. Speed change | PASS — 2x→1x (3 taps to land exactly 1x; see F-3) | PASS — 1 tap | PASS — 1 tap | -| 4. All 4 loop directions | PASS — no timeout/fallback | PASS | PASS | -| 5. B&W look | PASS (export chroma U=V=128 proves filter applied) | PASS | PASS | -| 6. Save → share sheet + worker SUCCESS | PASS (5.4 s render) | PASS (5.5 s) | PASS (5.4 s) | -| 7. Quality gate (below) | PASS | PASS | PASS | -| 8. Gallery playback + clean scan | PASS | PASS | PASS | -| Fold/unfold mid-preview | n/a | n/a | PASS — editor + player instance survived; emulator locks screen on fold (keyguard dismissed via `wm dismiss-keyguard` + wakeup) | - -Reverse pipeline per device (post-fix): pass1 `encoded=116/114/115, skipped=0`; pass2 -`frames=sync` (all-keyframe honored); `reverse.complete attempts=1` in ~3–4 s; no Samsung -fallback events (`samsung=false` throughout). - -## Quality gate - -| Metric | Pixel 6 | Pixel 8 | Fold | -|---|---|---|---| -| Structure | 1 video track H.264 1280×720 | same | same | -| Duration vs trim×speed×direction | 7.674 s ≈ 2×3.84 s ✓ | 7.541 ≈ 2×3.77 ✓ | 7.327 ≈ 2×3.66 ✓ | -| Frames (both halves 30 fps) | 231 | 227 | 213 | -| Mirror SSIM f25↔f75 | 0.954 | 0.963 | 0.962 | -| Ends SSIM f0↔f99 | 0.943 | 0.959 | 0.949 | -| freezedetect (-60 dB, 0.5 s) | none | none | none | -| Green/black scan (YAVG range; U/V) | 129.8–133.6; U=V=128 (B&W) | 129.7–133.6; same | 129.7–133.5; same | -| Visual frame check (reversed half) | clean | clean | clean | - -## Logcat scans - -| Signature (class) | Pixel 6* | Pixel 8 | Fold | -|---|---|---|---| -| FATAL / ANR / process died / surface released / dequeueOutputBuffer / CodecException (CRASH) | 0 | 0 | 0 | -| Reverse timeout / preview failure (TIMEOUT) | 0 | 0 | 0 | -| Codec reclaim pressure (CHURN) | 269 | 84 | 148 | -| Dead-thread handler race (CHURN) | 220 | 88 | 142 | -| Codec buffer starvation (CHURN) | 451 | 156 | 260 | -| Codec components created `[c2.*]` (CHURN) | 112 | 45 | 65 | - -\* Pixel 6 capture spans TWO full E2E runs (pre-fix + post-fix re-run) plus a `pm clear`, so its -CHURN counts are not comparable to the single-run devices. Baseline for future iterations: -Pixel 8 single-run ≈ 45 components. - -## Bugs - -### BUG-1 — FIXED: pass-1 subsampler halves at-cap sources to 15 fps and corrupts the reversed half - -- **Found on:** Pixel 6 (device-independent math; would have reproduced everywhere). -- **Symptom:** saved boomerang's reversed half ~15 fps (forward half 30 fps) **and** heavy - macroblock smear on moving regions of the reversed half (evidence frames pulled; static - background clean, face destroyed). -- **Root cause:** `VideoReverser.kt` — `minEncodeIntervalUs = 1_000_000/30 = 33,333 µs` - (`VideoReverser.kt:251`) vs the source's real 33,222 µs frame spacing; `pass1SampleAction`'s - floor comparison (`VideoReverser.kt:1088` pre-fix) skipped every other frame. Compounding: - pass 1 "skips" by **dropping compressed samples before the decoder** - (`runDecodeEncodeLoop`, `VideoReverser.kt:543-579`), so skipped P-frame references are lost - and every fed frame decodes against the wrong reference → smear baked into the all-keyframe - intermediate (pass 2's own seeks were fine: `frames=59, sync=59`). The export reuses the same - trim-keyed reverse cache as the preview, so the corruption ships in the saved file. -- **Fix:** quarter-interval jitter tolerance in `pass1SampleAction` so subsampling only engages - for sources genuinely above `MAX_PASS1_ENCODE_FPS`; 60 fps sources still halve to the cap - (worst-case overshoot cap×4/3 ≈ 40 fps for a 120 fps source). + 2 regression tests - (`Pass1SampleActionTest`). Commit `731b26b`. -- **Verified:** Pixel 6 re-run post-fix — pass1 116/116, 231-frame 30 fps output, corrupt region - clean, mirror SSIM 0.88→0.95; then Pixel 8 + Fold full passes on the same APK. -- **LOUD CALL-OUT (Samsung):** the fix is in `VideoReverser.kt` but does NOT touch the Samsung - carve-out / encoder try-order / sticky fallback. Behavior change that does reach Samsung: - an at-cap source whose timestamps jitter low now feeds **all** frames through pass 1 - (~2× encode work vs the buggy halving). If the verified S23 clip had low-jitter timestamps, - its pass-1 frame count doubles. **Recommend a one-off S23 re-verification of save when - hardware is available.** (If S23 timestamps were exact-interval, nothing changes there.) - -### BUG-2 — OPEN (latent, needs owner sign-off): legitimate subsampling still corrupts >30 fps sources - -- When the cap legitimately engages (a 60–120 fps library import), pass 1 still drops - *compressed* samples → broken P-frame chains → the same smear. Correct design: feed every - in-window sample to the decoder and make the ENCODE/SKIP decision at **render** time - (`releaseOutputBuffer(idx, false)` for skipped frames). -- Not fixed this iteration: the loop's prescribed source is 30 fps (never subsamples post-fix), - so an in-loop verification is impossible without a synthetic high-fps clip; and the change - restructures `runDecodeEncodeLoop`, the loop the verified S23 save depends on. -- Documented in `MAX_PASS1_ENCODE_FPS` KDoc. **Decision needed:** fix in a later iteration with - a synthetic 60 fps verification clip, or file as a standalone issue. - -## Findings (non-blocking) - -- **F-1** `ChooserPreview: Could not read content://…fileprovider/videos/boom_….mp4 metadata` - ×3 per save (system chooser process). The share sheet works; the OS preview can't query - metadata from `FileProvider` (standard FileProvider limitation). Cosmetic; no action proposed. -- **F-2** Trim handles behaved on all 3 devices with the slow-drag technique (~1300 ms) — the - known touch-sensitivity bug did not reproduce in this loop's runs. -- **F-3** Speed SeekBar tap-to-position took 3 attempts to land exactly on 1x on Pixel 6 - (0.9x first) — continuous slider, tap mapping is fine; driving friction, not a defect. -- **F-4** Fresh-install runs hit the POST_NOTIFICATIONS dialog after the first save (render - worker foreground notification); harness must dismiss it (Pixel 6 re-run did). -- **F-5** Folding the Fold AVD turns the screen off → keyguard on unfold. Harness needs - `wm dismiss-keyguard` + `KEYCODE_WAKEUP`. The app itself resumed perfectly. - -## Could not verify (honest list) - -- Filter/editor previews were confirmed by absence of error states + the export's chroma - evidence, not by visually watching the preview play (uiautomator-driven, no screen recording). -- The gallery playback was confirmed via fresh `ExoPlayerImpl Init` + zero playback errors + - newest-first grid order, not visually. -- Emulators run host software codecs: this sweep says **nothing** about the physical Fold's - `PreviewReverseTimeoutException` (Crashlytics `f0ec3148…`) or any Tensor/Exynos behavior. The - 120 s-timeout defects in the field guide (no in-pipeline deadline, timeout-abandons-not-reaps, - no save-path timeout) were NOT triggered on these emulators and remain open at HEAD. -- Samsung path untested (no hardware) — see BUG-1 call-out. diff --git a/docs/e2e/fold-loop/2026-06-04_162455-iter-2.md b/docs/e2e/fold-loop/2026-06-04_162455-iter-2.md deleted file mode 100644 index 0563037..0000000 --- a/docs/e2e/fold-loop/2026-06-04_162455-iter-2.md +++ /dev/null @@ -1,55 +0,0 @@ -# E2E Fold Loop — Iteration 2 (2026-06-04) — CLEAN SWEEP, GOAL MET - -**Verdict: all three devices PASS goal items 1–8 with ZERO code changes during the iteration. -Loop exit condition satisfied.** - -- Branch/commit under test: `feature/e2e-fold-loop-fixes` @ `fd920b0` (fix `731b26b` + iter-1 - docs; no code touched this iteration) -- Build: `:app:assembleDebug` BUILD SUCCESSFUL, exit 0 (all tasks UP-TO-DATE — same APK as the - iter-1 post-fix re-runs) -- All devices cold-booted (`-no-snapshot-load`), run sequentially, fresh app state (`pm clear`) - -## Per-device results - -| Step | Pixel 6 | Pixel 8 | Fold | -|---|---|---|---| -| Import → Trim | PASS | PASS | PASS | -| Trim drag | PASS (5.4→3.8 s) | PASS | PASS | -| Speed 2x→1x | PASS (1 tap) | PASS (1 tap) | PASS (1 tap) | -| All 4 directions | PASS, no timeout/fallback | PASS | PASS | -| B&W look | PASS | PASS | PASS | -| Save → share sheet + worker SUCCESS | PASS (6 s) | PASS (6 s) | PASS (6 s) | -| Quality gate | PASS | PASS | PASS | -| Playback + clean scan | PASS | PASS | PASS | -| Fold/unfold mid-preview | n/a | n/a | PASS (editor + selection intact after keyguard) | - -Reverse pipeline: pass1 `encoded=116/115/116, skipped=0`; pass2 `frames=sync`; ~3 s each; -`attempts=1`; no Samsung fallback. - -## Quality gate - -| Metric | Pixel 6 | Pixel 8 | Fold | -|---|---|---|---| -| Duration / frames | 7.674 s / 231 | 7.608 s / 229 | 7.392 s / 214 | -| Mirror SSIM f25↔f75 | 0.954 | 0.963 | 0.936 | -| freezedetect | none | none | none | - -## Logcat scans (single clean run each) - -| Signature | P6 | P8 | Fold | -|---|---|---|---| -| All CRASH classes | 0 | 0 | 0 | -| All TIMEOUT classes | 0 | 0 | 0 | -| Codec components `[c2.*]` (CHURN, advisory) | 43 | 43 | 54 | -| Reclaim / dead-thread / starvation (CHURN) | 74/58/144 | 70/58/142 | 95/70/189 | - -CHURN steady across devices and consistent with iter-1's single-run baseline (≈45 components; -Fold slightly higher due to the posture-toggle rebind). No growth signal. - -## Carried-forward items (unchanged from iter 1) - -- BUG-2 open (latent >30 fps subsampling corruption) — owner decision needed. -- S23 re-verification recommended (BUG-1 fix increases pass-1 encode count for low-jitter - at-cap sources). -- Emulator evidence says nothing about the physical Fold Crashlytics timeouts (field-guide - defects 1–3 remain open at HEAD). diff --git a/docs/e2e/fold-loop/STATE.md b/docs/e2e/fold-loop/STATE.md deleted file mode 100644 index 0e1c1d0..0000000 --- a/docs/e2e/fold-loop/STATE.md +++ /dev/null @@ -1,44 +0,0 @@ -# E2E Fold Loop — State - -- Iteration: 2 (last run 2026-06-04_162455) — **LOOP COMPLETE: clean sweep confirmed** -- Source video: `C:\Users\gates\Personal\OpenLoop\google-pro-fold-video.mp4` 2,602,743 bytes · - 5.447 s · 1280×720 H.264 8-bit SDR · 30 fps (real spacing 33,222 µs) · AAC + data track -- AVD names: `Pixel_6` | `Pixel_8` | `Pixel_10_Pro_Fold` (all Android 17 / API 37, 16k x86_64) -- Last iteration result: P6 PASS · P8 PASS · Fold PASS — zero code changes (iter-2 report: - `2026-06-04_162455-iter-2.md`) -- Consecutive clean sweeps: 1 - -## Open bugs - -- [BUG-2] Pass-1 subsampling (engages only for >30 fps sources) drops compressed samples before - the decoder → broken P-frame chains → macroblock smear in the reversed half — root cause - `VideoReverser.kt` `runDecodeEncodeLoop` (skip-at-input, ~:543-579); correct fix is - skip-at-render (`releaseOutputBuffer(idx, false)`). Fix attempts 0/3 — **deliberately - deferred**: not reachable with the loop's 30 fps source post-BUG-1-fix, and restructures the - decode loop the verified S23 save depends on. Needs owner decision (fix w/ synthetic 60 fps - clip vs standalone issue). - -## Fixed this loop - -- [BUG-1] At-cap (30 fps) sources subsampled to 15 fps + reversed-half macroblock corruption - (timestamp-jitter floor comparison in `pass1SampleAction`) — commit `731b26b`, verified on - Pixel 6 (pre/post evidence), Pixel 8, Fold. - -## Notes for the harness (cost time in iter 1) - -- Pull app-private files with `cmd /c "adb exec-out run-as io.github.stozo04.openloop cat > out"` - — `run-as … > /sdcard/…` writes 0 bytes; PowerShell pipes mangle binary. -- Fresh-install saves trigger the POST_NOTIFICATIONS dialog — dismiss it (tap Allow by coords). -- Fold AVD: `emu fold` locks the screen; after `emu unfold` run `wm dismiss-keyguard` + - `input keyevent KEYCODE_WAKEUP`. -- After any reverser fix, `pm clear` the app before re-running — the trim-keyed reverse cache - will otherwise serve the pre-fix artifact. -- Speed slider is continuous: tap ~35 px right of the "1x" tick label center (Pixel 6/8 scale). -- CHURN baseline (single clean run, Pixel 8): 45 `Created component [c2.*]`. - -## Loop exit (2026-06-04) - -- Iteration 2 confirmed the clean sweep — goal met, loop ended. Next step: open a PR from - `feature/e2e-fold-loop-fixes` (full Definition-of-Done gate applies at PR time). -- Carry forward to PR/issues: BUG-2 decision; S23 re-verify recommendation (BUG-1 fix increases - pass-1 encode count for low-jitter at-cap sources on every device incl. Samsung). diff --git a/docs/guides/README.md b/docs/guides/README.md index 6bdf7a6..c8b1606 100644 --- a/docs/guides/README.md +++ b/docs/guides/README.md @@ -6,9 +6,11 @@ step-by-step instructions rather than architecture specs. | Guide | When to read it | |-------|-----------------| | [`oem-regression-testing.md`](oem-regression-testing.md) | **API 34 / Samsung / LG regression lanes** — emulator sweeps, Robolectric, RTL, LG fault injection | -| [`robolectric-testing-explained.md`](robolectric-testing-explained.md) | What Robolectric is, repo setup, `@Config(sdk=[...])`, prioritized test targets | +| [`robolectric-testing-explained.md`](robolectric-testing-explained.md) | What Robolectric is, repo setup, `@Config(sdk=[...])`, when to use it vs device tests | +| [`robolectric-test-catalog.md`](robolectric-test-catalog.md) | **Inventory of all Robolectric/JVM-framework tests** — phases, run commands, method list, device complements | | [`samsung-rtl-steps.md`](samsung-rtl-steps.md) | One-time Samsung Remote Test Lab setup (RDB, adb PATH, manual smoke) | | [`jetpack-datastore-explained.md`](jetpack-datastore-explained.md) | What DataStore is; inspect/reset onboarding on a device | +| [`reverse-video-research.md`](reverse-video-research.md) | Locked two-pass MediaCodec reverse algorithm — cited in `VideoReverser.kt` | Testing strategy and inventory: [`../TEST_COVERAGE.md`](../TEST_COVERAGE.md). diff --git a/docs/guides/oem-regression-testing.md b/docs/guides/oem-regression-testing.md index 2b5cce6..a820498 100644 --- a/docs/guides/oem-regression-testing.md +++ b/docs/guides/oem-regression-testing.md @@ -11,8 +11,8 @@ LG phone when it isn't. |-----|------| | [`docs/TEST_COVERAGE.md`](../TEST_COVERAGE.md) | Overall testing pyramid and inventory | | [`docs/guides/robolectric-testing-explained.md`](robolectric-testing-explained.md) | Robolectric setup and when to use it | +| [`docs/guides/robolectric-test-catalog.md`](robolectric-test-catalog.md) | Inventory of all Robolectric tests, run commands, device complements | | [`docs/guides/samsung-rtl-steps.md`](samsung-rtl-steps.md) | One-time Samsung Remote Test Lab (RTL) setup | -| [`docs/e2e/2026-06-22_082330-api34-fgs-fix.md`](../e2e/2026-06-22_082330-api34-fgs-fix.md) | First verified API-34 FGS fix run | | [`docs/lessons_learned/024-fgs-type-constant-api-gating.md`](../lessons_learned/024-fgs-type-constant-api-gating.md) | FGS type must gate on the API that *added* the constant | **Agent skills** (same content, runnable commands): `.claude/skills/run-e2e-pixel-sweep/SKILL.md` @@ -236,7 +236,6 @@ Pass: `tests=1 failures=0 errors=0`. | `.claude/skills/run-e2e-pixel-sweep/scripts/samsung-rtl-prep.ps1` | RTL device prep | | `.claude/skills/run-e2e-pixel-sweep/scripts/samsung-rtl-sweep.ps1` | Full RTL regression | | `.claude/skills/run-e2e/scripts/scan-logcat.ps1` | Includes FGS-type-unknown CRASH signature | -| `docs/e2e/2026-06-22_082330-api34-fgs-fix.md` | First live API-34 pass report + screenshots | --- diff --git a/docs/active/boomerang-rollout/RESEARCH-reverse-video.md b/docs/guides/reverse-video-research.md similarity index 98% rename from docs/active/boomerang-rollout/RESEARCH-reverse-video.md rename to docs/guides/reverse-video-research.md index c8cf3c5..13caecf 100644 --- a/docs/active/boomerang-rollout/RESEARCH-reverse-video.md +++ b/docs/guides/reverse-video-research.md @@ -1,7 +1,8 @@ # Research — Reversing video on Android (verified) -Resolves the open question flagged in [slice 02](./02-auto-route-trim-and-default-save.md) -and [slice 03](./03-editor-direction-tab.md): *"Does Media3 1.10.1 ship a built-in +> **Status: COMPLETED** — shipped with boomerang slice 02 (`VideoReverser.kt`). Canonical algorithm reference (see also lessons 019–021). + +Resolves the open question from the boomerang rollout (slices 02–03): *"Does Media3 1.10.1 ship a built-in reverse video effect, and if not, what's the best alternative?"* > Verified against developer.android.com, the androidx/media GitHub, the Media3 @@ -113,7 +114,7 @@ would have meant: KB-aligned; you must rebuild with `-Wl,-z,max-page-size=16384 -Wl,-z,common-page-size=16384` on NDK r23+/r25+. Now that there are no maintained Maven artifacts at all, the only path is custom-build-from-source, which means a custom CI job per - release. See [Lesson 011](../../lessons_learned/011-16kb-uncompressed-native-libs.md) + release. See [Lesson 011](../lessons_learned/011-16kb-uncompressed-native-libs.md) for what we just went through getting compliant. **Verdict:** Dead-letter. Don't introduce. diff --git a/docs/guides/robolectric-test-catalog.md b/docs/guides/robolectric-test-catalog.md new file mode 100644 index 0000000..75679cf --- /dev/null +++ b/docs/guides/robolectric-test-catalog.md @@ -0,0 +1,177 @@ +# Robolectric Test Catalog — OpenLoop + +Quick-reference inventory of every device-free Robolectric and JVM-framework test in OpenLoop. +For the tutorial (what Robolectric is, when to use it, copy-paste recipes), see +[`robolectric-testing-explained.md`](robolectric-testing-explained.md). + +Testing strategy and pyramid context: [`../TEST_COVERAGE.md`](../TEST_COVERAGE.md). +OEM / API-34 / Samsung RTL lanes: [`oem-regression-testing.md`](oem-regression-testing.md). + +**Shipped in v1.0.26** (PR #81) — Phases 1–2 complete; Phase 3 (Robolectric Compose) deferred. + +--- + +## Run commands + +From the repo root (Windows: `gradlew.bat` instead of `./gradlew`): + +```bash +# All Robolectric-named tests (~30 s first run; cached after) +./gradlew :app:testDebugUnitTest --tests "*RobolectricTest" + +# JVM FGS mapping companion (no Robolectric runner — still fast) +./gradlew :app:testDebugUnitTest --tests "io.github.stozo04.openloop.work.BoomerangRenderNotificationsTest" + +# Full local unit suite (plain JUnit + Robolectric) +./gradlew :app:testDebugUnitTest + +# One class +./gradlew :app:testDebugUnitTest --tests "io.github.stozo04.openloop.work.BoomerangRenderWorkerRobolectricTest" + +# One method +./gradlew :app:testDebugUnitTest --tests "io.github.stozo04.openloop.work.BoomerangRenderForegroundInfoRobolectricTest.android14_foregroundInfo_isDataSync_reproducesAndProvesFix" +``` + +**Requirements:** Gradle JDK **21+** (Robolectric loads the API-36 `android-all` jar). Android Studio → Settings → Build → Gradle JDK. + +--- + +## Phase status + +| Phase | Scope | Status | +|-------|--------|--------| +| **1 — Tier 1** | FGS type, notification channel/progress, import copy, DataStore round-trip | ✅ Shipped | +| **2 — Tier 2** | Worker guard paths, POST_NOTIFICATIONS gate, OEM `DeviceMediaHints` | ✅ Shipped | +| **3 — Tier 3** | Non-graphical Compose on JVM | ⏸ Deferred (no Robolectric Compose harness yet) | + +--- + +## Master inventory + +| Test class | Runner | Production code | `@Config(sdk=…)` | Robolectric shadow / harness | Device complement | +|------------|--------|-----------------|------------------|------------------------------|-------------------| +| `work.BoomerangRenderNotificationsTest` | Plain JVM | `BoomerangRenderNotifications.foregroundServiceTypeForSdk()` | N/A (passes `sdkInt` param) | None | `BoomerangRenderForegroundInfoRobolectricTest` | +| `work.BoomerangRenderForegroundInfoRobolectricTest` | Robolectric | `BoomerangRenderNotifications.createForegroundInfo()` | 34, 35, 36 | Real `ForegroundInfo` + resources | `BoomerangRenderWorkerTest` (real FGS on device) | +| `work.BoomerangRenderNotificationsRobolectricTest` | Robolectric | Channel, progress notification, `PendingIntent` | 34 (class default) | `ShadowContentResolver` N/A; real `NotificationManager` | Same | +| `work.BoomerangRenderWorkerRobolectricTest` | Robolectric | `BoomerangRenderWorker` guards only | 34 on `getForegroundInfo` | `TestListenableWorkerBuilder` + `setForegroundUpdater` | `BoomerangRenderWorkerTest` (encode) | +| `data.VideoImporterImportRobolectricTest` | Robolectric | `VideoImporterImpl.importToFile()` | Any | `ShadowContentResolver` | Gallery import E2E / manual | +| `data.UserPreferencesRepositoryImplRobolectricTest` | Robolectric | `UserPreferencesRepositoryImpl` + real DataStore file | Any | Real `preferencesDataStore` delegate | `OpenLoopViewModelTest` (fake repo) | +| `media.DeviceMediaHintsOemRobolectricTest` | Robolectric | `DeviceMediaHints.isSamsungDevice()`, preview cap, encoder order | Any | `ShadowBuild.setManufacturer` / `setBrand` | Samsung RTL sweep | +| `PostNotificationsGateRobolectricTest` | Robolectric | `shouldRequestPostNotificationsPermission()`, `shouldShowNotificationExportHint()` | 32, 33 | `ShadowApplication.grantPermissions` / `denyPermissions` | Manual QA on API 33+ device | + +**Total:** 8 test classes · 37 test methods (including JVM companion). + +--- + +## By production area + +### Loop export / WorkManager / FGS + +**Crashlytics 9663c743** — Android 14 rejected `FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING` on a `dataSync`-declared service. These tests lock the fix: + +| Class | Test method | Asserts | +|-------|-------------|---------| +| `BoomerangRenderNotificationsTest` | `android14_usesDataSync_notMediaProcessing` | API 34 → `dataSync`, not `mediaProcessing` | +| | `android15AndAbove_usesMediaProcessing` | API 35+ → `mediaProcessing` | +| | `api29Through34_useDataSync` | Boundary sweep | +| | `belowApi29_isUntyped` | Pre-Q → type `0` | +| `BoomerangRenderForegroundInfoRobolectricTest` | `android14_foregroundInfo_isDataSync_reproducesAndProvesFix` | Real `ForegroundInfo` on API 34 | +| | `android15_foregroundInfo_isMediaProcessing` | API 35 | +| | `android16_foregroundInfo_isMediaProcessing` | API 36 | +| `BoomerangRenderNotificationsRobolectricTest` | `ensureChannel_createsSingleImportanceLowChannel` | Channel id + `IMPORTANCE_LOW` | +| | `ensureChannel_isIdempotent` | Second call is no-op | +| | `buildProgressNotification_clampsProgressAndSetsContent` | Progress clamped 0–100 | +| | `buildProgressNotification_clampsNegativeProgressToZero` | Negative → 0 | +| | `buildProgressNotification_usesImmutablePendingIntent` | `FLAG_IMMUTABLE` | +| | `buildCompleteNotification_usesImmutablePendingIntent` | Same on complete notification | +| `BoomerangRenderWorkerRobolectricTest` | `doWork_emptyInputData_returnsFailureWithoutEncode` | `Data.EMPTY` → failure | +| | `doWork_partialInputData_returnsFailureWithoutEncode` | Missing keys → failure | +| | `getForegroundInfo_returnsValidForegroundInfo` | Valid FGS info before encode | +| | `fgsPromotionDenied_failsGracefullyAndDeletesStalePartial` | Issue #67 F1 — denied FGS, partial deleted | + +**Never Robolectric:** `VideoProcessor.renderBoomerang` — covered by `androidTest` `BoomerangRenderWorkerTest`. + +### Library import + +| Class | Test method | Asserts | +|-------|-------------|---------| +| `VideoImporterImportRobolectricTest` | `importToFile_copiesRegisteredContentUri` | `content://` copy → `true`, bytes match | +| | `importToFile_returnsFalseWhenSecurityExceptionOnRead` | Revoked URI → `false`, no throw | +| | `importToFile_returnsFalseWhenFileNotFound` | Unregistered URI → `false` | +| | `importToFile_returnsFalseWhenStreamThrowsOnRead` | `IOException` on read → `false` | + +**Never Robolectric:** `VideoImporterImpl.probeDurationMs` (`MediaMetadataRetriever`). + +### Preferences / onboarding persistence + +| Class | Test method | Asserts | +|-------|-------------|---------| +| `UserPreferencesRepositoryImplRobolectricTest` | `freshStore_defaultsOnboardingIncomplete` | Default `hasCompletedOnboarding` = false | +| | `setOnboardingCompleted_persistsAndReadsBack` | Write true → read true | + +Clears `filesDir/datastore/openloop_preferences.preferences_pb` in `@Before` to avoid order leakage. + +ViewModel transitions with a **fake** repo stay in `OpenLoopViewModelTest`. + +### POST_NOTIFICATIONS (API 33+) + +Extracted pure gates in `MainActivity.kt`: + +- `shouldRequestPostNotificationsPermission(sdkInt, notificationsGranted)` +- `shouldShowNotificationExportHint(sdkInt, notificationsGranted)` + +| Class | Test method | `@Config` | Asserts | +|-------|-------------|-----------|---------| +| `PostNotificationsGateRobolectricTest` | `api32_deniedPermission_requestAndHintAreNoOp` | 32 | Both gates false | +| | `api32_grantedPermission_requestAndHintAreNoOp` | 32 | Both gates false | +| | `api33_deniedPermission_gateIsActive` | 33 | Both gates true when denied | +| | `api33_grantedPermission_gateIsInactive` | 33 | Both gates false when granted | + +Camera permission **state** remains in `OpenLoopViewModelTest` — not duplicated here. + +### OEM identity (Samsung / LG) + +| Class | Test method | Asserts | +|-------|-------------|---------| +| `DeviceMediaHintsOemRobolectricTest` | `samsungManufacturer_isSamsungDevice_and480pPreviewCap` | Samsung manufacturer → cap | +| | `samsungBrand_isSamsungDevice_evenWhenManufacturerDiffers` | Brand-only match | +| | `lgeManufacturer_notSamsung_noPreviewCap` | LG not Samsung | +| | `googleEmulatorIdentity_notSamsung_noPreviewCap` | Default emulator identity | +| | `samsungIdentity_encoderTryOrderPrefersC2GoogleFirst` | Encoder ranking | +| | `lgeIdentity_encoderTryOrderDoesNotForceC2GoogleFirst` | Non-Samsung order | + +**Never Robolectric:** vendor MediaCodec behavior — Samsung RTL + `VideoReverserTest` on device. + +--- + +## What must stay on a device + +| Code | Robolectric test covers | Device test owns | +|------|-------------------------|------------------| +| `BoomerangRenderWorker.doWork` (encode path) | Input parse, FGS info, denied FGS | Full render + scratch discard | +| `VideoProcessor`, `VideoReverser`, `MediaCodecLifecycle` | — | All encode/decode | +| `VideoImporter.probeDurationMs` | — | Duration probe | +| `CameraManager` | — | CameraX | +| Compose layout / screenshots | — | `OnboardingScreenTest`, `LoopifyingScreenshotTest` | + +--- + +## Adding a new Robolectric test + +1. Read [`robolectric-testing-explained.md`](robolectric-testing-explained.md) decision rule — if it needs real codecs, stop. +2. Put the class in `app/src/test/` with suffix `RobolectricTest` when it uses `@RunWith(RobolectricTestRunner)`. +3. Name `@Config(sdk=[…])` explicitly when behavior is version-gated. +4. Add a row to the **Master inventory** table in this file and a one-line row in [`../TEST_COVERAGE.md`](../TEST_COVERAGE.md). +5. Run `./gradlew :app:testDebugUnitTest` before opening a PR. + +--- + +## Related files + +| Path | Role | +|------|------| +| `app/build.gradle.kts` | `testImplementation` Robolectric, `androidx.test.core`, `work-testing`; JDK 21 test launcher | +| `app/src/test/java/io/github/stozo04/openloop/work/BoomerangRenderNotificationsTest.kt` | JVM FGS mapping | +| `app/src/androidTest/.../BoomerangRenderWorkerTest.kt` | Device encode + FGS integration | +| `docs/lessons_learned/024-fgs-type-constant-api-gating.md` | Why API 34 ≠ API 35 for FGS type | +| `docs/lessons_learned/017-androidtest-no-mockk-and-sweep-meaningful-mock-returns.md` | Why Robolectric stays in `src/test/` | diff --git a/docs/guides/robolectric-testing-explained.md b/docs/guides/robolectric-testing-explained.md index 40f6036..0ea75af 100644 --- a/docs/guides/robolectric-testing-explained.md +++ b/docs/guides/robolectric-testing-explained.md @@ -10,6 +10,8 @@ No prior knowledge needed. Technical words are defined in the [Glossary](#glossa > **OEM regression (Samsung / LG / API 34):** Robolectric is one lane in a larger matrix. > See **[`oem-regression-testing.md`](oem-regression-testing.md)** for the full picture (emulator > sweeps, Samsung RTL, LG fault injection, and when each applies). +> +> **Test inventory (run commands, every class/method):** **[`robolectric-test-catalog.md`](robolectric-test-catalog.md)**. --- @@ -81,6 +83,7 @@ testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.robolectric) // org.robolectric:robolectric:4.16.1 testImplementation(libs.androidx.test.core) // ApplicationProvider, etc. testImplementation(libs.androidx.test.ext.junit) // AndroidJUnit4 runner +testImplementation(libs.androidx.work.testing) // TestListenableWorkerBuilder (Tier 2 #4) ``` All three are `testImplementation` — they're on the **unit-test** classpath (`src/test/`), *not* @@ -131,8 +134,8 @@ Java-17 toolchain. ## A minimal annotated example -Here's the smallest real Robolectric test, modeled on the one already in this repo at -`app/src/test/java/io/github/stozo04/openloop/work/BoomerangRenderForegroundInfoRobolectricTest.kt`: +Here's the smallest real Robolectric test, modeled on +`BoomerangRenderForegroundInfoRobolectricTest` (see the [Implemented tests](#implemented-tests-synced-with-appsrc-test) table): ```kotlin import android.content.Context @@ -168,7 +171,7 @@ class ExampleRobolectricTest { The five things every Robolectric test needs: 1. `@RunWith(RobolectricTestRunner::class)` on the class (or `@RunWith(AndroidJUnit4::class)`, which - resolves to Robolectric in `src/test/` — both work; this repo's first test uses + resolves to Robolectric in `src/test/` — both work; this repo's Robolectric tests use `RobolectricTestRunner` directly). 2. `ApplicationProvider.getApplicationContext()` for a real `Context`. 3. `@Config(sdk = [...])` to choose the Android version. You can pass several: @@ -178,6 +181,30 @@ The five things every Robolectric test needs: --- +## Implemented tests (synced with `app/src/test/`) + +Eight test classes cover Phases 1–2 (Tier 1 + Tier 2). Seven use `@RunWith(RobolectricTestRunner)`; +`BoomerangRenderNotificationsTest` is a **plain JVM** companion that pins the FGS SDK mapping without +building framework objects (cheaper, but it cannot catch resource/manifest wiring bugs). + +**Full method-level inventory, run-all command, and device complements:** +[`robolectric-test-catalog.md`](robolectric-test-catalog.md). + +| Test class | Runner | Tier | What it validates | +|------------|--------|------|-------------------| +| `work/BoomerangRenderForegroundInfoRobolectricTest` | Robolectric | 1 (FGS) | Real `ForegroundInfo.foregroundServiceType` under `@Config(sdk=[34/35/36])` — reproduces Crashlytics 9663c743 | +| `work/BoomerangRenderNotificationsTest` | Plain JVM | 1 (FGS) | `foregroundServiceTypeForSdk()` mapping: API 34 → `dataSync`, API 35+ → `mediaProcessing`, below Q → untyped (`0`) | +| `work/BoomerangRenderNotificationsRobolectricTest` | Robolectric | 1 (channel/progress) | `ensureChannel` idempotency + `IMPORTANCE_LOW`; progress clamp 0–100; `PendingIntent.FLAG_IMMUTABLE` | +| `data/VideoImporterImportRobolectricTest` | Robolectric | 1 (import) | `importToFile` copy success; `false` on `IOException`, `SecurityException`, missing file | +| `data/UserPreferencesRepositoryImplRobolectricTest` | Robolectric | 1 (DataStore) | Real `preferencesDataStore` round-trip: default `false`, write `true` → read `true` | +| `media/DeviceMediaHintsOemRobolectricTest` | Robolectric | 2 (#6) | `ShadowBuild` Samsung/LG identity → `isSamsungDevice()`, preview cap, encoder try-order | +| `work/BoomerangRenderWorkerRobolectricTest` | Robolectric | 2 (#4) | Guard-only `doWork` (invalid input → failure; FGS denied → failure + partial cleanup); `getForegroundInfo` | +| `PostNotificationsGateRobolectricTest` | Robolectric | 2 (#5) | API 32 no-op vs API 33+ gate via `shouldRequestPostNotificationsPermission` / `shouldShowNotificationExportHint` | + +Run any of them with `./gradlew :app:testDebugUnitTest --tests "…fully.qualified.ClassName"`. + +--- + ## The highest-value Robolectric opportunities in *this* app Ranked by value. Each entry says **what** to test, **why Robolectric unlocks it** (which framework @@ -185,7 +212,7 @@ dependency blocks a plain JUnit test today), and **which `@Config(sdk=[…])` le ### Tier 1 — do these first -**1. `work/BoomerangRenderNotifications.kt` — notifications, channel, and FGS type** +**1. ✅ `work/BoomerangRenderNotifications.kt` — notifications, channel, and FGS type** - *What:* That `createForegroundInfo` picks `dataSync` below API 35 and `mediaProcessing` on API 35+ (the exact Android-14 crash, Crashlytics 9663c743); that `ensureChannel` creates a single `IMPORTANCE_LOW` channel and is idempotent; that `buildProgressNotification` clamps progress to @@ -195,11 +222,10 @@ dependency blocks a plain JUnit test today), and **which `@Config(sdk=[…])` le none constructible in a plain JUnit test. - *`@Config`:* `[26]` (below Q → untyped FGS, value `0`), `[34]` (`dataSync`), `[35]` and `[36]` (`mediaProcessing`). -- *Note:* the FGS-type portion is **already being added** by another worker - (`BoomerangRenderForegroundInfoRobolectricTest.kt`). The channel / progress / PendingIntent - behaviors are still open and are the natural follow-ups. +- *Status:* **✅ Done** — `BoomerangRenderForegroundInfoRobolectricTest` + `BoomerangRenderNotificationsTest` + (FGS type) + `BoomerangRenderNotificationsRobolectricTest` (channel, progress clamp, immutability). -**2. `data/VideoImporter.kt` (`VideoImporterImpl.importToFile`) — the import error contract** +**2. ✅ `data/VideoImporter.kt` (`VideoImporterImpl.importToFile`) — the import error contract** - *What:* That copying a readable `content://` URI into a dest file succeeds and returns `true`, and that an unreadable / revoked URI (`IOException` / `SecurityException`) returns **`false`** instead of throwing — the contract the ViewModel relies on to show a friendly snackbar instead of crashing. @@ -209,8 +235,9 @@ dependency blocks a plain JUnit test today), and **which `@Config(sdk=[…])` le - *`@Config`:* not version-sensitive; any SDK is fine. - *Boundary:* `probeDurationMs` uses `MediaMetadataRetriever` (native decode) — that part stays a device test; only the stream-copy contract is a Robolectric win. +- *Status:* **✅ Done** — `VideoImporterImportRobolectricTest` (`ShadowContentResolver`). -**3. `data/UserPreferencesRepositoryImpl.kt` — real DataStore round-trip, device-free** +**3. ✅ `data/UserPreferencesRepositoryImpl.kt` — real DataStore round-trip, device-free** - *What:* Write `setOnboardingCompleted(true)` then read `hasCompletedOnboarding.first()` → `true`; a fresh store defaults to `false`. This is the **real** `Context.dataStore` delegate end-to-end. - *Why Robolectric:* `TEST_COVERAGE.md` currently lists this class under *Coverage Gaps* as "needs @@ -220,10 +247,11 @@ dependency blocks a plain JUnit test today), and **which `@Config(sdk=[…])` le - *`@Config`:* not version-sensitive. - *Boundary:* the existing JVM tests fake this repository with a `MutableStateFlow`; those stay (they test the ViewModel). This new test would cover the *real implementation* that the fakes stand in for. +- *Status:* **✅ Done** — `UserPreferencesRepositoryImplRobolectricTest`. ### Tier 2 — strong, with a small caveat -**4. `work/BoomerangRenderWorker.kt` — orchestration guards only** +**4. ✅ `work/BoomerangRenderWorker.kt` — orchestration guards only** - *What:* `doWork()` returns `Result.failure()` on missing/invalid input; `getForegroundInfo()` returns a valid `ForegroundInfo`; the FGS-promotion-denied path (`setForeground` throws `IllegalStateException`) ends in `Result.failure()` + partial-output cleanup, not a crash. @@ -233,11 +261,11 @@ dependency blocks a plain JUnit test today), and **which `@Config(sdk=[…])` le - *Caveat / boundary:* `doWork` calls `videoProcessor.renderBoomerang`, which is **real Media3 Transformer + MediaCodec** — that cannot run under Robolectric and must not be faked. So Robolectric covers the *guards around* the render (input parsing, foreground info, the denied-FGS branch), while - the actual encode stays in `androidTest` (`BoomerangRenderWorkerTest`). Also note `work-testing` is - currently only on the `androidTest` classpath; using it here would require also adding it as - `testImplementation` — a deliberate dependency change, not assumed by this guide. + the actual encode stays in `androidTest` (`BoomerangRenderWorkerTest`). `work-testing` is also on the + `testImplementation` classpath (see `app/build.gradle.kts`). +- *Status:* **✅ Done** — `BoomerangRenderWorkerRobolectricTest` (guard paths only; never reaches encode). -**5. `MainActivity` permission decisions — the API-33 `POST_NOTIFICATIONS` gate** +**5. ✅ `MainActivity` permission decisions — the API-33 `POST_NOTIFICATIONS` gate** - *What:* `maybeRequestPostNotificationsPermission()` / `rememberNotificationExportHint()` do nothing below API 33 and only ask/hint when the permission is missing on 33+. The camera-permission branching (`granted → ready`, `denied-once → rationale`, `else → request`) is the other piece. @@ -249,8 +277,10 @@ dependency blocks a plain JUnit test today), and **which `@Config(sdk=[…])` le - *Caveat:* the camera-branch logic lives inside an Activity method tied to permission launchers, so testing it cleanly may want a small extraction into a pure decision function first. The pure *state* transitions are already covered in `OpenLoopViewModelTest`. +- *Status:* **✅ Done** — extracted `shouldRequestPostNotificationsPermission` / + `shouldShowNotificationExportHint`; `PostNotificationsGateRobolectricTest`. -**6. `media/DeviceMediaHints.kt` (`isSamsungDevice`)** — **implemented** +**6. ✅ `media/DeviceMediaHints.kt` (`isSamsungDevice`)** - *Tests:* `DeviceMediaHintsOemRobolectricTest` (ShadowBuild Samsung/LG identity, preview cap, encoder order). See [`oem-regression-testing.md`](oem-regression-testing.md) lane 2. - *What:* The manufacturer-OR-brand, case-insensitive Samsung check that gates the Samsung reverse @@ -268,6 +298,152 @@ checks off the emulator and make them much faster. This adds configuration and c graphics, so it's a "when we have time" item — and **screenshot tests (`LoopifyingScreenshotTest`) and benchmarks stay on a real device.** +*Evaluated June 2026:* skipped for now. The repo has no Robolectric Compose harness yet (would need +`ui-test-junit4` on `testImplementation` + graphics shadow tuning). The only low-hanging candidates +are two `GetStartedButton` tests in `OnboardingScreenTest` — not worth the setup risk until more +non-graphical Compose tests accumulate. + +--- + +## Copy-paste recipes (Tier 1 — shipped) + +The recipes below match the checked-in Phase 1 tests. Use them as templates for Tier 2 work. + +### Reference: `VideoImporter.importToFile` (ShadowContentResolver) + +Implemented in `VideoImporterImportRobolectricTest`. Key patterns: + +Plain JUnit cannot construct a working `content://` `Uri` or `ContentResolver`. Robolectric's +`ShadowContentResolver` lets you register fake stream bytes for a URI and drive the real +`VideoImporterImpl.importToFile` copy logic on the JVM. + +```kotlin +package io.github.stozo04.openloop.data + +import android.content.Context +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import java.io.ByteArrayInputStream +import java.io.File +import java.io.InputStream +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows +import org.robolectric.shadows.ShadowContentResolver + +@RunWith(RobolectricTestRunner::class) +class VideoImporterImportRobolectricTest { + + private val context: Context get() = ApplicationProvider.getApplicationContext() + + @Test + fun importToFile_copiesRegisteredContentUri() = runTest { + val uri = Uri.parse("content://test/import.mp4") + val payload = "fake-mp4-bytes".toByteArray() + shadowContentResolver().registerInputStreamSupplier(uri) { + ByteArrayInputStream(payload) + } + + val dest = File(context.cacheDir, "imported.mp4") + val importer = VideoImporterImpl(context) + + assertTrue(importer.importToFile(uri, dest)) + assertArrayEquals(payload, dest.readBytes()) + } + + @Test + fun importToFile_returnsFalseWhenUriHasNoRegisteredStream() = runTest { + val uri = Uri.parse("content://test/revoked.mp4") + // No registration → openInputStream fails during copy → importToFile returns false (never throws) + val dest = File(context.cacheDir, "imported.mp4") + val importer = VideoImporterImpl(context) + + assertFalse(importer.importToFile(uri, dest)) + } + + @Test + fun importToFile_returnsFalseWhenStreamThrowsOnRead() = runTest { + val uri = Uri.parse("content://test/corrupt.mp4") + shadowContentResolver().registerInputStream(uri, object : InputStream() { + override fun read(): Int = throw java.io.IOException("simulated revoked URI") + }) + + val dest = File(context.cacheDir, "imported.mp4") + val importer = VideoImporterImpl(context) + + assertFalse(importer.importToFile(uri, dest)) + } + + private fun shadowContentResolver(): ShadowContentResolver = + Shadows.shadowOf(context.contentResolver) +} +``` + +> Use `registerInputStreamSupplier` (not `registerInputStream`) when a test opens the same URI more +> than once — Robolectric closes a registered stream after the first read. Unregistered `content://` +> URIs return Robolectric's `UnregisteredInputStream` (not real device behavior) — register explicit +> streams for failure cases instead. + +### Reference: `UserPreferencesRepositoryImpl` (DataStore round-trip) + +Implemented in `UserPreferencesRepositoryImplRobolectricTest`. Key patterns: + +Robolectric supplies the real `Context` the top-level `preferencesDataStore` delegate needs, so +the production `UserPreferencesRepositoryImpl` can run against a temp file on the JVM — turning the +"needs instrumented test with real DataStore" gap in `TEST_COVERAGE.md` into a fast unit test. + +```kotlin +package io.github.stozo04.openloop.data + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import java.io.File +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class UserPreferencesRepositoryImplRobolectricTest { + + private val context: Context get() = ApplicationProvider.getApplicationContext() + + @Before + fun clearDataStoreFile() { + // preferencesDataStore(name = "openloop_preferences") → this path under filesDir/datastore/ + File(context.filesDir, "datastore/openloop_preferences.preferences_pb").delete() + } + + @Test + fun freshStore_defaultsOnboardingIncomplete() = runTest { + val repo = UserPreferencesRepositoryImpl(context.dataStore) + + assertFalse(repo.hasCompletedOnboarding.first()) + } + + @Test + fun setOnboardingCompleted_persistsAndReadsBack() = runTest { + val repo = UserPreferencesRepositoryImpl(context.dataStore) + + repo.setOnboardingCompleted(true) + + assertTrue(repo.hasCompletedOnboarding.first()) + } +} +``` + +> The `@Before` delete matters: DataStore is a process-wide singleton keyed by file name. Without +> clearing it, test order can leak state between methods. + --- ## What must NEVER move to Robolectric @@ -324,6 +500,9 @@ code). From the repo root: # Run every unit test (plain + Robolectric) in the debug variant ./gradlew :app:testDebugUnitTest + +# Run every *Robolectric-named* test class only +./gradlew :app:testDebugUnitTest --tests "*RobolectricTest" ``` On Windows use `gradlew.bat` instead of `./gradlew`. In Android Studio you can also click the green @@ -355,9 +534,17 @@ gutter arrow next to the test — just confirm the Gradle JDK is 21+ first (see ## Want the deeper version? -- The first real Robolectric test in this repo: - `app/src/test/java/io/github/stozo04/openloop/work/BoomerangRenderForegroundInfoRobolectricTest.kt`. -- The class it tests: `app/src/main/java/io/github/stozo04/openloop/work/BoomerangRenderNotifications.kt`. +- Robolectric tests in this repo: + - `app/src/test/java/io/github/stozo04/openloop/work/BoomerangRenderForegroundInfoRobolectricTest.kt` + - `app/src/test/java/io/github/stozo04/openloop/work/BoomerangRenderNotificationsRobolectricTest.kt` + - `app/src/test/java/io/github/stozo04/openloop/data/VideoImporterImportRobolectricTest.kt` + - `app/src/test/java/io/github/stozo04/openloop/data/UserPreferencesRepositoryImplRobolectricTest.kt` + - `app/src/test/java/io/github/stozo04/openloop/media/DeviceMediaHintsOemRobolectricTest.kt` + - `app/src/test/java/io/github/stozo04/openloop/work/BoomerangRenderWorkerRobolectricTest.kt` + - `app/src/test/java/io/github/stozo04/openloop/PostNotificationsGateRobolectricTest.kt` +- Plain-JVM companion (same FGS contract, no framework objects): + `app/src/test/java/io/github/stozo04/openloop/work/BoomerangRenderNotificationsTest.kt` +- The class under test for notifications: `app/src/main/java/io/github/stozo04/openloop/work/BoomerangRenderNotifications.kt`. - The testing strategy and pyramid: `docs/TEST_COVERAGE.md`. - Why JVM File tests use a real temp dir and one dispatcher: `docs/lessons_learned/008-jvm-test-file-and-dispatcher-pitfalls.md`. - Why `mockk` and JVM fakes can't be used in `androidTest`: `docs/lessons_learned/017-androidtest-no-mockk-and-sweep-meaningful-mock-returns.md`. diff --git a/docs/guides/samsung-rtl-steps.md b/docs/guides/samsung-rtl-steps.md index 3ba924b..d364970 100644 --- a/docs/guides/samsung-rtl-steps.md +++ b/docs/guides/samsung-rtl-steps.md @@ -139,7 +139,7 @@ On a **release** build specifically: |---|---| | `ClassNotFoundException` / `NoSuchMethodException` | R8 strict full mode stripped a reflectively-used class/constructor → add an explicit keep rule in `app/proguard-rules.pro` | | `Resources$NotFoundException` | Optimized resource shrinking removed a runtime-only resource → `tools:keep` in a `res/raw/keep.xml` | -| `MediaCodec` errors / reverse timeouts | Device codec behavior — see `docs/lessons_learned/020` and `023` | +| `MediaCodec` errors / reverse timeouts | Device codec behavior — see `docs/lessons_learned/020-imported-clips-hdr-codec-and-reverse-failure-recovery.md` and `023-media-pipeline-stages-must-count-output-samples.md` | --- diff --git a/docs/lessons_learned/011-16kb-uncompressed-native-libs.md b/docs/lessons_learned/011-16kb-uncompressed-native-libs.md index 734447b..1bc6223 100644 --- a/docs/lessons_learned/011-16kb-uncompressed-native-libs.md +++ b/docs/lessons_learned/011-16kb-uncompressed-native-libs.md @@ -51,4 +51,4 @@ Two independent things must both be true for 16 KB compliance: - [Support 16 KB page sizes](https://developer.android.com/guide/practices/page-sizes) — Google Play requirement (native-lib apps targeting API 35+, since Nov 1 2025) and verification tooling. - [`useLegacyPackaging`](https://developer.android.com/reference/tools/gradle-api/com/android/build/api/dsl/JniLibsPackaging) — AGP packaging DSL. -- Surfaced during the target-SDK-36 upgrade ([Issue #7](https://github.com/stozo04/OpenLoop/issues/7)); see [`docs/completed/007-target-sdk-upgrade/IMPLEMENTATION.md`](../completed/007-target-sdk-upgrade/IMPLEMENTATION.md). +- Surfaced during the target-SDK-36 upgrade ([Issue #7](https://github.com/stozo04/OpenLoop/issues/7)); see `ANDROID_STANDARDS.md` §11 and `app/build.gradle.kts` packaging block. diff --git a/docs/lessons_learned/018-boomerang-seam-drop-follows-sequence-position.md b/docs/lessons_learned/018-boomerang-seam-drop-follows-sequence-position.md index a6a8a2f..2f48dbb 100644 --- a/docs/lessons_learned/018-boomerang-seam-drop-follows-sequence-position.md +++ b/docs/lessons_learned/018-boomerang-seam-drop-follows-sequence-position.md @@ -66,6 +66,6 @@ when `dropLeadingFrame` is true. The preview applies the same rule. ## Reference -- Parent design `docs/active/boomerang-editor/IMPLEMENTATION.md` §6.4 (seam handling). +- Parent design [`docs/PRD-mission-control.md`](../PRD-mission-control.md) §6.4 (seam handling). - `app/src/main/java/com/OpenLoop/app/media/BoomerangSequence.kt` + `BoomerangSequenceTest.kt`. - Related: [[008-jvm-test-file-and-dispatcher-pitfalls]] / [[017-androidtest-no-mockk-and-sweep-meaningful-mock-returns]] (why the seam math is pure-JVM but the encoded is instrumented). diff --git a/docs/lessons_learned/023-media-pipeline-stages-must-count-output-samples.md b/docs/lessons_learned/023-media-pipeline-stages-must-count-output-samples.md index d4cf87a..f6198d2 100644 --- a/docs/lessons_learned/023-media-pipeline-stages-must-count-output-samples.md +++ b/docs/lessons_learned/023-media-pipeline-stages-must-count-output-samples.md @@ -1,8 +1,7 @@ # Lesson 023 — A media pipeline stage must count its own output samples; a zero-frame stage can exit "cleanly" -> Origin: the Samsung S23 "Could not save loop" bug (2026-06-04) — full forensics in -> `docs/active/reverse-output-validation/RESEARCH.md`, plain-English version in -> `EXPLAINED-SIMPLY.md` in the same folder. +> Origin: the Samsung S23 "Could not save loop" bug (2026-06-04) — PR #62 / Crashlytics `3a506c4e`. +> Origin: Samsung S23 zero-frame wedge (PR #62 / Crashlytics `3a506c4e`). See lessons 020 and [`docs/guides/reverse-video-research.md`](../guides/reverse-video-research.md). ## What went wrong @@ -66,5 +65,5 @@ file all along — the reverse half of the preview was a ~0-duration clip nobody - [MediaCodec — surface input, EOS semantics](https://developer.android.com/reference/android/media/MediaCodec) - [Media3 Transformer troubleshooting](https://developer.android.com/media/media3/transformer/troubleshooting) -- `docs/active/reverse-output-validation/` (IMPLEMENTATION.md spec, RESEARCH.md forensics + on-device pairing matrix) -- Originating work: PR #62 (branch `fix/crashlytics-3a506c4e-pass1-codec-lifecycle`); fixture-matrix follow-up: Issue #64. +- [`docs/guides/reverse-video-research.md`](../guides/reverse-video-research.md) — reverse pipeline reference +- Originating work: PR #62; fixture-matrix follow-up: Issue #64. diff --git a/docs/lessons_learned/KICKOFF-boomerang-slice-03-direction-tab.md b/docs/lessons_learned/KICKOFF-boomerang-slice-03-direction-tab.md deleted file mode 100644 index b23b5a2..0000000 --- a/docs/lessons_learned/KICKOFF-boomerang-slice-03-direction-tab.md +++ /dev/null @@ -1,148 +0,0 @@ -# KICKOFF — Boomerang Slice 03 (Direction tab) - -> **Temporary session doc, NOT a numbered lesson** — same convention as the slice-02 -> [`HANDOFF`](./HANDOFF-boomerang-slice-02-pr-24.md) and -> [`SECOND-REVIEW`](./SECOND-REVIEW-boomerang-slice-02-pr-24.md). It exists because CLAUDE.md makes -> every session read this whole folder at startup. **Delete it when slice 03 merges** — but first -> promote anything that survived the device pass into a real numbered lesson (the seam-per-ordering -> rule and the per-item-effects finding are the strongest candidates). - -You're starting [`docs/active/boomerang-rollout/03-editor-direction-tab.md`](../active/boomerang-rollout/03-editor-direction-tab.md). -Read **that** (it's the PRD), then [`RESEARCH-reverse-video.md`](../active/boomerang-rollout/RESEARCH-reverse-video.md), -then this. This doc is only the things the slice-03 PRD does **not** tell you and that I learned the -hard way reviewing slice 02. Read order matters; don't skip the two above. - ---- - - -## 1. THE slice-03 landmine: per-item effects in a sequence (#1658) - -This is the highest-risk item and slice 03 is exactly what surfaces it. Speed is applied **per -`EditedMediaItem`**. [androidx/media #1658](https://github.com/androidx/media/issues/1658) reported -that in an `EditedMediaItemSequence` **only the first item's effects are applied** (real in 1.4.1, -marked Closed — *unconfirmed in our 1.10.1*). If that bug is live, then with the `SpeedChangeEffect` -on every item: - -- `FORWARD_THEN_REVERSE` → forward (1st) plays 2×, **reverse half (2nd) plays 1×**. -- `REVERSE_THEN_FORWARD` → reverse (1st) plays 2×, **forward half (2nd) plays 1×**. - -So **one half of every two-part boomerang would play at the wrong speed, and which half flips with the -user's direction choice.** Maximally visible and confusing. - -**Verify on device before building the UI:** render `F→R` and `R→F`, confirm **both halves play at -2×**. If broken, move speed to a **Composition-level** effect instead of per-item — but **verify that -API exists in Media3 1.10.1 first** (don't guess; cross-check the -[[reference-media3-1-10-1-transformer-api]] memory and the source tag). This matters even more in -slice 04 when speed becomes user-controlled. - ---- - -## 2. The seam-drop location FLIPS per direction — `VideoProcessor` needs a real refactor, not an `if` - -Slice 02's `VideoProcessor` hard-codes the 1-frame seam drop onto the **reversed** clip -(`reverseItem()` does `setStartPositionMs(seamOffsetMs)`), because in `FORWARD_THEN_REVERSE` the -reversed clip is always **second**. Slice 03 makes ordering user-selectable, and the seam rule is -actually: **drop the first frame of whichever clip is SECOND in the sequence** — because the seam -duplicate is "last frame of clip 1 == first frame of clip 2." - -Walk it through (this is the trap): - -| mode | sequence | drop the head of… | current slice-02 code does… | -|------|----------|-------------------|------------------------------| -| `FORWARD` | `[forward]` | nothing (no seam) | builds via `forwardItem` (no drop) ✓ | -| `REVERSE` | `[reversed]` | **nothing (no seam!)** | would build via `reverseItem` → **drops a real frame ✗** | -| `FORWARD_THEN_REVERSE` | `[forward, reversed]` | reversed (2nd) | drops reversed head ✓ | -| `REVERSE_THEN_FORWARD` | `[reversed, forward]` | **forward (2nd)** | drops reversed head → **wrong clip ✗** | - -So a naïve "just reorder the list" gives you a lost frame on standalone `REVERSE` and a wrong-clip -drop + un-dropped real seam on `R→F`. **Fix:** make the seam offset a property of *sequence position* -(apply it to item[1] of a 2-item list, never to a lone item), not a property of "the reversed clip." -The slice-03 PRD's table says "drop 1 frame seam" for both two-clip modes but does **not** spell out -that the drop location moves — that omission is the bug waiting to happen. - ---- - -## 3. Preview is a NEW problem — the slice-02 trick does not carry over - -Slice 02's trim preview was a single clip with a manual `seekTo` poll-loop (a workaround for ExoPlayer -deduping a re-clipped same-URI `MediaItem`; see the slice-02 HANDOFF). Slice 03 must preview the -**concatenated boomerang** (e.g. `[forward, reversed]` looped). ExoPlayer **cannot reverse natively** -([ExoPlayer #2191](https://github.com/google/ExoPlayer/issues/2191)), which is why the reversed *file* -is fed as a playlist item. - -Two viable paths (the PRD leaves it open — decide deliberately): -- **`ConcatenatingMediaSource2`** (ExoPlayer playlist of `[forwardClip, reversedFile]`, `REPEAT_MODE_ALL`). - Simpler, but the preview is *not* the render path — speed effect + seam can look different in preview - vs. the exported Transformer `Composition`. Preview/export fidelity drift is a real QA risk. -- **`CompositionPlayer`** (`androidx.media3.transformer.CompositionPlayer`, exists in 1.10.x) previews - the *same* `Composition` the renderer exports → preview == export, no drift. Heavier, newer, and has - set up quirks ([androidx/media #1983](https://github.com/androidx/media/issues/1983)). - -My lean: if you want WYSIWYG, `CompositionPlayer` is worth the cost because it kills the drift class of -bugs outright. But prototype it early — if it fights you, `ConcatenatingMediaSource2` is the safe -fallback. Either way, **watch for the same-URI re-clip dedup gotcha** when you rebind the preview on a -direction change. - ---- - -## 4. `ensureReversedSegment()` concurrency — guard it - -The editor calls reverse **eagerly on entry** *and* **on direction change**, and the user can spam the -4 chips. `VideoReverser.reverse()` checks the cache at the top, but **two concurrent calls for the same -trim both miss the cache before the output file exists** → two full two-pass runs racing to write the -same output path. Serialize it: a single `Job` you cancel-and-replace, or a `Mutex`, or "ignore if a -run for this exact key is in flight." The PRD doesn't mention this; it will bite under fast chip-taps. - ---- - -## 5. The "VideoProcessorTest (JVM)" in the PRD is probably wrong — it needs a device - -Slice 03's testing plan asks for `VideoProcessorTest` as a **JVM** test with a fixture in -`src/test/resources/`. But `Media3VideoProcessor` drives a `Transformer` (Looper + MediaCodec + -OpenGL) — that does **not** run on a pure JVM. This is the same reason `VideoReverserTest` is -**instrumented** (Lesson 008/017). Expect to make the `VideoProcessor` correctness test **instrumented** -(`src/androidTest/`), and remember **androidTest can't use mockk** (Lesson 017) — hand-write fakes. -What *can* stay JVM: pure logic you extract out of the Transformer (e.g. a `fun sequenceFor(mode): -List` + seam math), mirroring how `MediaFormatUtils` was split out for testability. Extract -the ordering/seam decision into a pure function and unit-test *that* on the JVM; leave the actual -encode to an instrumented smoke test. - ---- - -## 6. Lessons that specifically apply to slice 03 - -- **Lesson 014 (exhaustive `when`, no `else`):** adding `BoomerangEditor` means a new branch in - `OpenLoopNavHost` — route it, no `else`. Build will fail until you do; that's the feature. -- **Lesson 015 (state-routed `BackHandler`):** the editor can lose work. The PRD says back confirms - "Discard changes?" only if `mode` changed from default. Gate the `BackHandler` on that condition — - don't always-intercept, don't let back finish the Activity. -- **Lesson 016 (defer high-frequency reads):** the preview position / shimmer progress tick — pass as - `() -> T` lambdas, don't read at the screen root next to the ExoPlayer `AndroidView`. -- **Lesson 002 / 004 / 001 / 003** all re-listed in the slice-03 acceptance criteria — they still hold. -- **DI:** the editor preview and `VideoProcessor` must share the **same `VideoReverser` instance** - (wired in the ViewModel `Factory`) or the cache won't be shared and you'll reverse twice. The PRD - acceptance list calls this out — honor it. - ---- - -## 7. Inherited from slice 02 — still open, now affecting all 4 directions - -Read [`SECOND-REVIEW-boomerang-slice-02-pr-24.md`](./SECOND-REVIEW-boomerang-slice-02-pr-24.md) in -full. The short version of what carries forward: -- **Rotation (W2):** I made the reverser strip `KEY_ROTATION` from the decoder format (deterministic - "coded pixels + metadata hint"). Still needs the on-device portrait A/B. Now it affects every - reverse-containing direction, not just the default. -- **W5 fixture / W4 TalkBack:** as documented there. -- **`ffmpeg` is NOT in the app.** If you see `ffmpeg` referenced, it was only ever a *dev-machine* - tool for generating a test fixture (`app/src/androidTest/assets/trim_fixture.mp4`) — never an APK - dependency. FFmpegKit (the in-app library) is dead and rejected (RESEARCH §2). The runtime reverser - is the hand-rolled two-pass MediaCodec class (= the `video-backwards.txt` / sisik.eu approach). - ---- - -## 8. One-line summary for the impatient - -Verify slice 02's reverse on the Fold first; then the two things most likely to make slice 03 wrong -are **(a) the seam drop must follow sequence *position*, not "the reversed clip"** and **(b) confirm -both halves of `F→R` and `R→F` actually play at 2× (the #1658 per-item-effects risk).** Everything -else is in the PRD. diff --git a/docs/lessons_learned/KICKOFF-boomerang-slice-04-speed-tab.md b/docs/lessons_learned/KICKOFF-boomerang-slice-04-speed-tab.md deleted file mode 100644 index 5710afa..0000000 --- a/docs/lessons_learned/KICKOFF-boomerang-slice-04-speed-tab.md +++ /dev/null @@ -1,205 +0,0 @@ -# KICKOFF — Boomerang Slice 04 (Speed tab) - -> **Temporary session doc, NOT a numbered lesson** — same convention as the slice-03 -> [`KICKOFF`](./KICKOFF-boomerang-slice-03-direction-tab.md). It exists because CLAUDE.md makes every -> session read this whole folder at startup. **Delete it when slice 04 merges** — but first promote -> anything durable into a real numbered lesson (the #1658 per-item-effects finding is the strongest -> candidate if the device pass turns up anything). - -You're starting [`docs/active/boomerang-rollout/04-editor-speed-tab.md`](../active/boomerang-rollout/04-editor-speed-tab.md). -Read **that** (it's the PRD), then this. This doc is only the things the slice-04 PRD does **not** tell -you, or tells you **wrong**, that I learned building slices 02–03. Read order matters. - ---- - -## 0. The PRD is wrong about the render API — read this before you touch `VideoProcessor` - -The slice-04 PRD (and several other docs, now mostly fixed) says: *"Replace `SpeedChangingVideoEffect(2.0f)` -with `SpeedChangingVideoEffect(speed)`."* **Two things are wrong with that:** - -1. **`SpeedChangingVideoEffect` does not exist in Media3 1.10.1.** The constant-speed video effect in our - version is **`SpeedChangeEffect`** (the deprecated float-constructor form — there's no public constant - `SpeedProvider` factory). This is verified against the 1.10.1 source tag, recorded in the - `reference-media3-1-10-1-transformer-api` memory, and — most conclusively — it's what the shipped code - compiles and runs with. Do **not** go looking for `SpeedChangingVideoEffect`; you'll waste time. -2. **The speed is already fully threaded and applied.** `VideoProcessor.renderBoomerang(...)` has taken a - `speed: Float` parameter since slice 02, and `Media3VideoProcessor` already builds - `SpeedChangeEffect(speed)` per clip via `speedEffects(speed)`. **There is no `2.0f` literal in - `VideoProcessor` to replace.** The render side is done. - -So the **entire render-side change for slice 04** is one line: `OpenLoopViewModel.saveBoomerang()` -currently passes `speed = DEFAULT_SPEED` — change it to `speed = _editorTabState.value.speed`. That's it. -Don't touch `Media3VideoProcessor`. - ---- - -## 1. THE slice-04 landmine: per-item speed effects (#1658) — now with a *variable* speed - -This is the highest-risk item and slice 04 is what makes it visible. Speed is applied **per -`EditedMediaItem`** (each clip in the sequence carries its own `SpeedChangeEffect`). -[androidx/media #1658](https://github.com/androidx/media/issues/1658) reported that in an -`EditedMediaItemSequence` **only the first item's effects are applied** (real in 1.4.1, marked Closed, -*unconfirmed in 1.10.1*). - -Slice 03's device pass with the constant 2× *appeared* correct (Steven: "everything works"), but a -constant 2× across both halves is exactly the case where a single-item-only bug is **hardest to notice** -— both halves at 2× and both halves at "1× because the effect dropped" can look similar at a glance if -you're not measuring. Slice 04 makes speed **user-variable**, which is the real test: - -**On device, before you trust it:** render `F→R` and `R→F` at a clearly non-2× speed (e.g. **0.5×**) and -confirm **both halves change speed together**. If the second half plays at 1× (or 2×) while the first -half is at 0.5×, #1658 is live: speed must move from a per-item effect to a **Composition-level** effect. -Verify that API exists in 1.10.1 **first** (cross-check the `reference-media3-1-10-1-transformer-api` -memory + the source tag — do **not** guess). This compounds in slice 05 when N repetitions mean N copies -of each clip, all relying on the per-item effect. - -**The trap that makes #1658 dangerous: the preview cannot catch it — the preview will LIE.** The editor -preview is a *single* ExoPlayer with `setPlaybackSpeed(speed)`, which is a **player-global** setting — -it always speeds up *every* playlist item uniformly. The export is different: it applies -`SpeedChangeEffect(speed)` **per `EditedMediaItem`**. So if #1658 is live, the preview shows both halves -at 0.5× (correct-looking) while the *saved file* plays the second half at 1×. **A perfect preview does -not prove a correct export.** You must validate the **saved file** in the gallery at a non-2× speed, not -the editor preview. This is the single most important thing in this slice. - ---- - -## 2. The Speed tab is NOT "just add a slider" — you're building the tab-switching scaffold the placeholder deferred - -Slice 03 shipped a **single hard-coded tab icon**. In `ui/BoomerangEditorScreen.kt` today: -- the bottom bar is one `Text("≫")` centered in a reserved-height `Box` (height held for exactly this - moment so the layout doesn't reflow); and -- the content panel above it is **hard-wired to the Direction chips** — there is no tab state, no - switching, no `activeTab`. - -So slice 04 introduces the real scaffold the placeholder stood in for: -- `enum class EditorTab { DIRECTION, SPEED }` and `activeTab` in `EditorTabState` (REPS lands in slice 05). -- a **two-entry** tab bar with active-pill styling (active = `DeepCharcoal` pill + `NeonPurple` icon; - inactive = `GlassWhite` pill + white icon, per the PRD), driven by `activeTab`. -- content switching between the Direction panel and the new `SpeedTabContent`, ideally `AnimatedContent` - with a ~200 ms cross-fade. - -Budget for "wire up tab routing," not "drop one slider into the existing panel." - ---- - -## 3. Preview speed is PLAYER-SIDE — do not put `speed` in the playlist rebind key - -The editor preview is an ExoPlayer **playlist** (`setMediaItems(...) + REPEAT_MODE_ALL`), rebuilt in a -`LaunchedEffect(mode, reversedFile, trimStartMs, trimEndMs)` (see `BoomerangEditorContent`). Speed is a -**player-side** effect: `exoPlayer.setPlaybackSpeed(speed)`. It applies to the reversed clip too (that -clip plays *forward* with reversed frames), so **the cached reversed file is reused across all speed -changes — no re-render, no re-reverse.** - -**Do this:** apply speed in a **separate** `LaunchedEffect`/`snapshotFlow { speed }.debounce(~50.ms)` -that calls `setPlaybackSpeed`. **Do NOT** add `speed` to the playlist `LaunchedEffect` key — that would -rebuild the entire playlist and `prepare()` on every slider tick (jank, and a flash). Keep the rebind -keyed on mode/file/trim only; let speed ride the player. - -This is also a Lesson 016 situation: the slider value ticks fast (every drag frame). If the screen root -reads `tab.speed` and threads it into the preview subtree, **every tick recomposes the `AndroidView`** -(the ExoPlayer surface) → jank and possibly a flash. Keep speed *out* of the preview's recomposition: do -**not** add `speed` to the preview `LaunchedEffect` key, do **not** pass `speed` into the `AndroidView` -block. Speed reaches the player only through the debounced `setPlaybackSpeed` effect. Read `speed` only -in the slider + the value label + the duration label (ideally via `() -> Float` lambdas). - -**Mute the preview.** The forward clip is the raw *source*, which still has audio — so at a non-1× speed -ExoPlayer **pitch-shifts** it (chipmunk at 3×, drone at 0.25×), while the reversed clip is silent (audio -stripped). That mismatch is jarring and the exported boomerang is silent anyway (D-3). Set -`exoPlayer.volume = 0f` on the editor preview. (At slice 03's constant 2× this was barely noticeable; -variable speed makes it obvious — that's why it lands here.) - ---- - -## 4. The output-duration label already divides by speed — just pass the real value - -`media/BoomerangSequence.kt` has `boomerangOutputDurationMs(mode, trimStartMs, trimEndMs, speed, repetitions)` -(pure, JVM-tested). `BoomerangEditorContent` already calls it — but currently passes -`OpenLoopViewModel.DEFAULT_SPEED`. Slice 04: pass `state.speed` and the "Xs" label updates live. No new -math, no new function. - ---- - -## 5. Where everything lives (so you don't re-learn the map) - -- **State + `EditorTabState`:** `ui/OpenLoopUiState.kt` (NOT inline in the ViewModel, despite the PRD - sketch). Add `speed: Float = 2.0f` and `activeTab: EditorTab`. Default speed = `OpenLoopViewModel.DEFAULT_SPEED` (2.0f). -- **VM mutators:** `ui/OpenLoopViewModel.kt` — alongside `updateMode` / `ensureReversedSegment` / - `saveBoomerang` / `backToTrim`, add `updateSpeed(speed)` (`coerceIn(0.25f, 3.0f)`) and `switchTab(tab)`. - `saveBoomerang()` is where you swap `DEFAULT_SPEED` → `state.speed`. -- **Editor UI:** `ui/BoomerangEditorScreen.kt` — `BoomerangEditorScreen` (thin) + `BoomerangEditorContent` - (hoisted, testable). Existing helpers: `DIRECTION_CHIPS`, `previewPlaylist`, `CircleIconButton`, - `SaveCheckmark`, `DirectionChipButton`. Add `SpeedTabContent`, the `EditorTab` tab bar, the - `setPlaybackSpeed` effect. -- **Pure math:** `media/BoomerangSequence.kt` (`boomerangSequence`, `boomerangOutputDurationMs`). -- **Render:** `media/VideoProcessor.kt` (`renderBoomerang` already applies `SpeedChangeEffect(speed)`; - `ensureReversed` for the preview). **You don't edit this file.** -- **Tests:** `OpenLoopViewModelTest` (JVM; `FakeVideoProcessor` — extend it if you add interface methods, - and sweep `NoopVideoProcessor` in `OpenLoopNavHostTest` too — Lesson 017), `BoomerangSequenceTest` - (JVM), `BoomerangEditorScreenTest` (instrumented; no mockk — Lesson 017). - ---- - -## 6. Lessons that specifically apply to slice 04 - -- **Lesson 001 (Color literals):** slider thumb (`NeonCoral`) + track (`NeonPurple` fill / `GlassWhite` - right) are existing 8-hex tokens in `CameraScreen.kt`. Reuse them; don't inline new hex. -- **Lesson 002 (`collectAsStateWithLifecycle`):** the editor already collects `editorTabState` this way; - keep it. -- **Lesson 016 (defer high-frequency reads):** the slider value, like the slice-01 record timer, ticks - fast — narrowest scope, lambda the reads, debounce the player call. -- **Lesson 018 (seam by position):** in place and untouched by slice 04. Reps (slice 05) is what - re-exercises the per-cycle seam — not you. -- **Web-verify (CLAUDE.md rule):** Material3 `Slider`'s custom `thumb`/`track` slot API has drifted - across versions — confirm the current signature. Confirm `HapticFeedbackConstants.CLOCK_TICK` on - minSdk 26 and the current `snapshotFlow`/`debounce` import paths before using them. -- **Stay scoped:** two tab icons. **Do NOT** pre-render a Reps placeholder (slice 05). - ---- - -## 7. Implementation gotchas the PRD glosses over - -- **Haptic at 1.0× — don't reach for Compose's `LocalHapticFeedback`.** It only exposes `LongPress` / - `TextHandleMove`, not `CLOCK_TICK`. Use the platform view: - `LocalView.current.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)`. And **edge-detect the - crossing** — fire only when the value passes *through* 1.0× (track the previous value), not on every - frame it sits at 1.0×, or it buzzes continuously. - -- **The custom Slider is a time sink — mostly avoid it.** Material3 `Slider`'s `thumb`/`track` slot - lambdas take a `SliderState` and the signature has drifted across Compose versions. You get ~90% of the - PRD's look for near-zero risk with the stock slider + `SliderDefaults.colors(thumbColor = NeonCoral, - activeTrackColor = NeonPurple, inactiveTrackColor = GlassWhite)`. The genuinely hard part is the - **floating value label** — there's no built-in; you must compute the thumb's x from the value fraction - and track width (same math as `TrimBar.leftPx` in `TrimScreen.kt`). For v1, consider an - **always-visible centered "1.75×"** label instead and only add the floating one if it feels worth it. - Don't sink hours into custom slots. - -- **Testing reality (same shape as slice 03):** - - The PRD's *"`VideoProcessorTest` (JVM): for each speed, render produces duration ≈ cycle/speed"* can't - be a JVM test — `Transformer` needs a device. The speed→duration **math** is the pure - `boomerangOutputDurationMs(...)` (already JVM-tested in `BoomerangSequenceTest`) — add the per-speed - cases *there*; leave the real encoded-duration check to the instrumented smoke path. - - `FakeVideoProcessor` today records only `renderCount` — it does **not** capture the `speed` it was - called with. The *"saveBoomerang passes the current speed"* test has nothing to assert against until - you add a `lastSpeed` (and `lastMode`) field to the fake (Lesson 017 — when a test needs an arg, - the fake must capture it). - - Extract **`formatSpeedLabel(speed): String`** (the "up to 2 decimals, strip trailing zeros → - `2×` / `1.75×` / `0.5×`" logic) as a pure function and JVM-test it. The PRD's wording ("2 decimal - places … trim trailing zeros") is self-contradictory; a tested helper settles it. - -- **Plan gap — output duration is now unbounded, with no guard yet.** Speed *divides* duration, so at - **0.25×** a long trim explodes: a 30 s trim in `F→R` is a 60 s cycle → **240 s** output. The parent's - D-5 warning (>30 s) / hard-error (>60 s) UI is **not built until slice 05** (it landed there because - reps was the obvious multiplier — but 0.25× gets there first). So slice 04 can already save a 4-minute - "boomerang." Decide with Steven: just **note the longest duration in the PR** (the acceptance criterion - already asks for it) and accept it for now, or pull a minimal duration guard forward. Either way, - **watch encoder time + file size on the Fold at the 0.25× + long-trim extreme** — that's where a slow - or failed export will first show up. - ---- - -## 8. One-line summary for the impatient - -The render is already done (speed is threaded + applied via `SpeedChangeEffect(speed)`; just stop -hard-coding `DEFAULT_SPEED` in `saveBoomerang`). The real work is **UI tab-switching scaffold + a slider -+ player-side `setPlaybackSpeed`**, and the one thing that can make it wrong is **#1658**: confirm both -halves of `F→R`/`R→F` change speed together at a non-2× speed on the Fold before you trust it. diff --git a/docs/play-store/README.md b/docs/play-store/README.md index ea69394..7a0618c 100644 --- a/docs/play-store/README.md +++ b/docs/play-store/README.md @@ -12,6 +12,8 @@ in**. Keep these in sync if the app's behavior changes. | [`store-listing.md`](store-listing.md) | Title, descriptions, asset specs, screenshot checklist | Store presence → Main store listing | | [`release-signing-and-aab.md`](release-signing-and-aab.md) | Generate the upload key + build the signed `.aab` | Release → upload bundle | +**Private (your machine only, gitignored):** `docs/local/play-store-beginner-guide.md` — plain-English walkthrough of tracks, keys, versionCode, and the release checklist. Not on GitHub. + --- ## Pre-submission checklist diff --git a/docs/play-store/data-safety.md b/docs/play-store/data-safety.md index 573e563..7df2954 100644 --- a/docs/play-store/data-safety.md +++ b/docs/play-store/data-safety.md @@ -63,7 +63,7 @@ it's collected = No / required** (there is no in-app opt-out toggle yet — see | Category | Data type | Purpose(s) | Source of the data | |---|---|---|---| -| **App activity** | **App interactions** | Analytics | Automatic events (`first_open`, `session_start`, `screen_view`) + the app's custom events (export success/failure, editor direction/speed/filter changes, gallery/import events) per `docs/active/firebase-analytics/IMPLEMENTATION.md` §4. | +| **App activity** | **App interactions** | Analytics | Automatic SDK events (`first_open`, `session_start`, …) plus planned custom events (export, editor tabs, gallery) — see `AnalyticsReporter.kt`. Option 1 (abstraction) shipped; full instrumentation not wired yet. | | **Location** | **Approximate location** | Analytics | Coarse location Google Analytics derives from the (masked) IP address. Not GPS; the app holds no location permission. | | **Device or other IDs** | **Device or other IDs** | Analytics | Firebase **App Instance ID** (pseudonymous). | @@ -102,8 +102,7 @@ that maps a request to a person, there is no way to find and delete "this user's per-user deletion would be a promise that can't be kept. "No" is permitted by Play and is the truthful answer for pseudonymous-only telemetry. -If you later want to offer real user control, the parked in-app opt-out toggle -(`docs/active/firebase-analytics/IMPLEMENTATION.md` §7) — a Settings switch calling +If you later want to offer real user control, add an in-app opt-out toggle — a Settings switch calling `FirebaseAnalytics.setAnalyticsCollectionEnabled(false)` + `FirebaseCrashlytics.setCrashlyticsCollectionEnabled(false)` — would let you flip "users can choose whether this data is collected" to **Yes** for every type above. That is an opt-out of *collection*, diff --git a/docs/pr-assets/issue-41/app-camera-emulator.png b/docs/pr-assets/issue-41/app-camera-emulator.png deleted file mode 100644 index 14c2266..0000000 Binary files a/docs/pr-assets/issue-41/app-camera-emulator.png and /dev/null differ diff --git a/docs/pr-assets/issue-41/loopifying-processing-42pct.png b/docs/pr-assets/issue-41/loopifying-processing-42pct.png deleted file mode 100644 index 6b587f0..0000000 Binary files a/docs/pr-assets/issue-41/loopifying-processing-42pct.png and /dev/null differ diff --git a/docs/prompts/PR-FEEDBACK-RESOLUTION.md b/docs/prompts/PR-FEEDBACK-RESOLUTION.md deleted file mode 100644 index c42573e..0000000 --- a/docs/prompts/PR-FEEDBACK-RESOLUTION.md +++ /dev/null @@ -1,95 +0,0 @@ -# PR Feedback Resolution — Reusable Session Prompt - -Copy everything below the line into a fresh Claude session with the OpenLoop folder mounted. Replace `#XX` with your PR number. - ---- - -## Session Prompt — Address PR Review Feedback & Re-Review - -You are working on **OpenLoop** — an open-source Android camera app (Kotlin/Jetpack Compose) for creating speed-controlled video loops. Repo: `stozo04/OpenLoop`. Owner: Steven Gates (@stozo04). - -## Critical Rule — Do Not Trust Your Training Data - -Your knowledge cutoff could be a year old. **Do not assume** you know the current version of any Google standard, Android API behavior, Jetpack library pattern, testing framework convention, or Play Store requirement. Before making any claim about how something works or what Google recommends, **web-search `developer.android.com` first**. This applies to everything — architecture patterns, Compose APIs, DataStore usage, CameraX, coroutines, permissions, accessibility, Play Store requirements, and any external package or library. If you catch yourself writing "Google recommends X" without having searched for it in this session, stop and search. - -## Context - -PR #XX received an automated standards review from our PR Reviewer skill. The review posted feedback directly on the PR as a comment with specific PASS/FAIL/WARNING findings, each citing Google documentation. - -Your job is to **fix every issue** the reviewer found, then **prove the fixes are clean** by running a fresh review. - -## Before Writing Any Code — Read These Files - -These are your ground truth. They contain decisions, conventions, and constraints that override any assumptions: - -1. `CLAUDE.md` — Operating instructions, architecture snapshot, reference doc pointers -2. `docs/PRD-mission-control.md` — Authoritative component specs, decision log (check before changing patterns) -3. `docs/ANDROID_STANDARDS.md` — Google best practices with official doc links -4. `docs/TEST_COVERAGE.md` — Test directory structure, frameworks, coroutine testing, current inventory - -Also check `docs/active/` for the feature's IMPLEMENTATION.md if one exists. - -**Read all of these before touching any code.** - -## Phase 1: Read the PR Review Feedback - -1. Go to the PR on `stozo04/OpenLoop` (use GitHub tools — `pull_request_read` with method `get_comments`) -2. Find the automated review comment (from Claude, titled "PR Review — Google Android Standards Compliance") -3. Read every FAIL, WARNING, and RECOMMENDATION carefully - -## Phase 2: Web-Search Before Fixing - -For each FAIL and WARNING, **web-search the cited Google documentation URL** to confirm the finding is still valid and to understand the correct fix. Do not assume the reviewer was right — verify independently. Standards change. If a finding turns out to be stale or incorrect, note that in your response. - -## Phase 3: Fix Every Issue - -Address each FAIL and WARNING: - -- For each fix, explain **what** you're changing, **why**, and **which Google standard** it satisfies -- Make the actual code changes on the PR's branch -- If a fix requires new tests, add them (follow conventions in `docs/TEST_COVERAGE.md`) -- If a fix conflicts with a decision in `docs/PRD-mission-control.md` Decision Log, flag the tension — don't silently override - -After all fixes are made, **commit and push** to the PR's branch on GitHub. - -## Phase 4: Post a Response Comment - -After pushing, post a comment on the PR (using GitHub `add_issue_comment`) documenting what you fixed: - -```markdown -## PR Review Response — Fixes Applied - -**Date:** [today] -**Commit:** [sha] - -### Issues Addressed - -For each FAIL/WARNING from the review: - -- **[Category] [Original finding]** - - **Action taken:** [what was changed] - - **File(s):** [file:line] - - **Google standard verified via:** [URL you searched] - - **Status:** RESOLVED / ACKNOWLEDGED / DISPUTED (with reasoning) -``` - -## Phase 5: Kick Off a Fresh Review - -After posting your response, run the PR Reviewer skill against the same PR. The skill lives at `.claude/skills/pr-reviewer/`. Follow its 5-phase process: - -1. Bootstrap — read project context files -2. Research — web-search Google standards (fresh, not cached) -3. Identify — target the PR -4. Review — evaluate all code against the 11-category, 75+ item checklist -5. Post — write the structured PASS/FAIL/WARNING report as a new comment on the PR - -The goal: **zero FAILs** on the re-review. If any remain, fix them and repeat until clean. - -## Behavioral Rules - -- **Web-search before every claim.** You do not know what Google's current guidance says until you search for it. -- **Show your work.** Every fix includes the reasoning and the Google doc citation. -- **Be specific.** File names, line numbers, code snippets. No vague statements. -- **Respect the Decision Log.** Check `docs/PRD-mission-control.md` before overriding any existing pattern. -- **Tests matter.** If a fix changes behavior, update or add tests. Check `docs/TEST_COVERAGE.md` for conventions. -- **One branch, clean commits.** All work happens on the PR's branch. Push via GitHub API if local git has issues. diff --git a/docs/prompts/boomerang-slice-01-kickoff.md b/docs/prompts/boomerang-slice-01-kickoff.md deleted file mode 100644 index 7fd95ad..0000000 --- a/docs/prompts/boomerang-slice-01-kickoff.md +++ /dev/null @@ -1,124 +0,0 @@ -## Session Prompt — Implement Boomerang Slice 01 - -You are working on **OpenLoop** — an open-source Android camera app (Kotlin/Jetpack Compose) for creating speed-controlled video loops ("Boomerangs"). Repo: `stozo04/OpenLoop`. Owner: Steven Gates (@stozo04). Apache 2.0. - -The app is in active development. Today's app surface is: 3-step Onboarding → live camera viewfinder with front/back lens toggle → 1.5 s auto-stop burst recording → captured clip lands in a Gallery view. There is no boomerang generation yet. The full boomerang feature is being rolled out in **7 thin vertical slices**. This session is for **slice 01** — the foundational slice that every later slice depends on. - -## Critical Rule — Do Not Trust Your Training Data - -Your knowledge cutoff could be a year old. **Do not assume** you know the current version of any Google standard, Android API behavior, Jetpack library pattern, testing framework convention, or Play Store requirement. Before making any claim about how something works or what Google recommends, **web-search `developer.android.com` first**. This applies to everything — Compose APIs, CameraX `Recording` lifecycle, coroutine testing, `Canvas.drawArc`, accessibility touch targets, anything. If you catch yourself writing "Google recommends X" without having searched for it in this session, stop and search. - -## What slice 01 ships (one-paragraph summary) - -Replace the existing 1.5 s self-stop with **user-controlled stop + 30 s auto-cap**. Shutter becomes **tap-to-start / tap-to-stop**, with a visible **progress ring** sweeping clockwise around the button and a `00:00 / 00:30` countdown chip top-center while recording. After this slice merges, the gallery starts holding clips long enough to be useful — every later slice in the boomerang rollout assumes captures up to 30 s. - -The full slice spec — UX, technical deltas, testing plan, acceptance criteria — lives in `docs/active/boomerang-rollout/01-capture-variable-length.md`. **That doc is your PRD for this PR.** Treat it as authoritative. - -## Before Writing Any Code — Read These Files (in this order) - -These are your ground truth. They contain decisions, conventions, and constraints that override any assumptions you bring in. - -1. **`CLAUDE.md`** — operating instructions, the "Critical Rule" above (full version), the architecture snapshot, and pointers to the reference docs. -2. **Every file in `docs/lessons_learned/`** — start with `README.md`, then read 001 through 011 in order. **This is non-negotiable** (see CLAUDE.md "Required Reading"). Lesson 002 (`collectAsStateWithLifecycle`) and Lesson 008 (JVM test patterns: `TemporaryFolder`, single `TestDispatcher`) are especially relevant to this slice. -3. **`docs/DEFINITION_OF_DONE.md`** — the verification gate every non-trivial change must clear before being called done. Baseline → debug + release green → unit + instrumented tests → **run the app + screenshot** → honest coverage statement. Not a nice-to-have; the bar. -4. **`docs/active/boomerang-editor/IMPLEMENTATION.md`** — the parent design doc. You don't need every section, but skim the "Status updates" at the top and §3 (UX flow), §5 (state machine), §7 (data layer) so you understand how slice 01 sits in the broader design. -5. **`docs/active/boomerang-rollout/README.md`** — the rollout map. Tells you what's around slice 01 so you don't over-build (don't ship slice 02's flow inside slice 01's PR). -6. **`docs/active/boomerang-rollout/01-capture-variable-length.md`** — your PRD. Read it end-to-end. Read it twice. -7. **`docs/PRD-mission-control.md`** §3 (Design System Tokens) — for the `NeonCoral`, `NeonPurple`, `GlassWhite`, `DeepCharcoal` token values and the gradient rules used by the shutter and countdown chip. -8. **`docs/ANDROID_STANDARDS.md`** — consult before introducing any new pattern. If the slice doc proposes something not in the standards doc, decide whether it's a *new* pattern or a clarification of an existing one. -9. **`docs/TEST_COVERAGE.md`** — test directory structure, frameworks, coroutine testing conventions. Follow this for the new unit and instrumented tests this slice adds. - -**Read all of these before touching any code.** No exceptions. - -## Phase 1: Cut the branch + capture a green baseline - -```powershell -$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr" -git checkout main -git pull --rebase -git checkout -b feature/boomerang-slice-01-variable-length -.\gradlew.bat clean assembleDebug --console=plain -``` - -Confirm `BUILD SUCCESSFUL`, `$LASTEXITCODE -eq 0`, zero `e:` lines. **Do not pipe Gradle through `| tail`** — that gives you the tail's exit code, not Gradle's, and a failed build looks green (see CLAUDE.md and `HEY_CLAUDE_ITS_ME.md`). A green baseline now means any later failure is unambiguously yours. - -## Phase 2: Web-verify the moving pieces (before writing any code) - -For each of the following, **search developer.android.com this session** and confirm the API you intend to use exists as you remember it, in the versions pinned in `gradle/libs.versions.toml`: - -- `androidx.camera.video.Recording.stop()` and `VideoRecordEvent.Finalize` behavior in CameraX **1.6.1**. -- `androidx.compose.foundation.Canvas` + `DrawScope.drawArc` for the progress ring (`startAngle = -90f`, `sweepAngle = elapsed / 30_000f * 360f`). -- `androidx.lifecycle.compose.collectAsStateWithLifecycle` import path (Lesson 002). -- `kotlinx.coroutines.test.runTest` + `TestDispatcher` virtual-time pattern as it works in `kotlinx.coroutines` 1.8+ (Lesson 008 calls out the failure mode). - -If any signature has drifted from what the slice doc implies, **stop and surface the discrepancy** before coding. Don't silently adapt. - -## Phase 3: Implement to the slice spec - -Work the technical deltas listed in `docs/active/boomerang-rollout/01-capture-variable-length.md` §"Technical deltas": - -- `OpenLoopViewModel.kt` — remove the 1.5 s `delay()` self-stop; add `stopBurstCapture(...)`; add `recordingElapsedMs: StateFlow`; add the 30 s auto-cap coroutine. -- `CameraScreen.kt` — shutter button toggles between idle and recording glyphs; progress ring overlay via `Canvas`; `00:00 / 00:30` countdown chip top-center while recording. All Flow collection via `collectAsStateWithLifecycle()` (Lesson 002). -- No changes to `OpenLoopUiState.kt`. No changes to `VideoStorageRepository` (those land in slice 02). - -**Stay scoped to slice 01.** Do not introduce `ScratchCapture`, `BoomerangEditor`, `VideoProcessor`, `VideoReverser`, or any other slice-02+ surface. If you find yourself reaching for them, you've over-shot the slice. - -## Phase 4: Test - -- Add the unit tests from §"Testing plan / Unit tests" — `OpenLoopViewModelTest` cases for the new behavior. Use `TemporaryFolder` and a single shared `TestDispatcher` per Lesson 008. Do not mock `File`. Do not stack `MainDispatcherRule` with bare `runTest { }`. -- Add the instrumented tests from §"Testing plan / Instrumented tests" — `CameraScreenTest` for the shutter toggle, progress ring visibility, countdown text. -- Run both: - ```powershell - .\gradlew.bat testDebugUnitTest --console=plain; echo "EXIT=$LASTEXITCODE" - $env:ANDROID_SERIAL = "" - .\gradlew.bat connectedDebugAndroidTest --console=plain; echo "EXIT=$LASTEXITCODE" - ``` -- Open the XML results under `app/build/.../*-results/` and confirm **0 failures, 0 errors** — not just `BUILD SUCCESSFUL`. - -## Phase 5: Run the app for real + screenshot (the DoD gate) - -This is the step that separates "should work" from "works." - -```powershell -.\gradlew.bat clean assembleDebug assembleRelease --console=plain; echo "EXIT=$LASTEXITCODE" -# Verify zipalign on release per Lesson 011 — `(OK)` not `(OK - compressed)` for every .so. -& "$env:LOCALAPPDATA\Android\Sdk\build-tools\\zipalign.exe" -c -P 16 -v 4 ` - app\build\outputs\apk\release\app-release-unsigned.apk -``` - -Boot an emulator (or use Steven's Pixel 10 Pro Fold if available — see `HEY_CLAUDE_ITS_ME.md` for adb / multi-device gotchas), install the debug APK, and walk the slice 01 manual QA checklist end-to-end: - -- Short capture (< 1 s) saves cleanly. -- 30 s untouched auto-stops and the clip plays back in `LoopingPreview` without freezing. -- Front and back camera both honor the new behavior. -- Background mid-recording does not crash and the partial clip is saved. - -**Capture a screenshot of the recording state** (shutter mid-progress, countdown chip visible) and attach it to the PR description. Foldables and the multi-display screencap gotcha are documented in `HEY_CLAUDE_ITS_ME.md` — use `screencap -p /sdcard/x.png` + `pull` rather than piping through `exec-out screencap -p >`. - -## Phase 6: Open the PR - -- Push the branch to `stozo04/OpenLoop`. -- Open the PR. Title: `Slice 01 — Variable-length capture (≤30 s)`. -- Use the acceptance-criteria checklist from `docs/active/boomerang-rollout/01-capture-variable-length.md` §"Acceptance criteria" as the PR description's body, checking each box. -- Attach the screenshot from Phase 5. -- In a final paragraph, **honestly state what you could not verify** — anything skipped, anything you couldn't reproduce, anything that worked on the emulator, but you couldn't test on a real device. This is the spirit of Lesson 007 and the DoD doc. - -After the PR is open, you're done with this session. The next session picks up either the PR review (`pr-reviewer` skill under `.claude/skills/`) or slice 02 (`docs/prompts/boomerang-slice-02-kickoff.md` once that exists). - -## Behavioral Rules - -- **PRD-first.** The slice doc is the spec. If you're tempted to add behavior not in the spec, stop and ask. If you think the spec is wrong, **push back** before coding around it — don't silently override (see CLAUDE.md "Pushback — required"). -- **Web-search before every API claim.** You do not know what's current until you check this session. -- **Lessons compliance is checkable by grep.** Before pushing: confirm no `collectAsState(` calls (Lesson 002), no unwrapped DataStore writes (Lesson 003), no `Context` parameter on ViewModel methods (Lesson 004), every `Color(0x…)` literal has exactly 8 hex digits (Lesson 001). The slice doc's acceptance criteria list these. -- **`BUILD SUCCESSFUL` is not enough.** Read the verdict line itself, check `$LASTEXITCODE`, scan for `e:` lines. Never trust a piped exit code (CLAUDE.md "the `| tail` trap"). -- **Done is not "it compiles."** Done is the DoD gate: build + tests + run + screenshot + honest coverage statement. Anything less and the PR isn't ready. -- **Reversibility protocol applies.** Before destructive operations (force-push, deleting branches, rewriting history), show the plan and wait for explicit "proceed." -- **Stay scoped.** Slice 01 only. No slice 02+ surface area in this PR. - -## When to stop and come back to Steven - -- If the slice doc and an existing lesson disagree. -- If web verification shows a CameraX / Compose / coroutine API has changed in a way that breaks the slice plan. -- If the green baseline in Phase 1 is *not* green (someone broke `main` and it needs fixing first). -- If `zipalign` on the release APK shows `(OK - compressed)` for any `.so` (Lesson 011 regression — flag immediately). -- If you discover the work is meaningfully larger than the slice doc implies (would be > ~600 LOC). Slices are PR-sized by design; if this one isn't, the slicing was wrong. diff --git a/docs/prompts/boomerang-slice-02-kickoff.md b/docs/prompts/boomerang-slice-02-kickoff.md deleted file mode 100644 index 6353cf3..0000000 --- a/docs/prompts/boomerang-slice-02-kickoff.md +++ /dev/null @@ -1,162 +0,0 @@ -# Boomerang Slice 02 — Kickoff Prompt for a Fresh Claude Code Session - -Copy everything below the line into a fresh Claude Code session with the OpenLoop folder mounted. This kickoff is specific to **slice 02 (Auto-route to Trim screen + default-render Save)**. Assumes slice 01 has shipped and is merged to `main`. - ---- - -## Session Prompt — Implement Boomerang Slice 02 - -You are working on **OpenLoop** — an open-source Android camera app (Kotlin/Jetpack Compose) for creating speed-controlled video loops ("Boomerangs"). Repo: `stozo04/OpenLoop`. Owner: Steven Gates (@stozo04). Apache 2.0. - -The app currently captures variable-length clips up to 30 s (slice 01 shipped). There is still no boomerang generation. This slice is where boomerangs *start existing* — but with no editor tabs yet. It is the largest, riskiest slice in the rollout: it introduces the new `VideoReverser` class (hand-rolled two-pass MediaCodec), the new `VideoProcessor` class (Media3 Composition), the new `Trim` screen, the per-UUID scratch file model, and the `boomerangs/` directory + `RecordedVideo.kind`. Plan for it. - -## Critical Rule — Do Not Trust Your Training Data - -Your knowledge cutoff could be a year old. **Do not assume** you know the current version of any Google standard, Android API behavior, Jetpack library pattern, testing framework convention, or Play Store requirement. Before making any claim about how something works or what Google recommends, **web-search `developer.android.com` first**. This applies to everything — Media3 Transformer / Composition APIs, MediaCodec/MediaExtractor/MediaMuxer behavior, FileProvider, FileProvider URI behavior, coroutine cancellation semantics. If you catch yourself writing "Google recommends X" without having searched for it in this session, stop and search. - -## What slice 02 ships (one-paragraph summary) - -After a capture finalizes, the app **auto-routes** (no buttons) onto a new dedicated **Trim screen** with the just-captured clip loaded. The user drags start / end handles to crop, then taps `NEXT`. A default boomerang (`FORWARD_THEN_REVERSE`, 2× speed, 1 rep) is rendered using a new `media/VideoProcessor.kt` that depends on a new `media/VideoReverser.kt` (two-pass MediaCodec reverser per `RESEARCH-reverse-video.md`). Output saves to `filesDir/boomerangs/`, raw promotes from scratch to `filesDir/videos/`, a snackbar "Saved — view in gallery" appears, and the user lands back on the camera. **No tabbed editor exists yet** — slice 03 introduces it. - -The full slice spec — UX, technical deltas, testing plan, acceptance criteria — lives in `docs/active/boomerang-rollout/02-auto-route-trim-and-default-save.md`. **That doc is your PRD for this PR.** Treat it as authoritative. - -## What slice 01 already shipped (your starting point — build on it, don't regress it) - -Slice 01 merged variable-length capture **plus a PR-review hardening pass (PR #19)** that changed structure you will touch. Concretely, already in `main`: - -- **`OpenLoopUiState.Processing` already exists** and is routed to an `InfinityLoadingScreen()` **placeholder** with a `// TODO(slice-02)` marker. Slice 02 replaces that placeholder with the real `ProcessingScreen`. -- **Routing was extracted** out of `setContent` into a stateless, **exhaustive `@Composable fun OpenLoopNavHost(...)` with no `else`** (Lesson 014). Add `Trim`/replace `Processing` **there**. -- **`CameraScreen` has a `BackHandler(enabled = isRecording)`** that stops & finalizes mid-record (Lesson 015). The capture states still share **one** `CameraScreenHost` call site (Lesson 012) — `grep "CameraScreen(" MainActivity.kt` must stay at **1**. -- **High-frequency UI state is read via `() -> T` lambdas** (`ShutterButton(progressFraction = { … })`, `RecordingCountdownChip(text = { … })`) so the viewfinder doesn't recompose per tick (Lesson 016). Mirror this in the `Trim` scrubber. -- **`OpenLoopViewModel.startBurstCapture` now checks the `Recording?` return** (null → abort to `ReadyToCapture`) and **catches only `IllegalStateException` + `SecurityException`** (Lesson 013). Your new `VideoProcessor`/`VideoReverser` error handling should follow the same discipline: media APIs report runtime errors via callbacks, not throws. -- **The `LoopingPreview` "Loopify" button is still the intentional placeholder** (deferred to this slice). Slice 02 is where `Finalize(success)` stops going to `LoopingPreview` and instead routes to `Trim(ScratchClip)`. - -Lessons **013–017** were written from that PR — they are the distilled, web-verified knowledge behind these changes. Read them; they're cheaper than re-deriving the CameraX throwable set or the predictive-back rules. - -## Before Writing Any Code — Read These Files (in this order) - -These are your ground truth. They contain decisions, conventions, and constraints that override any assumptions you bring in. - -1. **`CLAUDE.md`** — operating instructions, the "Critical Rule" above (full version), architecture snapshot, reference doc pointers. -2. **Every file in `docs/lessons_learned/`** — start with `README.md`, then read **001 through 017** in order. **Non-negotiable** per CLAUDE.md "Required Reading." Especially relevant to slice 02: - - Lesson 003 (DataStore IOException wrapping) and Lesson 004 (no `Context` on ViewModel methods — your new repository methods are subject to this). - - Lesson 008 (JVM test patterns: `TemporaryFolder`, single `TestDispatcher`) **and Lesson 017** (instrumented tests can't use `mockk`; write inline fakes — your `VideoReverserTest`/`TrimScreenTest` are instrumented). - - **Lesson 012 + 014** — the post-capture routing you're rewiring. Routing now lives in the extracted, exhaustive `OpenLoopNavHost` (no `else`); the `ReadyToCapture, Recording` pair shares one `CameraScreenHost` branch — don't split it, and don't add an `else`. - - **Lesson 013** — Media3 `Transformer`/`MediaCodec` report runtime errors via listener callbacks (`Transformer.Listener.onError`), **not** synchronous throws; catch only documented throwables and check return/failure signals. This is the heart of `VideoReverser`/`VideoProcessor` error handling. - - **Lesson 015** — `Trim` and `Processing` each need a deliberate `BackHandler` decision (predictive back is default-on at target 36; default behavior is "finish the Activity and lose the work"). - - **Lesson 016** — defer the `Trim` scrubber/preview-position reads behind `() -> T` lambdas; don't read tick-rate state at the `TrimScreen` root next to the ExoPlayer `AndroidView`. -3. **`docs/DEFINITION_OF_DONE.md`** — the verification gate. Baseline → debug + release green → unit + instrumented tests → **run the app + screenshot** → honest coverage statement. Not a nice-to-have; the bar. -4. **`docs/active/boomerang-editor/IMPLEMENTATION.md`** — parent design doc. Required sections: §3 (UX flow), §5 (state machine), §6 (Media3 pipeline), §7 (data layer). The pipeline math (cycle_ms, seam handling) is here. -5. **`docs/active/boomerang-rollout/README.md`** — rollout map. Confirms what slice 02 ships and what it does NOT. -6. **`docs/active/boomerang-rollout/RESEARCH-reverse-video.md`** — the locked decision on how reverse works. §3 (the two-pass algorithm) and §5 (the recommended `VideoReverser` surface) are the spec for the heaviest new class in this slice. Read this before opening any MediaCodec docs. -7. **`docs/active/boomerang-rollout/02-auto-route-trim-and-default-save.md`** — your PRD. Read it end-to-end. Read it twice. The `VideoReverser` and `VideoProcessor` surfaces, the scratch-file model, and the directory layout are all here. -8. **`docs/PRD-mission-control.md`** §3 (Design System Tokens) and §6 (Data Layer / File Structure) — for the Trim screen visual tokens and the existing storage conventions you're extending. -9. **`docs/ANDROID_STANDARDS.md`** — consult before introducing any new pattern. The Composition + EditedMediaItem path is a new pattern; verify it aligns. -10. **`docs/TEST_COVERAGE.md`** — test directory structure, frameworks, coroutine testing conventions. - -**Read all of these before touching any code.** No exceptions. - -## Phase 1: Cut the branch + capture a green baseline - -```powershell -$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr" -git checkout main -git pull --rebase -git checkout -b feature/boomerang-slice-02-trim-and-default-save -.\gradlew.bat clean assembleDebug --console=plain -``` - -Confirm `BUILD SUCCESSFUL`, `$LASTEXITCODE -eq 0`, zero `e:` lines. **Do not pipe Gradle through `| tail`** — that gives you the tail's exit code, not Gradle's. A green baseline now means any later failure is unambiguously yours. - -## Phase 2: Web-verify the moving pieces (before writing any code) - -For each of the following, **search developer.android.com this session** and confirm the API matches your assumption, in the versions pinned in `gradle/libs.versions.toml` (Media3 **1.10.1**): - -- `androidx.media3.transformer.Transformer`, `Composition`, `EditedMediaItem` — current builder signatures and listener callbacks for progress / completion / error. -- `androidx.media3.common.MediaItem.ClippingConfiguration` — `setStartPositionMs` / `setEndPositionMs`. -- `androidx.media3.effect.SpeedChangingVideoEffect(2.0f)` — confirmed available in Media3 1.10.x effect package. -- `android.media.MediaCodec.createInputSurface()` + decoder-output-Surface-as-encoder-input-Surface pattern (single-Surface path described in `RESEARCH-reverse-video.md` §3). -- `android.media.MediaExtractor.seekTo(timeUs, mode)` with `SEEK_TO_CLOSEST_SYNC`, and `SAMPLE_FLAG_SYNC` semantics. -- `androidx.core.content.FileProvider` — only the *exists* check; the `Intent.ACTION_SEND` wiring is **slice 06**, not this slice. - -If any signature has drifted from what the slice doc / research doc implies, **stop and surface the discrepancy** before coding. Don't silently adapt. - -## Phase 3: Implement to the slice spec - -Work the technical deltas listed in `docs/active/boomerang-rollout/02-auto-route-trim-and-default-save.md` §"Technical deltas": - -- **`OpenLoopUiState.kt`** — add `Trim(source: EditorSource)` state, the `EditorSource` sealed interface with `ScratchClip` (only — `GalleryClip` is slice 07). -- **`OpenLoopViewModel.kt`** — on `Finalize` success, build a `ScratchClip(uuid)` and post `Trim(ScratchClip)` instead of `LoopingPreview`. Add the `editorState: StateFlow` + `updateTrim` / `discardTrim` / `saveBoomerangDefault` mutators. Every repository write wrapped in `try / catch (IOException)` per Lesson 003. No `Context` parameter on any method per Lesson 004. -- **`VideoStorageRepository` (interface + impl)** — add `createScratchCapture`, `promoteScratchToRaw`, `discardScratch`, `allocateBoomerangFile`, `registerBoomerang`. Extend `RecordedVideo` with `kind: VideoKind` and `sourceRawId: Long?`. Infer `kind` from the file's parent directory. -- **`media/VideoReverser.kt`** (new) — implement the two-pass algorithm from `RESEARCH-reverse-video.md` §3 and §5. Cache key `__`. Decoder + encoder + muxer released in a `finally` block. Cancellable. Strips audio. -- **`media/VideoProcessor.kt`** (new) — single `renderBoomerang(...)` entry, hard-wired to `mode=FORWARD_THEN_REVERSE, speed=2.0f, reps=1`. Pipeline: trim → call `videoReverser.reverse(...)` → build `Composition` of `[trimmed, reversed]` (with seam offset) → `SpeedChangingVideoEffect(2.0f)` → strip audio → Transformer export. -- **`ui/TrimScreen.kt`** (new) — preview top ~75% (ExoPlayer with `ClippingConfiguration`, `repeatMode = REPEAT_MODE_ALL`), trim bar with two drag handles, `NEXT` button bottom. Save checkmark **hidden** in this slice. All Flow collection via `collectAsStateWithLifecycle()` (Lesson 002). -- **`ui/ProcessingScreen.kt`** (new or extended) — centered spinner + "Creating boomerang…" caption. -- **Routing — `OpenLoopNavHost` in `MainActivity.kt` (NOT inline `setContent`).** Slice 01 extracted the state router into a stateless, exhaustive `@Composable fun OpenLoopNavHost(...)` with **no `else`** (Lesson 014), and `OpenLoopUiState.Processing` **already exists** routed to an `InfinityLoadingScreen()` placeholder marked `// TODO(slice-02)`. So: **replace** the `Processing` placeholder branch with the real `ProcessingScreen`, and **add** the new `is OpenLoopUiState.Trim ->` branch — both inside `OpenLoopNavHost`. Keep `ReadyToCapture, Recording` on their single `CameraScreenHost` branch (Lesson 012) and keep the `when` exhaustive (do not add an `else`). Adding `Trim` to the sealed interface will fail to compile until it's routed here — that's the guard working. - -**Stay scoped to slice 02.** Do not introduce: the tabbed `BoomerangEditor` screen (slice 03), direction picker (slice 03), speed slider (slice 04), reps tab (slice 05), `Intent.ACTION_SEND` / FileProvider wiring (slice 06), gallery filter / kind badge / `GalleryClip` source (slice 07). If you find yourself reaching for any of them, you've over-shot the slice. - -## Phase 4: Test - -- Unit tests from §"Testing plan / Unit tests": `OpenLoopViewModelTest` cases for the new transition, `VideoStorageRepositoryImplTest` cases for the new methods. Use `TemporaryFolder` and a single shared `TestDispatcher` per Lesson 008. Do not mock `File`. Do not stack `MainDispatcherRule` with bare `runTest { }`. -- Instrumented `VideoReverserTest` (needs real MediaCodec — JVM unit tests can't exercise it): cache idempotency, intermediate file cleanup on success/failure/cancellation, first-frame-of-reversed ≈ last-frame-of-source (histogram distance, not pixel equality). -- Instrumented `TrimScreenTest`: handles drag, `NEXT` disabled below 400 ms, preview rebinds on trim change. -- End-to-end: record 3 s → trim to 1.5 s → tap `NEXT` → boomerang file exists with expected duration; raw + boomerang both registered; scratch dir cleaned. -- Run both: - ```powershell - .\gradlew.bat testDebugUnitTest --console=plain; echo "EXIT=$LASTEXITCODE" - $env:ANDROID_SERIAL = "" - .\gradlew.bat connectedDebugAndroidTest --console=plain; echo "EXIT=$LASTEXITCODE" - ``` -- Confirm 0 failures, 0 errors via the XML results — not just `BUILD SUCCESSFUL`. - -## Phase 5: Run the app for real + screenshot (the DoD gate) - -```powershell -.\gradlew.bat clean assembleDebug assembleRelease --console=plain; echo "EXIT=$LASTEXITCODE" -& "$env:LOCALAPPDATA\Android\Sdk\build-tools\\zipalign.exe" -c -P 16 -v 4 ` - app\build\outputs\apk\release\app-release-unsigned.apk -``` - -Boot an emulator (or Pixel 10 Pro Fold per `HEY_CLAUDE_ITS_ME.md`), install the debug APK, and walk the slice 02 manual QA checklist: - -- Record 5 s → lands on Trim screen, preview loops the full 5 s, duration indicator reads "5.0s". -- Drag handles to a 3 s range → preview loops 3 s, indicator updates to "3.0s". -- Tap `NEXT` → "Creating boomerang…" spinner, then snackbar "Saved — view in gallery". **Record elapsed time** in the PR description. Target ≤ 1.5 s for a 3 s trim on Pixel 10 Pro Fold per `RESEARCH-reverse-video.md` §5 perf estimates. -- Inspect `cacheDir/scratch/reversed/` via `adb shell run-as com.OpenLoop.app ls` — exactly one reversed `.mp4`; no `_intermediate_*.mp4` lingering. -- Save a second boomerang from the *same* trim window → noticeably faster (cache hit; no new reversed file). -- Save a third from a *different* trim window of the same raw → second reversed file appears (cache miss). -- Discard from Trim screen → confirm dialog → "Yes" returns to camera, scratch cleaned. -- Force-stop *during* an in-flight render → on relaunch, no zombie intermediate or partial output files; codecs released (`adb shell dumpsys media.codec | grep com.OpenLoop.app` shows no held instances). - -**Capture a screenshot of the Trim screen** (preview + trim bar + NEXT button visible) and attach it to the PR description. Foldable/multi-display screencap gotchas in `HEY_CLAUDE_ITS_ME.md`. - -## Phase 6: Open the PR - -- Push the branch to `stozo04/OpenLoop`. -- Open the PR. Title: `Slice 02 — Auto-route to Trim screen + default-render Save`. -- Use the acceptance-criteria checklist from `docs/active/boomerang-rollout/02-auto-route-trim-and-default-save.md` §"Acceptance criteria" as the PR description's body. Check each box, including the `VideoReverser` cache + cleanup verifications and the latency measurement. -- Attach the screenshot from Phase 5. -- Final paragraph: **honestly state what you could not verify** — process-kill resume of scratch is explicitly out-of-scope (slice 07), say so. - -After the PR is open, you're done with this session. - -## Behavioral Rules - -- **PRD-first.** Slice doc + research doc are the spec. If tempted to add behavior not in spec, stop and ask. If you think the spec is wrong, **push back** before coding around it (CLAUDE.md "Pushback — required"). -- **Web-search before every API claim.** -- **Lessons compliance is checkable by grep.** Before pushing: no `collectAsState(` (Lesson 002), no unwrapped DataStore writes (Lesson 003), no `Context` parameter on ViewModel methods (Lesson 004), every `Color(0x…)` has exactly 8 hex digits (Lesson 001), zipalign passes with real `(OK)` (Lesson 011). -- **`BUILD SUCCESSFUL` is not enough.** Read the verdict, check `$LASTEXITCODE`, scan for `e:` lines. Never trust a piped exit code. -- **Done is not "it compiles."** DoD gate: build + tests + run + screenshot + honest coverage statement. -- **Reversibility protocol applies** for destructive operations. -- **Stay scoped.** Slice 02 only. - -## When to stop and come back to Steven - -- If the slice doc / research doc and an existing lesson disagree. -- If web verification shows a Media3 / MediaCodec API has changed in a way that breaks the plan. -- If the green baseline in Phase 1 isn't green (someone broke `main`). -- If `zipalign` on the release APK shows `(OK - compressed)` for any `.so` (Lesson 011 regression). -- If `VideoReverser` first-time latency on a 3 s clip is significantly worse than the ≤ 1.5 s budget in `RESEARCH-reverse-video.md` §5 (≥ 3 s warrants investigation before merging). -- If the cache key strategy collides with anything else in `cacheDir/scratch/` from prior slices. -- If the work is meaningfully larger than the slice doc implies (would be > ~800 LOC; this slice's budget is the largest in the rollout but still finite). diff --git a/docs/prompts/boomerang-slice-03-kickoff.md b/docs/prompts/boomerang-slice-03-kickoff.md deleted file mode 100644 index 7bb9fdc..0000000 --- a/docs/prompts/boomerang-slice-03-kickoff.md +++ /dev/null @@ -1,131 +0,0 @@ -# Boomerang Slice 03 — Kickoff Prompt for a Fresh Claude Code Session - -Copy everything below the line into a fresh Claude Code session with the OpenLoop folder mounted. This kickoff is specific to **slice 03 (Tabbed Editor + Direction tab)**. Assumes slices 01–02 have shipped and are merged to `main`. - ---- - -## Session Prompt — Implement Boomerang Slice 03 - -You are working on **OpenLoop** — an open-source Android camera app (Kotlin/Jetpack Compose) for creating speed-controlled video loops ("Boomerangs"). Repo: `stozo04/OpenLoop`. Owner: Steven Gates (@stozo04). Apache 2.0. - -The app currently captures variable-length clips up to 30 s (slice 01), auto-routes to a Trim screen, and renders default boomerangs (slice 02). This slice is where the user gets **expression** for the first time — they can pick how their boomerang plays. The tabbed editor screen lands in this slice, but with only the Direction tab populated. Speed (slice 04) and Reps (slice 05) layer on later. - -## Critical Rule — Do Not Trust Your Training Data - -Your knowledge cutoff could be a year old. **Do not assume** you know the current version of any Google standard, Android API behavior, Jetpack library pattern, testing framework convention, or Play Store requirement. Before making any claim about how something works or what Google recommends, **web-search `developer.android.com` first**. This applies to everything — ExoPlayer composition / preview APIs, `ConcatenatingMediaSource2`, Compose `AnimatedContent`, glassmorphic surface conventions. If you catch yourself writing "Google recommends X" without having searched for it in this session, stop and search. - -## What slice 03 ships (one-paragraph summary) - -Insert a new tabbed **`BoomerangEditorScreen`** between Trim's `NEXT` and the render. The editor's preview (top ~75%) reflects the chosen direction live — for the three reverse-containing modes this means the editor **eagerly** calls the slice-02 `VideoReverser` on entry, showing a "Preparing reverse…" shimmer until the cached file is ready. Bottom of the screen has a single-icon tab bar (Direction `>>`) and the Direction tab content: 4 chips (Forward / Reverse / Forward→Reverse / Reverse→Forward) with `FORWARD_THEN_REVERSE` selected by default. The save checkmark (top-right) now exists. `VideoProcessor.renderBoomerang()` honors the chosen mode; speed remains hard-coded at 2.0× and reps at 1 (those arrive in slices 04 and 05). - -The full slice spec lives in `docs/active/boomerang-rollout/03-editor-direction-tab.md`. **That doc is your PRD for this PR.** Treat it as authoritative. - -## Before Writing Any Code — Read These Files (in this order) - -1. **`CLAUDE.md`** — operating instructions, "Critical Rule" full version, architecture snapshot. -2. **Every file in `docs/lessons_learned/`** — start with `README.md`, then 001–011. **Non-negotiable**. Lessons 001 (Color literals), 002 (Flow collection), and 008 (test patterns) are most relevant. -3. **`docs/DEFINITION_OF_DONE.md`** — the verification gate. -4. **`docs/active/boomerang-editor/IMPLEMENTATION.md`** — parent design doc. §3 (UX flow), §5 (state machine — `BoomerangEditor` state arrives in this slice), §6 (Media3 pipeline). -5. **`docs/active/boomerang-rollout/README.md`** — rollout map. -6. **`docs/active/boomerang-rollout/RESEARCH-reverse-video.md`** — the locked decision on reverse. This slice consumes the same `VideoReverser` that slice 02 introduced; you do **not** build a parallel preview-only reverser. -7. **`docs/active/boomerang-rollout/02-auto-route-trim-and-default-save.md`** — for the `VideoReverser` and `VideoProcessor` surfaces this slice extends. -8. **`docs/active/boomerang-rollout/03-editor-direction-tab.md`** — your PRD. Read it end-to-end. Read it twice. The §"Reverse preview — decision LOCKED" section is the source of truth on the preview-fidelity approach. -9. **`docs/PRD-mission-control.md`** §3 (Design System Tokens) — for the chip gradient, glass surface tokens, tab pill colors. -10. **`docs/ANDROID_STANDARDS.md`** — consult before introducing patterns. The `ConcatenatingMediaSource2` and `AnimatedContent` usages should align. -11. **`docs/TEST_COVERAGE.md`** — test conventions. - -**Read all of these before touching any code.** - -## Phase 1: Cut the branch + capture a green baseline - -```powershell -$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr" -git checkout main -git pull --rebase -git checkout -b feature/boomerang-slice-03-direction-tab -.\gradlew.bat clean assembleDebug --console=plain -``` - -Confirm `BUILD SUCCESSFUL`, `$LASTEXITCODE -eq 0`, zero `e:` lines. - -## Phase 2: Web-verify the moving pieces - -Search developer.android.com this session for, at minimum: - -- `androidx.media3.exoplayer.source.ConcatenatingMediaSource2` (or whatever the current Media3 1.10.1 name is) — for chaining `[trimmed, reversed]` in the preview player. -- ExoPlayer preview composition API as of Media3 1.10.1 — alternative to `ConcatenatingMediaSource2` for the preview. -- `androidx.compose.animation.AnimatedContent` — current import path, `with` infix function, fade-cross transition. -- Material 3 chip / segmented-button hit-target guidance (≥ 44 dp for the 4 direction chips). - -If anything has drifted from what the slice doc implies, **stop and surface** before coding. - -## Phase 3: Implement to the slice spec - -Work the technical deltas in `docs/active/boomerang-rollout/03-editor-direction-tab.md` §"Technical deltas": - -- **`OpenLoopUiState.kt`** — add `BoomerangEditor(source, trim)` state and `TrimWindow(startMs, endMs)` data class. -- **`OpenLoopViewModel.kt`** — add `editorTabState: StateFlow` with `mode`, `reversedFile`, `isReversedFileLoading`, plus mutators `updateMode`, `ensureReversedSegment`, `saveBoomerang`, `discardEditor`. Trim's `NEXT` now posts `BoomerangEditor(source, trim)` (was: `Processing`). **Delete** `saveBoomerangDefault()` from slice 02 — superseded. -- **`media/VideoProcessor.kt`** — honor the `mode` parameter (no longer hard-coded). For `FORWARD` mode, **skip** `videoReverser.reverse(...)` entirely (saves work and disk I/O). Apply seam offset for two-clip compositions. -- **`ui/BoomerangEditorScreen.kt`** (new) — layout per the ASCII mock in the slice doc. Direction chips selected styling per design tokens. Save checkmark wired. Preview eagerly calls `ensureReversedSegment()` on entry (per the locked decision). "Preparing reverse…" shimmer overlay while loading. All Flow collection via `collectAsStateWithLifecycle()` (Lesson 002). -- **`MainActivity.kt`** — route `BoomerangEditor` to `BoomerangEditorScreen(...)`. - -**Stay scoped to slice 03.** Do not introduce: speed slider (slice 04), reps tab (slice 05), share intent / FileProvider (slice 06), gallery filter / `GalleryClip` / kind badge (slice 07). The tab bar shows **one** icon only in this slice; do not pre-render Speed or Reps placeholders. - -## Phase 4: Test - -- Unit tests from §"Testing plan / Unit tests": `OpenLoopViewModelTest` for Trim→Editor transition, `updateMode`, `saveBoomerang` parameter forwarding. `VideoProcessorTest` for each of the 4 modes' Composition layout + duration math + seam frame drop. -- Instrumented `BoomerangEditorScreenTest`: chip selection / deselection, save disabled while reversed-file loading, back returns to Trim with trim preserved. -- End-to-end: capture → trim → editor → pick `REVERSE` → shimmer → resolves → preview plays reversed → save → file exists with reversed playback. -- Run: - ```powershell - .\gradlew.bat testDebugUnitTest --console=plain; echo "EXIT=$LASTEXITCODE" - $env:ANDROID_SERIAL = "" - .\gradlew.bat connectedDebugAndroidTest --console=plain; echo "EXIT=$LASTEXITCODE" - ``` - -## Phase 5: Run the app for real + screenshot (the DoD gate) - -```powershell -.\gradlew.bat clean assembleDebug assembleRelease --console=plain; echo "EXIT=$LASTEXITCODE" -& "$env:LOCALAPPDATA\Android\Sdk\build-tools\\zipalign.exe" -c -P 16 -v 4 ` - app\build\outputs\apk\release\app-release-unsigned.apk -``` - -Walk the slice 03 manual QA checklist on emulator AND Pixel 10 Pro Fold: - -- All 4 directions produce visually correct previews. -- First switch to Reverse on a 3 s clip: shimmer < 2 s on Pixel 10 Pro Fold. **Record the times** for 3 s, 10 s, and 30 s trims — calibration data for the `RESEARCH-reverse-video.md` §5 estimates. -- Second switch to a different reverse-containing direction: instant (cached reversed file reused). -- Verify the editor and `VideoProcessor` share the **same** `VideoReverser` instance: snapshot the cache file `lastModified` across an editor-preview-then-save flow on the same trim — should not change. If a new reversed file appears, DI is wrong and the cache is doubled. -- Save → file in `filesDir/boomerangs/` plays the chosen direction correctly. -- Back from editor preserves trim window on Trim screen. - -**Capture screenshots** of all 4 direction chips selected (collage or 4 individual shots) attached to the PR. - -## Phase 6: Open the PR - -- Push the branch to `stozo04/OpenLoop`. -- Open the PR. Title: `Slice 03 — Tabbed Editor + Direction tab`. -- PR description: acceptance-criteria checklist from `docs/active/boomerang-rollout/03-editor-direction-tab.md` §"Acceptance criteria", each box checked. -- Include the reverse-shimmer latency measurements from Phase 5 in the description. -- Attach the direction-chip screenshots. -- Final paragraph: state what was not verified. - -## Behavioral Rules - -- **PRD-first.** Slice doc + research doc are the spec. **Push back** before coding around what you think is wrong (CLAUDE.md). -- **Web-search before every API claim.** -- **Lessons compliance is checkable by grep.** No `collectAsState(` (Lesson 002), no unwrapped writes (Lesson 003), no `Context` on VM methods (Lesson 004), 8-digit Color literals (Lesson 001), zipalign `(OK)` not `(OK - compressed)` (Lesson 011). -- **`BUILD SUCCESSFUL` is not enough.** Verdict + exit code + no `e:`. Never trust piped exit codes. -- **Done is the DoD gate.** Build + tests + run + screenshot + honest coverage statement. -- **Stay scoped.** Slice 03 only. One tab icon visible. No Speed / Reps surface. - -## When to stop and come back to Steven - -- If the slice doc and an existing lesson disagree. -- If the `VideoReverser` from slice 02 is missing functionality slice 03 depends on (e.g., the cache isn't keyed correctly for shared use between preview + render). -- If web verification shows `ConcatenatingMediaSource2` (or the preview composition API) has been renamed or deprecated. -- If the green baseline in Phase 1 isn't green. -- If `zipalign` regresses. -- If preview-fidelity for reverse modes is meaningfully slower than the budget in `RESEARCH-reverse-video.md` §5 (e.g., a 3 s clip shimmering for > 3 s is investigation-worthy). -- If the work is meaningfully larger than the slice doc implies (would be > ~500 LOC). diff --git a/docs/prompts/boomerang-slice-04-kickoff.md b/docs/prompts/boomerang-slice-04-kickoff.md deleted file mode 100644 index 38b461a..0000000 --- a/docs/prompts/boomerang-slice-04-kickoff.md +++ /dev/null @@ -1,126 +0,0 @@ -# Boomerang Slice 04 — Kickoff Prompt for a Fresh Claude Code Session - -Copy everything below the line into a fresh Claude Code session with the OpenLoop folder mounted. This kickoff is specific to **slice 04 (Speed tab)**. Assumes slices 01–03 have shipped and are merged to `main`. - ---- - -## Session Prompt — Implement Boomerang Slice 04 - -You are working on **OpenLoop** — an open-source Android camera app (Kotlin/Jetpack Compose) for creating speed-controlled video loops ("Boomerangs"). Repo: `stozo04/OpenLoop`. Owner: Steven Gates (@stozo04). Apache 2.0. - -The editor has a Direction tab and saves boomerangs at a hard-coded 2.0× speed (slices 02–03 shipped). This slice adds the **Speed tab** as the second icon in the bottom tab bar and a horizontal slider that controls both preview playback speed (live) and render speed. - -## Critical Rule — Do Not Trust Your Training Data - -Your knowledge cutoff could be a year old. **Do not assume** you know the current version of any Google standard, Android API behavior, Jetpack library pattern, testing framework convention, or Play Store requirement. Before making any claim about how something works or what Google recommends, **web-search `developer.android.com` first**. This applies to everything — ExoPlayer `setPlaybackSpeed`, Compose `Slider`, `HapticFeedbackConstants`, `snapshotFlow` + `debounce`. If you catch yourself writing "Google recommends X" without having searched for it in this session, stop and search. - -## What slice 04 ships (one-paragraph summary) - -The bottom tab bar grows from 1 icon (Direction `>>`) to 2 icons (+ Speed `⚡`). Tapping the Speed icon swaps the content panel to a horizontal slider, range **0.25× – 3.0×**, default **2.0×**, with a floating value label above the thumb and a haptic tick at exactly 1.0×. Preview's `setPlaybackSpeed` is called on every slider change (debounced ~50 ms) so the preview reflects the new speed instantly — no re-render. `VideoProcessor.renderBoomerang()` now passes the chosen `speed` instead of the hard-coded 2.0×. The cached reversed file from slice 02 is reused across speed changes — speed is a player-side effect, not a render-side one. - -The full slice spec lives in `docs/active/boomerang-rollout/04-editor-speed-tab.md`. **That doc is your PRD for this PR.** Treat it as authoritative. - -## Before Writing Any Code — Read These Files (in this order) - -1. **`CLAUDE.md`** — operating instructions. -2. **Every file in `docs/lessons_learned/`** — 001 (Color literals — slider thumb color), 002 (Flow collection), 008 (test patterns) most relevant. -3. **`docs/DEFINITION_OF_DONE.md`** — the verification gate. -4. **`docs/active/boomerang-editor/IMPLEMENTATION.md`** — parent design doc. §6 (Media3 pipeline) for the speed-effect semantics (it's `SpeedChangeEffect` in Media3 1.10.1 — see the slice-04 KICKOFF §0). -5. **`docs/lessons_learned/KICKOFF-boomerang-slice-04-speed-tab.md`** — the hard-won deltas this PRD gets wrong (the render side is already done; the real risk is #1658). Read it before coding. -6. **`docs/active/boomerang-rollout/README.md`** — rollout map. -7. **`docs/active/boomerang-rollout/03-editor-direction-tab.md`** — for the `EditorTabState` and tab bar surface this slice extends. -8. **`docs/active/boomerang-rollout/04-editor-speed-tab.md`** — your PRD. End-to-end, twice. -9. **`docs/PRD-mission-control.md`** §3 (Design System Tokens) — for slider track / thumb colors. -10. **`docs/ANDROID_STANDARDS.md`** — consult before adding new patterns. -11. **`docs/TEST_COVERAGE.md`** — test conventions. - -**Read all of these before touching any code.** - -## Phase 1: Cut the branch + capture a green baseline - -```powershell -$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr" -git checkout main -git pull --rebase -git checkout -b feature/boomerang-slice-04-speed-tab -.\gradlew.bat clean assembleDebug --console=plain -``` - -`BUILD SUCCESSFUL` + exit 0 + zero `e:`. - -## Phase 2: Web-verify the moving pieces - -- `androidx.media3.exoplayer.ExoPlayer.setPlaybackSpeed(Float)` — current signature in Media3 1.10.1; range constraints if any. -- `androidx.compose.material3.Slider` — current parameters for custom thumb / track via `track` / `thumb` slot composables. -- `android.view.HapticFeedbackConstants.CLOCK_TICK` — confirm availability on minSdk 26. -- `androidx.compose.runtime.snapshotFlow { ... }.debounce(...)` — current import paths. -- `androidx.media3.effect.SpeedChangeEffect(Float)` — the constant-speed effect in Media3 1.10.1 (NOT `SpeedChangingVideoEffect`, which isn't in this version). It's already wired in `Media3VideoProcessor.speedEffects(speed)`; confirm it supports the full 0.25 – 3.0 range. - -If anything has drifted, **stop and surface** before coding. - -## Phase 3: Implement to the slice spec - -- **`OpenLoopViewModel.kt`** — extend `EditorTabState` with `speed: Float = 2.0f` and `activeTab: EditorTab = EditorTab.DIRECTION`. Add `enum class EditorTab { DIRECTION, SPEED }` (REPS lands in slice 05). Mutators: `updateSpeed(speed: Float)` with `coerceIn(0.25f, 3.0f)`, `switchTab(tab: EditorTab)`. -- **`media/VideoProcessor.kt`** — **no change needed.** `renderBoomerang(speed=…)` already applies `SpeedChangeEffect(speed)` per clip (the `speed` param has been threaded since slice 02). The only render-side change is in `OpenLoopViewModel.saveBoomerang()`: pass `speed = _editorTabState.value.speed` instead of `DEFAULT_SPEED`. -- **`ui/BoomerangEditorScreen.kt`** — render the tab bar with 2 entries instead of 1, driven by `editorTabState.activeTab`. Add `SpeedTabContent` composable with Compose `Slider` + floating value label + haptic tick at 1.0×. Debounce slider emissions (~50 ms) before calling `player.setPlaybackSpeed(...)`. Animate tab content cross-fade with `AnimatedContent` (200 ms fade). -- **`MainActivity.kt`** — no route changes. - -**Stay scoped to slice 04.** Do not add: reps tab (slice 05), share intent (slice 06), gallery filter / tap-to-edit (slice 07). The tab bar shows **two** icons in this slice; do not pre-render a Reps placeholder. - -## Phase 4: Test - -- Unit tests: `OpenLoopViewModelTest` for `updateSpeed` clamping, `switchTab` state change, `saveBoomerang` passing current speed to `VideoProcessor`. `VideoProcessorTest` for each of `[0.25, 0.5, 1.0, 1.5, 2.0, 3.0]` producing output duration ≈ `cycle_ms / speed` (±1 frame). -- Instrumented `BoomerangEditorScreenTest`: tab bar shows 2 icons, Speed tab shows slider, slider drag updates state + preview `playbackSpeed`, output-duration indicator updates as speed changes. -- End-to-end: capture → trim → editor → switch to Speed tab → drag to 0.5× → preview slow → save → output duration ≈ `cycle_ms × reps / speed`. -- Run: - ```powershell - .\gradlew.bat testDebugUnitTest --console=plain; echo "EXIT=$LASTEXITCODE" - $env:ANDROID_SERIAL = "" - .\gradlew.bat connectedDebugAndroidTest --console=plain; echo "EXIT=$LASTEXITCODE" - ``` - -## Phase 5: Run the app for real + screenshot (the DoD gate) - -```powershell -.\gradlew.bat clean assembleDebug assembleRelease --console=plain; echo "EXIT=$LASTEXITCODE" -& "$env:LOCALAPPDATA\Android\Sdk\build-tools\\zipalign.exe" -c -P 16 -v 4 ` - app\build\outputs\apk\release\app-release-unsigned.apk -``` - -Walk the slice 04 manual QA on emulator + Pixel 10 Pro Fold: - -- Drag slider full range; preview speed changes smoothly with no jank. -- 0.25× = clearly slow-motion. 3.0× = clearly fast. -- Haptic tick felt at exactly 1.0× on Pixel 10 Pro Fold. -- Save at 3 speeds (0.5×, 1×, 2.5×) — verify each output plays at the expected speed in the gallery's `LoopingPreview`. -- Switch tabs (Direction ↔ Speed) several times — selections persist, cross-fade clean. -- **Note** the longest output duration produced (so we know we're under the 60 s hard cap that lands in slice 05). - -**Capture a screenshot** of the Speed tab with the slider mid-drag (value label visible) attached to the PR. - -## Phase 6: Open the PR - -- Push the branch to `stozo04/OpenLoop`. -- PR title: `Slice 04 — Speed tab`. -- PR description: acceptance-criteria checklist from `docs/active/boomerang-rollout/04-editor-speed-tab.md` §"Acceptance criteria", each box checked. -- Attach the screenshot. -- State what was not verified. - -## Behavioral Rules - -- **PRD-first.** Push back before coding around what seems wrong. -- **Web-search before every API claim.** -- **Lessons compliance.** No `collectAsState(` (Lesson 002), 8-digit Color literals (Lesson 001), no `Context` on VM methods (Lesson 004), zipalign `(OK)` (Lesson 011). -- **`BUILD SUCCESSFUL` is not enough.** Verdict + exit code + no `e:`. -- **Done is the DoD gate.** -- **Stay scoped.** Slice 04 only. Two tab icons. No Reps surface. - -## When to stop and come back to Steven - -- If the slice doc and a lesson disagree. -- If `setPlaybackSpeed` doesn't behave as expected on reverse-composition previews (e.g., the cached reversed `MediaItem` chain ignores the player-side speed). That's a real risk worth investigating before merging. -- If `SpeedChangeEffect` has different range constraints than 0.25 – 3.0×, or if #1658 (per-item effects only applying to the first clip) is live at a non-2× speed — see the slice-04 KICKOFF §1. -- If `HapticFeedbackConstants.CLOCK_TICK` requires a higher minSdk than the current 26. -- If the green baseline isn't green. -- If `zipalign` regresses. -- If the work is meaningfully larger than the slice doc implies (would be > ~300 LOC; this is one of the smaller slices). diff --git a/docs/prompts/boomerang-slice-05-kickoff.md b/docs/prompts/boomerang-slice-05-kickoff.md deleted file mode 100644 index 322568b..0000000 --- a/docs/prompts/boomerang-slice-05-kickoff.md +++ /dev/null @@ -1,121 +0,0 @@ -# Boomerang Slice 05 — Kickoff Prompt for a Fresh Claude Code Session - -Copy everything below the line into a fresh Claude Code session with the OpenLoop folder mounted. This kickoff is specific to **slice 05 (Repetitions tab)**. Assumes slices 01–04 have shipped and are merged to `main`. - ---- - -## Session Prompt — Implement Boomerang Slice 05 - -You are working on **OpenLoop** — an open-source Android camera app (Kotlin/Jetpack Compose) for creating speed-controlled video loops ("Boomerangs"). Repo: `stozo04/OpenLoop`. Owner: Steven Gates (@stozo04). Apache 2.0. - -The editor has Direction and Speed tabs (slices 03–04 shipped). After this slice the editor is **feature-complete** — direction + speed + reps all dial-able, plus the long-boomerang warning that protects against accidental 5-minute renders. - -## Critical Rule — Do Not Trust Your Training Data - -Your knowledge cutoff could be a year old. **Do not assume** you know the current version of any Google standard, Android API behavior, Jetpack library pattern, testing framework convention, or Play Store requirement. Before making any claim about how something works or what Google recommends, **web-search `developer.android.com` first**. This applies to everything — Media3 Composition repeat handling, `ConcatenatingMediaSource2` item appending, Compose segmented buttons. If you catch yourself writing "Google recommends X" without having searched for it in this session, stop and search. - -## What slice 05 ships (one-paragraph summary) - -Tab bar grows from 2 icons to 3 (+ Reps `⏱`). The Reps tab content is 4 circular buttons labeled 1 / 2 / 3 / 4, default 1, single-select with gradient fill on the selected option. Live preview now loops the cycle N times before repeating. `VideoProcessor.renderBoomerang()` appends the cycle N times in the Composition (reusing the same `EditedMediaItem`s — no source re-reads). A soft warning chip ("Long boomerang — Ns") appears above the tab bar when projected output > 30 s; a hard error disables save when > 60 s. - -The full slice spec lives in `docs/active/boomerang-rollout/05-editor-repetitions-tab.md`. **That doc is your PRD for this PR.** Treat it as authoritative. - -## Before Writing Any Code — Read These Files (in this order) - -1. **`CLAUDE.md`**. -2. **Every file in `docs/lessons_learned/`** — 001, 002, 008 most relevant. -3. **`docs/DEFINITION_OF_DONE.md`**. -4. **`docs/active/boomerang-editor/IMPLEMENTATION.md`** — §6.3 has the duration math (`cycle_ms × reps / speed`) and the 30 s / 60 s thresholds (D-5). -5. **`docs/active/boomerang-rollout/README.md`**. -6. **`docs/active/boomerang-rollout/04-editor-speed-tab.md`** — for the `EditorTabState`, tab bar pattern, and tab content cross-fade this slice extends. -7. **`docs/active/boomerang-rollout/05-editor-repetitions-tab.md`** — your PRD. End-to-end, twice. -8. **`docs/PRD-mission-control.md`** §3 (Design System Tokens) — for circle button gradient, warning chip `NeonCoral` tone. -9. **`docs/ANDROID_STANDARDS.md`**. -10. **`docs/TEST_COVERAGE.md`**. - -**Read all of these before touching any code.** - -## Phase 1: Cut the branch + capture a green baseline - -```powershell -$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr" -git checkout main -git pull --rebase -git checkout -b feature/boomerang-slice-05-reps-tab -.\gradlew.bat clean assembleDebug --console=plain -``` - -`BUILD SUCCESSFUL` + exit 0 + zero `e:`. - -## Phase 2: Web-verify the moving pieces - -- `androidx.media3.transformer.Composition.Builder` — confirm appending the same `EditedMediaItem` multiple times is supported and doesn't re-read the source per item in 1.10.1. -- ExoPlayer preview: rebinding `ConcatenatingMediaSource2` with N copies of the cycle — interruption cost (slice doc estimates ~100 ms; verify against Media3 1.10.1 release notes). -- Compose `Touch Target` recommendation — confirm 56 dp is current Material guidance for the circle reps buttons (or 48 dp minimum). - -If anything has drifted, **stop and surface** before coding. - -## Phase 3: Implement to the slice spec - -- **`OpenLoopViewModel.kt`** — extend `EditorTabState` with `repetitions: Int = 1` and add `REPETITIONS` to the `EditorTab` enum. Mutator `updateRepetitions(reps: Int)` with `coerceIn(1, 4)`. Add the derived `outputDurationMs` computed property (`cycle_ms × reps / speed`, where `cycle_ms` depends on mode). `saveBoomerang()` blocks (and emits a "too long" event) when `outputDurationMs > 60_000`. -- **`media/VideoProcessor.kt`** — append the cycle `repetitions` times to the `Composition`. Apply the 1-frame seam offset at cycle boundaries too (not just at the F→R seam). -- **`ui/BoomerangEditorScreen.kt`** — render 3-icon tab bar. Add `RepetitionsTabContent` composable: row of 4 circular buttons, 56 dp, single-select with gradient. Rebuild preview composition on `repetitions` change. Output-duration label updates live. Warning chip slides in/out at the 30 s threshold; save button disables at the 60 s threshold (with tab-icon pulse on long-press of save when blocked). -- **`MainActivity.kt`** — no route changes. - -**Stay scoped to slice 05.** Do not add: share intent / FileProvider (slice 06), gallery filter / kind badge / tap-to-edit (slice 07). The Reps tab caps at 4; do not add 5–10 just because it's a small change. - -## Phase 4: Test - -- Unit tests: `OpenLoopViewModelTest` for `updateRepetitions` clamping, `outputDurationMs` math across all 4 modes × edge speeds × reps, `saveBoomerang` no-op + "too long" event at > 60 s. `VideoProcessorTest` for `mode=F→R, trim=2s, speed=1×, reps=3` ≈ 12 s output; `mode=FWD, trim=1s, speed=2×, reps=4` ≈ 2 s output. -- Instrumented `BoomerangEditorScreenTest`: 3-icon tab bar, reps button selection, warning chip at 30 s threshold, save disable at 60 s threshold, tab persistence across switches. -- End-to-end: capture → trim → editor → F→R, 1× speed, 3 reps → save → output duration ≈ 9 s. -- Run: - ```powershell - .\gradlew.bat testDebugUnitTest --console=plain; echo "EXIT=$LASTEXITCODE" - $env:ANDROID_SERIAL = "" - .\gradlew.bat connectedDebugAndroidTest --console=plain; echo "EXIT=$LASTEXITCODE" - ``` - -## Phase 5: Run the app for real + screenshot (the DoD gate) - -```powershell -.\gradlew.bat clean assembleDebug assembleRelease --console=plain; echo "EXIT=$LASTEXITCODE" -& "$env:LOCALAPPDATA\Android\Sdk\build-tools\\zipalign.exe" -c -P 16 -v 4 ` - app\build\outputs\apk\release\app-release-unsigned.apk -``` - -Walk the slice 05 manual QA on emulator + Pixel 10 Pro Fold: - -- Each rep value (1, 2, 3, 4): preview loops cycle N times before repeating; output file matches. -- Default settings (F→R, 2×, 5 s trim, 4 reps): output = 10 s, no warning chip. -- F→R, 0.5×, 4 s trim, 4 reps: output = 64 s, save disables, tooltip on long-press, tab icons pulse. -- Switch tabs (Dir ↔ Speed ↔ Reps): all selections persist. -- **Record the largest output file produced** (file size + duration) in the PR description. - -**Capture a screenshot** of the Reps tab with `2` selected, attached to the PR. - -## Phase 6: Open the PR - -- Push to `stozo04/OpenLoop`. -- PR title: `Slice 05 — Repetitions tab`. -- PR description: acceptance-criteria checklist from `docs/active/boomerang-rollout/05-editor-repetitions-tab.md`, each box checked. Include the largest output file size + duration observed. -- Attach the screenshot. -- State what was not verified. - -## Behavioral Rules - -- **PRD-first.** Push back before coding around what seems wrong. -- **Web-search before every API claim.** -- **Lessons compliance.** No `collectAsState(` (Lesson 002), 8-digit Color literals (Lesson 001), no `Context` on VM methods (Lesson 004), zipalign `(OK)` (Lesson 011). -- **`BUILD SUCCESSFUL` is not enough.** -- **Done is the DoD gate.** -- **Stay scoped.** Slice 05 only. 3 tab icons. No share intent, no gallery surface. - -## When to stop and come back to Steven - -- If the slice doc and a lesson disagree. -- If Composition-with-N-cycle-copies is slow or causes encoder failures on Pixel 10 Pro Fold at the upper end (e.g., 4 reps at 0.25× of a 10 s trim). -- If Media3 1.10.1 doesn't let you append the same `EditedMediaItem` multiple times without re-reading the source — the slice doc assumes you can; verify before committing to the implementation. -- If the green baseline isn't green. -- If `zipalign` regresses. -- If the work is meaningfully larger than the slice doc implies (would be > ~300 LOC). diff --git a/docs/prompts/boomerang-slice-06-kickoff.md b/docs/prompts/boomerang-slice-06-kickoff.md deleted file mode 100644 index 84fbb29..0000000 --- a/docs/prompts/boomerang-slice-06-kickoff.md +++ /dev/null @@ -1,129 +0,0 @@ -# Boomerang Slice 06 — Kickoff Prompt for a Fresh Claude Code Session - -Copy everything below the line into a fresh Claude Code session with the OpenLoop folder mounted. This kickoff is specific to **slice 06 (Share sheet + return-to-camera)**. Assumes slices 01–05 have shipped and are merged to `main`. - ---- - -## Session Prompt — Implement Boomerang Slice 06 - -You are working on **OpenLoop** — an open-source Android camera app (Kotlin/Jetpack Compose) for creating speed-controlled video loops ("Boomerangs"). Repo: `stozo04/OpenLoop`. Owner: Steven Gates (@stozo04). Apache 2.0. - -The editor is feature-complete (direction + speed + reps + trim, slices 02–05 shipped) and saves rendered boomerangs to internal storage. This slice adds the **single-tap distribution path** — Save now pops the Android share sheet on the new MP4, and the user lands back on the camera with a "View in gallery" snackbar. - -## Critical Rule — Do Not Trust Your Training Data - -Your knowledge cutoff could be a year old. **Do not assume** you know the current version of any Google standard, Android API behavior, Jetpack library pattern, testing framework convention, or Play Store requirement. Before making any claim about how something works or what Google recommends, **web-search `developer.android.com` first**. This applies to everything — `FileProvider` configuration, `Intent.ACTION_SEND` + `EXTRA_STREAM`, `Intent.createChooser`, `SnackbarHostState`, scoped storage rules. **Especially relevant:** Android 16 (API 36) brought changes to URI permission grants and share-target behavior — verify against developer.android.com before relying on training-data muscle memory. If you catch yourself writing "Google recommends X" without having searched for it in this session, stop and search. - -## What slice 06 ships (one-paragraph summary) - -Add a `FileProvider` configured for `filesDir/boomerangs/` only (raws and scratch stay private). On `saveBoomerang()` success, the ViewModel emits a `ShareBoomerang(file)` UI effect; `MainActivity` consumes it and launches `Intent.createChooser(ACTION_SEND, ...)` with the rendered MP4 as `EXTRA_STREAM` + `FLAG_GRANT_READ_URI_PERMISSION`. After the chooser dismisses (shared or canceled), the user lands on `ReadyToCapture` with a `Snackbar` ("Saved — view in gallery") hosted at the `Scaffold` level; tapping "View" routes to `Gallery`. No copy to public `DCIM` / `MediaStore` in this slice (parent doc D-6). - -The full slice spec lives in `docs/active/boomerang-rollout/06-share-sheet-and-return.md`. **That doc is your PRD for this PR.** Treat it as authoritative. - -## Before Writing Any Code — Read These Files (in this order) - -1. **`CLAUDE.md`**. -2. **Every file in `docs/lessons_learned/`** — 003 (DataStore writes, also applies to repository IO), 005 (Play Store target API) most relevant. Also `docs/android-16/` — Android 16 brought share / URI changes worth checking. -3. **`docs/DEFINITION_OF_DONE.md`**. -4. **`docs/active/boomerang-editor/IMPLEMENTATION.md`** — §10 (Post-save flow) is the canonical reference for the snackbar + share-intent shape. -5. **`docs/active/boomerang-rollout/README.md`**. -6. **`docs/active/boomerang-rollout/05-editor-repetitions-tab.md`** — for the `saveBoomerang()` surface this slice extends. -7. **`docs/active/boomerang-rollout/06-share-sheet-and-return.md`** — your PRD. End-to-end, twice. -8. **`docs/PRD-mission-control.md`** §3 (Design System Tokens) — for snackbar text color (`NeonCoral` for the "View" action). -9. **`docs/ANDROID_STANDARDS.md`** — particularly the "Sharing / FileProvider" section if it exists, plus Section 11 (Android 16) for behavior changes. -10. **`docs/TEST_COVERAGE.md`** — for the Espresso `Intents` library conventions. - -**Read all of these before touching any code.** - -## Phase 1: Cut the branch + capture a green baseline - -```powershell -$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr" -git checkout main -git pull --rebase -git checkout -b feature/boomerang-slice-06-share-sheet -.\gradlew.bat clean assembleDebug --console=plain -``` - -`BUILD SUCCESSFUL` + exit 0 + zero `e:`. - -## Phase 2: Web-verify the moving pieces - -- `androidx.core.content.FileProvider` — current manifest provider declaration; `` XML schema; `getUriForFile(context, authority, file)` signature. -- `android.content.Intent.ACTION_SEND` + `EXTRA_STREAM` + `EXTRA_SUBJECT` + `setType("video/mp4")` + `FLAG_GRANT_READ_URI_PERMISSION` — current best practice for sharing a private file via a system chooser. -- `android.content.Intent.createChooser(intent, title)` — current signature; Android 16 behavior changes if any. -- `androidx.compose.material3.SnackbarHostState.showSnackbar(message, actionLabel, ...)` — current return type (`SnackbarResult`) and action handling. -- `androidx.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED)` — current import and usage from a `LaunchedEffect`. -- Espresso `Intents` library (`androidx.test.espresso.intent.Intents`) — for intent capture in `MainActivityTest`. - -If anything has drifted, **stop and surface** before coding. - -## Phase 3: Implement to the slice spec - -- **`AndroidManifest.xml`** — add `` for `androidx.core.content.FileProvider` with authority `${applicationId}.fileprovider`, `exported=false`, `grantUriPermissions=true`, meta-data pointing at `@xml/file_paths`. -- **`res/xml/file_paths.xml`** (new) — `` only. Raws and scratch are NOT exposed. -- **`OpenLoopViewModel.kt`** — replace the snackbar-emitting success path with a `MutableSharedFlow` (`extraBufferCapacity = 4`) exposing `uiEffects`. Add `UiEffect.ShareBoomerang(file)`, `UiEffect.Saved`, `UiEffect.SaveFailed`. On render success, emit `ShareBoomerang(file)` then post `ReadyToCapture`. `Saved` is emitted after `withResumed { ... }` resolves. -- **`MainActivity.kt`** — collect `uiEffects` with `repeatOnLifecycle(STARTED)`. On `ShareBoomerang`, build the `Intent.createChooser(...)` and `startActivity`. Host a `SnackbarHostState` in the `Scaffold`; on `Saved`, `showSnackbar(message, actionLabel = "View")`; on `ActionPerformed`, post `OpenLoopUiState.Gallery`. - -**Stay scoped to slice 06.** Do not add: gallery tap-to-edit / kind badge / filter chips (slice 07). Do not write to public `MediaStore` (parent doc D-6 explicitly defers this). - -## Phase 4: Test - -- Unit tests: `OpenLoopViewModelTest` for `saveBoomerang` emitting `ShareBoomerang(file)` on success then transitioning to `ReadyToCapture`; emitting `SaveFailed` on error and staying in `BoomerangEditor`; `Saved` ordering verified via flow collection. -- Instrumented test (`MainActivityTest` or `ShareIntentTest`): use `androidx.test.espresso.intent.Intents` to assert `intended(allOf(hasAction(ACTION_SEND), hasType("video/mp4"), hasExtraWithKey(EXTRA_STREAM)))`. After returning from the chooser, snackbar text is visible; tapping "View" routes to `Gallery`. -- End-to-end: capture → trim → editor → save → share sheet appears → cancel → snackbar on camera screen with "View" action. -- Run: - ```powershell - .\gradlew.bat testDebugUnitTest --console=plain; echo "EXIT=$LASTEXITCODE" - $env:ANDROID_SERIAL = "" - .\gradlew.bat connectedDebugAndroidTest --console=plain; echo "EXIT=$LASTEXITCODE" - ``` - -## Phase 5: Run the app for real + screenshot (the DoD gate) - -```powershell -.\gradlew.bat clean assembleDebug assembleRelease --console=plain; echo "EXIT=$LASTEXITCODE" -& "$env:LOCALAPPDATA\Android\Sdk\build-tools\\zipalign.exe" -c -P 16 -v 4 ` - app\build\outputs\apk\release\app-release-unsigned.apk -``` - -Walk the slice 06 manual QA on emulator + Pixel 10 Pro Fold: - -- Save a boomerang → Android share sheet appears with at least Drive, Messages, and any installed video-receiving apps. -- Share to Drive (or Telegram if installed): file uploads + plays in the receiver. -- Cancel the share sheet: snackbar "Saved — view in gallery" appears on the camera screen. -- Tap "View" → gallery opens; new boomerang visible. -- Repeat the save+share flow 3× in a row — no FileProvider permission errors in logcat: - ```powershell - adb logcat -d | Select-String -Pattern "FileUriExposedException|SecurityException" - ``` -- Verify FileProvider scope — attempt to construct a URI for a `filesDir/videos/clip_.mp4` path (raw) via `getUriForFile`. Should throw `IllegalArgumentException` (not in the exposed paths). - -**Capture a screenshot** of the Android share sheet (top half visible), attached to the PR. - -## Phase 6: Open the PR - -- Push to `stozo04/OpenLoop`. -- PR title: `Slice 06 — Share sheet + return-to-camera`. -- PR description: acceptance-criteria checklist from `docs/active/boomerang-rollout/06-share-sheet-and-return.md`, each box checked. -- Attach the screenshot. -- State what was not verified — specifically the set of receiver apps you tested against (e.g., "verified on Drive + Telegram; did not test Snapchat / WhatsApp"). - -## Behavioral Rules - -- **PRD-first.** Push back before coding around what seems wrong. -- **Web-search before every API claim.** Especially for Android 16 share / URI behavior. -- **Lessons compliance.** No `collectAsState(` (Lesson 002), 8-digit Color literals (Lesson 001), no `Context` on VM methods (Lesson 004 — note: `MainActivity` *is* the right place for the share intent, not the VM), zipalign `(OK)` (Lesson 011). -- **`BUILD SUCCESSFUL` is not enough.** -- **Done is the DoD gate.** -- **Stay scoped.** Slice 06 only. No gallery edits in this PR. - -## When to stop and come back to Steven - -- If the slice doc and a lesson disagree. -- If `FileProvider` URIs from internal storage fail to share to at least one common app (Drive, Messages, Telegram) — investigate before merging. -- If Android 16 (`targetSdk 36`) introduced share / URI changes that break the slice doc's plan — surface the deviation. -- If `SnackbarHostState` placement at the `Scaffold` level causes layout issues with the existing screens (camera viewfinder etc.). -- If the green baseline isn't green. -- If `zipalign` regresses. -- If the work is meaningfully larger than the slice doc implies (would be > ~250 LOC). diff --git a/docs/prompts/boomerang-slice-07-kickoff.md b/docs/prompts/boomerang-slice-07-kickoff.md deleted file mode 100644 index 36c538b..0000000 --- a/docs/prompts/boomerang-slice-07-kickoff.md +++ /dev/null @@ -1,138 +0,0 @@ -# Boomerang Slice 07 — Kickoff Prompt for a Fresh Claude Code Session - -Copy everything below the line into a fresh Claude Code session with the OpenLoop folder mounted. This kickoff is specific to **slice 07 (Gallery tap-to-edit + raw/boomerang distinction)**. Assumes slices 01–06 have shipped and are merged to `main`. **This is the final slice in the rollout** — after it merges, OpenLoop v1 is feature-complete. - ---- - -## Session Prompt — Implement Boomerang Slice 07 - -You are working on **OpenLoop** — an open-source Android camera app (Kotlin/Jetpack Compose) for creating speed-controlled video loops ("Boomerangs"). Repo: `stozo04/OpenLoop`. Owner: Steven Gates (@stozo04). Apache 2.0. - -The boomerang flow works end-to-end for fresh captures: capture → trim → editor → save → share (slices 01–06 shipped). But every prior slice operates on a **fresh capture only** — there is no path to boomerang a clip that's already in the gallery. This slice closes that loop and ships the gallery distinction between raws and boomerangs. After this merges, **OpenLoop v1 is done**. - -## Critical Rule — Do Not Trust Your Training Data - -Your knowledge cutoff could be a year old. **Do not assume** you know the current version of any Google standard, Android API behavior, Jetpack library pattern, testing framework convention, or Play Store requirement. Before making any claim about how something works or what Google recommends, **web-search `developer.android.com` first**. This applies to everything — Compose `combinedClickable`, `DropdownMenu`, large-screen (>= 600 dp) layout patterns for foldables, `File.setLastModified` semantics for the scratch-prune test. If you catch yourself writing "Google recommends X" without having searched for it in this session, stop and search. - -## What slice 07 ships (one-paragraph summary) - -Tapping a **raw** clip in the gallery routes into the same Trim → Editor flow as a fresh capture (via the new `EditorSource.GalleryClip(rawId)` variant added in this slice). Tapping a **boomerang** still plays it in `LoopingPreview` (unchanged). A 3-state filter chip row at the top of the gallery toggles All / Boomerangs / Raw. Raw thumbnails get a small "R" badge in the bottom-right. Long-press menus: raw → "Make a boomerang" / "Delete"; boomerang → "Re-edit from source" (grayed when source raw is missing) / "Delete". Deleting a raw that has linked boomerangs prompts with a count and leaves the boomerangs intact. Orphan scratch files older than 24 h are pruned at app start. - -The full slice spec lives in `docs/active/boomerang-rollout/07-gallery-tap-to-edit.md`. **That doc is your PRD for this PR.** Treat it as authoritative. - -## Before Writing Any Code — Read These Files (in this order) - -1. **`CLAUDE.md`**. -2. **Every file in `docs/lessons_learned/`** — 002, 003, 004, 008 all apply. -3. **`docs/DEFINITION_OF_DONE.md`**. -4. **`docs/active/boomerang-editor/IMPLEMENTATION.md`** — §3.2 (Edit-from-gallery path), §7 (data layer — `RecordedVideo.kind` was added back in slice 02; you're surfacing it now). -5. **`docs/active/boomerang-rollout/README.md`**. -6. **`docs/active/boomerang-rollout/02-auto-route-trim-and-default-save.md`** — for the `EditorSource` sealed interface this slice adds the second variant to, and the storage model. -7. **`docs/active/boomerang-rollout/07-gallery-tap-to-edit.md`** — your PRD. End-to-end, twice. -8. **`docs/PRD-mission-control.md`** §3 (Design System Tokens) — for the badge `NeonPurple` 80%, filter chip gradient. -9. **`docs/ANDROID_STANDARDS.md`** — especially the accessibility section (touch targets for the long-press / context menus) and Section 11 (Android 16) for large-screen behavior on foldables. -10. **`docs/TEST_COVERAGE.md`**. - -**Read all of these before touching any code.** - -## Phase 1: Cut the branch + capture a green baseline - -```powershell -$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr" -git checkout main -git pull --rebase -git checkout -b feature/boomerang-slice-07-gallery-tap-to-edit -.\gradlew.bat clean assembleDebug --console=plain -``` - -`BUILD SUCCESSFUL` + exit 0 + zero `e:`. - -## Phase 2: Web-verify the moving pieces - -- `androidx.compose.foundation.combinedClickable(onClick, onLongClick, ...)` — current parameter order and indication / interaction-source handling. -- `androidx.compose.material3.DropdownMenu` + `DropdownMenuItem` — current anchoring, dismissal behavior. -- `androidx.compose.material3.AssistChip` / `FilterChip` — current API for the All / Boomerangs / Raw filter row. -- Window-size-class guidance for foldables (large screen, ≥ 600 dp) — the gallery should breathe, not just stretch a 3-col grid. -- `File.setLastModified(time)` — confirm it's reliable on the device's filesystem for the scratch-prune unit test (sometimes returns false on certain Android FS configurations). - -If anything has drifted, **stop and surface** before coding. - -## Phase 3: Implement to the slice spec - -- **`OpenLoopUiState.kt`** — extend `EditorSource` with `data class GalleryClip(val rawId: Long) : EditorSource`. Add `GalleryFilter { ALL, BOOMERANGS, RAW }` enum. -- **`OpenLoopViewModel.kt`** — add `startEditorFromGallery(rawId: Long)` (resolves the raw via `videoStorage.loadRecordedVideos().firstOrNull { it.id == rawId && it.kind == RAW }`, posts `Trim(GalleryClip(rawId))`); `playBoomerang(video)` (posts `LoopingPreview(...)`); `deleteVideoAndRefresh(video)` (calls `videoStorage.deleteVideo` wrapped in try/catch IOException per Lesson 003, re-emits `recordedVideos`); `setGalleryFilter(filter)`. On `init`, kick off `videoStorage.pruneStaleScratch(24.hours)` in `viewModelScope`. -- **`VideoStorageRepository`** — add `fun pruneStaleScratch(olderThanMs: Long)`. Implementation: list `cacheDir/scratch/` and `cacheDir/scratch/reversed/`, delete files with `lastModified < (now - olderThanMs)`. Log the count. -- **Trim + BoomerangEditor source resolution** — when source is `GalleryClip(rawId)`, resolve the file via `videoStorage.loadRecordedVideos().first { it.id == rawId }`. Saving from a `GalleryClip` source skips the scratch-promotion step (raw is already in the gallery). -- **`ui/GalleryScreen.kt`** — add filter chip row above the grid. Add raw badge overlay on `RAW` thumbnails (bottom-right, 24 dp, `NeonPurple` 80%, "R" glyph). Wire `combinedClickable` (tap + long-press) per slice doc. `DropdownMenu` for context menus. - -**Stay scoped to slice 07.** This is the final slice — no slice-08+ surface exists. Do NOT add v1.5 stretch goals like drag-to-rearrange, multi-select, search, share-from-gallery, etc. Those are out-of-scope per the rollout README. - -## Phase 4: Test - -- Unit tests: `OpenLoopViewModelTest` for `startEditorFromGallery` (existing raw → posts `Trim(GalleryClip)`; non-existent rawId → no-op); `playBoomerang` (posts `LoopingPreview`); `deleteVideoAndRefresh` (removes file via fake + re-emits list); `setGalleryFilter` (filters the exposed list). -- `VideoStorageRepositoryImplTest`: `pruneStaleScratch` deletes files older than threshold, leaves newer ones alone. Use `TemporaryFolder` + `File.setLastModified` (verify it works on your test FS first). -- Instrumented `GalleryScreenTest`: filter chips switch visible items; raw badge on raws not boomerangs; tap raw routes to `Trim(GalleryClip)`; tap boomerang routes to `LoopingPreview`; long-press menus show correct items per kind. -- End-to-end: capture 2 raws + render 1 boomerang → gallery shows 3 items, one badged → filter Raw shows 2 → tap a raw → Trim opens with raw loaded → long-press boomerang → "Re-edit from source" → editor opens on the **source raw** (not the rendered output). -- Run: - ```powershell - .\gradlew.bat testDebugUnitTest --console=plain; echo "EXIT=$LASTEXITCODE" - $env:ANDROID_SERIAL = "" - .\gradlew.bat connectedDebugAndroidTest --console=plain; echo "EXIT=$LASTEXITCODE" - ``` - -## Phase 5: Run the app for real + screenshot (the DoD gate) - -```powershell -.\gradlew.bat clean assembleDebug assembleRelease --console=plain; echo "EXIT=$LASTEXITCODE" -& "$env:LOCALAPPDATA\Android\Sdk\build-tools\\zipalign.exe" -c -P 16 -v 4 ` - app\build\outputs\apk\release\app-release-unsigned.apk -``` - -Walk the slice 07 manual QA on emulator + **Pixel 10 Pro Fold (both folded AND unfolded)**: - -- Mix of raws + boomerangs in gallery — filter chips work correctly. -- Raw badge readable at thumbnail size on both folded and unfolded screen. -- Delete a raw with 2 linked boomerangs → dialog says "2 boomerang(s)" correctly; after delete, the boomerangs' "Re-edit from source" menu item is grayed out. -- Process kill + relaunch: scratch files >24 h old are pruned. Verify via: - ```powershell - adb shell run-as com.OpenLoop.app ls cache/scratch/ - adb shell run-as com.OpenLoop.app ls cache/scratch/reversed/ - ``` -- Re-edit from a boomerang opens its actual source raw — not the rendered boomerang. -- Tap a raw from the gallery → Trim opens with the raw loaded → save a new boomerang → both raws and the new boomerang appear in gallery. -- **Large-screen (unfolded) layout** sanity-checked — the grid should breathe (more columns, larger thumbnails), not just stretch the 3-col fixed layout to full width. If the layout looks wrong on unfolded, that may warrant a separate small slice; flag in the PR description. - -**Capture screenshots** of: -1. Gallery with mixed raws + boomerangs + filter chip selected (folded screen). -2. Same view on the unfolded screen. -3. Long-press context menu for a boomerang with "Re-edit from source" visible. - -All attached to the PR. - -## Phase 6: Open the PR - -- Push to `stozo04/OpenLoop`. -- PR title: `Slice 07 — Gallery tap-to-edit + raw/boomerang distinction (v1 complete)`. -- PR description: acceptance-criteria checklist from `docs/active/boomerang-rollout/07-gallery-tap-to-edit.md`, each box checked. -- Attach the screenshots. -- State what was not verified, particularly around very large numbers of clips (>100) — performance was not exercised at scale. -- **Final note:** this PR closes the boomerang rollout. Once merged, move `docs/active/boomerang-editor/` and `docs/active/boomerang-rollout/` to `docs/completed/` per the `docs/active/README.md` convention. Update the "Current Active Features" table accordingly. - -## Behavioral Rules - -- **PRD-first.** Push back before coding around what seems wrong. -- **Web-search before every API claim.** -- **Lessons compliance.** No `collectAsState(` (Lesson 002), 8-digit Color literals (Lesson 001), all repository writes wrapped in `try / catch (IOException)` (Lesson 003 — `deleteVideoAndRefresh` is subject to this), no `Context` on VM methods (Lesson 004), zipalign `(OK)` (Lesson 011). -- **`BUILD SUCCESSFUL` is not enough.** -- **Done is the DoD gate.** This is the LAST slice — DoD matters most here. -- **Stay scoped.** Slice 07 only. No v1.5 stretch goals. - -## When to stop and come back to Steven - -- If the slice doc and a lesson disagree. -- If the unfolded (>=600 dp) layout is meaningfully broken and would require its own slice to fix properly — flag it; don't ship a hack. -- If `pruneStaleScratch` deletes things it shouldn't (e.g., catches a scratch file from an in-flight render in another lifecycle). -- If the green baseline isn't green. -- If `zipalign` regresses. -- If the work is meaningfully larger than the slice doc implies (would be > ~500 LOC). -- **After this slice merges**, check in before moving the doc folders to `docs/completed/` — that's a notable structural change to `docs/active/` and Steven should confirm. diff --git a/docs/prompts/gallery-selection-delete-kickoff.md b/docs/prompts/gallery-selection-delete-kickoff.md deleted file mode 100644 index 7ec8778..0000000 --- a/docs/prompts/gallery-selection-delete-kickoff.md +++ /dev/null @@ -1,117 +0,0 @@ -# Kickoff — Gallery: long-press selection + Undo delete - -> Paste this whole file into a fresh agent session to implement the feature. It is self-contained. - -## Your task - -Replace the gallery's per-thumbnail trash-can delete with a **long-press multi-select model + a contextual action bar + an Undo snackbar** (the real file deletion is **deferred** until the snackbar is dismissed). This resolves the genuine bug in **GitHub issue #35** ("delete is instant and irreversible") and removes the emoji 🗑 clutter from every tile. - -This was designed and approved in a prior session. The "why": the old design stamped a coral emoji trash can on every thumbnail, deleted instantly with no undo, and the icon clashed with the app's Material/vector icon language. Long-press selection (Google Photos–style) declutters the grid, makes delete safe via Undo, and scales to multi-select. - -## First — required reading (do this before coding) - -Per `CLAUDE.md` (repo root): -1. Re-read `CLAUDE.md`. -2. Read **every** file in `docs/lessons_learned/` (esp. 001 color literals, 002 lifecycle flow collection, 014 exhaustive `when`, 015 state-routed BackHandler, 016 defer high-frequency reads). -3. Read `docs/DEFINITION_OF_DONE.md`, `docs/ANDROID_STANDARDS.md` (§7 a11y, §9 threading), `docs/PRD-mission-control.md` (architecture + gallery spec). -4. **Do not trust training data on Android APIs — web-search `developer.android.com` first** (e.g. `Modifier.combinedClickable`, `SnackbarHostState.showSnackbar` result handling, `rememberSaveable` Savers). -5. Skim GitHub issue #35 (`gh issue view 35`) — this feature is its "Add a confirmation (or undo) to delete" checkbox. - -## Design-system context (already shipped — USE these, don't reinvent) - -A `ui/theme/` package now exists and is the single source of truth. Relevant tokens (in `io.github.stozo04.openloop.ui.theme`): -- `ElectricLime` `#CDFF4F` = primary accent (flat). `LimeInk` `#15200A` = text/icon on lime. -- `CoralRed` `#FF5A5F` = error/destructive/record. -- `OverlayWhite` / `OverlayWhiteBorder` = glassy controls over video. `SurfaceContainerHigh` = card fills. `OutlineVariant` = hairlines. `Canvas` = app background. -- Type: `MaterialTheme.typography` (Space Grotesk display / Inter body / JetBrains Mono via `TimerTextStyle`). Shapes: `MaterialTheme.shapes`. -- NOTE: most screens still hardcode `fontSize`/`RoundedCornerShape` (type/shape *adoption* is a separate pending task). For NEW code you write, prefer `MaterialTheme.typography.*`, `MaterialTheme.shapes.*`, and the named color tokens. Do not introduce inline hex (Lesson 001). - -## Current code you will touch - -**`app/src/main/java/io/github/stozo04/openloop/ui/GalleryScreen.kt`** -- `GalleryScreen(viewModel, onBackClick, onImportVideo)` collects `viewModel.recordedVideos` into `videos`, renders a top bar (back ✕ import) + a `LazyVerticalGrid` of `VideoThumbnailCard`, and a `BackHandler(enabled = selectedVideo == null) { onBackClick() }` plus a tap-to-play `LoopingVideoOverlay`. -- `VideoThumbnailCard(video, onClick, onDelete)` currently has a **top-end delete Box** with an emoji `🗑` on a `CoralRed` circle that calls `onDelete()` immediately. **Remove this delete control entirely.** - -**`app/src/main/java/io/github/stozo04/openloop/ui/OpenLoopViewModel.kt`** -- `val recordedVideos: StateFlow>` (backed by `_recordedVideos`). -- `fun deleteVideo(video: RecordedVideo)` → `videoStorage.deleteVideo(video)` then reloads. (Keep or supersede; see plan.) -- One-shot events: `private val _events = Channel(BUFFERED)`, `val events: Flow`. `sealed interface BoomerangEvent { Share, Saved, Failed, ImportTooLong, ImportFailed }`. -- `fun loadRecordedVideos()`, `fun navigateToGallery()`. - -**`app/src/main/java/io/github/stozo04/openloop/MainActivity.kt`** -- Has an app-level `snackbarHostState` + a `LaunchedEffect` collecting `viewModel.events`. The `BoomerangEvent.Saved` branch shows a snackbar with a **"View" action** and handles `SnackbarResult.ActionPerformed`. Mirror this pattern for Undo. `SnackbarHost(hostState = snackbarHostState, ...)` is already wired into the scaffold. - -**`app/src/main/java/io/github/stozo04/openloop/data/VideoStorageRepository.kt` / `Impl.kt`** -- `suspend fun deleteVideo(video: RecordedVideo)` (runs on `Dispatchers.IO`). `RecordedVideo.id: Long`. - -**`app/src/main/res/values/strings.xml`** — add new strings here (there are existing `snackbar_*` and `gallery_*` strings to match style). - -## Implementation plan - -### 1) ViewModel — deferred deletion -- Add `private val _pendingDeletionIds = MutableStateFlow>(emptySet())` + public `pendingDeletionIds: StateFlow>`. -- Add an in-memory `private var pendingBatch: List = emptyList()`. -- Add `val visibleVideos: StateFlow>` = `combine(recordedVideos, pendingDeletionIds) { vids, pending -> vids.filterNot { it.id in pending } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())` (collect Lesson 002 patterns; import `combine`, `stateIn`, `SharingStarted`). -- `fun requestDeleteVideos(videos: List)`: - - if `pendingBatch.isNotEmpty()` → `commitPendingDeletion()` first (supersede), - - set `pendingBatch = videos`, `_pendingDeletionIds.value = videos.map { it.id }.toSet()`, - - `viewModelScope.launch { _events.send(BoomerangEvent.LoopsDeleted(videos.size)) }`. -- `fun undoPendingDeletion()` → `pendingBatch = emptyList(); _pendingDeletionIds.value = emptySet()`. -- `fun commitPendingDeletion()`: - - capture `val batch = pendingBatch`; return if empty; clear `pendingBatch` + ids, - - `viewModelScope.launch { batch.forEach { videoStorage.deleteVideo(it) }; _recordedVideos.value = videoStorage.loadRecordedVideos() }`. -- Safe-by-design: batch is in-memory, so process death before commit ⇒ files survive (implicit undo, never data loss). Note this in a comment. -- Keep the old `deleteVideo(video)` only if still referenced; otherwise remove it and any now-dead references. - -### 2) Events + MainActivity -- Add `data class LoopsDeleted(val count: Int) : BoomerangEvent` to the sealed interface. -- In MainActivity's events collector, add a branch: show snackbar with message `resources.getQuantityString(R.plurals.gallery_loops_deleted, count, count)` and action label `getString(R.string.undo)`, `duration = SnackbarDuration.Short`. On result: `ActionPerformed -> viewModel.undoPendingDeletion()`, else `viewModel.commitPendingDeletion()`. (Strings must be resolved in composable scope like the existing ones.) - -### 3) Strings (`res/values/strings.xml`) -- `Undo` -- ` 1 loop deleted %d loops deleted ` -- `gallery_selection_count` (e.g. `%d selected`), `gallery_delete_selected` (content desc "Delete selected"), `gallery_exit_selection` (content desc "Exit selection"). - -### 4) GalleryScreen — selection model -- Collect `viewModel.visibleVideos` (NOT `recordedVideos`) so deleted tiles vanish instantly and reappear on undo. -- `var selectedIds by rememberSaveable(stateSaver = >) { mutableStateOf(emptySet()) }` — or store as `LongArray`/`List` via `rememberSaveable`. `val inSelectionMode = selectedIds.isNotEmpty()`. -- `VideoThumbnailCard`: replace `onClick` with `combinedClickable(onClick = { if (inSelectionMode) toggle(id) else play() }, onLongClick = { addToSelection(id) })`. Remove the `onDelete` param + the trash Box. Add a **selected overlay**: a lime ring (`border(2.dp, ElectricLime, shape)`) + a check badge (`Icons.Filled.Check` on a small `ElectricLime` circle with `LimeInk` tint) in a corner, plus a slight scrim/scale. Verify whether `combinedClickable` needs `@OptIn(ExperimentalFoundationApi::class)` in this Compose version (web-search/confirm) and add it if so. -- **Top bar**: when `inSelectionMode`, swap the back/import bar for a **contextual action bar**: a leading ✕ (`Icons.Filled.Close`, clears `selectedIds`), a `gallery_selection_count` label (`MaterialTheme.typography.titleMedium`), and a trailing **Delete** (`Icons.Outlined.Delete`). Delete → `viewModel.requestDeleteVideos(visibleVideos.filter { it.id in selectedIds })` then `selectedIds = emptySet()`. Otherwise show the existing back + import bar (back = neutral glass `OverlayWhite` + `Icons.AutoMirrored.Filled.ArrowBack`; import = flat `ElectricLime` + `LimeInk` `VideoLibrary` icon — already implemented, keep). -- **BackHandler** (Lesson 015 — gate, don't always intercept): if `selectedVideo != null` → let the dialog handle it (keep disabled); else if `inSelectionMode` → clear selection; else → `onBackClick()`. -- Accessibility (ANDROID_STANDARDS §7): ≥48 dp targets; selected tiles announce selected state via `semantics { selected = ... }`; action-bar buttons get `contentDescription`s + `Role.Button`. - -### 5) Tidy -- The `ic_film_slate` drawable is already unused by the gallery; leave the file unless you confirm it's unused everywhere (then it's a separate cleanup). -- Remove any now-unused imports (`painterResource`, etc.) to keep lint clean. - -## Edge-case decisions (already approved) -- A single long-press enters multi-select immediately (no separate single-selected state). -- Selection survives rotation (`rememberSaveable`). -- Starting a new delete while one is pending commits the previous batch first. -- Undo defers the real file delete; process death before commit = implicit undo (safe). - -## Tests (add these) -- VM unit tests (`app/src/test/...`): `requestDeleteVideos` hides ids + emits `LoopsDeleted`; `undoPendingDeletion` restores; `commitPendingDeletion` calls `videoStorage.deleteVideo` per item + reloads; superseding commits the prior batch. Follow `docs/TEST_COVERAGE.md` (fake repo, `StandardTestDispatcher`, Lesson 008). -- Gallery Compose UI test (`app/src/androidTest/...`): long-press a tile → action bar appears with "1 selected"; tap Delete → tile removed from grid; assert `requestDeleteVideos` invoked (use a fake/recording VM or hoisted stateless content like `TrimScreenContent`/`BoomerangEditorContent` precedent). Prefer extracting a stateless `GalleryContent` if it makes testing cleaner. - -## Definition of Done (non-negotiable — see docs/DEFINITION_OF_DONE.md) -1. Clean **debug AND release** build (`BUILD SUCCESSFUL`, exit 0, zero `e:` — never trust `| tail`-masked exit codes). -2. Unit + instrumented tests pass (0 failures). -3. `./gradlew :app:lintDebug` reports **no new** issues; run IDE inspections (`docs/STATIC_ANALYSIS.md`). -4. **Run the app on an emulator/device, exercise long-press → select → delete → undo, and capture a screenshot as proof.** Attach it to the PR. -5. Honestly state anything you could not verify + a manual QA checklist. - -## Docs + tracking to update (REQUIRED) -1. Create `docs/active/gallery-selection-delete/IMPLEMENTATION.md` following `docs/active/CLAUDE.md` (problem / scope / architecture / steps / testing / acceptance). Reference `docs/PRD-mission-control.md`. -2. Update **GitHub issue #35**: check off the "Add a confirmation (or undo) to delete" box in section 3, and post a short progress comment (`gh issue comment 35 ...`) summarizing what shipped. The companion review canvas is `openloop-ui-ux-review.canvas.tsx`. -3. If a new lesson emerges during review, add it to `docs/lessons_learned/` per that folder's README. -4. Branch name: `feature/gallery-selection-delete` (per `.github` conventions). Only commit/open a PR when the user asks. - -## Acceptance criteria -- [ ] No per-thumbnail delete control; grid is content-only at rest. -- [ ] Long-press enters multi-select; tap toggles in-mode, plays out-of-mode. -- [ ] Contextual action bar shows count + Delete + exit; system back exits selection first. -- [ ] Delete shows an Undo snackbar; Undo restores instantly; dismiss commits the real file delete. -- [ ] Deleted tiles disappear immediately (optimistic) via `visibleVideos`. -- [ ] No inline hex; uses theme tokens + `MaterialTheme` for any new text/shape. -- [ ] Accessibility + Compose-perf properties preserved; DoD cleared with a screenshot. diff --git a/docs/prompts/issue-40-background-export-kickoff.md b/docs/prompts/issue-40-background-export-kickoff.md deleted file mode 100644 index 5a1ec94..0000000 --- a/docs/prompts/issue-40-background-export-kickoff.md +++ /dev/null @@ -1,157 +0,0 @@ -# Issue #40 — Background Export Kickoff Prompt - -Copy everything below the line into a fresh Claude Code / Cursor session with the OpenLoop repo mounted. This kickoff implements **[Issue #40 — P0: Survive backgrounding during Loopifying](https://github.com/stozo04/OpenLoop/issues/40)** (WorkManager + `mediaProcessing` FGS). - ---- - -## Session Prompt — Issue #40 Background Export During Loopifying - -You are working on **OpenLoop** — an open-source Android camera app (Kotlin/Jetpack Compose) for speed-controlled video loops. Repo: `stozo04/OpenLoop`. Package: `io.github.stozo04.openloop`. Owner: Steven Gates (@stozo04). - -**Severity: P0 for public Production** (owner timeline: ~2 weeks). Internal testing can proceed with a "stay on screen" warning until this ships — **do not go public without it**. - -## Problem (plain English) - -When the user taps Save, **"Loopifying…"** runs `VideoReverser` + Media3 `Transformer` inside `viewModelScope` in `OpenLoopViewModel.saveBoomerang()`. If the user presses **Home**, switches apps, locks the screen, or closes the Fold cover, Android can kill the process — the loop fails with no notification and no recovery. - -**Back is already handled correctly** (consumed). This issue is **leaving the app**, not Back navigation. - -## Critical Rule — Do Not Trust Your Training Data - -Web-search `developer.android.com` for: WorkManager long-running workers, `mediaProcessing` foreground service type, `FOREGROUND_SERVICE_MEDIA_PROCESSING`, FGS timeouts (6 h/24 h), `POST_NOTIFICATIONS` on API 33+, Android 16 progress-centric notifications. Verify WorkManager version against `developer.android.com/jetpack/androidx/releases/work`. - -## Authoritative spec - -**Issue #40 on GitHub** — full implementation plan. This kickoff is the session execution guide. On conflict, Issue #40 wins after owner confirmation. - -**Related:** Issue #39 (audit), Issue #41 (perf — orthogonal), P2 cancel button (coordinate — cancel must cancel WorkManager work). - -## Before Writing Any Code — Read These Files - -1. **`CLAUDE.md`** -2. **Every file in `docs/lessons_learned/`** — 004 (Context), 013 (cancel/narrow catch), 015 (back during processing) -3. **`docs/DEFINITION_OF_DONE.md`** -4. **`docs/ANDROID_STANDARDS.md`** -5. **`app/src/main/java/.../ui/OpenLoopViewModel.kt`** — `saveBoomerang()` -6. **`app/src/main/java/.../media/VideoProcessor.kt`** — `runTransformer()`, cancellation -7. **`app/src/main/java/.../ui/ProcessingScreen.kt`** -8. **`app/src/main/java/.../MainActivity.kt`** — Factory wiring for `VideoProcessor` / `VideoReverser` -9. **Issue #40** — acceptance criteria - -## Phase 1 — Branch + baseline - -```powershell -git checkout main -git pull --rebase -git checkout -b feature/issue-40-background-export -.\gradlew.bat clean assembleDebug --console=plain; echo "EXIT=$LASTEXITCODE" -``` - -## Phase 2 — Web-verify (mandatory searches) - -Document URLs in PR: - -| API / library | Verify | -|---------------|--------| -| WorkManager 2.x latest stable | `setForegroundAsync`, `CoroutineWorker`, `WorkInfo` progress | -| `ForegroundInfo` + `FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING` | API 29+ service type param | -| Manifest merge for `SystemForegroundService` | `android:foregroundServiceType="mediaProcessing"` | -| Play Console FGS declaration | Policy → App content → Foreground service permissions | -| `POST_NOTIFICATIONS` request timing | API 33+ — when to request for export notification | - -## Phase 3 — Implement (follow Issue #40 phases) - -### 3a. Gradle + Manifest - -- Add `androidx.work:work-runtime-ktx` (verify latest version via web search) -- Permissions: `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_MEDIA_PROCESSING`, `POST_NOTIFICATIONS` -- Merge WorkManager foreground service with `mediaProcessing` type - -### 3b. `BoomerangRenderWorker` - -New: `app/src/main/java/io/github/stozo04/openloop/work/BoomerangRenderWorker.kt` - -- Input `Data`: paths, trim, mode, speed, filter, repetitions, raw_id, output_path, return_to_gallery -- `setForegroundAsync()` before encode with progress notification channel **"Loop export"** (low importance) -- Call existing `VideoProcessor.renderBoomerang()` — reuse MainActivity wiring pattern via `Application` context -- **Transformer on Main:** today `runTransformer` uses `Dispatchers.Main` — preserve or refactor carefully; document decision -- Progress: `setProgressAsync()` throttled ~1 Hz → notification + WorkInfo -- Success: `registerBoomerang`, `discardScratch` -- Failure/cancel: delete partial output, `Result.failure()` — **no retry loop** for user-initiated render - -### 3c. ViewModel refactor - -`saveBoomerang()`: - -1. Promote scratch → raw (sync, unchanged) -2. Allocate boomerang path -3. `enqueueUniqueWork("render_${scratchUuid}", KEEP, request)` -4. `_uiState = Processing` -5. Observe `WorkManager.getWorkInfoByIdFlow(workId)` → map progress → `_renderProgress` -6. On `SUCCEEDED`: emit Share event, navigate (gallery vs camera per `importedSession`) -7. On `FAILED`: `failBackToEditor` - -**Keep** `ensureReversed()` in ViewModel for editor preview — not in Worker. - -### 3d. UI + permissions - -- `ProcessingScreen`: copy — **"You can leave — check your notification for progress."** -- Request `POST_NOTIFICATIONS` on first save (API 33+) — if denied, show in-app-only progress + explain background may not survive -- Notification tap → `PendingIntent` to `MainActivity` `SINGLE_TOP` - -### 3e. Play Console note for owner - -Add comment template in PR: declare `mediaProcessing` FGS in Console. - -## Phase 4 — Tests - -**Unit:** Worker Data keys round-trip; failure deletes partial file (mock storage) - -**Instrumented:** - -- Enqueue worker with tiny clip → `SUCCEEDED` -- Optional: Home key during work → still completes (Fold, long timeout) - -Update `OpenLoopViewModelTest` — save enqueues work (inject `WorkManager` test double or robolectric if project pattern exists). - -## Phase 5 — Manual QA (Pixel Fold 10 — required) - -1. Save 15 s F→R → Home at ~20% → wait → return → loop in gallery ✅ -2. 4K import save → Home → completes ✅ -3. Notification shows progress, dismisses on complete ✅ -4. Deny POST_NOTIFICATIONS → in-app still works; document background behavior ✅ -5. Fold cover close during render → completes ✅ - -## Phase 6 — DoD + PR - -Full `docs/DEFINITION_OF_DONE.md` gate including lint + release build + screenshot of notification + Processing screen. - -PR title: `Issue #40 — WorkManager background Loopifying export` -Closes #40 - -## Edge cases (from Issue #40) - -| Scenario | Handling | -|----------|----------| -| Duplicate Save | `enqueueUniqueWork` KEEP/REPLACE on scratch UUID | -| Raw promoted, render fails | Keep raw — user retries (owner decision) | -| Share sheet | Defer until Activity resumed (existing `onShareSheetClosed` pattern) | -| Force-stop | Work cancelled — acceptable | - -## Behavioral Rules - -- Do not rewrite `VideoReverser` / pipeline logic — **move orchestration only** -- Lesson 013: never swallow `CancellationException` -- Coordinate with P2 cancel: expose `cancelRenderWork()` calling `WorkManager.cancelUniqueWork` - -## When to Stop and Ask Steven - -- Transformer cannot run off Main without major refactor — propose `runBlocking(Main)` in Worker with perf note -- WorkManager + Media3 interaction fails on API 26 device — need minSdk verification -- Notification UX choice: indeterminate vs percent bar (recommend percent; Android 16 ProgressStyle optional) -- Merge conflict with P2 cancel branch - -## Out of Scope - -- Issue #41 perf optimizations (CodecDB Lite, pre-scale) -- Replacing `VideoReverser` algorithm diff --git a/docs/prompts/issue-41-loopifying-optimization-kickoff.md b/docs/prompts/issue-41-loopifying-optimization-kickoff.md deleted file mode 100644 index cfb03ae..0000000 --- a/docs/prompts/issue-41-loopifying-optimization-kickoff.md +++ /dev/null @@ -1,200 +0,0 @@ -# Issue #41 — Loopifying Optimization Kickoff Prompt - -Copy everything below the line into a fresh Claude Code / Cursor session with the OpenLoop repo mounted. This kickoff implements **[Issue #41 — Optimize Loopifying render pipeline](https://github.com/stozo04/OpenLoop/issues/41)** (quality-preserving performance). - ---- - -## Session Prompt — Issue #41 Loopifying Performance - -You are working on **OpenLoop** — an open-source Android camera app (Kotlin/Jetpack Compose) for speed-controlled video loops. Repo: `stozo04/OpenLoop`. Owner: Steven Gates (@stozo04). - -**Goal:** Make **"Loopifying…"** faster without reducing share/upload quality. Owner explicitly rejected a user-facing Fast/720p toggle — **quality first**. - -**Constraints:** - -- 30 s max clip (`MAX_RECORDING_MS`) -- Primary test device: **Pixel Fold 10** -- Background survival is **Issue #40** — do not conflate; perf work applies inside `VideoProcessor` / `VideoReverser` regardless of caller (ViewModel or Worker) - -**Blockers before Tier 1 code:** - -- **Issue #29** — manual QA verifying per-item `SpeedChangeEffect` on export (correctness gate) -- **Baseline timings** — measure before optimizing (Issue #41 measurement matrix) - -## Critical Rule — Do Not Trust Your Training Data - -Web-search `developer.android.com` for: Media3 Transformer `DefaultEncoderFactory` CodecDB Lite, `EditedMediaItem.Builder.setFrameRate()`, Transformer rescaling, video sharing bitrate guidance, ADPF thermal API. Verify Media3 **1.10.1** APIs against `developer.android.com/jetpack/androidx/releases/media3` — OpenLoop uses BOM 2026.05.01 / Media3 1.10.1 per `CLAUDE.md`. - -## Authoritative spec - -**Issue #41 on GitHub** — tier list, forbidden regressions, acceptance criteria. - -**Must-read lessons:** - -- **018** — seam drops by sequence position (do not touch) -- **019** — rotation strip/re-stamp (verify on Fold portrait after changes) -- **020** — HDR import failure recovery -- **021** — **never downscale inside VideoReverser** (Surface mismatch → green macroblocks) - -## Before Writing Any Code — Read These Files - -1. **`CLAUDE.md`** -2. **Every file in `docs/lessons_learned/`** -3. **`docs/DEFINITION_OF_DONE.md`** -4. **`docs/active/boomerang-rollout/RESEARCH-reverse-video.md`** -5. **`app/src/main/java/.../media/VideoProcessor.kt`** -6. **`app/src/main/java/.../media/VideoReverser.kt`** -7. **`app/src/main/java/.../media/BoomerangSequence.kt`** -8. **`app/src/androidTest/.../media/VideoReverserTest.kt`** -9. **Issue #29**, **Issue #41** - -## Phase 1 — Branch + baseline + timings - -```powershell -git checkout main -git pull --rebase -git checkout -b feature/issue-41-loopifying-perf -.\gradlew.bat clean assembleDebug --console=plain; echo "EXIT=$LASTEXITCODE" -``` - -**Before any optimization**, record baseline wall times on **Pixel Fold 10** (post in PR / Issue #41): - -| # | Source | Trim | Mode | Speed | -|---|--------|------|------|-------| -| 1 | Camera HD 10 s | full | F→R | 2× | -| 2 | Camera HD 30 s | full | F→R | 2× | -| 3 | 4K SDR import 10 s | full | F→R | 2× | -| 4 | 4K HDR import 10 s | full | F→R | 2× | -| 5 | Camera HD 10 s | full | F→R | 0.5× (#29 correctness) | - -Run **#29 manual QA** first on row 5 — inspect **saved gallery MP4**, not preview. - -## Phase 2 — Web-verify Media3 APIs - -Search and confirm signatures for Media3 1.10.1: - -- `DefaultEncoderFactory.Builder.setEnableCodecDbLite(true)` -- `EditedMediaItem.Builder.setFrameRate(int)` -- `Presentation.createForShortSide(int)` -- `Composition.HDR_MODE_TONE_MAP_HDR_TO_SDR_USING_OPEN_GL` - -## Phase 3 — Implementation order (Issue #41 tiers) - -### PR 1 (or commit 1): Tier 2A — Benchmark test - -Add `@LargeTest` `LoopifyingBenchmarkTest` (androidTest): - -- Synthetic 720p 3 s fixture → F→R @ 2× → assert output exists -- Log `System.currentTimeMillis()` delta — generous upper bound for CI emulator -- Does not gate CI on tight timing initially - -### PR 2: Tier 1B — CodecDB Lite (low risk) - -In `VideoProcessor.runTransformer()`: - -```kotlin -Transformer.Builder(context) - .setEncoderFactory( - DefaultEncoderFactory.Builder(context) - .setEnableCodecDbLite(true) - .build() - ) -``` - -Measure composition-step time before/after on Fold row 1. - -### PR 3: Tier 1C — Frame rate cap when speed > 1× - -In `VideoProcessor` when building `EditedMediaItem`: - -- If `speed > 1f`: `.setFrameRate((sourceFps / speed).toInt().coerceAtLeast(24))` -- If `speed <= 1f`: **do not set** (slow-mo needs frames) - -Test at 3× — measurable win; 1× and 0.5× unchanged. - -### PR 4: Tier 1A — Pre-scale before reverse ⭐ (largest win, most complex) - -**Only when** `sourceShortSide > MAX_OUTPUT_SHORT_SIDE` (1080): - -1. New package-private or internal function `scaleSourceForReverse()` using Media3 Transformer: - - Trim clip (same window) - - HDR tone-map - - `Presentation.createForShortSide(1080)` - - Output to `scratch/scaled_.mp4` -2. Pass scaled file to `VideoReverser.reverse(scaled, 0, trimDurationMs)` — trim window now full file -3. Final composition export unchanged - -**Forbidden:** downscaling inside `VideoReverser` (Lesson 021). - -**Regression tests:** - -- [ ] `VideoReverserTest` unchanged behavior on ≤1080p sources -- [ ] New test: >1080p synthetic → reverse input dimensions ≤1080p -- [ ] Portrait rotation Fold manual -- [ ] HDR import manual (Lesson 020) - -**Success:** Rows 3–4 ≥ **50% faster** vs baseline; side-by-side share quality indistinguishable. - -### Defer (owner quality preference) - -- Lower pass-1 intermediate bitrate (Tier 3) -- Thermal 720p adaptive cap (needs owner sign-off for v2) -- HEVC output — rejected - -## Phase 4 — Forbidden changes checklist - -Before PR merge, confirm **none** of: - -| Change | Lesson | -|--------|--------| -| Downscale in VideoReverser | 021 | -| Remove seam drops | 018 | -| Skip HDR tone-map | 020 | -| Remove hardware encoder selection | — | -| Move speed to Composition-level without #29 failure observed | #29 | - -## Phase 5 — Tests + DoD - -```powershell -.\gradlew.bat testDebugUnitTest connectedDebugAndroidTest :app:lintDebug assembleRelease --console=plain -``` - -- All existing media tests green -- New benchmark + pre-scale tests -- Manual matrix rows re-run with after timings in PR table -- Visual: no seam stutter, no green blocks, no rotation bug on Fold portrait -- `./gradlew :app:lintDebug` zero new errors -- Screenshot: Loopifying progress on long 4K import (before/after times in PR description) - -## Phase 6 — PR strategy - -Prefer **stacked PRs** or single PR with logical commits: - -1. Benchmark only -2. CodecDB Lite -3. setFrameRate -4. Pre-scale (separate review — highest risk) - -Link **Closes #41** on final merge PR or each sub-PR referencing issue. - -## Behavioral Rules - -- **Measure first** — no speculative optimization without baseline row -- **#29 before speed architecture change** — never move to Composition-level speed without observed failure -- **Minimal scope** per commit — easier bisect if regression -- Issue #40 merge: ensure Worker calls same `VideoProcessor` path (no duplicate logic) - -## When to Stop and Ask Steven - -- Pre-scale breaks HDR or rotation on Fold — needs design review -- CodecDB Lite changes visual output noticeably — revert and document device -- #29 fails (second half wrong speed) — fix correctness before any Tier 1 perf -- 4K baseline already fast enough on Fold — owner may deprioritize 1A for launch -- Thermal v2 adaptive 720p requested — needs explicit approval - -## Out of Scope - -- Issue #40 WorkManager / notifications -- FFmpegKit / native reverse -- User-facing quality/speed toggle -- Removing two-pass reverse (wait for Media3 upstream) diff --git a/docs/prompts/play-store-p0-production-blockers-kickoff.md b/docs/prompts/play-store-p0-production-blockers-kickoff.md deleted file mode 100644 index 0af2f7d..0000000 --- a/docs/prompts/play-store-p0-production-blockers-kickoff.md +++ /dev/null @@ -1,172 +0,0 @@ -# Play Store P0 — Production Blockers Kickoff Prompt - -Copy everything below the line into a fresh Claude Code / Cursor session with the OpenLoop repo mounted. This kickoff resolves **P0 — Must resolve before Production** from [Issue #39](https://github.com/stozo04/OpenLoop/issues/39). - ---- - -## Session Prompt — Play Store P0 Production Blockers - -You are working on **OpenLoop** — an open-source Android camera app (Kotlin/Jetpack Compose) for creating speed-controlled video loops ("Boomerangs"). Repo: `stozo04/OpenLoop`. Package: `io.github.stozo04.openloop`. Owner: Steven Gates (@stozo04). Apache 2.0. - -**Timeline:** Internal testing first; **public Production in ~2 weeks**. P0 items are hard gates — nothing ships to Production until these are checked off. - -**Your mission:** Close every P0 blocker from Issue #39. This is a **verification + submission** sprint, not a feature sprint. Code changes are only in scope when QA reveals a defect. - -## Critical Rule — Do Not Trust Your Training Data - -Your knowledge cutoff could be a year old. **Do not assume** you know current Google Play requirements, target API floors, 16 KB page-size rules, Data safety form wording, or internal-testing vs production promotion steps. Before making any claim about Play policy or Android behavior, **web-search `developer.android.com` and `support.google.com/googleplay` first**. If you write "Google requires X" without searching in this session, stop and search. - -## P0 Checklist (authoritative scope) - -From Issue #39 and `docs/play-store/README.md`: - -| # | Blocker | Owner | Agent role | -|---|---------|-------|------------| -| P0-1 | **Real-device QA matrix** — API 26 / 29 / 30 / 33 / 36; capture, import (HDR + 4K), reverse, render, share | Steven + agent | Execute matrix; document results; file defects | -| P0-2 | **Developer account + signed AAB** | Steven (account); agent (build) | Verify `./gradlew :app:bundleRelease` green; guide keystore steps | -| P0-3 | **Store assets** — icon 512×512, feature graphic 1024×500, ≥2 phone screenshots | Steven captures; agent specs | Verify specs; capture screenshots during QA if device available | - -**Out of scope for this prompt:** P1/P2 items (#39), Issue #40 (background export), Issue #41 (Loopifying perf) — separate kickoffs. - -## Owner context (do not re-litigate) - -- **Primary test device:** Google Pixel Fold 10 (Android Pro Fold) — use for all physical QA rows first. -- **No audio in loops** — video-only product (see P1 kickoff for permission cleanup). -- **Quality first** — no Fast/HD export toggle. -- **30 s max clip** at capture/import (`MAX_RECORDING_MS = 30_000L`). -- Fold-only testing is **not sufficient** for P0-1 — emulators required for API 26/29/30/33 floors. - -## Before Any Work — Read These Files (in order) - -1. **`CLAUDE.md`** — operating instructions. -2. **Every file in `docs/lessons_learned/`** — especially 005 (target API), 011 (16 KB), 013 (media failures), 020 (HDR import). -3. **`docs/DEFINITION_OF_DONE.md`** — the verification gate (mandatory for any code fix this sprint spawns). -4. **`docs/play-store/README.md`** — pre-submission checklist (source of truth). -5. **`docs/play-store/store-listing.md`** — asset specs + screenshot checklist. -6. **`docs/play-store/release-signing-and-aab.md`** — AAB build + signing. -7. **`docs/play-store/data-safety.md`** — Data safety answers (verify still accurate). -8. **`docs/TEST_COVERAGE.md`** — test conventions. -9. **Issue #39** on GitHub — full audit context. - -## Phase 1 — Green baseline + release artifact - -```powershell -$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr" -git checkout main -git pull --rebase -.\gradlew.bat clean assembleDebug assembleRelease --console=plain; echo "EXIT=$LASTEXITCODE" -``` - -Then verify 16 KB alignment per `docs/DEFINITION_OF_DONE.md`: - -```powershell -& "$env:LOCALAPPDATA\Android\Sdk\build-tools\\zipalign.exe" -c -P 16 -v 4 ` - app\build\outputs\apk\release\app-release-unsigned.apk -``` - -If `keystore.properties` exists: - -```powershell -.\gradlew.bat :app:bundleRelease --console=plain; echo "EXIT=$LASTEXITCODE" -``` - -Document: debug/release exit codes, zipalign `(OK)` at 16384, AAB path if signed. - -## Phase 2 — Web-verify Play requirements (search first) - -Search and cite live URLs for: - -| Topic | Start here | -|-------|------------| -| Target API level for new apps (2025–2026) | `developer.android.com/google/play/requirements/target-sdk` | -| 16 KB page size (Nov 2025+) | `developer.android.com/guide/practices/page-sizes` | -| Internal testing track promotion | `support.google.com/googleplay/android-developer` (internal testing) | -| Store listing asset specs | `support.google.com/googleplay/android-developer/answer/9866151` | -| Data safety form | `developer.android.com/privacy-and-security/declare-data-use` | - -Confirm OpenLoop's `targetSdk 36`, 16 KB packaging, and "no data collected" posture still match live policy. Flag any drift in a comment on Issue #39. - -## Phase 3 — QA matrix (P0-1) - -Create or update a **QA results artifact** in the PR or Issue #39 comment (table format). Minimum rows: - -| Row | API | Surface | Flow | Pass/Fail | Notes | -|-----|-----|---------|------|-----------|-------| -| 1 | 36 | Pixel Fold 10 | Capture 10 s → Trim → F→R @2× → Save → Share | | | -| 2 | 36 | Pixel Fold 10 | Import 4K SDR 10 s → F→R @2× → Save | | | -| 3 | 36 | Pixel Fold 10 | Import HDR 10 s → F→R → Save (Lesson 020) | | | -| 4 | 36 | Pixel Fold 10 | Portrait rear camera → verify rotation both halves (Lesson 019) | | | -| 5 | 36 | Pixel Fold 10 | 30 s max capture auto-cap | | | -| 6 | 26 | Emulator | Photo Picker import (GMS backport) → Trim → Save | | | -| 7 | 29 | Emulator | Capture or import → full editor path | | | -| 8 | 33 | Emulator | POST_NOTIFICATIONS N/A yet — baseline capture path | | | - -**Pass criteria per row:** no crash, loop plays in gallery, share sheet opens, no visible seam stutter, no green macroblocks on reversed half. - -Run automated tests in parallel: - -```powershell -.\gradlew.bat testDebugUnitTest --console=plain; echo "EXIT=$LASTEXITCODE" -$env:ANDROID_SERIAL = "" -.\gradlew.bat connectedDebugAndroidTest --console=plain; echo "EXIT=$LASTEXITCODE" -.\gradlew.bat :app:lintDebug --console=plain; echo "EXIT=$LASTEXITCODE" -``` - -Any failure → fix in a focused branch OR file a new GitHub issue with repro steps; do not mark P0-1 done with open defects. - -## Phase 4 — Store assets (P0-3) - -Per `docs/play-store/store-listing.md`: - -- [ ] **512×512** app icon (PNG, no transparency for Play icon rules — verify current policy via web search) -- [ ] **1024×500** feature graphic -- [ ] **≥2 phone screenshots** — capture during QA (editor with speed slider, gallery with loop, or Loopifying screen) - -If generating assets from emulator screenshots, note device density and crop to phone aspect. Attach to internal testing release notes. - -## Phase 5 — Play Console readiness (P0-2) — owner-operated, agent-assisted - -Agent prepares a **submission packet comment** for Steven with copy-paste blocks from: - -- `docs/play-store/data-safety.md` -- `docs/play-store/content-rating.md` (for awareness — P2 owns form entry) -- `docs/play-store/store-listing.md` -- Privacy policy URL: `https://stozo04.github.io/OpenLoop/privacy-policy.html` - -Steven must: create developer account, upload AAB to **Internal testing**, complete Data safety + store listing minimum fields. - -Agent verifies repo docs match app behavior (especially permissions list after any P1 audio removal). - -## Phase 6 — Definition of Done - -For any code fixes spawned by QA: - -- Full DoD gate in `docs/DEFINITION_OF_DONE.md` -- Screenshot proof of fixed flow on Fold or emulator -- PR references Issue #39 P0 row that failed - -For this **verification sprint** (no code): deliverable is the **completed QA matrix table** + green build log + asset checklist — posted to Issue #39. - -## Behavioral Rules - -- **PRD-first for fixes** — if QA finds a bug, write a one-paragraph fix plan before coding. -- **Pushback** — if a P0 row cannot run (no API 26 emulator), say so and propose the minimum substitute. -- **Honesty** — "Fold only" is insufficient; state coverage gaps explicitly. -- **Do not** start Issue #40 or #41 work in this session — stay on P0 gates. - -## When to Stop and Come Back to Steven - -- Developer account / Play Console access needed (agent cannot log in). -- `keystore.properties` missing and release AAB cannot be signed. -- QA reveals a **P0 defect** needing a multi-day fix (background export #40, etc.) — recommend reordering launch timeline. -- Live Play policy contradicts `docs/play-store/*` — stop and propose doc + app updates before submission. -- Green baseline fails on `main`. - -## Success Definition - -P0 is **done** when: - -1. QA matrix rows 1–8 recorded with Pass (or documented Fail + linked fix issue). -2. Release build + zipalign verified green. -3. Store asset checklist complete (files ready for Console upload). -4. Internal testing AAB uploaded (owner confirms) OR explicit blocker documented with date. diff --git a/docs/prompts/play-store-p1-review-risks-kickoff.md b/docs/prompts/play-store-p1-review-risks-kickoff.md deleted file mode 100644 index a0f1fa3..0000000 --- a/docs/prompts/play-store-p1-review-risks-kickoff.md +++ /dev/null @@ -1,133 +0,0 @@ -# Play Store P1 — Review Risks Kickoff Prompt - -Copy everything below the line into a fresh Claude Code / Cursor session with the OpenLoop repo mounted. This kickoff resolves **P1 — Likely Play review questions** from [Issue #39](https://github.com/stozo04/OpenLoop/issues/39). - ---- - -## Session Prompt — Play Store P1 Review Risks - -You are working on **OpenLoop** — an open-source Android camera app (Kotlin/Jetpack Compose) for speed-controlled video loops. Repo: `stozo04/OpenLoop`. Package: `io.github.stozo04.openloop`. Owner: Steven Gates (@stozo04). - -**Timeline:** Internal testing now → public Production ~2 weeks. P1 items reduce **Play review rejection risk** and **user confusion** at launch. - -## Critical Rule — Do Not Trust Your Training Data - -Before claiming how Play reviews permissions, large-screen adaptivity, or foreground service policies work, **web-search `developer.android.com` and Play policy docs in this session**. Training data is stale on API 36 behavior changes. - -## P1 Scope (from Issue #39) - -| # | Risk | Owner decision | Primary action | -|---|------|----------------|----------------| -| P1-1 | **`RECORD_AUDIO` declared but export strips audio** | **No audio — video-only app.** Loops are silent forever. | **Remove `RECORD_AUDIO`** end-to-end | -| P1-2 | **Large-screen adaptivity (targetSdk 36)** | Camera app; Fold is primary device | Verify UX on sw600dp+ / fold; document or fix | -| P1-3 | **Foreground service policy** | Background export in scope | **Tracked in Issue #40** — do not duplicate; ensure P1 docs reference #40 | - -**This session owns P1-1 and P1-2.** Issue #40 has its own kickoff. - -## Before Writing Any Code — Read These Files - -1. **`CLAUDE.md`** -2. **Every file in `docs/lessons_learned/`** — 006 (permission rationale), 004 (no Context on VM), 012 (camera screen). -3. **`docs/DEFINITION_OF_DONE.md`** -4. **`docs/ANDROID_STANDARDS.md`** — permissions section -5. **`docs/play-store/data-safety.md`** + **`docs/play-store/store-listing.md`** — must stay consistent after audio removal -6. **`docs/android-16/README.md`** + **`docs/android-16/behavior-changes-targeting-16.md`** — large-screen rules -7. **Issue #39** P1 section - -## Phase 1 — Branch + baseline - -```powershell -$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr" -git checkout main -git pull --rebase -git checkout -b feature/remove-record-audio-permission -.\gradlew.bat clean assembleDebug --console=plain; echo "EXIT=$LASTEXITCODE" -``` - -## Phase 2 — Web-verify (search before coding) - -| Topic | Search target | -|-------|---------------| -| Permission best practices / minimal permissions | `developer.android.com/training/permissions/usage-notes` | -| Request only needed permissions | `developer.android.com/quality/privacy-and-security` | -| Android 16 large screen / orientation overrides (targetSdk 36) | `developer.android.com/develop/adaptive-apps/guides/app-orientation-aspect-ratio-resizability` | -| Camera without audio recording | CameraX `prepareRecording` without `withAudioEnabled` | - -Document findings in PR description with URLs. - -## Phase 3 — P1-1: Remove RECORD_AUDIO (primary code task) - -**Owner mandate:** OpenLoop is **video-only**. Exported boomerangs use `setRemoveAudio(true)`. Microphone permission invites Play review questions and user distrust. - -### Files to touch (grep to confirm nothing missed) - -| File | Change | -|------|--------| -| `app/src/main/AndroidManifest.xml` | Remove `RECORD_AUDIO` uses-permission | -| `app/src/main/java/.../MainActivity.kt` | Remove from `REQUIRED_PERMISSIONS`; update rationale copy | -| `app/src/main/java/.../camera/CameraManager.kt` | Remove `withAudioEnabled()` path entirely — never record audio | -| `app/src/main/java/.../ui/OpenLoopViewModel.kt` | Remove audio from permission checks / `SecurityException` catch for audio | -| `app/src/androidTest/.../PermissionExplanationScreenTest.kt` | Update expected strings (Camera only) | -| `app/src/test/.../OpenLoopViewModelTest.kt` | Remove/update RECORD_AUDIO denial tests | -| `docs/play-store/store-listing.md` | Remove microphone bullet from PERMISSIONS section | -| `docs/play-store/data-safety.md` | Update camera-only rationale if microphone mentioned | -| `docs/privacy-policy.html` / `docs/play-store/privacy-policy.md` | Camera only — verify consistency | - -**Do not** add a "optional audio" toggle — owner rejected audio permanently. - -### Permission UX after change - -- Rationale screen: **Camera only** — "OpenLoop needs camera access to record video for your loops." -- Permanent denial flow: settings deep link still works with single permission. - -## Phase 4 — P1-2: Large-screen / Fold adaptivity - -**Context:** Apps targeting API 36 on devices with smallest width ≥600dp may have orientation/resizability restrictions ignored by the system ([adaptive apps guide](https://developer.android.com/develop/adaptive-apps/guides/app-orientation-aspect-ratio-resizability)). - -**Primary device:** Pixel Fold 10 — test **both folded and unfolded**: -- Camera viewfinder layout -- Editor preview aspect ratio -- Trim bar usability -- Gallery grid - -**Emulator:** Create sw600dp tablet AVD (API 36) — verify app launches and core flow works without broken letterboxing. - -**Acceptable outcomes:** -1. **Works well** — document in PR with Fold + tablet screenshots. -2. **Camera UX broken on tablet** — document known limitation in store listing OR add `PROPERTY_COMPAT_ALLOW_RESTRICTED_RESIZABILITY` per Google docs (search current opt-out rules for API 36 — opt-out removed at API 37). - -Do **not** block launch on perfect tablet camera UX unless Play policy requires it — but must not crash. - -## Phase 5 — Tests + DoD - -```powershell -.\gradlew.bat testDebugUnitTest connectedDebugAndroidTest :app:lintDebug --console=plain -.\gradlew.bat assembleRelease --console=plain; echo "EXIT=$LASTEXITCODE" -``` - -Manual QA on **Pixel Fold 10**: -- [ ] Fresh install → permission dialog asks **Camera only** (not microphone) -- [ ] Record 5 s clip → save loop → no crash without audio permission -- [ ] Unfolded + folded camera + editor — no layout catastrophe -- [ ] Import from Photo Picker still works - -**Screenshot:** Permission rationale screen (camera only) + editor on Fold unfolded. - -## Phase 6 — PR - -- Title: `Remove RECORD_AUDIO — video-only app (Play P1)` -- Link Issue #39 -- Checklist: P1-1 + P1-2 verification -- Note: Issue #40 still required before Production for background Loopifying - -## Behavioral Rules - -- **Minimal diff** — permission removal only; no unrelated refactors. -- **Docs stay honest** — update store listing + privacy policy in same PR. -- **Web-search every policy claim.** - -## When to Stop and Ask Steven - -- Removing audio breaks CameraX recording on a specific device (unlikely without `withAudioEnabled`). -- Fold unfolded layout needs a design decision (letterbox vs fill). -- Play policy search suggests camera-only video apps still need microphone (cite source — likely false for silent export). diff --git a/docs/prompts/play-store-p2-polish-hygiene-kickoff.md b/docs/prompts/play-store-p2-polish-hygiene-kickoff.md deleted file mode 100644 index 8ebb814..0000000 --- a/docs/prompts/play-store-p2-polish-hygiene-kickoff.md +++ /dev/null @@ -1,126 +0,0 @@ -# Play Store P2 — Polish & Policy Hygiene Kickoff Prompt - -Copy everything below the line into a fresh Claude Code / Cursor session with the OpenLoop repo mounted. This kickoff resolves **P2 — Polish / policy hygiene** from [Issue #39](https://github.com/stozo04/OpenLoop/issues/39). - ---- - -## Session Prompt — Play Store P2 Polish & Policy Hygiene - -You are working on **OpenLoop** — an open-source Android camera app (Kotlin/Jetpack Compose) for speed-controlled video loops. Repo: `stozo04/OpenLoop`. Owner: Steven Gates (@stozo04). - -**Timeline:** P2 items should land **before or with** public Production (~2 weeks). They are not launch blockers like P0, but improve review completeness and UX on slow renders. - -**Depends on:** P1 audio removal (if merged) — update content-rating / store copy to camera-only. - -## Critical Rule — Do Not Trust Your Training Data - -Web-search `developer.android.com` and Play Console help before stating requirements for content ratings, target audience, IARC questionnaires, or cancel UX during long operations. - -## P2 Scope (from Issue #39) - -| # | Item | Type | -|---|------|------| -| P2-1 | **Loopifying cancel path** — `ProcessingScreen` consumes Back; no mid-render cancel | Code + UX | -| P2-2 | **Content rating + target audience** — Console forms manual | Docs + owner paste | - -**Not in P2:** Issue #40 (background export — better fix for "stuck" feeling), Issue #41 (perf). - -## Before Writing Any Code — Read These Files - -1. **`CLAUDE.md`** -2. **Every file in `docs/lessons_learned/`** — 013 (cancellation), 015 (predictive back), 016 (deferred progress reads) -3. **`docs/DEFINITION_OF_DONE.md`** -4. **`docs/play-store/content-rating.md`** -5. **`docs/play-store/store-listing.md`** — target audience 13+ -6. **`app/src/main/java/.../ui/ProcessingScreen.kt`** -7. **`app/src/main/java/.../ui/OpenLoopViewModel.kt`** — `saveBoomerang()`, render job -8. **`app/src/main/java/.../MainActivity.kt`** — Processing branch BackHandler - -## Phase 1 — Branch + baseline - -```powershell -git checkout main -git pull --rebase -git checkout -b feature/processing-cancel-and-play-console-p2 -.\gradlew.bat clean assembleDebug --console=plain; echo "EXIT=$LASTEXITCODE" -``` - -## Phase 2 — Web-verify - -| Topic | Why | -|-------|-----| -| Predictive back + custom cancel dialogs | `developer.android.com` — confirm dialog + BackHandler interaction at targetSdk 36 | -| Long-running operation UX | Material guidance for progress + cancel | -| Play content rating / IARC | `support.google.com/googleplay/android-developer` content rating | -| Target audience declaration | Play policy for apps without user-generated public content | - -## Phase 3 — P2-1: Mid-render cancel (scoped UX) - -**Problem:** On slow devices (4K HDR import, 30 s trim), Loopifying can run 30–60+ s. User cannot cancel — feels stuck. Back is intentionally consumed to prevent Activity finish mid-encode (Lesson 015). - -**Design (propose in PR, implement minimally):** - -1. Add **Cancel** text button on `ProcessingScreen` (not system Back — explicit affordance). -2. On cancel: - - Cancel render coroutine / call `Transformer.cancel()` (Lesson 013 — propagate `CancellationException`) - - Delete partial boomerang output if allocated - - **Keep promoted raw** (matches #40 open question — user can retry from editor) - - Route to `OpenLoopUiState.BoomerangEditor` with trim/direction/speed preserved -3. Show confirmation dialog: "Stop creating this loop?" — prevents accidental tap. - -**Coordination with Issue #40:** When WorkManager lands, cancel must call `WorkManager.cancelUniqueWork()`. Structure cancel behind a `RenderJobHandle` interface if #40 merges soon — or note follow-up issue. - -**Out of scope:** Background export notification (that's #40). - -### Files likely touched - -- `ProcessingScreen.kt` — Cancel button + optional confirm dialog -- `OpenLoopViewModel.kt` — hold `Job` reference for save; `cancelBoomerangRender()` -- `VideoProcessor.kt` — ensure `transformer.cancel()` in finally (already present — verify) -- `OpenLoopNavHostTest.kt` / new test — Cancel visible on Processing screen - -### Regression guards - -- [ ] Successful render unchanged — no cancel side effects -- [ ] Cancel mid-render does not register partial boomerang in gallery -- [ ] Predictive back still does not finish Activity during render (unless Cancel confirmed) - -## Phase 4 — P2-2: Content rating & target audience (owner-operated) - -Agent **does not** log into Play Console. Deliver: - -1. Open `docs/play-store/content-rating.md` — verify answers still match app (no violence, no UGC network, no ads, camera-only after P1). -2. Produce a **Console walkthrough comment** for Issue #39 with step-by-step: Policy → App content → Content ratings → Start questionnaire → paste answers from doc. -3. Confirm **Target audience 13+** in store listing aligns with questionnaire. - -Web-search current IARC question wording if questionnaire changed since doc was written — update `content-rating.md` if drift found. - -## Phase 5 — Tests + DoD - -```powershell -.\gradlew.bat testDebugUnitTest connectedDebugAndroidTest :app:lintDebug --console=plain -``` - -Manual on Pixel Fold 10: -- [ ] Start Loopifying on a long import → tap Cancel → confirm → back in editor, selections intact -- [ ] Complete normal save — unaffected - -Screenshot: Processing screen with Cancel visible. - -## Phase 6 — PR - -- Title: `P2: Loopifying cancel + content rating doc sync` -- Link Issue #39 -- Separate commits: (1) cancel UX, (2) content-rating doc updates if any - -## Behavioral Rules - -- Cancel is **explicit button**, not re-enabling system Back mid-render (Lesson 015). -- Keep diff small — no full render architecture rewrite (that's #40). -- Update Play docs if behavior changes. - -## When to Stop and Ask Steven - -- Cancel leaves orphan raw files user cannot understand — needs copy/messaging decision. -- Content rating questionnaire answers conflict with open-source / camera-only model. -- Cancel implementation conflicts with in-flight #40 branch — coordinate merge order. diff --git a/docs/prompts/pr-19-fix-review-findings.md b/docs/prompts/pr-19-fix-review-findings.md deleted file mode 100644 index 0187dba..0000000 --- a/docs/prompts/pr-19-fix-review-findings.md +++ /dev/null @@ -1,140 +0,0 @@ -# PR #19 — Fix Review Findings (Slice 01: variable-length capture) - -Copy everything below the line into a **fresh Claude Code session** with the OpenLoop folder mounted and the branch `feature/boomerang-slice-01-variable-length` checked out. - -This prompt is a **finding-specific** companion to `docs/prompts/PR-FEEDBACK-RESOLUTION.md`. That file is the generic "address feedback + re-review" harness; **this** file enumerates the exact 7 findings from the automated review on PR #19 that are in scope (3 WARNINGs + 4 Recommendations), each with file:line, the fix, and the **covering test** that must prove it. Use both: the steps below for *what* to change, the generic doc's Phase 4/5 for the response comment + re-review. - -> **Deliberately out of scope:** the review's REC about the "Loopify" no-op button (`PreviewScreen.kt`) is **intentional** — it's a placeholder that slice 02 repoints to the Trim screen (`docs/prompts/boomerang-slice-02-kickoff.md` → `02-auto-route-trim-and-default-save.md`, which routes `Finalize` success to `Trim(ScratchClip)`). **Do not change it.** Acknowledge it as intentional/deferred in the PR response comment; do not "fix" it. - -> Source review comment: - ---- - -## Session Prompt — Fix the PR #19 review findings (with tests) - -You are working on **OpenLoop** — an open-source Android camera app (Kotlin/Jetpack Compose) for creating speed-controlled video loops ("Boomerangs"). Repo: `stozo04/OpenLoop`. Owner: Steven Gates (@stozo04). Apache 2.0. You are on branch `feature/boomerang-slice-01-variable-length` (PR #19). - -### Critical Rule — Do Not Trust Your Training Data - -Your knowledge cutoff could be a year old. **Do not assume** you know current Android/Compose/CameraX/coroutines behavior. Before any "Google recommends X" claim or any API-behavior assumption (what `Recording.start()` throws, how `BackHandler` resolves its dispatcher, the 48dp touch-target rule, deferred state reads), **web-search `developer.android.com` first** in this session. If you catch yourself asserting an API behavior you haven't searched for this session, stop and search. - -### Phase 0 — Required reading (before touching code) - -1. `CLAUDE.md` — operating instructions, architecture snapshot, Definition-of-Done pointer. -2. **Every file in `docs/lessons_learned/`** — especially `012-camera-bound-screen-single-call-site.md` (this PR added it; several findings below interact with it) and `008` (bounded coroutine loops in virtual-time tests). -3. `docs/PRD-mission-control.md` — **Decision Log #1** ("Sealed interface for UI state — exhaustive `when` matching **at compile time**") is the basis for WARNING-1, and the state table lists `Processing` as a planned state. The hand-off notes in Lesson 012 §"slice 02" record Steven's leanings on the Loopify button — read them. -4. `docs/ANDROID_STANDARDS.md` — §2 (defer state reads), §3 (catch specific exceptions), §7 (coroutines), §10 (exhaustive `when`, extract-for-testability), §11 (predictive back at target 36). -5. `docs/TEST_COVERAGE.md` — test directory split (`test/` JVM unit vs `androidTest/` instrumented), MockK + `kotlinx-coroutines-test` conventions, the `MainDispatcherRule`, and the backtick test-naming convention. -6. `docs/DEFINITION_OF_DONE.md` — the verification gate you must clear before calling this done. - -Then read the files you'll touch in full: `app/src/main/java/com/OpenLoop/app/MainActivity.kt`, `ui/CameraScreen.kt`, `ui/OpenLoopViewModel.kt`, `ui/OpenLoopUiState.kt`, `ui/PreviewScreen.kt`, `camera/CameraManager.kt`, and the two test files `test/.../ui/OpenLoopViewModelTest.kt`, `androidTest/.../ui/CameraScreenTest.kt`. - -### Phase 1 — Confirm owner decisions BEFORE building - -Two findings cross into UX/behavior choices. Per OpenLoop's pushback + reversibility rules, **surface these to Steven and get a one-word answer before writing the code for them.** Propose the recommended default in parentheses; don't silently pick: - -- **D1 — Back while Recording (WARNING-2).** Should the system back gesture during `Recording` (a) **stop & finalize** the clip (same as tapping stop → `LoopingPreview`), or (b) **discard** the in-flight clip and return to `ReadyToCapture`? *(Recommended: (a) stop & finalize — least surprising, no silent data loss, reuses `stopBurstCapture`.)* -- **D2 — `Processing` routing (WARNING-1).** `Processing` is a defined-but-unrouted state (slice 02 will give it a real UI per `02-auto-route-trim-and-default-save.md`). For *this* cleanup, render it as (a) the existing **`InfinityLoadingScreen()`** placeholder, or (b) a dedicated minimal "processing" UI? *(Recommended: (a) — a safe placeholder that closes the exhaustiveness/unguarded-`CameraScreen` gap now; slice 02 replaces it with the real Processing surface.)* - -If Steven is unavailable, build the recommended defaults and clearly flag the assumption in the PR response comment. - -### Phase 2 — Verify each finding is still valid - -For every item below, web-search the cited doc to confirm the standard before fixing. If a finding is stale, or you disagree after reading the code, **say so with reasoning** rather than making a change you can't justify. - ---- - -## The findings — fix + covering test for each - -Order them by risk: warnings first, then recommendations. Each must end green with its own test. - -### WARNING-1 — `else ->` defeats sealed-interface exhaustiveness + reintroduces an unguarded `CameraScreen` call site -- **Where:** `MainActivity.kt:178-184` (`else -> { CameraScreen(...) }`); unhandled state `OpenLoopUiState.Processing` at `OpenLoopUiState.kt:17`. -- **Why it matters:** Decision Log #1 chose a sealed interface specifically so the compiler forces every state to be handled. The `else` defeats that, leaves `Processing` silently falling through to a **bare `CameraScreen`** (no `CameraScreenHost`), and makes Lesson 012's own detection check fail — `grep -n "CameraScreen(" MainActivity.kt` returns **2** call sites, but the lesson requires exactly **1** reachable during capture. That second call site is the exact seam the `ERROR_SOURCE_INACTIVE` fix closed. -- **Fix:** Remove the `else ->` branch. Add an explicit `is OpenLoopUiState.Processing ->` branch rendering the agreed D2 placeholder UI. Let the `when` be exhaustive (no `else`) so the next new state won't compile until handled. (Slice 02 will swap the placeholder for the real Processing surface — leave a `// TODO(slice-02)` so that hand-off is obvious.) -- **Recommended structural move (enables the test):** Extract the routing `when` out of `setContent` into a testable, stateless `@Composable fun OpenLoopNavHost(uiState, viewModel, cameraManager)` — mirrors the project's existing extract-for-testability pattern (`OnboardingNavigation`, PRD §UI). MainActivity then just calls `OpenLoopNavHost(uiState, ...)`. -- **Covering tests:** - - **Compile-time (the real guard):** after removing `else`, intentionally comment out one branch locally and confirm the build fails with a non-exhaustive-`when` error, then restore it. (Don't commit the broken state — this just proves the guard is live.) - - **Instrumented** (`CameraScreenTest.kt` or a new `OpenLoopNavHostTest.kt`): mount `OpenLoopNavHost` with `uiState = OpenLoopUiState.Processing` and assert the camera content is **not** mounted (e.g. reuse the `host_content`/`LaunchedEffect` counter trick from `cameraScreenHost_keepsContentMounted_acrossCaptureTransition`, or assert the loading marker shows and `progress_ring`/shutter `contentDescription` do not exist). - - **Lesson-012 grep:** confirm `grep -n "CameraScreen(" app/src/main/java/com/OpenLoop/app/MainActivity.kt` returns exactly **1** match after the fix. - -### WARNING-2 — No `BackHandler` for the `Recording` state (predictive back is default-on at target 36) -- **Where:** `CameraScreen.kt` (no `BackHandler` anywhere; `PreviewScreen.kt:47` shows the correct pattern). -- **Why it matters:** At `targetSdk 36`, predictive back is on by default and `onBackPressed`/`KEYCODE_BACK` are no longer dispatched (ANDROID_STANDARDS §11). A back gesture mid-record finishes the Activity → `onDestroy` → `cameraManager.shutdown()`, silently discarding an up-to-30 s recording. §11 requires back to route through the state machine. -- **Fix (per D1):** In `CameraScreen`, add `BackHandler(enabled = isRecording) { viewModel.stopBurstCapture(cameraManager) }` (or the discard variant if D1 = (b)). Leave `ReadyToCapture` with **no** handler — exiting from the home screen via back is correct, so the handler must be gated on `isRecording`. -- **Covering test (instrumented):** Use `createAndroidComposeRule()` (it provides an `OnBackPressedDispatcher`; the plain `createComposeRule` does not). Mount a small stateless host exposing `isRecording` + an `onBack` lambda wrapping `BackHandler(enabled = isRecording) { onBack() }`, then: - - with `isRecording = false`, dispatch back (`activity.onBackPressedDispatcher.onBackPressed()` on the main thread) → assert `onBack` was **not** called (the handler is disabled, so the event passes through); - - with `isRecording = true`, dispatch back → assert `onBack` **was** called exactly once. - - If you'd rather test the real wiring, add a ViewModel-level assertion that the lambda invokes `stopBurstCapture` (already covered by existing ViewModel tests, so the new test only needs to prove the enabled-gating). - -### WARNING-3 — Gallery/home button touch target is 44dp (< 48dp minimum) -- **Where:** `CameraScreen.kt:159` — the home/gallery `Box` is `.size(44.dp)`. (Pre-existing; the shutter at 86dp and lens toggle at 54dp are fine.) -- **Why it matters:** Material/accessibility requires ≥48×48dp interactive targets; 44dp is a standard pre-launch accessibility-scanner failure. -- **Fix:** Bump to `.size(48.dp)` (keep the 20dp icon). To make it testable, **extract** the button into a stateless `@Composable fun HomeButton(onClick: () -> Unit, modifier: Modifier = Modifier)` (same hoisting pattern as `ShutterButton`). -- **Covering test (instrumented, `CameraScreenTest.kt`):** mount `HomeButton {}` and assert - `onNodeWithContentDescription("Gallery").assertWidthIsAtLeast(48.dp).assertHeightIsAtLeast(48.dp)`. - -### REC-1 — Defer the `recordingElapsedMs` read out of `CameraScreen`'s root (recomposition scope) -- **Where:** `CameraScreen.kt:102` collects the flow at the top level; `:106-115` recompute `progress` + two `String.format(...)` allocations every ~33 ms tick (~30/s), recomposing the entire screen body. -- **Why it matters:** Compose best practices say defer state reads to the narrowest scope so only the consumers (`ShutterButton`, `RecordingCountdownChip`) recompose, not the whole viewfinder tree. The arc already draws in the draw phase via `Canvas`. -- **Fix (pick one, prefer A):** - - **A.** Change the consumers to take **lambdas**: `ShutterButton(isRecording, progressFraction: () -> Float, onClick)` and `RecordingCountdownChip(visible, text: () -> String)`. Move the `recordingElapsedMs` collection + label formatting into a small child composable (e.g. `CaptureControlsRow`) so the read happens inside the lambda scope. Update the existing `CameraScreenTest` call sites to the lambda signatures. - - **B.** If the lambda churn isn't worth it, extract a `@Composable fun RecordingOverlay(viewModel)` child that does the `collectAsStateWithLifecycle()` itself, so the parent `CameraScreen` no longer reads the flow. -- **Covering tests:** - - Update the existing `ShutterButton`/`RecordingCountdownChip` tests to the new signatures and keep them green (they already assert ring/glyph/chip behavior). - - **Optional but ideal — recomposition counter (instrumented):** mount the screen/overlay, increment a counter inside the parent composable body (a plain `var` captured + `SideEffect { parentRecompositions++ }`), drive several elapsed updates via a `MutableStateFlow`, and assert the **parent** recomposition count stays ~constant while the child updates. If a reliable counter proves too fiddly, document that the perf win was verified manually via Layout Inspector recomposition counts and skip the automated assert — don't ship a flaky test. - -### REC-2 — Handle `startRecording(...) == null` (stuck-in-`Recording` hang) -- **Where:** `OpenLoopViewModel.kt:110` ignores the `Recording?` return. `CameraManager.startRecording` returns `null` when `videoCapture` isn't bound — then no `Finalize` ever fires, the auto-cap's `stopRecording()` is a no-op, and the UI is stuck in `Recording` with a full ring for 30 s. -- **Fix:** Capture the return value; if `null`, `clearRecordingTimers()`, set `_uiState.value = ReadyToCapture`, and **return before launching the timer coroutine**. -- **Covering test (`OpenLoopViewModelTest.kt`, JVM unit):** - ```kotlin - @Test - fun `startBurstCapture reverts to ReadyToCapture when recording cannot start`() = - runTest(mainDispatcherRule.testDispatcher) { - viewModel.onPermissionsChecked(true) - every { cameraManager.startRecording(any(), any()) } returns null - viewModel.startBurstCapture(cameraManager) - advanceUntilIdle() - assertEquals(OpenLoopUiState.ReadyToCapture, viewModel.uiState.value) - assertEquals(0L, viewModel.recordingElapsedMs.value) - verify(exactly = 0) { cameraManager.stopRecording() } - } - ``` - (Confirm there's no orphan timer coroutine left spinning — `advanceUntilIdle()` should settle immediately.) - -### REC-3 — Narrow the broad `catch (e: Exception)` in `startBurstCapture` -- **Where:** `OpenLoopViewModel.kt:150`. -- **Why it matters:** ANDROID_STANDARDS §3 — catch specific types; a bare `Exception` swallows programming errors as a silent state reset. -- **Fix:** **Web-search the exact throwables** of `PendingRecording.prepareRecording`/`Recording.start()` (and the audio-permission path) on `developer.android.com` before narrowing — likely `IllegalStateException`, `IllegalArgumentException`, `SecurityException`, `IOException`. Catch those explicitly; let anything unexpected propagate. -- **Covering test (`OpenLoopViewModelTest.kt`, JVM unit):** `every { cameraManager.startRecording(any(), any()) } throws IllegalStateException("camera busy")`, call `startBurstCapture`, assert it recovers to `ReadyToCapture` with `recordingElapsedMs == 0L` and no leaked timer. - -> **Not in this prompt — "Loopify" no-op button (`PreviewScreen.kt:105-106`).** The review flagged it, but it is an **intentional** slice-02 placeholder: slice 02 routes `Finalize` success to the Trim screen and repoints this button (see `boomerang-slice-02-kickoff.md` / `02-auto-route-trim-and-default-save.md`, plus the Lesson 012 §slice-02 hand-off). **Do not modify it.** In the PR response comment, mark this finding **ACKNOWLEDGED — intentional, deferred to slice 02.** - ---- - -## Phase 3 — Verify (Definition of Done gate) - -Do **not** call this done because it compiles. Clear `docs/DEFINITION_OF_DONE.md`: - -1. **Baseline first:** confirm `main`/branch is green before your changes (so a pre-existing failure isn't blamed on you). -2. **Clean build, debug AND release:** `./gradlew assembleDebug assembleRelease` → `BUILD SUCCESSFUL`, **exit code 0**, **zero `e:` lines**. Do not trust a `| tail`-masked exit code (Lesson on masked exits). -3. **16 KB alignment:** `zipalign -c -P 16 -v 4` shows `(OK)` per native `.so` (Lesson 011 — must be the *uncompressed* pass, not "OK - compressed"). -4. **Unit tests:** `./gradlew testDebugUnitTest` — 0 failures, including the new REC-2/REC-3 ViewModel tests. -5. **Instrumented tests:** `./gradlew connectedDebugAndroidTest` — 0 failures, including the new WARNING-1/2/3 + updated REC-1 Compose tests. (Boot the AVD with `-memory 4096`; a default-RAM AVD OOM-kills the instrumentation process and reports a false "Process crashed" — Lesson 012 hand-off note. Don't run `gradlew` while Android Studio is syncing the same project — Gradle build-lock deadlock.) -6. **Run the app on an emulator, launch it, capture a screenshot as proof.** Confirm via a visible marker that the running build is current (e.g. the `30s` shutter badge) so you're not debugging a stale APK. Manually walk: record → ring/chip update → **press back mid-record** (WARNING-2 behaves per D1) → tap the gallery button (WARNING-3 still opens gallery) → preview (Loopify stays the untouched slice-02 placeholder). -7. **Honest coverage:** state what could NOT be auto-verified (e.g. the on-Fold `ERROR_SOURCE_INACTIVE` path stays manual; REC-1's recomposition win if you skipped the counter test) and give a short manual-QA checklist. Attach the screenshot to the PR. - -## Phase 4 — Commit, push, respond, re-review - -- Commit on `feature/boomerang-slice-01-variable-length` with focused messages (one logical fix per commit is fine). End commit messages with the project's `Co-Authored-By` trailer per `CLAUDE.md`. Push to the PR branch. -- Post a **PR Review Response — Fixes Applied** comment using the template in `docs/prompts/PR-FEEDBACK-RESOLUTION.md` Phase 4: per finding, the action taken, `file:line`, the Google doc you verified against, the **test that now covers it**, and status RESOLVED / ACKNOWLEDGED / DISPUTED. Mark the Loopify REC as ACKNOWLEDGED (intentional slice-02 placeholder). Note any D1/D2 assumptions made without Steven. -- Then run the **`pr-reviewer` skill** against PR #19 again (its 5 phases) for a fresh report. Goal: **zero FAILs** and the prior WARNINGs cleared. Repeat until clean. - -## Behavioral rules - -- **Web-search before every API/standard claim** — especially `Recording.start()` throwables (REC-3), `BackHandler` dispatcher resolution (WARNING-2), and the 48dp rule (WARNING-3). -- **Respect the Decision Log + Lesson 012 hand-off** — WARNING-1 *is* Decision Log #1; the Loopify button is an intentional slice-02 placeholder, leave it alone. -- **Every behavior change ships with a test** — that's the point of this prompt. If a fix genuinely can't be unit/instrumented-tested (e.g. REC-1's recomposition count), say so explicitly and cover it with a manual-QA line rather than a flaky assert. -- **Don't reopen the Lesson 012 bug** — keep exactly one `CameraScreen(` call site reachable during capture; never remount a camera-bound composable mid-recording. -- **If any fix grows past its slice** (e.g. `Processing` UI balloons into real slice-02 work), stop and flag it — these are review-cleanup fixes, not new features. diff --git a/images/build_feature_graphic.py b/images/build_feature_graphic.py deleted file mode 100644 index e4ae8cd..0000000 --- a/images/build_feature_graphic.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Build the 1024x500 Play Store Feature Graphic for OpenLoop.""" -from __future__ import annotations -from pathlib import Path -from PIL import Image, ImageDraw, ImageFont, ImageFilter - -W, H = 1024, 500 -CANVAS = (10, 10, 12) -TEXT_PRIMARY = (243, 243, 246) -TEXT_SECONDARY = (173, 173, 184) -LIME = (205, 255, 79) -AQUA = (52, 225, 213) - -# Use relative paths so it works across environments -ROOT = Path(__file__).parent.parent -FONT_SPACE_GROTESK = str(ROOT / "app/src/main/res/font/space_grotesk.ttf") -FONT_INTER = str(ROOT / "app/src/main/res/font/inter.ttf") -LOGO_FOREGROUND = str(ROOT / "app/src/main/res/drawable-nodpi/ic_launcher_foreground.png") -OUT = ROOT / "docs/play-store/feature_graphic_1024x500.png" - -def load_variable_font(path: str, size: int, weight: int) -> ImageFont.FreeTypeFont: - f = ImageFont.truetype(path, size) - try: - f.set_variation_by_axes([weight]) - except Exception: - pass - return f - -# Background -bg = Image.new("RGB", (W, H), CANVAS) -glow = Image.new("RGBA", (W, H), (0, 0, 0, 0)) -gd = ImageDraw.Draw(glow) -gd.ellipse((-120, 70, 520, 700), fill=LIME + (28,)) -gd.ellipse((520, -150, 1180, 480), fill=AQUA + (22,)) -glow = glow.filter(ImageFilter.GaussianBlur(radius=80)) -bg_rgba = bg.convert("RGBA") -bg_rgba.alpha_composite(glow) - -# Logo -logo = Image.open(LOGO_FOREGROUND).convert("RGBA") -LOGO_SIZE = 420 -logo_resized = logo.resize((LOGO_SIZE, LOGO_SIZE), Image.LANCZOS) -bg_rgba.alpha_composite(logo_resized, dest=(30, (H - LOGO_SIZE) // 2)) - -# Text -draw = ImageDraw.Draw(bg_rgba) -wordmark_font = load_variable_font(FONT_SPACE_GROTESK, 96, 700) # Bold -tagline_font = load_variable_font(FONT_INTER, 32, 500) # Medium - -text_x = 490 -wm_bbox = draw.textbbox((0, 0), "OpenLoop", font=wordmark_font) -wm_h = wm_bbox[3] - wm_bbox[1] -tag_bbox = draw.textbbox((0, 0), "Point. Tap. Loop.", font=tagline_font) -tag_h = tag_bbox[3] - tag_bbox[1] -block_h = wm_h + 24 + tag_h -block_top = (H - block_h) // 2 - 16 - -draw.text((text_x, block_top - wm_bbox[1]), "OpenLoop", - font=wordmark_font, fill=TEXT_PRIMARY) - -under_y = block_top + wm_h + 14 -under_x0, under_x1 = text_x, text_x + 220 -for i in range(under_x1 - under_x0): - t = i / max(1, (under_x1 - under_x0 - 1)) - r = int(LIME[0] + (AQUA[0] - LIME[0]) * t) - g = int(LIME[1] + (AQUA[1] - LIME[1]) * t) - b = int(LIME[2] + (AQUA[2] - LIME[2]) * t) - draw.rectangle((under_x0 + i, under_y, under_x0 + i + 1, under_y + 6), fill=(r, g, b)) - -tag_y = under_y + 6 + 20 -draw.text((text_x, tag_y - tag_bbox[1]), "Point. Tap. Loop.", - font=tagline_font, fill=TEXT_SECONDARY) - -final = bg_rgba.convert("RGB") -final.save(OUT, "PNG", optimize=True) - -import os -print(f"wrote {OUT} ({W}x{H}, {os.path.getsize(OUT)/1024:.1f} KB, mode={final.mode})") diff --git a/images/build_play_store_icon.py b/images/build_play_store_icon.py deleted file mode 100644 index 03688a0..0000000 --- a/images/build_play_store_icon.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Build the 512x512 Play Store listing icon. - -Spec (Play Console, 2026): -- 512x512 PNG, 32-bit color -- NO transparency (solid background) -- < 1024 KB -- Play auto-applies a 30% corner radius at display time -> we ship a square. -- Safe area: keep critical art well inside the central ~70% (Play's display crop + - rounded corners can clip near the edges on some surfaces). -""" -from __future__ import annotations -from pathlib import Path -from PIL import Image - -# OpenLoop brand surface (matches the SurfaceContainerHigh / Canvas tokens in Color.kt) -CANVAS_BG = (10, 10, 12) # #0A0A0C — matches Canvas token - -SIZE = 512 -SAFE_FRACTION = 0.78 # logo occupies ~78% of canvas width - -# Use relative paths so it works across environments -ROOT = Path(__file__).parent.parent -src_foreground = ROOT / "app/src/main/res/drawable-nodpi/ic_launcher_foreground.png" -dst = ROOT / "docs/play-store/play_store_icon_512.png" - -# 1) Solid dark square background — no transparency, no rounded corners -bg = Image.new("RGB", (SIZE, SIZE), CANVAS_BG) - -# 2) Load the recolored foreground (1080x1080 with transparent bg) and downscale -fg = Image.open(src_foreground).convert("RGBA") -target_w = int(SIZE * SAFE_FRACTION) -fg_resized = fg.resize((target_w, target_w), Image.LANCZOS) - -# 3) Center it -offset = ((SIZE - target_w) // 2, (SIZE - target_w) // 2) -# Composite RGBA over RGB by converting bg to RGBA first -bg_rgba = bg.convert("RGBA") -bg_rgba.alpha_composite(fg_resized, dest=offset) - -# 4) Flatten back to RGB (no alpha) and save -out = bg_rgba.convert("RGB") -out.save(dst, "PNG", optimize=True) - -# 5) Sanity check size / format -import os -size_kb = os.path.getsize(dst) / 1024 -print(f"wrote {dst} ({SIZE}x{SIZE}, {size_kb:.1f} KB, mode={out.mode})") -assert size_kb < 1024, f"Over 1024 KB: {size_kb:.1f}" diff --git a/images/feature_graphic_1024x500.png b/images/feature_graphic_1024x500.png deleted file mode 100644 index 11263db..0000000 Binary files a/images/feature_graphic_1024x500.png and /dev/null differ diff --git a/images/ic_launcher_foreground_preview_1080.png b/images/ic_launcher_foreground_preview_1080.png deleted file mode 100644 index 42c8215..0000000 Binary files a/images/ic_launcher_foreground_preview_1080.png and /dev/null differ diff --git a/images/ic_launcher_foreground_recolored_1080.png b/images/ic_launcher_foreground_recolored_1080.png deleted file mode 100644 index 4198624..0000000 Binary files a/images/ic_launcher_foreground_recolored_1080.png and /dev/null differ diff --git a/images/ic_launcher_on_black_preview_1080.png b/images/ic_launcher_on_black_preview_1080.png deleted file mode 100644 index 5c99abd..0000000 Binary files a/images/ic_launcher_on_black_preview_1080.png and /dev/null differ diff --git a/images/ic_launcher_on_black_recolored_1080.png b/images/ic_launcher_on_black_recolored_1080.png deleted file mode 100644 index b3c9be2..0000000 Binary files a/images/ic_launcher_on_black_recolored_1080.png and /dev/null differ diff --git a/images/originals/ic_launcher.png b/images/originals/ic_launcher.png deleted file mode 100644 index 5e9876b..0000000 Binary files a/images/originals/ic_launcher.png and /dev/null differ diff --git a/images/originals/ic_launcher_foreground.png b/images/originals/ic_launcher_foreground.png deleted file mode 100644 index bff09d9..0000000 Binary files a/images/originals/ic_launcher_foreground.png and /dev/null differ diff --git a/images/originals/ic_launcher_round.png b/images/originals/ic_launcher_round.png deleted file mode 100644 index 5e9876b..0000000 Binary files a/images/originals/ic_launcher_round.png and /dev/null differ diff --git a/images/play_store_icon_512.png b/images/play_store_icon_512.png deleted file mode 100644 index 5fb71c5..0000000 Binary files a/images/play_store_icon_512.png and /dev/null differ diff --git a/images/play_store_icon_512_preview_circle.png b/images/play_store_icon_512_preview_circle.png deleted file mode 100644 index 0160891..0000000 Binary files a/images/play_store_icon_512_preview_circle.png and /dev/null differ diff --git a/images/play_store_icon_512_preview_rounded30.png b/images/play_store_icon_512_preview_rounded30.png deleted file mode 100644 index 3a8faa7..0000000 Binary files a/images/play_store_icon_512_preview_rounded30.png and /dev/null differ diff --git a/images/recolor_logo.py b/images/recolor_logo.py deleted file mode 100644 index 47555b5..0000000 --- a/images/recolor_logo.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Hue-targeted recolor for composite launcher PNGs. - -The card has a slight dark-purple tint (sat ~0.18). The infinity uses -the same purple family + coral. We need to recolor the infinity + its -halo without disturbing the card's overall darkness. - -Strategy: detect "logo-hue" pixels (originally purple ~270° or coral ~5°) -AND require either some chroma OR meaningfully-above-card luminance. -Tint weight ramps with how confidently we think the pixel is "logo". -""" -from __future__ import annotations -import sys -from pathlib import Path -from PIL import Image -import numpy as np - -LIME = np.array([205, 255, 79], dtype=np.float32) -AQUA = np.array([52, 225, 213], dtype=np.float32) - -def recolor(src_path: Path, dst_path: Path) -> None: - src = Image.open(src_path).convert("RGBA") - arr = np.asarray(src, dtype=np.float32) - h, w, _ = arr.shape - rgb = arr[..., :3] - alpha = arr[..., 3:4] - r = rgb[..., 0]; g = rgb[..., 1]; b = rgb[..., 2] - - lum = (0.2126*r + 0.7152*g + 0.0722*b) / 255.0 - - # Compute saturation - mx = rgb.max(axis=2); mn = rgb.min(axis=2) - sat = np.where(mx > 1e-6, (mx - mn) / mx, 0.0) - - # Detect "logo-ness": pixel is either bright OR strongly chromatic in a - # red/purple direction. The card is dark grey-purple — moderate sat but - # very low brightness. The infinity body + halo is brighter. - # Gate combines luminance lift above card baseline + saturation. - bright = np.clip((lum - 0.18) / 0.20, 0.0, 1.0) # 0 at card, 1 by lum~0.38 - chroma = np.clip((sat - 0.30) / 0.30, 0.0, 1.0) # 0 at card sat ~0.18, 1 at body - tint_weight = np.maximum(bright, chroma) # either signal is enough - tint_weight = tint_weight[..., None] - - # Horizontal gradient based on chromatic bounding box - chrom_mask = (sat > 0.30) & (lum > 0.2) - xs = np.where(chrom_mask.any(axis=0))[0] - x_min, x_max = (int(xs.min()), int(xs.max())) if len(xs) else (0, w-1) - x_coords = np.arange(w, dtype=np.float32) - t = np.clip((x_coords - x_min) / max(1.0, (x_max - x_min)), 0.0, 1.0) - tint = LIME[None, :] + (AQUA - LIME)[None, :] * t[:, None] - tint = np.broadcast_to(tint[None, :, :], (h, w, 3)) - - lum3 = lum[..., None] - base = tint * lum3 - highlight_mix = np.clip((lum3 - 0.55) / 0.45, 0.0, 1.0) - tinted = base + (255.0 - base) * (highlight_mix * 0.85) - - out_rgb = rgb * (1.0 - tint_weight) + tinted * tint_weight - out_rgb = np.clip(out_rgb, 0, 255) - - out = np.concatenate([out_rgb, alpha], axis=2).astype(np.uint8) - Image.fromarray(out, "RGBA").save(dst_path, "PNG") - print(f"wrote {dst_path}") - -if __name__ == "__main__": - recolor(Path(sys.argv[1]), Path(sys.argv[2])) diff --git a/images/recolor_simple.py b/images/recolor_simple.py deleted file mode 100644 index bc501c0..0000000 --- a/images/recolor_simple.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Full-tint recolor (no saturation gate) — for images whose background is transparent.""" -from __future__ import annotations -import sys -from pathlib import Path -from PIL import Image -import numpy as np - -LIME = np.array([205, 255, 79], dtype=np.float32) -AQUA = np.array([52, 225, 213], dtype=np.float32) - -def recolor(src_path: Path, dst_path: Path) -> None: - src = Image.open(src_path).convert("RGBA") - arr = np.asarray(src, dtype=np.float32) - h, w, _ = arr.shape - rgb = arr[..., :3] - alpha = arr[..., 3:4] - lum = (0.2126*rgb[..., 0] + 0.7152*rgb[..., 1] + 0.0722*rgb[..., 2]) / 255.0 - lum = lum[..., None] - mask = arr[..., 3] > 8 - xs = np.where(mask.any(axis=0))[0] - x_min, x_max = (int(xs.min()), int(xs.max())) if len(xs) else (0, w-1) - x_coords = np.arange(w, dtype=np.float32) - t = np.clip((x_coords - x_min) / max(1.0, (x_max - x_min)), 0.0, 1.0) - tint = LIME[None, :] + (AQUA - LIME)[None, :] * t[:, None] - tint = np.broadcast_to(tint[None, :, :], (h, w, 3)) - base = tint * lum - highlight_mix = np.clip((lum - 0.55) / 0.45, 0.0, 1.0) - out_rgb = base + (255.0 - base) * (highlight_mix * 0.85) - out_rgb = np.clip(out_rgb, 0, 255) - out = np.concatenate([out_rgb, alpha], axis=2).astype(np.uint8) - Image.fromarray(out, "RGBA").save(dst_path, "PNG") - print(f"wrote {dst_path}") - -if __name__ == "__main__": - recolor(Path(sys.argv[1]), Path(sys.argv[2])) diff --git a/images/render_logo.py b/images/render_logo.py deleted file mode 100644 index 2329e13..0000000 --- a/images/render_logo.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Render the OpenLoop neon-infinity launcher icon in the new color schema. - -Silhouette: two open circles overlapping slightly so they read as a figure-8 with -open lobe interiors (matches Steven's POC). Per-pixel gradient places Electric -Lime on the left circle, Aqua on the right, blended through the crossover. -""" -from __future__ import annotations -import math, sys -from pathlib import Path -from PIL import Image, ImageDraw, ImageFilter - -LIME = (205, 255, 79) # #CDFF4F -AQUA = (52, 225, 213) # #34E1D5 - -def lerp(a, b, t): - t = max(0.0, min(1.0, t)) - return (int(a[0]+(b[0]-a[0])*t), int(a[1]+(b[1]-a[1])*t), int(a[2]+(b[2]-a[2])*t)) - -def circle_points(cx, cy, r, n): - return [(cx + r*math.cos(2*math.pi*i/n), cy + r*math.sin(2*math.pi*i/n)) for i in range(n)] - -def stroke_tube(size, pts, base_radius, sample_color): - img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) - draw = ImageDraw.Draw(img) - r = base_radius - for x, y in pts: - rgb = sample_color(x, y) - draw.ellipse((x - r, y - r, x + r, y + r), fill=rgb + (255,)) - return img - -def render(size: int) -> Image.Image: - cx = cy = size / 2 - ring_r = size * 0.20 # radius of each lobe circle - offset = ring_r * 0.95 # horizontal offset of lobe centers (slight overlap) - tube_radius = size * 0.045 # tube thickness - - left_c = (cx - offset, cy) - right_c = (cx + offset, cy) - left_edge = cx - offset - ring_r - right_edge = cx + offset + ring_r - - def grad_color(x, _y): - t = (x - left_edge) / (right_edge - left_edge) - return lerp(LIME, AQUA, t) - - n = int(size * 4) - pts = circle_points(*left_c, ring_r, n) + circle_points(*right_c, ring_r, n) - - # Wide soft outer halo - halo_wide = stroke_tube(size, pts, tube_radius * 3.0, grad_color) - halo_wide = halo_wide.filter(ImageFilter.GaussianBlur(radius=size * 0.06)) - hw = halo_wide.split()[3].point(lambda a: int(a * 0.55)) - halo_wide.putalpha(hw) - - # Tight bright halo - halo_tight = stroke_tube(size, pts, tube_radius * 1.6, grad_color) - halo_tight = halo_tight.filter(ImageFilter.GaussianBlur(radius=size * 0.018)) - ht = halo_tight.split()[3].point(lambda a: min(255, int(a * 1.15))) - halo_tight.putalpha(ht) - - # Saturated tube body - body = stroke_tube(size, pts, tube_radius, grad_color) - body = body.filter(ImageFilter.GaussianBlur(radius=size * 0.0015)) - - canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0)) - canvas = Image.alpha_composite(canvas, halo_wide) - canvas = Image.alpha_composite(canvas, halo_tight) - canvas = Image.alpha_composite(canvas, body) - return canvas - -def render_preview_on_black(logo): - black = Image.new("RGBA", logo.size, (0, 0, 0, 255)) - return Image.alpha_composite(black, logo) - -if __name__ == "__main__": - size = int(sys.argv[1]) if len(sys.argv) > 1 else 1080 - out_dir = Path(sys.argv[2]) if len(sys.argv) > 2 else Path("/sessions/busy-brave-cannon/mnt/outputs") - out_dir.mkdir(parents=True, exist_ok=True) - logo = render(size) - logo.save(out_dir / f"ic_launcher_foreground_preview_{size}.png", "PNG") - render_preview_on_black(logo).save(out_dir / f"ic_launcher_on_black_preview_{size}.png", "PNG") - print("done", size) diff --git a/trimming-loop.md b/trimming-loop.md deleted file mode 100644 index 649627e..0000000 --- a/trimming-loop.md +++ /dev/null @@ -1,134 +0,0 @@ -# “Trimming..” stuck / preview reverse failures — postmortem - -Users saw a full-screen **“Trimming..”** overlay after leaving the Trim screen (or tapping Speed/Loop/Filter into the editor). **Save stayed disabled.** On some devices (notably **Samsung S24+** in Brazil) the overlay never cleared and **never showed “Couldn’t loop that clip”** — even after waiting many minutes. - -**That overlay is not a separate trim step.** It means the app is building a **reversed MP4** for the default boomerang mode (`FORWARD_THEN_REVERSE`) via a two-pass `MediaCodec` pipeline in `VideoReverser`. - -| `EditorLoadingKind` | UI copy | -|---------------------|---------| -| `TRIMMING` | **Trimming..** — first entry from Trim into the editor | -| `LOOPIFYING` | **Loopifying..** — later reverse kicks (mode change, return from Trim) | - ---- - -## Fix timeline (releases & PRs) - -| Release / PR | What it addressed | -|--------------|-------------------| -| [#50](https://github.com/stozo04/OpenLoop/pull/50) — **1.0.3** (`versionCode` 4) | Library import seek wedge, 30 fps pass-1 cap, MediaCodec buffer lifecycle, encoder ranking, ViewModel stale loading, **120 s `withTimeoutOrNull`** (often **did not unblock UI** on wedged codecs) | -| [#51](https://github.com/stozo04/OpenLoop/pull/51) — **1.0.5** (`versionCode` 5) | **Hard UI deadline** (`select` + `onTimeout`), HEVC/HDR pre-normalize, Media3 **decoder fallback**, vendor encoder preference, **Send debug info**, Crashlytics `recordException` | - ---- - -## Root cause (why “Trimming..” never ended) - -Several independent failures stacked; Samsung **camera + gallery** hits more than one. - -### 1. Heavy preview reverse (expected slowness) - -Pass 1 re-encodes the trim window so **every kept frame is a keyframe** (required for frame-by-frame reverse in pass 2). Library and phone-export clips are **adversarial**: HEVC, HDR, dense 60 fps samples, odd sync points ([lesson 020](docs/lessons_learned/020-imported-clips-hdr-codec-and-reverse-failure-recovery.md)). - -Symptom: **Trimming..** for 30–120+ seconds on a slow software encoder (`c2.google.avc.encoder`). - -### 2. `withTimeoutOrNull` did not unblock the UI (critical) - -Kotlin/Android guidance ([`withTimeout` docs](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/with-timeout.html), [coroutines best practices](https://developer.android.com/kotlin/coroutines/coroutines-best-practices)): **cancellation is cooperative**. If `MediaCodec` blocks in native code, the timeout coroutine **may never return**, so: - -- `reverseFailed` is never set -- **“Couldn’t loop that clip”** never appears -- **Trimming..** stays forever - -This matches reports: testers waited **much longer than 2 minutes** with no failure screen. - -**Fix (PR #51):** Race preview reverse with `select` + `onTimeout`, return immediately on deadline, **`recordException`-ready failure path**, do **not** wait for the wedged worker to finish cancelling. - -### 3. Samsung / OEM codecs - -- CameraX `Quality.HD` does not force H.264; Samsung often records **HEVC and/or HDR** at ≤1080p — previously **skipped** the >1080p pre-scale path and went straight into raw `VideoReverser`. -- Media3 `Transformer` defaults **disable decoder fallback**; HW decoder init can fail on HEVC/high profile ([androidx/media#2189](https://github.com/androidx/media/issues/2189), [#2751](https://github.com/androidx/media/issues/2751)). - -**Fix (PR #51):** Pre-normalize HEVC/HDR via Transformer before reverse; `DefaultDecoderFactory.setEnableDecoderFallback(true)` on Transformer; prefer Exynos/SEC/QTI encoders over Google software AVC. - -### 4. Older bug families (PR #50) - -| Issue | Effect | -|--------|--------| -| Pass-1 seek before trim start → zero frames | Immediate EOS / hung loop | -| Frame skip without queueing decoder buffers | `CodecException` — “Could not loop” | -| `ensureReversedSegment` restarted active job | Progress discarded | -| Stale `previewLoading` with no job | TRIMMING forever | - ---- - -## Architecture (how the overlay is wired) - -```mermaid -flowchart TD - Trim[Trim → editor] --> VM["ensureReversedSegment(TRIMMING)"] - VM --> Set["previewLoading = TRIMMING"] - VM --> Worker["async(IO): VideoProcessor.ensureReversed()"] - Worker --> Norm{HEVC/HDR or >1080p?} - Norm -->|yes| M3[Media3 Transformer normalize] - Norm -->|no| VR["VideoReverser.reverse()"] - M3 --> VR - Worker --> Select["select: onAwait vs onTimeout 120s"] - Select -->|success| OK["reversedFile set, clear overlay"] - Select -->|timeout/fail| Fail["reverseFailed + retry + diagnostics"] - Fail --> Share[Send debug info] - Fail --> CL[Crashlytics recordException] -``` - -Save is disabled while `awaitingReverse` (reverse mode, no `reversedFile`, no failure yet). - -Key files: - -- `OpenLoopViewModel.kt` — `ensureReversedSegment`, timeout, failure state -- `VideoProcessor.kt` — `prepareReverseInput`, Transformer decoder fallback -- `VideoReverser.kt` — two-pass reverse, encoder selection, logging -- `BoomerangEditorScreen.kt` — overlay, retry, Send debug info -- `diagnostics/ReverseCrashlytics.kt` — non-fatal reporting - ---- - -## Diagnostics (testers without Android Studio) - -| Path | When | What you get | -|------|------|----------------| -| **Send debug info** (in-app) | After **Couldn’t loop that clip** | Immediate plain-text (device, version, mime, trim) | -| **Firebase Crashlytics** | Same failure; tester reopens app | Non-fatal in console with custom keys — [setup & viewing](docs/diagnostics/firebase-crashlytics-trimming.md) | -| **Take bug report** (Developer options) | Stuck on old build / no Firebase | Full system zip; heavy for users | -| **adb logcat / bugreport** | You or a technical helper | Best codec detail; needs platform-tools | - -Do **not** rely on Play Store “Logcat Reader” apps for average testers — most need a PC `adb` setup first. - ---- - -## Regression QA - -1. Install build **≥ 1.0.5** with `google-services.json` if testing Crashlytics. -2. **Camera** and **gallery import** on a Samsung (or HEVC/HDR sample). -3. Trim → enter editor → note **Trimming..**. -4. **Healthy:** Overlay clears; preview loops; Save enables (may take up to ~2 min on slow devices). -5. **Failure:** Within **~120 s**, **Couldn’t loop that clip** + **Try again** + **Send debug info** — not infinite Trimming. -6. After failure: tester force-stops app, reopens → verify non-fatal in Firebase (if configured). -7. Logcat (optional): `VideoReverser`, `OpenLoopViewModel`, tags `reverse start`, `reverse pass1`, `reverse pass2`. - -Unit tests (no real MediaCodec): -`./gradlew :app:testDebugUnitTest --tests "io.github.stozo04.openloop.ui.OpenLoopViewModelTest.reverse*"` - ---- - -## One-sentence summary - -**Trimming..** meant preview reverse was running; Samsung and library clips often use **HEVC/HDR** and **slow or wedged MediaCodec** work, and **`withTimeoutOrNull` could wait forever on native code**, so the UI never reached the failure screen — fixed by a **non-blocking timeout**, **normalize + decoder fallback**, and **Crashlytics / Send debug info** for remote triage. - ---- - -## Related docs - -- [**Engineering handoff (full)**](docs/active/editor-trimming-overlay-stuck/ENGINEERING-HANDOFF.md) — start here for next engineer / agent -- [Firebase Crashlytics & tester instructions](docs/diagnostics/firebase-crashlytics-trimming.md) -- [Earlier investigation notes](docs/active/editor-trimming-overlay-stuck/HANDOFF.md) -- [Lesson 020 — HDR / imports](docs/lessons_learned/020-imported-clips-hdr-codec-and-reverse-failure-recovery.md) -- [Lesson 021 — no downscale inside VideoReverser](docs/lessons_learned/021-reverse-downscale-surface-mismatch.md) -- [Reverse video research](docs/active/boomerang-rollout/RESEARCH-reverse-video.md)