diff --git a/.claude/skills/run-e2e-pixel-sweep/SKILL.md b/.claude/skills/run-e2e-pixel-sweep/SKILL.md index c9a316e..c617bdd 100644 --- a/.claude/skills/run-e2e-pixel-sweep/SKILL.md +++ b/.claude/skills/run-e2e-pixel-sweep/SKILL.md @@ -1,23 +1,25 @@ --- name: run-e2e-pixel-sweep description: >- - Run the repeatable OpenLoop 3-emulator E2E sweep (Pixel 6, Pixel 8, Pixel 10 Pro Fold) using - the canonical imported test video: build once, then per device cold-boot → scripted - import → trim → speed → all four loop directions (real reverse previews) → B&W look → save → - share sheet → gallery playback, followed by a programmatic output-quality gate (per-half fps, - mirror SSIM, freeze/green/black scans) and a logcat scan. Use whenever the user says "pixel - sweep", "run the 3-device e2e", "/run-e2e-pixel-sweep", "regression-test the boomerang flow - on the emulators", or wants proof a media-pipeline change didn't break the import→save path - on Google devices. Scripts compute every gesture from uiautomator bounds, so they run - unmodified on any screen size. + Run the repeatable OpenLoop 4-emulator E2E sweep (Pixel 6, Pixel 8, Pixel 10 Pro Fold, Pixel 8 + API 34) using the canonical imported test video: build once, then per device cold-boot → + scripted import → trim → speed → all four loop directions (real reverse previews) → B&W look → + save → share sheet → gallery playback, followed by a programmatic output-quality gate (per-half + fps, mirror SSIM, freeze/green/black scans) and a logcat scan. Use whenever the user says + "pixel sweep", "run the 4-device e2e", "/run-e2e-pixel-sweep", "api 34 fgs regression", + "samsung rtl sweep", "/samsung-rtl-sweep", "regression-test the boomerang flow on the emulators", + or wants proof a media-pipeline or foreground-service change didn't break the import→save path + on Google devices and Android 14. Scripts compute every gesture from uiautomator bounds, so they + run unmodified on any screen size. --- -# run-e2e-pixel-sweep — the repeatable 3-emulator OpenLoop sweep +# run-e2e-pixel-sweep — the repeatable 4-emulator OpenLoop sweep Proven end-to-end on 2026-06-04 (fold-loop iterations 1–2 + scripted validation runs on -Pixel 6 and the Fold). One sweep ≈ 10 minutes/device, ~30 minutes total, fully scripted — -your job is to run the phases, read the PASS/FAIL lines, and apply judgment where the scripts -explicitly hand it back (borderline SSIM, any FAIL, churn growth). +Pixel 6 and the Fold) and 2026-06-22 (API-34 FGS fix verification on `Pixel_8_API34`). +One sweep ≈ 10 minutes/device, ~40 minutes total, fully scripted — your job is to run the +phases, read the PASS/FAIL lines, and apply judgment where the scripts explicitly hand it back +(borderline SSIM, any FAIL, churn growth). `` = this directory. Sibling skill `run-e2e` provides `scripts/scan-logcat.ps1` and `scripts/uiauto.ps1` (manual driving / debugging) — reuse them, don't duplicate. @@ -34,8 +36,11 @@ tell the user; do not substitute another video. ## Preconditions (verify, never assume) -- AVDs `Pixel_6`, `Pixel_8`, `Pixel_10_Pro_Fold` exist +- AVDs `Pixel_6`, `Pixel_8`, `Pixel_10_Pro_Fold`, and **`Pixel_8_API34`** exist (`& "$env:LOCALAPPDATA\Android\Sdk\emulator\emulator.exe" -list-avds`). + If `Pixel_8_API34` is missing, create it: + `pwsh \scripts\create-api34-avd.ps1` (requires the + `system-images;android-34;google_apis_playstore;x86_64` SDK image). - ffmpeg/ffprobe on PATH (`winget install Gyan.FFmpeg` if not). - `$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr"`. - No emulator already running (`adb devices` empty) — sweep-prep refuses otherwise. @@ -45,11 +50,19 @@ tell the user; do not substitute another video. ## The sweep (run for each AVD, sequentially, in this order) -Order: `Pixel_6` → `Pixel_8` → `Pixel_10_Pro_Fold`. One emulator at a time — parallel -emulators fight over host CPU/codecs and fake contention bugs. +Order: `Pixel_6` → `Pixel_8` → `Pixel_10_Pro_Fold` → **`Pixel_8_API34`**. One emulator at a +time — parallel emulators fight over host CPU/codecs and fake contention bugs. + +**Why `Pixel_8_API34` last:** it runs **Android 14 (API 34)** — the OS level where +`FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING` (8192) is *not recognized* and v1.0.23 crashed every +Loopify save with `InvalidForegroundServiceTypeException: Starting FGS with type unknown` +(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. ```powershell -$avd = "Pixel_6" # then Pixel_8, then Pixel_10_Pro_Fold +$avd = "Pixel_6" # then Pixel_8, then Pixel_10_Pro_Fold, then Pixel_8_API34 $dir = "$env:TEMP\openloop_sweep\$avd" # 1. Cold-boot + install (fresh app state) + push video + start logcat capture + launch. @@ -73,6 +86,48 @@ adb -s $serial emu kill A device passes when: all drive-flow steps PASS + quality gate PASS + scan shows **0 in every CRASH and TIMEOUT row**. Anything else is a finding — root-cause it (see "Reading failures"). +## OEM regression lanes (API 34 / Samsung / LG) + +Not all OEM bugs reproduce on stock emulators. Use the right lane: + +| Lane | What it catches | How to run | Emulator? | +|------|-----------------|------------|-----------| +| **API 34 FGS** | `InvalidForegroundServiceTypeException` on Android 14 saves (9663c743) | 4th device in sweep: `Pixel_8_API34` | ✅ Stock AVD | +| **Samsung codec + preview cap** | Exynos/QC wedging, 480p preview cap, `c2.google` encoder order | **Samsung RTL sweep** (real Galaxy) | ❌ Use RTL | +| **Samsung app logic (identity)** | `isSamsungDevice()` branches, preview cap, encoder ordering policy | JVM: `DeviceMediaHintsOemRobolectricTest` + `SamsungReversePreviewRegressionTest` | ✅ Robolectric | +| **LG `start failed` recovery** | Hardware codec rejects `start()` → software fallback (47233ad7) | Instrumented: `VideoReverserTest.reverse_recoversFromCodecStartFailure_viaSoftwareFallback` | ✅ Any device/emulator | + +**Do NOT** add fake `Pixel_8_Samsung` AVDs — Google Play images ignore `-prop ro.product.manufacturer` +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, +start RDB, verify `adb devices` shows `localhost:`. + +```powershell +$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr" +& "\gradlew.bat" :app:assembleDebug +pwsh \scripts\samsung-rtl-sweep.ps1 -ArtifactDir $env:TEMP\openloop_rtl\s23 +# Optional: -Serial localhost:52172 (port changes every RTL session) +``` + +Pass = drive-flow PASS + quality-gate PASS + scan **0 CRASH/TIMEOUT** + grep shows +`ensureReversed.ok` and `Worker result SUCCESS … BoomerangRenderWorker`. + +### LG instrumented lane (headless on emulator) + +LG has no public RTL. The recovery path is fault-injected on any attached device: + +```powershell +& "\gradlew.bat" :app:assembleDebug :app:installDebug +adb shell am instrument -w -r ` + -e class io.github.stozo04.openloop.media.VideoReverserTest#reverse_recoversFromCodecStartFailure_viaSoftwareFallback ` + io.github.stozo04.openloop.test/androidx.test.runner.AndroidJUnitRunner +``` + +Pass = `tests=1 failures=0`. Literal LM-X540 hardware still needs a physical device if available. + ## What the scripts verify (and what they hand back to you) | Phase | Hard checks | Judgment handed to the agent | @@ -95,6 +150,7 @@ device-specific Crashlytics issue is fixed from emulator evidence alone). | first-half fps ≈ 15, second ≈ 30 | Pass-1 subsampling regression (BUG-1 family) — check `reverse pass1:` log line for `skipped=` ≠ 0 on this 30 fps source. | | Moving-region macroblock smear in reversed half, static background clean | Compressed samples dropped before the decoder (broken P-frame references). Pull the cached reversed clip from `cache/scratch/reversed/` to isolate reverser vs Transformer. | | `MPEG4Writer … encoded 0 frames` + tiny output | Zero-frame completion (S23 family, PR #62 validator territory) — different from a timeout. | +| `InvalidForegroundServiceTypeException` / `Starting FGS with type unknown` on API 34 | **FGS type regression** — the worker is passing `mediaProcessing` (8192) on Android 14. Check `BoomerangRenderNotifications.foregroundServiceTypeForSdk` gates on `VANILLA_ICE_CREAM` (35), not `UPSIDE_DOWN_CAKE` (34). Only fires on `Pixel_8_API34`; the three API-35+ emulators won't catch it. | | `ChooserPreview: Could not read content://…fileprovider…` ×3 | Cosmetic — the OS share-sheet preview can't query FileProvider metadata. Ignore. | ## Field notes (each of these cost real time once — don't relearn them) diff --git a/.claude/skills/run-e2e-pixel-sweep/scripts/create-api34-avd.ps1 b/.claude/skills/run-e2e-pixel-sweep/scripts/create-api34-avd.ps1 new file mode 100644 index 0000000..5bdfd2d --- /dev/null +++ b/.claude/skills/run-e2e-pixel-sweep/scripts/create-api34-avd.ps1 @@ -0,0 +1,90 @@ +<# +.SYNOPSIS + Create (or verify) the Pixel_8_API34 AVD — Android 14 (API 34) regression target for + foreground-service-type bugs that only reproduce below API 35 (e.g. Crashlytics 9663c743). + +.USAGE + pwsh create-api34-avd.ps1 + +.NOTES + Requires the system image already installed: + system-images;android-34;google_apis_playstore;x86_64 + Install via Android Studio SDK Manager if missing. This script writes the AVD config files + directly (no avdmanager/cmdline-tools required). +#> +$ErrorActionPreference = 'Stop' +$avdId = 'Pixel_8_API34' +$avdHome = Join-Path $env:USERPROFILE '.android\avd' +$avdDir = Join-Path $avdHome "$avdId.avd" +$iniPath = Join-Path $avdHome "$avdId.ini" +$sdk = Join-Path $env:LOCALAPPDATA 'Android\Sdk' +$sysImg = Join-Path $sdk 'system-images\android-34\google_apis_playstore\x86_64' + +if (-not (Test-Path $sysImg)) { + Write-Error "API 34 system image missing: $sysImg — install via Android Studio SDK Manager." + exit 1 +} + +New-Item -ItemType Directory -Force $avdDir | Out-Null + +@( + "avd.ini.encoding=UTF-8", + "path=$avdDir", + "path.rel=avd\$avdId.avd", + 'target=android-34' +) | Set-Content -Encoding UTF8 $iniPath + +$config = @" +AvdId=$avdId +PlayStore.enabled=true +abi.type=x86_64 +avd.ini.displayname=Pixel 8 (API 34) +avd.ini.encoding=UTF-8 +disk.dataPartition.size=6G +fastboot.forceColdBoot=no +fastboot.forceFastBoot=yes +hw.accelerometer=yes +hw.arc=false +hw.audioInput=yes +hw.battery=yes +hw.camera.back=virtualscene +hw.camera.front=emulated +hw.cpu.arch=x86_64 +hw.cpu.ncore=4 +hw.dPad=no +hw.device.manufacturer=Google +hw.device.name=pixel_8 +hw.gps=yes +hw.gpu.enabled=yes +hw.gpu.mode=auto +hw.gyroscope=yes +hw.initialOrientation=portrait +hw.keyboard=yes +hw.lcd.density=420 +hw.lcd.height=2400 +hw.lcd.width=1080 +hw.mainKeys=no +hw.ramSize=2048 +hw.sdCard=yes +hw.sensors.light=yes +hw.sensors.magnetic_field=yes +hw.sensors.orientation=yes +hw.sensors.pressure=yes +hw.sensors.proximity=yes +hw.trackBall=no +image.sysdir.1=system-images\android-34\google_apis_playstore\x86_64\ +runtime.network.latency=none +runtime.network.speed=full +sdcard.size=512M +showDeviceFrame=yes +skin.dynamic=yes +skin.name=pixel_8 +skin.path=$sdk\skins\pixel_8 +tag.display=Google Play +tag.id=google_apis_playstore +target=android-34 +vm.heapSize=228 +"@ +$config | Set-Content -Encoding UTF8 (Join-Path $avdDir 'config.ini') + +Write-Host "AVD '$avdId' ready. Verify: emulator -list-avds" diff --git a/.claude/skills/run-e2e-pixel-sweep/scripts/drive-flow.ps1 b/.claude/skills/run-e2e-pixel-sweep/scripts/drive-flow.ps1 index 22cc912..6ed16b7 100644 --- a/.claude/skills/run-e2e-pixel-sweep/scripts/drive-flow.ps1 +++ b/.claude/skills/run-e2e-pixel-sweep/scripts/drive-flow.ps1 @@ -122,8 +122,13 @@ Step "import" { Start-Sleep -Seconds 4 if (-not (Tap "Video taken on")) { return $false } # the pushed clip in the system photo picker Start-Sleep -Seconds 2 - if (-not (Tap "Done")) { return $false } - Start-Sleep -Seconds 6 + # Some OEM pickers (Samsung RTL) land on Trim immediately; others need "Done" first. + if (-not (Find-Node (Get-Nodes) "TRIM YOUR VIDEO")) { + if (-not (Tap "Done")) { return $false } + Start-Sleep -Seconds 6 + } else { + Start-Sleep -Seconds 2 + } [bool](Find-Node (Get-Nodes) "TRIM YOUR VIDEO") } diff --git a/.claude/skills/run-e2e-pixel-sweep/scripts/samsung-rtl-prep.ps1 b/.claude/skills/run-e2e-pixel-sweep/scripts/samsung-rtl-prep.ps1 new file mode 100644 index 0000000..57c1530 --- /dev/null +++ b/.claude/skills/run-e2e-pixel-sweep/scripts/samsung-rtl-prep.ps1 @@ -0,0 +1,84 @@ +<# +.SYNOPSIS + Prep a Samsung Remote Test Lab (RTL) device for the pixel-sweep flow — install debug APK, + fresh app state, push canonical test video, 3-button nav, logcat capture, launch app. + +.USAGE + # After RDB connect (adb devices shows localhost:): + pwsh samsung-rtl-prep.ps1 -Serial localhost:52172 -ArtifactDir $env:TEMP\openloop_rtl\s23 + + Auto-detects the first localhost:* serial when -Serial is omitted. + +.NOTES + RTL devices are REAL Galaxy hardware — not emulators. See docs/guides/samsung-rtl-steps.md. + Does NOT start an emulator. Refuses non-Samsung manufacturers (LGE, Google, etc.). +#> +param( + [string]$Serial, + [Parameter(Mandatory = $true)][string]$ArtifactDir, + [string]$RepoRoot = "C:\Users\gates\Personal\OpenRang", + [string]$VideoName = "google-pro-fold-video.mp4" +) +$ErrorActionPreference = 'Stop' +$pkg = "io.github.stozo04.openloop" +$video = Join-Path $RepoRoot $VideoName +$apk = Join-Path $RepoRoot "app\build\outputs\apk\debug\app-debug.apk" + +if (-not (Test-Path $video)) { Write-Error "Canonical test video missing: $video — STOP, do not substitute."; exit 1 } +if (-not (Test-Path $apk)) { Write-Error "APK not built: $apk — run :app:assembleDebug first."; exit 1 } +New-Item -ItemType Directory -Force $ArtifactDir | Out-Null + +if (-not $Serial) { + $line = (adb devices) | Select-String 'localhost:\d+\s+device' | Select-Object -First 1 + if (-not $line) { Write-Error "No RTL device (localhost:) in adb devices. Connect RDB first — see docs/guides/samsung-rtl-steps.md"; exit 1 } + $Serial = ($line.ToString() -split '\s+')[0] +} + +$state = (adb devices) | Select-String "$([regex]::Escape($Serial))\s+device" +if (-not $state) { Write-Error "Serial '$Serial' not in 'device' state. Run adb devices."; exit 1 } + +$manufacturer = ((adb -s $Serial shell getprop ro.product.manufacturer) -join '').Trim() +$model = ((adb -s $Serial shell getprop ro.product.model) -join '').Trim() +$sdk = ((adb -s $Serial shell getprop ro.build.version.sdk) -join '').Trim() +if ($manufacturer -notmatch 'samsung') { + Write-Error "Refusing RTL prep: ro.product.manufacturer='$manufacturer' (expected samsung). Wrong device or not RTL Galaxy." + exit 1 +} +Write-Host "RTL device: $manufacturer $model (API $sdk) serial=$Serial" + +adb -s $Serial install -r -g $apk | Out-Null +adb -s $Serial shell pm clear $pkg | Out-Null +adb -s $Serial shell pm grant $pkg android.permission.CAMERA +adb -s $Serial push $video /sdcard/Download/ | Out-Null +adb -s $Serial shell am broadcast -a android.intent.action.MEDIA_SCANNER_SCAN_FILE -d "file:///sdcard/Download/$VideoName" | Out-Null +adb -s $Serial shell cmd overlay enable com.android.internal.systemui.navbar.threebutton 2>$null + +adb -s $Serial logcat -c +Start-Process adb -ArgumentList "-s", $Serial, "logcat" ` + -RedirectStandardOutput (Join-Path $ArtifactDir "logcat.txt") -WindowStyle Hidden +Start-Sleep -Seconds 1 + +# RTL devices often arrive locked / on the keyguard — drive-flow cannot see onboarding until unlocked. +adb -s $Serial shell input keyevent KEYCODE_WAKEUP | Out-Null +Start-Sleep -Seconds 1 +adb -s $Serial shell wm dismiss-keyguard 2>$null | Out-Null +$sizeLine = (adb -s $Serial shell wm size) -join '' +if ($sizeLine -match '(\d+)x(\d+)') { + $w = [int]$Matches[1]; $h = [int]$Matches[2] + adb -s $Serial shell input swipe ([int]($w / 2)) ([int]($h * 0.78)) ([int]($w / 2)) ([int]($h * 0.34)) 300 | Out-Null +} +Start-Sleep -Seconds 2 + +Start-Sleep -Seconds 8 +adb -s $Serial shell am start -n "$pkg/.MainActivity" | Out-Null +Start-Sleep -Seconds 4 + +@( + "serial=$Serial", + "manufacturer=$manufacturer", + "model=$model", + "sdk=$sdk" +) | Set-Content (Join-Path $ArtifactDir "device-info.txt") + +Write-Host "SERIAL=$Serial" +exit 0 diff --git a/.claude/skills/run-e2e-pixel-sweep/scripts/samsung-rtl-sweep.ps1 b/.claude/skills/run-e2e-pixel-sweep/scripts/samsung-rtl-sweep.ps1 new file mode 100644 index 0000000..8b6f61b --- /dev/null +++ b/.claude/skills/run-e2e-pixel-sweep/scripts/samsung-rtl-sweep.ps1 @@ -0,0 +1,57 @@ +<# +.SYNOPSIS + Full Samsung Remote Test Lab (RTL) regression: prep → drive-flow → quality-gate → logcat scan. + +.USAGE + # 1. Reserve a Galaxy on Samsung RTL, start RDB, connect in browser (see docs/guides/samsung-rtl-steps.md) + # 2. Build once: + # $env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr" + # .\gradlew.bat :app:assembleDebug + # 3. Run: + pwsh samsung-rtl-sweep.ps1 -ArtifactDir $env:TEMP\openloop_rtl\s23 + # Optional: -Serial localhost:52172 (auto-detects first localhost:* if omitted) + +.NOTES + Reuses drive-flow.ps1, quality-gate.ps1, and scan-logcat.ps1 from this skill + run-e2e. + Keep the RTL browser tab awake — debugging drops when the remote screen sleeps. +#> +param( + [string]$Serial, + [Parameter(Mandatory = $true)][string]$ArtifactDir, + [string]$RepoRoot = "C:\Users\gates\Personal\OpenRang", + [switch]$SkipQualityGate +) +$ErrorActionPreference = 'Stop' +$skill = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoSkill = Join-Path $RepoRoot ".claude\skills\run-e2e-pixel-sweep\scripts" +$e2eSkill = Join-Path $RepoRoot ".claude\skills\run-e2e\scripts" + +foreach ($dir in @($skill, $repoSkill, $e2eSkill)) { + if (-not (Test-Path $dir)) { Write-Error "Skill scripts missing: $dir"; exit 1 } +} + +Write-Host "=== Samsung RTL sweep ===" +& (Join-Path $skill "samsung-rtl-prep.ps1") -Serial $Serial -ArtifactDir $ArtifactDir -RepoRoot $RepoRoot +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +$info = Get-Content (Join-Path $ArtifactDir "device-info.txt") -ErrorAction SilentlyContinue +$serialLine = $info | Where-Object { $_ -like 'serial=*' } | Select-Object -First 1 +if ($serialLine) { $Serial = ($serialLine -split '=', 2)[1] } +if (-not $Serial) { Write-Error "Prep did not yield a serial."; exit 1 } + +Write-Host "=== drive-flow on $Serial ===" +& (Join-Path $skill "drive-flow.ps1") -Serial $Serial -ArtifactDir $ArtifactDir +$flowExit = $LASTEXITCODE + +if (-not $SkipQualityGate) { + Write-Host "=== quality-gate ===" + & (Join-Path $skill "quality-gate.ps1") -Serial $Serial -ArtifactDir $ArtifactDir + if ($LASTEXITCODE -ne 0) { $flowExit = [Math]::Max($flowExit, $LASTEXITCODE) } +} + +Write-Host "=== scan-logcat ===" +& (Join-Path $e2eSkill "scan-logcat.ps1") -LogFile (Join-Path $ArtifactDir "logcat.txt") + +Write-Host "=== RTL sweep complete (flow exit=$flowExit) ===" +Write-Host "Artifacts: $ArtifactDir" +exit $flowExit diff --git a/.claude/skills/run-e2e/SKILL.md b/.claude/skills/run-e2e/SKILL.md index a4172c2..054694d 100644 --- a/.claude/skills/run-e2e/SKILL.md +++ b/.claude/skills/run-e2e/SKILL.md @@ -9,7 +9,8 @@ description: >- end test", "/run-e2e", "full app test on the emulator", "exercise the editor tabs", "smoke test the boomerang flow", or wants the app driven through its real UI (not unit tests) with a logcat findings report at the end. Drives via adb input + uiautomator (NOT screenshots, which hit a - per-session image limit). + per-session image limit). Also see sibling skill `run-e2e-pixel-sweep` for the 4-emulator sweep, + API-34 FGS gate, Samsung RTL sweep, and OEM regression lanes. --- # run-e2e — OpenLoop end-to-end emulator test @@ -28,10 +29,27 @@ run them with `pwsh /scripts/.ps1 ...`. The app package is - An emulator/device is attached: `adb devices` shows one `device`. Capture its serial (e.g. `emulator-5556`) into `$Serial` and pass `-s $Serial` to every adb call — never rely on a default when more than one may exist. +- For **Android 14 (API 34) FGS regression** (Crashlytics 9663c743 family), use AVD + **`Pixel_8_API34`**. Create if missing: + `pwsh \.claude\skills\run-e2e-pixel-sweep\scripts\create-api34-avd.ps1`. + This is also the **4th and final device** in the `run-e2e-pixel-sweep` repeatable sweep. - A JDK for the build: `$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr"`. - You're at the repo root for gradle. If the working dir has drifted, call gradle with an explicit project dir: `& "\gradlew.bat" -p "" ...`. +## OEM regression lanes (when the bug is OEM-specific) + +See **`run-e2e-pixel-sweep` → "OEM regression lanes"** for the full matrix. Quick reference: + +| OEM / issue | Headless on emulator? | Command | +|-------------|----------------------|---------| +| Android 14 FGS crash | ✅ | Cold-boot `Pixel_8_API34`, capture→save or import sweep | +| Samsung identity logic | ✅ | `./gradlew :app:testDebugUnitTest --tests DeviceMediaHintsOemRobolectricTest` | +| Samsung vendor codecs | ❌ | `pwsh …/samsung-rtl-sweep.ps1` after Samsung RTL RDB connect | +| LG `start failed` fallback | ✅ (injected) | `VideoReverserTest#reverse_recoversFromCodecStartFailure_viaSoftwareFallback` via `adb shell am instrument` | + +Stock emulators **cannot** spoof Samsung/LG `Build.MANUFACTURER` — verified 2026-06-22 (`-prop` ignored). + ## 1. Build + install ```powershell diff --git a/.claude/skills/run-e2e/scripts/scan-logcat.ps1 b/.claude/skills/run-e2e/scripts/scan-logcat.ps1 index b5546a7..3da80f1 100644 --- a/.claude/skills/run-e2e/scripts/scan-logcat.ps1 +++ b/.claude/skills/run-e2e/scripts/scan-logcat.ps1 @@ -23,6 +23,7 @@ $sigs = @( @{ Name = 'surface has been released (b09e527)'; Class = 'CRASH'; Pattern = 'surface has been released' } @{ Name = 'dequeueOutputBuffer native (3a506c4e)'; Class = 'CRASH'; Pattern = 'native_dequeueOutputBuffer' } @{ Name = 'MediaCodec CodecException'; Class = 'CRASH'; Pattern = 'CodecException' } + @{ Name = 'FGS type unknown (API 34 regression)'; Class = 'CRASH'; Pattern = 'InvalidForegroundServiceTypeException|Starting FGS with type unknown' } @{ Name = 'reverse preview timeout (120s bucket)'; Class = 'TIMEOUT'; Pattern = 'ensureReversed\.timeout|Timed out after' } @{ Name = 'reverse preview failure (non-fatal)'; Class = 'TIMEOUT'; Pattern = 'reverse_preview_failure' } @{ Name = 'codec reclaim pressure'; Class = 'CHURN'; Pattern = 'keep callback message for reclaim' } diff --git a/CLAUDE.md b/CLAUDE.md index 906c993..d59f992 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -141,13 +141,13 @@ All design tokens, storage patterns, testing strategy, and engineering decisions | `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. | +| `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/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 of project concepts (e.g. `jetpack-datastore-explained.md` — what DataStore is and how to inspect/reset it on a device). | +| `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). | | `.github/` | PR template, branch naming (`feature/`), and workflow conventions. | diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 198579d..f7972e7 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 = 23 - versionName = "1.0.23" + versionCode = 24 + versionName = "1.0.24" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -75,6 +75,13 @@ android { compose = true buildConfig = true } + testOptions { + unitTests { + // Robolectric needs the merged manifest + resources on the unit-test classpath so a test + // can build real framework objects (notifications, ForegroundInfo, etc.) on the JVM. + isIncludeAndroidResources = true + } + } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" @@ -118,6 +125,20 @@ kotlin { } } +// App code targets/compiles to Java 17 (above), but the JVM that *runs* the unit tests is pinned to +// JDK 21. Robolectric must run on JDK 21 to load the API-36 android-all jar (Android's SDK 36 jars +// are compiled with Java 21), and Robolectric defaults to the project's targetSdk (36). Running +// Java-17 test bytecode on a JDK-21 launcher is fully supported, so this unblocks device-free, +// per-API-level testing (e.g. @Config(sdk=[34]) FGS-type regression) without disturbing the +// documented Java-17 app toolchain. +tasks.withType().configureEach { + javaLauncher.set( + javaToolchains.launcherFor { + languageVersion.set(JavaLanguageVersion.of(21)) + }, + ) +} + dependencies { constraints { // AGP 9 no longer aligns the compile classpath to runtime versions @@ -175,6 +196,11 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.mockk) testImplementation(libs.kotlinx.coroutines.test) + // Robolectric — run real Android framework code on the JVM at a chosen API level + // (@Config(sdk=[...])), so version-gated platform behavior is verifiable without a device. + testImplementation(libs.robolectric) + testImplementation(libs.androidx.test.core) + testImplementation(libs.androidx.test.ext.junit) // Compose UI Testing (instrumented) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 12a3b70..8bba8a3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -17,9 +17,15 @@ android:name="com.google.android.gms.permission.AD_ID" tools:node="remove" /> - + + @@ -84,11 +90,15 @@ no clean per-line manifest suppression for it — so those red markers are accepted, not a defect. Do NOT remove the to clear them: it is what gives API 26–29 the picker. See https://developer.android.com/training/data-storage/shared/photopicker --> - = Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - @Suppress("InlinedApi") - ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING - } else { - 0 - } return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - ForegroundInfo(NOTIFICATION_ID, notification, fgsType) + ForegroundInfo(NOTIFICATION_ID, notification, foregroundServiceTypeForSdk(Build.VERSION.SDK_INT)) } else { @Suppress("DEPRECATION") ForegroundInfo(NOTIFICATION_ID, notification) } } + + /** + * Foreground-service type to pass to `startForeground()` for the OS version actually running. + * + * `mediaProcessing` ([ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING] = 8192) is only a + * *recognized* FGS type from **API 35 (Android 15)** — the version that ADDED it. Passing it on + * API 34 (Android 14) makes the platform reject it as an unknown type and abort the service with + * `InvalidForegroundServiceTypeException: Starting FGS with type unknown ... has been prohibited` + * — a fatal crash (Crashlytics 9663c743…, Samsung Galaxy A55 / Android 14, v1.0.23). The original + * code gated this on `UPSIDE_DOWN_CAKE` (API 34), one version too low: the constant *compiles* + * against compileSdk 36 but the value isn't honored by an API-34 device's service validator. + * + * On API 29–34 fall back to `dataSync` ([ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC] = 1, + * added API 29) — Google's documented type for "import/export … transfer over network" work and + * the type WorkManager's own long-running-worker sample uses. Both types are declared on the + * merged `SystemForegroundService` in the manifest, so the requested type is always a subset of + * what's declared. Below API 29 there is no typed FGS, so 0 (untyped). + */ + // InlinedApi: the FGS-type constants (DATA_SYNC API 29, MEDIA_PROCESSING API 35) are above + // minSdk 26, but they're compile-time int literals chosen behind an explicit SDK_INT guard here, + // so inlining them is exactly the intent. + @VisibleForTesting + @Suppress("InlinedApi") + fun foregroundServiceTypeForSdk(sdkInt: Int): Int = when { + sdkInt >= Build.VERSION_CODES.VANILLA_ICE_CREAM -> + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING + sdkInt >= Build.VERSION_CODES.Q -> ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + else -> 0 + } } diff --git a/app/src/test/java/io/github/stozo04/openloop/media/DeviceMediaHintsOemRobolectricTest.kt b/app/src/test/java/io/github/stozo04/openloop/media/DeviceMediaHintsOemRobolectricTest.kt new file mode 100644 index 0000000..fd9b2bf --- /dev/null +++ b/app/src/test/java/io/github/stozo04/openloop/media/DeviceMediaHintsOemRobolectricTest.kt @@ -0,0 +1,124 @@ +package io.github.stozo04.openloop.media + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowBuild + +/** + * Device-free OEM identity tests via Robolectric [ShadowBuild]. + * + * Stock emulators cannot spoof `Build.MANUFACTURER` (Google Play images ignore `-prop` and block + * `setprop`). These tests exercise the same branches the ViewModel and [VideoReverser] read at + * runtime on real Samsung hardware — preview reverse cap and Samsung-first encoder ordering. + * + * LG has no equivalent identity gate in OpenLoop; its Crashlytics path (47233ad7, `start failed`) + * is covered by [io.github.stozo04.openloop.media.VideoReverserTest.reverse_recoversFromCodecStartFailure_viaSoftwareFallback]. + */ +@RunWith(RobolectricTestRunner::class) +class DeviceMediaHintsOemRobolectricTest { + + @Test + @Config(sdk = [34]) + fun samsungManufacturer_isSamsungDevice_and480pPreviewCap() { + ShadowBuild.setManufacturer("samsung") + ShadowBuild.setBrand("google") + + assertTrue(isSamsungDevice()) + assertEquals(SAMSUNG_PREVIEW_REVERSE_MAX_SHORT_SIDE, previewReverseMaxShortSideOrNull()) + } + + @Test + @Config(sdk = [34]) + fun samsungBrand_isSamsungDevice_evenWhenManufacturerDiffers() { + ShadowBuild.setManufacturer("Google") + ShadowBuild.setBrand("samsung") + + assertTrue(isSamsungDevice()) + assertEquals(480, previewReverseMaxShortSideOrNull()) + } + + @Test + @Config(sdk = [34]) + fun lgeManufacturer_notSamsung_noPreviewCap() { + ShadowBuild.setManufacturer("LGE") + ShadowBuild.setBrand("lge") + + assertFalse(isSamsungDevice()) + assertNull(previewReverseMaxShortSideOrNull()) + } + + @Test + @Config(sdk = [34]) + fun googleEmulatorIdentity_notSamsung_noPreviewCap() { + ShadowBuild.setManufacturer("Google") + ShadowBuild.setBrand("google") + + assertFalse(isSamsungDevice()) + assertNull(previewReverseMaxShortSideOrNull()) + } + + @Test + @Config(sdk = [34]) + fun samsungIdentity_encoderTryOrderPrefersC2GoogleFirst() { + ShadowBuild.setManufacturer("samsung") + ShadowBuild.setBrand("samsung") + + val installed = setOf( + SAMSUNG_REVERSE_AVC_SOFTWARE_ENCODER, + "c2.exynos.h264.encoder", + "c2.android.avc.encoder", + ) + val formatSupported = installed.toList() + + val order = avcEncoderTryOrderForReverse( + formatSupportedNames = formatSupported, + installedEncoderNames = installed, + isSamsung = isSamsungDevice(), + isHardwareAccelerated = { true }, + sdkInt = 34, + ) + + assertEquals( + "Samsung preview reverse must try c2.google.avc.encoder before vendor HW", + SAMSUNG_REVERSE_AVC_SOFTWARE_ENCODER, + order.first(), + ) + assertFalse( + "Samsung vendor AVC encoders must be excluded from the try-order", + order.any { isSamsungVendorAvcCodec(it) }, + ) + } + + @Test + @Config(sdk = [34]) + fun lgeIdentity_encoderTryOrderDoesNotForceC2GoogleFirst() { + ShadowBuild.setManufacturer("LGE") + ShadowBuild.setBrand("lge") + + val installed = setOf( + SAMSUNG_REVERSE_AVC_SOFTWARE_ENCODER, + "c2.exynos.h264.encoder", + "c2.android.avc.encoder", + ) + val formatSupported = installed.toList() + + val order = avcEncoderTryOrderForReverse( + formatSupportedNames = formatSupported, + installedEncoderNames = installed, + isSamsung = isSamsungDevice(), + isHardwareAccelerated = { true }, + sdkInt = 34, + ) + + assertFalse( + "Non-Samsung must not pin c2.google.avc.encoder first — rank by preference only", + order.first() == SAMSUNG_REVERSE_AVC_SOFTWARE_ENCODER, + ) + } +} diff --git a/app/src/test/java/io/github/stozo04/openloop/work/BoomerangRenderForegroundInfoRobolectricTest.kt b/app/src/test/java/io/github/stozo04/openloop/work/BoomerangRenderForegroundInfoRobolectricTest.kt new file mode 100644 index 0000000..e13100d --- /dev/null +++ b/app/src/test/java/io/github/stozo04/openloop/work/BoomerangRenderForegroundInfoRobolectricTest.kt @@ -0,0 +1,70 @@ +package io.github.stozo04.openloop.work + +import android.content.Context +import android.content.pm.ServiceInfo +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Device-free reproduction of Crashlytics 9663c743 (Galaxy A55 / Android 14, v1.0.23) using + * Robolectric to run the **real** [BoomerangRenderNotifications.createForegroundInfo] path under a + * specific API level (`@Config(sdk = [...])`). + * + * Unlike the pure-function test, this builds the actual [androidx.work.ForegroundInfo] the worker + * hands WorkManager — notification, channel, and the foreground-service type WorkManager passes to + * `startForeground()`. On API 34 that type must NOT be `mediaProcessing` (8192); the OS rejects it + * as unknown and the FGS crashes. Asserting `ForegroundInfo.foregroundServiceType` here exercises + * the same value that crashed in production. + * + * Runs on JDK 21 (configured in app/build.gradle.kts) so Robolectric can load the SDK-36 jar. + */ +@RunWith(RobolectricTestRunner::class) +class BoomerangRenderForegroundInfoRobolectricTest { + + private val context: Context get() = ApplicationProvider.getApplicationContext() + + @Test + @Config(sdk = [34]) + fun android14_foregroundInfo_isDataSync_reproducesAndProvesFix() { + val info = BoomerangRenderNotifications.createForegroundInfo(context, progressPercent = 0) + + // The exact production value that crashed v1.0.23. Pre-fix this was 8192 (mediaProcessing), + // which Android 14 rejects with InvalidForegroundServiceTypeException ("type unknown"). + assertEquals( + "Android 14 FGS must be dataSync (1)", + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + info.foregroundServiceType, + ) + assertNotEquals( + "Android 14 FGS must never be mediaProcessing (8192) — the OS aborts the service", + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING, + info.foregroundServiceType, + ) + } + + @Test + @Config(sdk = [35]) + fun android15_foregroundInfo_isMediaProcessing() { + val info = BoomerangRenderNotifications.createForegroundInfo(context, progressPercent = 0) + assertEquals( + "Android 15+ supports and should request mediaProcessing (8192)", + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING, + info.foregroundServiceType, + ) + } + + @Test + @Config(sdk = [36]) + fun android16_foregroundInfo_isMediaProcessing() { + val info = BoomerangRenderNotifications.createForegroundInfo(context, progressPercent = 0) + assertEquals( + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING, + info.foregroundServiceType, + ) + } +} diff --git a/app/src/test/java/io/github/stozo04/openloop/work/BoomerangRenderNotificationsTest.kt b/app/src/test/java/io/github/stozo04/openloop/work/BoomerangRenderNotificationsTest.kt new file mode 100644 index 0000000..5db2985 --- /dev/null +++ b/app/src/test/java/io/github/stozo04/openloop/work/BoomerangRenderNotificationsTest.kt @@ -0,0 +1,78 @@ +package io.github.stozo04.openloop.work + +import android.content.pm.ServiceInfo +import android.os.Build +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +/** + * Regression guard for Crashlytics issue 9663c743 (Samsung Galaxy A55 / Android 14, v1.0.23): + * + * Fatal: java.lang.RuntimeException: Unable to start service SystemForegroundService … + * Caused by: android.app.InvalidForegroundServiceTypeException: Starting FGS with type unknown + * … targetSDK=36 has been prohibited + * + * The Loopify render runs under a WorkManager foreground service. v1.0.23 always passed + * `mediaProcessing` ([ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING] = 8192) once the device + * was API ≥ 34, but that type was only ADDED in API 35 (Android 15). On an Android 14 device the + * platform doesn't recognize the type and aborts the service — a fatal crash. + * + * These tests pin the version → FGS-type contract. The FGS-type and SDK constants are compile-time + * `int`s (inlined by the compiler), so this runs as a plain JVM unit test with no Robolectric. + */ +class BoomerangRenderNotificationsTest { + + @Test + fun android14_usesDataSync_notMediaProcessing() { + // The exact crashing condition: API 34 must NOT receive the API-35-only mediaProcessing type. + val type = BoomerangRenderNotifications.foregroundServiceTypeForSdk(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + + assertEquals( + "Android 14 (API 34) must use dataSync (1), the type that actually exists on that OS", + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + type, + ) + assertNotEquals( + "Android 14 must never be handed mediaProcessing (8192) — the OS rejects it as unknown", + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING, + type, + ) + } + + @Test + fun android15AndAbove_usesMediaProcessing() { + for (sdk in intArrayOf(Build.VERSION_CODES.VANILLA_ICE_CREAM, 36, 37)) { + assertEquals( + "API $sdk supports mediaProcessing and should use it", + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING, + BoomerangRenderNotifications.foregroundServiceTypeForSdk(sdk), + ) + } + } + + @Test + fun api29Through34_useDataSync() { + // dataSync was added in API 29 (Q); it is the broadest valid type before mediaProcessing. + for (sdk in Build.VERSION_CODES.Q..Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + assertEquals( + "API $sdk should fall back to dataSync", + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + BoomerangRenderNotifications.foregroundServiceTypeForSdk(sdk), + ) + } + } + + @Test + fun belowApi29_isUntyped() { + // minSdk is 26; typed foreground services don't exist before API 29, so the worker uses the + // untyped ForegroundInfo constructor and this helper's value is 0. + for (sdk in 26..(Build.VERSION_CODES.Q - 1)) { + assertEquals( + "API $sdk predates typed FGS and must be untyped (0)", + 0, + BoomerangRenderNotifications.foregroundServiceTypeForSdk(sdk), + ) + } + } +} diff --git a/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md index 5c2f48c..179ba00 100644 --- a/docs/TEST_COVERAGE.md +++ b/docs/TEST_COVERAGE.md @@ -75,6 +75,26 @@ Full user flows across multiple screens. Most expensive to write and maintain. R --- +## OEM & API-level regression testing + +Some crashes only reproduce on **one API level** (Android 14 FGS) or **one OEM** (Samsung Exynos, +LG hardware codec). Stock emulators cannot spoof Samsung/LG identity or vendor codecs. + +**Authoritative guide:** [`docs/guides/oem-regression-testing.md`](guides/oem-regression-testing.md) + +| Lane | What | Where | +|------|------|-------| +| API 34 FGS | `Pixel_8_API34` AVD + save smoke; Robolectric `ForegroundInfo` tests | Emulator + `test/` | +| Samsung app logic | `DeviceMediaHintsOemRobolectricTest` (ShadowBuild) | `test/` | +| Samsung vendor codecs | `samsung-rtl-sweep.ps1` on Samsung Remote Test Lab | Real Galaxy | +| LG `start failed` recovery | `VideoReverserTest#reverse_recoversFromCodecStartFailure_viaSoftwareFallback` | `androidTest/` on any device | + +The **4-emulator pixel sweep** (`Pixel_6` → `Pixel_8` → `Pixel_10_Pro_Fold` → `Pixel_8_API34`) +is documented in `.claude/skills/run-e2e-pixel-sweep/SKILL.md`. Run the full OEM matrix before +media-pipeline or FGS changes. + +--- + ## Frameworks & Tools | Tool | Purpose | Used In | @@ -83,6 +103,7 @@ Full user flows across multiple screens. Most expensive to write and maintain. R | **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)) | | **Compose UI Test** | `createComposeRule`, semantic matchers, UI assertions | `androidTest/` | | **AndroidJUnit4** | Instrumented test runner | `androidTest/` | @@ -150,7 +171,18 @@ Compose tests use a `ComposeTestRule` to set content, find nodes via the semanti ## Current Test Inventory -### Local Unit Tests (`app/src/test/`) — 24 tests +### Local Unit Tests (`app/src/test/`) — see `./gradlew :app:testDebugUnitTest` for current count + +**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])` | +| `DeviceMediaHintsOemRobolectricTest` | `ShadowBuild` Samsung/LG identity → preview cap + encoder order | +| `SamsungReversePreviewRegressionTest` | Samsung encoder ranking (RTL-derived, explicit `isSamsung=true`) | + +**Core ViewModel / storage (representative rows):** | Test | Category | What It Validates | |------|----------|------------------| @@ -179,10 +211,16 @@ Compose tests use a `ComposeTestRule` to set content, find nodes via the semanti | `deleteVideo removes files and reloads empty list` | Storage | Deletion flow works | | `recordedVideos flow starts as empty list` | State | Initial state is clean | -### Instrumented UI Tests (`app/src/androidTest/`) — 10 tests +### Instrumented Tests (`app/src/androidTest/`) + +**Media / OEM:** + +| Test | What it guards | +|------|----------------| +| `VideoReverserTest#reverse_recoversFromCodecStartFailure_viaSoftwareFallback` | LG 47233ad7 — `start failed` → software codec retry (fault injection) | +| `VideoReverserTest` (Samsung-only cases) | Skip on emulator; run on Samsung RTL hardware | -| Test | What It Guards | -|------|---------------| +**Onboarding UI (Compose):** | `page0_nextButton_isDisplayedAndCentered` | Button centering regression (page 1) | | `page1_backAndNextButtons_areDisplayedAndCentered` | Button centering regression (page 2) | | `page2_ctaButton_isDisplayedAndFillsWidth` | CTA layout regression (page 3) | @@ -202,7 +240,9 @@ These are areas that need tests but don't have them yet: | Area | Why It's Missing | Priority | |------|-----------------|----------| -| `UserPreferencesRepositoryImpl` | Needs instrumented test with real DataStore | Medium | +| `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 | diff --git a/docs/e2e/2026-06-22_082330-api34-fgs-fix.md b/docs/e2e/2026-06-22_082330-api34-fgs-fix.md new file mode 100644 index 0000000..6d5ee43 --- /dev/null +++ b/docs/e2e/2026-06-22_082330-api34-fgs-fix.md @@ -0,0 +1,55 @@ +# 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 new file mode 100644 index 0000000..33a218b Binary files /dev/null and b/docs/e2e/api34-fgs-fix/01_share_sheet_api34_fix.png 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 new file mode 100644 index 0000000..416c363 Binary files /dev/null and b/docs/e2e/api34-fgs-fix/02_gallery_playback_api34_fix.png differ diff --git a/docs/guides/README.md b/docs/guides/README.md new file mode 100644 index 0000000..6bdf7a6 --- /dev/null +++ b/docs/guides/README.md @@ -0,0 +1,15 @@ +# Guides + +Plain-English how-to docs for OpenLoop concepts and workflows. Start here when you need +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 | +| [`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 | + +Testing strategy and inventory: [`../TEST_COVERAGE.md`](../TEST_COVERAGE.md). + +Agent-runnable E2E skills: `.claude/skills/run-e2e/` and `.claude/skills/run-e2e-pixel-sweep/`. diff --git a/docs/guides/oem-regression-testing.md b/docs/guides/oem-regression-testing.md new file mode 100644 index 0000000..2b5cce6 --- /dev/null +++ b/docs/guides/oem-regression-testing.md @@ -0,0 +1,250 @@ +# OEM Regression Testing — API 34, Samsung, and LG + +OpenLoop ships to billions of devices. Crashes and media bugs often appear on **one OEM or one +API level** and nowhere else. This guide documents the three regression lanes we added so those +bugs are caught **before** Crashlytics — without pretending a stock emulator is a Galaxy or an +LG phone when it isn't. + +**Related docs:** + +| Doc | Role | +|-----|------| +| [`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/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` +(OEM lanes + 4-emulator sweep) and `.claude/skills/run-e2e/SKILL.md` (manual E2E + quick table). + +--- + +## The core idea: match the lane to the bug class + +Not every OEM bug can be reproduced the same way. Pick the lane that actually exercises the +failing layer: + +| Bug class | Example | Lane | Runs on | +|-----------|---------|------|---------| +| **Stock Android framework** | FGS type rejected on API 34 (`InvalidForegroundServiceTypeException`) | **API 34 AVD** | Emulator | +| **App logic gated on `Build.MANUFACTURER`** | Samsung 480p preview cap, `c2.google` encoder first | **Robolectric + ShadowBuild** | JVM (seconds) | +| **Vendor MediaCodec stack** | Exynos/QC reverse wedge, Samsung RTL regressions | **Samsung RTL sweep** | Real Galaxy (cloud) | +| **Hardware codec init failure** | LG LM-X540 `IllegalArgumentException: start failed` | **Instrumented fault injection** | Any emulator/device | + +**Critical honesty rule:** A green run on a **stock Google emulator does not prove** a Samsung or +LG codec bug is fixed. Emulator codecs are `c2.android` / goldfish software paths — not Exynos, +QTI, or LG hardware. We verified (2026-06-22) that `-prop ro.product.manufacturer=samsung` on +Google Play images is **ignored**; the device still reports `Google`. + +--- + +## Lane 1 — Android 14 (API 34) foreground-service regression + +### What it catches + +Crashlytics **9663c743** (Galaxy A55, Android 14, v1.0.23): every Loopify save crashed because +the app passed `FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING` (8192) on API 34, but that type was +**added in API 35**. The OS rejected it as `Starting FGS with type unknown`. + +### AVD: `Pixel_8_API34` + +Create once (requires SDK image `system-images;android-34;google_apis_playstore;x86_64`): + +```powershell +pwsh .claude/skills/run-e2e-pixel-sweep/scripts/create-api34-avd.ps1 +emulator -list-avds # should include Pixel_8_API34 +``` + +### How to run + +**Option A — part of the 4-device pixel sweep** (import path, scripted): + +Order: `Pixel_6` → `Pixel_8` → `Pixel_10_Pro_Fold` → **`Pixel_8_API34`** (last = mandatory FGS gate). + +See `.claude/skills/run-e2e-pixel-sweep/SKILL.md` for the full sweep. + +**Option B — manual capture→save** (exercises camera + save): + +See `.claude/skills/run-e2e/SKILL.md`. Cold-boot `Pixel_8_API34`, record a clip, change one +setting per editor tab, tap Save, confirm share sheet shows `boom_*.mp4`. + +### Pass criteria + +- `Worker result SUCCESS … BoomerangRenderWorker` in logcat +- **Zero** lines matching `InvalidForegroundServiceTypeException` or `Starting FGS with type unknown` +- (`scan-logcat.ps1` includes an **FGS type unknown (API 34 regression)** CRASH row — count must be 0) + +### Unit / Robolectric proof (same bug, no device) + +| Test | File | +|------|------| +| SDK → FGS type mapping | `BoomerangRenderNotificationsTest` | +| Real `ForegroundInfo` on API 34 | `BoomerangRenderForegroundInfoRobolectricTest` | + +```powershell +./gradlew :app:testDebugUnitTest --tests "io.github.stozo04.openloop.work.BoomerangRenderNotificationsTest" +./gradlew :app:testDebugUnitTest --tests "io.github.stozo04.openloop.work.BoomerangRenderForegroundInfoRobolectricTest" +``` + +--- + +## Lane 2 — Samsung identity and codec policy (Robolectric) + +### What it catches + +App branches that read **`Build.MANUFACTURER` / `Build.BRAND`**: + +- `isSamsungDevice()` → 480p preview reverse cap (`previewReverseMaxShortSideOrNull()`) +- Samsung-first AVC encoder try-order (`c2.google.avc.encoder`, vendor codecs excluded) + +These are **policy** tests. They do not run real Exynos codecs. + +### Test file + +`app/src/test/java/io/github/stozo04/openloop/media/DeviceMediaHintsOemRobolectricTest.kt` + +Uses Robolectric **`ShadowBuild.setManufacturer()` / `setBrand()`** to simulate Galaxy vs LGE vs +Google emulator identity on the JVM. + +Companion JVM tests (explicit `isSamsung=true` parameter, no ShadowBuild): + +- `SamsungReversePreviewRegressionTest` — encoder ranking regressions from Samsung RTL logcat + +### How to run + +```powershell +./gradlew :app:testDebugUnitTest --tests "io.github.stozo04.openloop.media.DeviceMediaHintsOemRobolectricTest" +./gradlew :app:testDebugUnitTest --tests "io.github.stozo04.openloop.media.SamsungReversePreviewRegressionTest" +``` + +### Pass criteria + +All tests green. Samsung shadows → cap `480` and `c2.google.avc.encoder` first; LGE/Google → no cap, +no Samsung-only encoder pinning. + +### When to run + +- Any change to `DeviceMediaHints.kt`, `ReverseEncoderSelection.kt`, or ViewModel preview-reverse cap +- Before/after media-pipeline PRs that touch Samsung carve-outs + +--- + +## Lane 3 — Samsung real hardware (RTL sweep) + +### What it catches + +**Vendor codec behavior** that emulators cannot model: Exynos/QC reverse wedges, preview timeouts, +real `ensureReversed.ok` / `Worker result SUCCESS` on Galaxy hardware. + +### Prerequisites + +1. Read [`samsung-rtl-steps.md`](samsung-rtl-steps.md) — reserve a Galaxy, start RDB, fix adb PATH +2. Build debug APK once +3. Canonical test video at repo root: `google-pro-fold-video.mp4` (gitignored — see pixel-sweep skill) + +### How to run + +```powershell +$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr" +.\gradlew.bat :app:assembleDebug + +# adb devices must show localhost: (port changes every RTL session) +pwsh .claude/skills/run-e2e-pixel-sweep/scripts/samsung-rtl-sweep.ps1 ` + -ArtifactDir $env:TEMP\openloop_rtl\s23 + +# Optional explicit serial: +# pwsh .../samsung-rtl-sweep.ps1 -Serial localhost:52172 -ArtifactDir ... +``` + +Scripts: + +| Script | Purpose | +|--------|---------| +| `samsung-rtl-prep.ps1` | Install, `pm clear`, push video, logcat, launch (refuses non-Samsung) | +| `samsung-rtl-sweep.ps1` | prep → `drive-flow.ps1` → `quality-gate.ps1` → `scan-logcat.ps1` | + +### Pass criteria + +- All `drive-flow` steps `[PASS]` +- `quality-gate` PASS (fps, SSIM, no green/black frames) +- `scan-logcat`: **0** CRASH and TIMEOUT rows +- Logcat contains `ensureReversed.ok` and `Worker result SUCCESS … BoomerangRenderWorker` + +### When to run + +- Media pipeline / reverser / encoder selection changes +- Before release if Samsung Crashlytics issues are open +- **Not** required for pure UI changes with no media touch + +--- + +## Lane 4 — LG `start failed` recovery (instrumented fault injection) + +### What it catches + +Crashlytics **47233ad7** (LG LM-X540): hardware AVC codec throws +`IllegalArgumentException: start failed` on first `MediaCodec.start()`. The app must retry on +**software** encoder + decoder and still produce a valid reversed clip. + +LG has **no** public Remote Test Lab equivalent to Samsung RTL. + +### Test + +`VideoReverserTest.reverse_recoversFromCodecStartFailure_viaSoftwareFallback` + +Injects the exact failure on attempt 0 via `VideoReverser.attemptHook`, then asserts recovery. + +### How to run + +```powershell +.\gradlew.bat :app:assembleDebug :app:installDebug + +adb shell am instrument -w -r ` + -e class io.github.stozo04.openloop.media.VideoReverserTest#reverse_recoversFromCodecStartFailure_viaSoftwareFallback ` + io.github.stozo04.openloop.test/androidx.test.runner.AndroidJUnitRunner +``` + +Pass: `tests=1 failures=0 errors=0`. + +**Literal LM-X540 hardware** is still the only way to prove the vendor codec actually rejects +`start()` — Test Lab rarely lists LG devices. + +--- + +## Quick reference — which command when? + +| You're changing… | Run at minimum | +|------------------|----------------| +| FGS / WorkManager / notifications | API 34 unit + Robolectric FGS tests; API 34 AVD save smoke | +| `DeviceMediaHints` / Samsung encoder order | `DeviceMediaHintsOemRobolectricTest` + `SamsungReversePreviewRegressionTest` | +| `VideoReverser` / reverse pipeline | 4-emulator pixel sweep + Samsung RTL if Samsung-specific | +| Anything touching save/render | Pixel sweep includes API 34; grep logcat for FGS CRASH row | +| LG codec fallback logic | `VideoReverserTest#reverse_recoversFromCodecStartFailure_viaSoftwareFallback` | + +--- + +## File map (everything added for OEM regression) + +| Path | Purpose | +|------|---------| +| `app/src/test/.../DeviceMediaHintsOemRobolectricTest.kt` | ShadowBuild Samsung/LG identity tests | +| `app/src/test/.../BoomerangRenderForegroundInfoRobolectricTest.kt` | API 34/35/36 FGS type on real `ForegroundInfo` | +| `app/src/test/.../BoomerangRenderNotificationsTest.kt` | Pure SDK → FGS type mapping | +| `app/src/main/.../DeviceMediaHints.kt` | `previewReverseMaxShortSideOrNull()` helper | +| `.claude/skills/run-e2e-pixel-sweep/scripts/create-api34-avd.ps1` | Create `Pixel_8_API34` AVD | +| `.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 | + +--- + +## Adding a new OEM regression lane + +When Crashlytics shows a **device- or API-specific** crash: + +1. Classify it (framework vs identity-gated logic vs vendor codec vs injectable failure). +2. Add the **cheapest lane that reproduces the real failure layer** — don't default to "boot an emulator." +3. Document the lane here + add a row to the pixel-sweep / run-e2e skill OEM table. +4. If it's a new pattern, add a lesson in `docs/lessons_learned/`. diff --git a/docs/guides/robolectric-testing-explained.md b/docs/guides/robolectric-testing-explained.md new file mode 100644 index 0000000..40f6036 --- /dev/null +++ b/docs/guides/robolectric-testing-explained.md @@ -0,0 +1,364 @@ +# Robolectric Testing — Explained Like You're Five + +A plain-English guide to the new test tool OpenLoop just gained — what Robolectric is, +when to reach for it (and when *not* to), the exact setup already wired into this repo, a +minimal copy-pasteable example, and a prioritized list of the highest-value things in *this* +codebase that Robolectric can finally let us test. + +No prior knowledge needed. Technical words are defined in the [Glossary](#glossary) at the bottom. + +> **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). + +--- + +## The one-sentence version + +**Robolectric lets a unit test run real Android framework code — `Notification`, +`NotificationManager`, `ServiceInfo`, `Uri`, `ContentResolver`, `Build.VERSION` — on your +laptop's JVM, in milliseconds, with no emulator and no phone, and even *pretend to be a +specific Android version* with one annotation.** + +That last part is the magic. A lot of OpenLoop's nastiest bugs are **version-specific** (the +Loop-export crash only happened on Android 14; notification permission only exists on Android +13+). Before Robolectric, the only way to test "does this behave right on Android 14?" was to +boot an Android-14 emulator. Now we can test all of those, instantly, from a normal unit test. + +--- + +## Three kinds of test (and how to choose) + +OpenLoop now has **three** places a test can run. Picking the right one is the whole game. + +| | Plain JUnit unit test | **Robolectric unit test** | Instrumented test | +|---|---|---|---| +| **Lives in** | `app/src/test/` | `app/src/test/` | `app/src/androidTest/` | +| **Runs on** | Your laptop's JVM | Your laptop's JVM | A real device / emulator | +| **Speed** | Milliseconds | Tens–hundreds of ms | Seconds (build + install) | +| **Can touch Android APIs?** | No (unless you mock them) | **Yes — simulated** | Yes — the *real* thing | +| **Can fake an Android version?** | No | **Yes — `@Config(sdk=[34])`** | No (you get the device's version) | +| **Real pixels / real codecs / real camera?** | No | **No** | **Yes** | +| **Good for** | Pure logic, math, state machines | Framework *objects & branching* without a device | Real UI rendering, real MediaCodec, real CameraX | + +### The decision rule + +1. **Does the code touch zero Android APIs?** (pure Kotlin: math, string parsing, state + transitions, list sorting) → **Plain JUnit.** Fastest. This is most of `media/BoomerangSequence.kt`, + `media/MediaFormatUtils.kt`'s pure functions, and every `OpenLoopViewModel` state-transition test + we already have. + +2. **Does it touch the Android framework, but you only care about the *logic and the objects it + builds* — not real pixels or real audio/video bytes?** (Did it pick the right `ServiceInfo` type? + Did it build a `Notification` with the right channel? Did it copy a `content://` stream and return + `false` on failure? Does it behave differently on API 33 vs 32?) → **Robolectric.** + +3. **Do you need to *see* something real happen — a Composable actually drawing on screen, a real + `MediaCodec` actually encoding video, a real camera?** → **Instrumented test on a device.** + +> **The golden boundary for OpenLoop:** Robolectric *simulates* the framework. It does **not** run +> real codecs. Our entire media pipeline — `VideoReverser`, `VideoProcessor`, `MediaCodecLifecycle`, +> `ReverseOutputValidator` — produces and inspects *actual encoded video frames*. Robolectric would +> hand those classes a fake `MediaCodec` that encodes nothing, so a "passing" Robolectric test would +> prove **nothing** about whether the loop actually rendered. **Never move the media pipeline to +> Robolectric.** Those tests live in `app/src/androidTest/` for a reason and must stay there. + +--- + +## The setup that's already in this repo + +You don't have to configure anything — the wiring is done. Here's what's there and *why*, so you +understand it. + +### 1. The dependencies (`app/build.gradle.kts`) + +```kotlin +// Testing +testImplementation(libs.junit) +testImplementation(libs.mockk) +testImplementation(libs.kotlinx.coroutines.test) +// Robolectric — run real Android framework code on the JVM at a chosen API level +testImplementation(libs.robolectric) // org.robolectric:robolectric:4.16.1 +testImplementation(libs.androidx.test.core) // ApplicationProvider, etc. +testImplementation(libs.androidx.test.ext.junit) // AndroidJUnit4 runner +``` + +All three are `testImplementation` — they're on the **unit-test** classpath (`src/test/`), *not* +the instrumented one. (Remember Lesson 017: `src/androidTest/` can't see these.) + +### 2. Resources are turned on (`app/build.gradle.kts`) + +```kotlin +testOptions { + unitTests { + // Robolectric needs the merged manifest + resources on the unit-test classpath so a test + // can build real framework objects (notifications, ForegroundInfo, etc.) on the JVM. + isIncludeAndroidResources = true + } +} +``` + +Without this, a Robolectric test that calls `context.getString(R.string.…)` (which our notification +builders do) would not find the resource. This flag hands Robolectric the merged manifest + `res/`. + +### 3. Tests run on JDK 21 (the part that trips people up) + +The app **compiles** to Java 17 (`compileOptions { sourceCompatibility = VERSION_17 }`). But the JVM +that **runs** the unit tests is pinned to JDK 21: + +```kotlin +// app/build.gradle.kts +tasks.withType().configureEach { + javaLauncher.set( + javaToolchains.launcherFor { + languageVersion.set(JavaLanguageVersion.of(21)) + }, + ) +} +``` + +**Why:** Robolectric defaults to the project's `targetSdk` (36), and to simulate API 36 it loads +Android's "android-all" jar for SDK 36 — which Google compiled with **Java 21**. A JDK-17 launcher +**cannot load** that jar and Robolectric tests fail to start. Running Java-17 test bytecode on a +JDK-21 launcher is fully supported, so this unblocks per-API testing without changing the app's +Java-17 toolchain. + +> If you ever see a Robolectric test fail with a class-version / "unsupported major.minor" error, +> the cause is almost always a JDK older than 21 running the tests. The toolchain block above +> prevents that on the command line; in Android Studio make sure the Gradle JDK is 21+. + +--- + +## 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`: + +```kotlin +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +// 1. Run this class under Robolectric (it stands in for AndroidJUnit4 on the JVM). +@RunWith(RobolectricTestRunner::class) +class ExampleRobolectricTest { + + // 2. A REAL android.content.Context — backed by Robolectric, no device needed. + private val context: Context get() = ApplicationProvider.getApplicationContext() + + @Test + @Config(sdk = [34]) // 3. Pretend to be Android 14. Build.VERSION.SDK_INT == 34 inside this test. + fun behavesCorrectlyOnAndroid14() { + // 4. Call the REAL production code. It runs the real framework branching for API 34. + val info = BoomerangRenderNotifications.createForegroundInfo(context, progressPercent = 0) + + // 5. Assert on the real object the framework produced. + assertEquals( + android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + info.foregroundServiceType, + ) + } +} +``` + +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 + `RobolectricTestRunner` directly). +2. `ApplicationProvider.getApplicationContext()` for a real `Context`. +3. `@Config(sdk = [...])` to choose the Android version. You can pass several: + `@Config(sdk = [29, 34, 35])` runs the test once per version. +4. Call the **actual production method** — that's the point; you're exercising real framework code. +5. Assert on the real result. + +--- + +## The highest-value Robolectric opportunities in *this* app + +Ranked by value. Each entry says **what** to test, **why Robolectric unlocks it** (which framework +dependency blocks a plain JUnit test today), and **which `@Config(sdk=[…])` levels matter**. + +### Tier 1 — do these first + +**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 + 0–100 and sets the right title/text; that the `PendingIntent` is `FLAG_IMMUTABLE`. +- *Why Robolectric:* every line touches `NotificationManager`, `NotificationChannel`, + `Notification`, `PendingIntent`, `ServiceInfo`, `Build.VERSION.SDK_INT`, and string resources — + 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. + +**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. +- *Why Robolectric:* a plain JUnit test cannot build a working `Uri` (`Uri.parse` is a stub that + throws in unit tests) or a `ContentResolver`. Robolectric's `ShadowContentResolver` lets you + register fake stream contents for a URI and drive the real copy logic on the JVM. +- *`@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. + +**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 + instrumented test with real DataStore." Robolectric supplies the `Context` the + `preferencesDataStore` delegate needs, so the real DataStore can run on the JVM against a temp file + — turning a device-only gap into a fast unit test. +- *`@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. + +### Tier 2 — strong, with a small caveat + +**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. +- *Why Robolectric:* `CoroutineWorker` needs a `Context` and WorkManager's `WorkerParameters`; + `androidx.work.testing`'s `TestListenableWorkerBuilder` can build and run it on the JVM under + Robolectric. +- *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. + +**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. +- *Why Robolectric:* `ContextCompat.checkSelfPermission`, `shouldShowRequestPermissionRationale`, and + the `Build.VERSION.SDK_INT >= TIRAMISU` gate need a real `Context`/Activity and a settable API + level. Robolectric's `ShadowApplication.grantPermissions(...)` / `denyPermissions(...)` plus + `@Config(sdk=[32])` vs `[33]` make the branches deterministic. +- *`@Config`:* `[32]` (no POST_NOTIFICATIONS → no-op) vs `[33]` (gate active). +- *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`. + +**6. `media/DeviceMediaHints.kt` (`isSamsungDevice`)** — **implemented** +- *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 + workarounds. +- *Why Robolectric:* `Build.MANUFACTURER` / `Build.BRAND` are final statics that read empty in plain + JUnit. Robolectric's `ShadowBuild.setManufacturer("samsung")` / `setBrand(...)` make both branches + testable deterministically. +- *`@Config`:* not version-sensitive. (Small win, but trivially cheap.) + +### Tier 3 — possible later, not urgent + +**7. Move *non-graphical* Compose interaction tests to Robolectric for speed.** Robolectric can run +Compose UI tests on the JVM, which could take some of our `androidTest` onboarding/editor interaction +checks off the emulator and make them much faster. This adds configuration and can be flaky around +graphics, so it's a "when we have time" item — and **screenshot tests +(`LoopifyingScreenshotTest`) and benchmarks stay on a real device.** + +--- + +## What must NEVER move to Robolectric + +These are real-device tests for a real reason. Faking them would produce green tests that prove +nothing: + +| Code | Why it must stay on a device | +|---|---| +| `media/VideoReverser.kt`, `media/VideoProcessor.kt`, `media/MediaCodecLifecycle.kt`, `media/ReverseOutputValidator.kt` | Real `MediaCodec` encode/decode + Media3 Transformer. Robolectric's `MediaCodec` shadow encodes nothing — the output loop wouldn't exist to validate. | +| `camera/CameraManager.kt` | Real CameraX + camera hardware. | +| `MediaMetadataRetriever` paths (`VideoStorageRepositoryImpl` thumbnails/duration, `VideoImporterImpl.probeDurationMs`) | Native frame decode; no real frames under Robolectric. | +| `LoopifyingScreenshotTest`, `media/LoopifyingBenchmarkTest.kt` | Real pixels / real performance numbers. | + +And the flip side — **already pure JUnit, leave them alone** (don't add Robolectric where it buys +nothing): `media/BoomerangSequence.kt`, the lambda-based pure functions in `media/MediaFormatUtils.kt` +(`resolveFrameRate` / `resolveRotationDegrees` were *deliberately* designed to be testable without a +real `MediaFormat`), `work/BoomerangRenderWorkerInput.kt` parsing, the `VideoStorageRepositoryImpl` +file-path/timestamp/migration logic (already JVM-tested with `TemporaryFolder` — Lesson 008), and all +the `OpenLoopViewModel` state-transition tests. + +--- + +## Common pitfalls + +- **Don't fake the media pipeline.** (Said three times on purpose.) If a test's value depends on real + encoded video, it belongs on a device. A Robolectric `MediaCodec` is a hollow shell. +- **SDK 36 needs JDK 21.** A class-version / "unsupported major.minor" failure at test startup means + the tests are running on a JDK older than 21. The toolchain block in `build.gradle.kts` handles the + CLI; set Android Studio's Gradle JDK to 21+ too. +- **Pick the `@Config(sdk=[…])` that matters — don't just default.** OpenLoop's bugs are + version-specific. If the behavior under test is version-gated, *name the versions* on both sides of + the boundary (e.g. `[34]` and `[35]` for FGS type; `[32]` and `[33]` for POST_NOTIFICATIONS). A + test with no `@Config` runs at `targetSdk` (36) only and would have missed the Android-14 crash. +- **Resources need `isIncludeAndroidResources = true`.** Already set. If you split modules later and + resource lookups (`getString`) start returning nothing in a Robolectric test, this flag is the + first thing to check. +- **`src/test/` only.** Robolectric, `mockk`, and the JVM fakes are invisible to `src/androidTest/` + (Lesson 017). Don't try to import a Robolectric test helper into an instrumented test. + +--- + +## How to run a single Robolectric test + +It's a normal unit test, so the normal unit-test commands work (it just happens to run framework +code). From the repo root: + +```bash +# Run one test CLASS +./gradlew :app:testDebugUnitTest --tests "io.github.stozo04.openloop.work.BoomerangRenderForegroundInfoRobolectricTest" + +# Run one test METHOD +./gradlew :app:testDebugUnitTest --tests "io.github.stozo04.openloop.work.BoomerangRenderForegroundInfoRobolectricTest.android14_foregroundInfo_isDataSync_reproducesAndProvesFix" + +# Run every unit test (plain + Robolectric) in the debug variant +./gradlew :app:testDebugUnitTest +``` + +On Windows use `gradlew.bat` instead of `./gradlew`. In Android Studio you can also click the green +gutter arrow next to the test — just confirm the Gradle JDK is 21+ first (see the pitfalls above). + +> First run is slow: Robolectric downloads the "android-all" jar for each `@Config` SDK level once, +> then caches it. Subsequent runs are fast. + +--- + +## Glossary + +| Term | Plain meaning | +|------|---------------| +| **Robolectric** | A library that runs *simulated* Android framework code inside a normal JVM unit test — no device. Can pretend to be any Android version. | +| **JVM** | The "Java Virtual Machine" — what runs your Kotlin/Java on your laptop. | +| **Unit test (`src/test/`)** | A fast test that runs on the JVM, no device. | +| **Instrumented test (`src/androidTest/`)** | A test that runs on a real device/emulator with the real Android framework. | +| **`@Config(sdk=[34])`** | A Robolectric annotation that makes the test pretend to run on a specific Android API level (here, Android 14). | +| **`ApplicationProvider.getApplicationContext()`** | The Robolectric-friendly way to get a real `Context` in a test. | +| **Shadow** | Robolectric's stand-in for a framework class (e.g. `ShadowBuild`, `ShadowContentResolver`) you configure from the test. | +| **FGS** | Foreground Service — a long-running, user-visible background task (OpenLoop uses one to export loops). | +| **`@Config`** | Robolectric annotation for per-test config — most importantly the SDK level(s). | +| **`testImplementation`** | A dependency available only to `src/test/` (unit tests), not the app or instrumented tests. | +| **targetSdk / compileSdk** | The Android version the app targets (36 here). Robolectric defaults its simulated version to this. | +| **MediaCodec / Media3 Transformer** | Android's real video encode/decode engines. Robolectric does *not* run these for real — that's why the media pipeline stays on a device. | + +--- + +## 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`. +- 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`. +- Robolectric's official docs: [robolectric.org](https://robolectric.org/) · Google's testing guidance: [Build local unit tests](https://developer.android.com/training/testing/local-tests). diff --git a/docs/guides/samsung-rtl-steps.md b/docs/guides/samsung-rtl-steps.md index 4d95432..3ba924b 100644 --- a/docs/guides/samsung-rtl-steps.md +++ b/docs/guides/samsung-rtl-steps.md @@ -9,6 +9,10 @@ Galaxy hardware. > Samsung's lab**, streamed to your browser. That's why camera capture shows a wall and > why you test the media pipeline via **import** instead. +> **Automated sweep:** After RDB connect, run the scripted regression: +> `pwsh .claude/skills/run-e2e-pixel-sweep/scripts/samsung-rtl-sweep.ps1 -ArtifactDir $env:TEMP\openloop_rtl\s23` +> Full OEM context: [`oem-regression-testing.md`](oem-regression-testing.md). + --- ## 1. One-time setup diff --git a/docs/lessons_learned/024-fgs-type-constant-api-gating.md b/docs/lessons_learned/024-fgs-type-constant-api-gating.md new file mode 100644 index 0000000..91901f9 --- /dev/null +++ b/docs/lessons_learned/024-fgs-type-constant-api-gating.md @@ -0,0 +1,66 @@ +# 024 — Gate a foreground-service type on the API level that ADDED it, not one below + +## What went wrong + +The Loopify render runs under a WorkManager foreground service. `BoomerangRenderNotifications` +picked the FGS type like this: + +```kotlin +val fgsType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { // API 34 — WRONG + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING // value 8192 +} else 0 +``` + +`FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING` was **added in API 35 (Android 15)**, not 34. The +constant happily *compiles* against `compileSdk 36`, so nothing flagged it — but it is inlined to the +literal `8192`, and on an actual **Android 14 (API 34)** device the platform's service validator does +not recognize that type bit. With `targetSdk 36`, an unrecognized type is fatal: + +``` +java.lang.RuntimeException: Unable to start service SystemForegroundService … +Caused by: android.app.InvalidForegroundServiceTypeException: + Starting FGS with type unknown … targetSDK=36 has been prohibited +``` + +Real crash: Crashlytics `9663c743…`, Samsung Galaxy A55 (SM-A556E), Android 14, v1.0.23 — 100% of +Loopify saves on every Android-14 device. + +## Pattern + +- A new `FOREGROUND_SERVICE_TYPE_*` constant is usable **only from the API level where it was added**, + even though it compiles against any newer `compileSdk`. Gate on that exact level and fall back to a + type that exists on older OSes. +- For media/transcode work the broadly-valid fallback is `dataSync` + (`FOREGROUND_SERVICE_TYPE_DATA_SYNC` = 1, added API 29 — Google's documented type for + "import/export … transfer" and what WorkManager's long-running-worker sample uses). + +```kotlin +fun foregroundServiceTypeForSdk(sdkInt: Int): Int = when { + sdkInt >= Build.VERSION_CODES.VANILLA_ICE_CREAM -> ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING // 35+ + sdkInt >= Build.VERSION_CODES.Q -> ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC // 29–34 + else -> 0 // < 29 +} +``` + +- Declare **every** type the app can request on the `` in the manifest + (`android:foregroundServiceType="dataSync|mediaProcessing"`) plus each type's permission. The + platform requires the requested type to be a subset of the declared set. +- The type passed to `startForeground()` comes from the `ForegroundInfo` you hand WorkManager — the + manifest declaration alone is not what's validated at runtime. + +## Detection checklist + +- `grep -rn "FOREGROUND_SERVICE_TYPE_" app/src/main` — for each constant, confirm the gate uses the + API level it was **added** in (`UPSIDE_DOWN_CAKE`=34, `VANILLA_ICE_CREAM`=35), not an adjacent one. +- Any FGS type requested at runtime must also appear in `android:foregroundServiceType` AND have its + `FOREGROUND_SERVICE_*` permission in the manifest. +- Extract the SDK→type choice into a pure `fun(sdkInt): Int` so it's unit-testable without Robolectric + (the constants inline) — see `BoomerangRenderNotificationsTest`. + +## Reference + +- ServiceInfo (per-constant "Added in API level"): + https://developer.android.com/reference/android/content/pm/ServiceInfo +- Changes to foreground service types for Android 15: + https://developer.android.com/about/versions/15/changes/foreground-service-types +- Origin: Crashlytics issue `9663c7436eb21e7ec4219eba1125416f` (Galaxy A55 / Android 14, v1.0.23). diff --git a/docs/lessons_learned/README.md b/docs/lessons_learned/README.md index 04e4c88..a32e099 100644 --- a/docs/lessons_learned/README.md +++ b/docs/lessons_learned/README.md @@ -36,6 +36,7 @@ Each lesson follows the same shape: | 021 | [Don't downscale inside a decode→encode-Surface transcode by sizing the encoder Surface smaller than the decoder output (green corruption on the SW path); cap resolution in the Media3 render instead](./021-reverse-downscale-surface-mismatch.md) | Onboarding video | | 022 | [Release CameraX when the PreviewView leaves composition, not only when the Activity stops](./022-release-camera-when-preview-leaves-composition.md) | Issue #36 | | 023 | [A media pipeline stage must count its own output samples; a zero-frame stage can exit "cleanly"](./023-media-pipeline-stages-must-count-output-samples.md) | PR #62 (S23 zero-frame wedge) | +| 024 | [Gate a foreground-service type on the API level that ADDED it, not one below](./024-fgs-type-constant-api-gating.md) | Crashlytics 9663c743 (Galaxy A55 / Android 14) | ## Adding a new lesson diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8f5b7f9..96814f7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,8 @@ coreSplashscreen = "1.2.0" fragment = "1.5.4" androidxTestExtJunit = "1.3.0" androidxTestRules = "1.6.1" +androidxTestCore = "1.6.1" +robolectric = "4.16.1" espressoCore = "3.7.0" work = "2.11.1" firebaseBom = "34.4.0" @@ -62,6 +64,11 @@ androidx-fragment = { group = "androidx.fragment", name = "fragment", version.re junit = { group = "junit", name = "junit", version.ref = "junit" } mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesTest" } + +# Robolectric — JVM unit tests that run real Android framework code under a chosen API level +# (@Config(sdk=[...])), so version-gated platform logic can be verified on the JVM without a device. +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" } androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExtJunit" }