Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .claude/skills/run-e2e-pixel-sweep/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,3 +194,7 @@ device-specific Crashlytics issue is fixed from emulator evidence alone).
14. **Emulator quirks:** all three AVDs report model `sdk_gphone16k_x86_64` (verify identity
with `adb emu avd name`, not the model prop); adb sometimes holds a stale
`localhost:NNNNN` transport — always pass `-s $serial`.
15. **API 34 Photo Picker + Download/:** `sweep-prep` pushes the fixture to `Download/` on
every device, and **also** to `DCIM/Camera/` when `ro.build.version.sdk` ≤ 34 — otherwise
`Pixel_8_API34` import flakes with an empty picker ("No photos or videos") even though
the file is on disk. Not an app bug; drive-flow still taps `"Video taken on"`.
18 changes: 16 additions & 2 deletions .claude/skills/run-e2e-pixel-sweep/scripts/sweep-prep.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,22 @@ adb -s $serial install -r -g $apk | Out-Null
# drive-flow's step 0 self-heals by relaunching if the race still fires.
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

# Stage the canonical fixture for drive-flow's Gallery → Import → Photo Picker path.
adb -s $serial push $video "/sdcard/Download/$VideoName" | Out-Null
adb -s $serial shell am broadcast -a android.intent.action.MEDIA_SCANNER_SCAN_FILE `
-d "file:///sdcard/Download/$VideoName" | Out-Null
# Android 14 (API 34) Photo Picker often shows "No photos or videos" for Download/ only —
# drive-flow taps "Video taken on" in the default Photos grid, which indexes DCIM/Camera.
$sdk = [int](((adb -s $serial shell getprop ro.build.version.sdk) -join '').Trim())
if ($sdk -le 34) {
adb -s $serial shell mkdir -p /sdcard/DCIM/Camera | Out-Null
adb -s $serial push $video "/sdcard/DCIM/Camera/$VideoName" | Out-Null
adb -s $serial shell am broadcast -a android.intent.action.MEDIA_SCANNER_SCAN_FILE `
-d "file:///sdcard/DCIM/Camera/$VideoName" | Out-Null
Start-Sleep -Seconds 3
}

# 3-button nav so the trim-handle edge drag can't fire the back gesture.
adb -s $serial shell cmd overlay enable com.android.internal.systemui.navbar.threebutton

Expand Down
92 changes: 90 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.gradle.testing.jacoco.plugins.JacocoTaskExtension
import org.gradle.testing.jacoco.tasks.JacocoCoverageVerification
import org.gradle.testing.jacoco.tasks.JacocoReport
import java.util.Properties

plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.baselineprofile)
jacoco
}

// Release signing is driven by a gitignored keystore.properties at the repo root (never commit
Expand All @@ -24,8 +28,8 @@ android {
applicationId = "io.github.stozo04.openloop"
minSdk = 26
targetSdk = 36
versionCode = 26
versionName = "1.0.26"
versionCode = 27
versionName = "1.0.27"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
Expand Down Expand Up @@ -125,6 +129,46 @@ kotlin {
}
}

jacoco {
// 0.8.13 supports the Java 21 test runtime used by Robolectric in this project.
toolVersion = "0.8.13"
}

val debugCoverageClassExcludes = listOf(
// Android/AGP generated classes.
"**/R.class",
"**/R$*.class",
"**/BuildConfig.*",
"**/Manifest*.*",
"**/*Test*.*",
// Compose compiler and Kotlin synthetic implementation details that do not map cleanly to
// source-level business logic coverage.
"**/*ComposableSingletons*.*",
)

fun debugUnitCoverageClassDirectories() =
files(
layout.buildDirectory.dir("intermediates/javac/debug/compileDebugJavaWithJavac/classes"),
layout.buildDirectory.dir("intermediates/built_in_kotlinc/debug/compileDebugKotlin/classes"),
).asFileTree.matching {
exclude(debugCoverageClassExcludes)
}

fun debugViewModelCoverageClassDirectories() =
debugUnitCoverageClassDirectories().matching {
include("**/ui/OpenLoopViewModel*.class")
exclude(
"**/OpenLoopViewModel\$Companion.class",
"**/OpenLoopViewModel\$Factory.class",
"**/OpenLoopViewModel\$WhenMappings.class",
)
}

val debugUnitCoverageSourceDirectories = files(
"src/main/java",
"src/main/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
Expand All @@ -137,6 +181,50 @@ tasks.withType<Test>().configureEach {
languageVersion.set(JavaLanguageVersion.of(21))
},
)
extensions.configure(JacocoTaskExtension::class) {
isIncludeNoLocationClasses = true
excludes = listOf("jdk.internal.*")
}
}

tasks.register<JacocoReport>("jacocoDebugUnitTestReport") {
group = "verification"
description = "Runs debug JVM/Robolectric unit tests and generates JaCoCo coverage reports."

dependsOn("testDebugUnitTest")

classDirectories.setFrom(debugUnitCoverageClassDirectories())
sourceDirectories.setFrom(debugUnitCoverageSourceDirectories)
executionData.setFrom(layout.buildDirectory.file("jacoco/testDebugUnitTest.exec"))

reports {
xml.required.set(true)
html.required.set(true)
csv.required.set(false)
}
}

tasks.register<JacocoCoverageVerification>("jacocoDebugViewModelCoverageVerification") {
group = "verification"
description = "Fails when debug JVM/Robolectric coverage for the ViewModel state machine drops below 90%."

dependsOn("jacocoDebugUnitTestReport")

classDirectories.setFrom(debugViewModelCoverageClassDirectories())
sourceDirectories.setFrom(debugUnitCoverageSourceDirectories)
executionData.setFrom(layout.buildDirectory.file("jacoco/testDebugUnitTest.exec"))

violationRules {
rule {
limit {
minimum = "0.90".toBigDecimal()
}
}
}
}

tasks.named("check") {
dependsOn("jacocoDebugViewModelCoverageVerification")
}

dependencies {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import java.io.File
*
* 1. **FileProvider scope** — the provider in AndroidManifest + res/xml/file_paths.xml must expose
* `filesDir/videos/`. A persisted clip resolves to a `content://` URI; scratch files under
* `cacheDir/scratch/` must NOT (so in-flight captures can never leak through the share sheet).
* `filesDir/scratch/` must NOT (so in-flight captures can never leak through the share sheet).
* 2. **Share intent shape** — [buildBoomerangShareIntent] must produce an ACTION_SEND `video/mp4`
* intent carrying the URI as EXTRA_STREAM, the subject, and the temporary read-grant flag.
*
Expand Down Expand Up @@ -48,8 +48,8 @@ class ShareBoomerangTest {

@Test
fun fileProvider_rejectsScratchCapture_notInConfiguredPaths() {
// In-flight captures live in cacheDir/scratch/ — deliberately NOT listed in file_paths.xml.
val scratch = File(context.cacheDir, "scratch").apply { mkdirs() }
// In-flight captures live in filesDir/scratch/ — deliberately NOT listed in file_paths.xml.
val scratch = File(context.filesDir, "scratch").apply { mkdirs() }
val scratchFile = File(scratch, "raw_test.mp4").apply { writeBytes(ByteArray(4)) }
try {
assertThrows(IllegalArgumentException::class.java) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ data class RecordedVideo(
)

/**
* A per-capture scratch target under `cacheDir/scratch/`, identified by a UUID.
* A per-capture scratch target under `filesDir/scratch/`, identified by a UUID.
*
* One [ScratchCapture] is minted per shutter press ([VideoStorageRepository.createScratchCapture]).
* The camera records into [file]; on a successful capture the scratch is promoted to a persistent
Expand All @@ -45,7 +45,7 @@ data class ScratchCapture(val uuid: String, val file: File)
* Contract for persisting and reading recorded clips (raws + boomerangs).
*
* Backed by the app's private filesystem:
* - `cacheDir/scratch/raw_<uuid>.mp4` — per-capture scratch (volatile; cache-evictable)
* - `filesDir/scratch/raw_<uuid>.mp4` — per-capture scratch, pruned after abandonment
* - `filesDir/videos/clip_<ts>.mp4` — persisted raw captures
* - `filesDir/videos/boom_<ts>_from_<rawTs>.mp4` — rendered loops
* - `filesDir/thumbnails/<stem>.jpg` — thumbnails for both kinds
Expand All @@ -57,7 +57,7 @@ data class ScratchCapture(val uuid: String, val file: File)
interface VideoStorageRepository {

/**
* Mint a fresh per-capture scratch target (`cacheDir/scratch/raw_<uuid>.mp4`). The returned
* Mint a fresh per-capture scratch target (`filesDir/scratch/raw_<uuid>.mp4`). The returned
* [ScratchCapture.file] does NOT yet exist on disk — the camera creates it by recording into it.
*/
fun createScratchCapture(): ScratchCapture
Expand All @@ -76,14 +76,14 @@ interface VideoStorageRepository {
fun discardScratch(scratch: ScratchCapture)

/**
* Delete scratch files under `cacheDir/scratch/` whose `lastModified()` is older than
* Delete scratch files under `filesDir/scratch/` whose `lastModified()` is older than
* [olderThanMs] (i.e. age > [olderThanMs]); returns the count deleted (parent D-8). Best-effort
* cleanup of orphaned captures/imports left behind by an interrupted session — imports raise this
* churn since an abandoned copy can be a whole library video. Sweeps both the top-level scratch
* captures/imports AND the cached reversed clips in `scratch/reversed/` (one ≤1080p MP4 per
* distinct trim window, never overwritten — the heaviest orphans), so the reclaim reaches the
* cache that actually grows. Younger files (a session possibly still in flight) are left
* untouched. `suspend` + off the main thread (filesystem scan).
* churn since an abandoned copy can be a whole library video. Also sweeps legacy
* `cacheDir/scratch/raw_*.mp4` captures and the cached reversed clips in
* `cacheDir/scratch/reversed/` (one ≤1080p MP4 per distinct trim window, never overwritten), so
* reclaim reaches both the old layout and the cache that actually grows. Younger files (a session
* possibly still in flight) are left untouched. `suspend` + off the main thread (filesystem scan).
*/
suspend fun pruneStaleScratch(olderThanMs: Long): Int

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import java.util.UUID
* `Factory` bridges Context to these paths once, in `MainActivity`.
*
* On-disk layout:
* - scratch capture: `cacheDir/scratch/raw_<uuid>.mp4` (per capture; cache-evictable)
* - scratch capture: `filesDir/scratch/raw_<uuid>.mp4` (per capture; private app data)
* - persisted clips: `filesDir/videos/` — raws as `clip_<timestamp>.mp4`, rendered loops as
* `boom_<timestamp>_from_<rawTimestamp>.mp4` (same directory; distinguished by filename prefix)
* - thumbnails: `filesDir/thumbnails/<same-stem>.jpg` (JPEG, 90% quality)
Expand All @@ -30,7 +30,9 @@ class VideoStorageRepositoryImpl(

private val videosDir = File(filesDir, "videos")
private val thumbnailsDir = File(filesDir, "thumbnails")
private val scratchDir = File(cacheDir, "scratch")
private val scratchDir = File(filesDir, "scratch")
private val legacyCacheScratchDir = File(cacheDir, "scratch")
private val reversedScratchDir = File(legacyCacheScratchDir, REVERSED_SUBDIR)

/**
* Last minted millis-timestamp id. Two saves in the same wall-clock millisecond would otherwise
Expand Down Expand Up @@ -105,15 +107,12 @@ class VideoStorageRepositoryImpl(

override suspend fun pruneStaleScratch(olderThanMs: Long): Int = withContext(Dispatchers.IO) {
val cutoff = System.currentTimeMillis() - olderThanMs
// Prune BOTH the per-capture/import scratch files directly under scratch/ (raw_<uuid>.mp4)
// AND the cached reversed clips under scratch/reversed/. The reversed cache is where the
// heaviest orphans accumulate — one ≤1080p reversed MP4 per distinct trim window, written
// once and never overwritten (keyed by sha1(path_trimStart_trimEnd) in VideoReverser) — so
// D-8's reclaim must reach it. The prior sweep only looked at the top level and skipped the
// reversed/ directory entirely (isFile == false for it), leaving it to grow until opaque OS
// cache eviction. (Dir name mirrors VideoReverser's scratchDir = cacheDir/scratch/reversed.)
// Prune active capture/import scratch files in filesDir/scratch/ plus legacy raw scratch
// files from the old cacheDir/scratch/ layout. Reverse-preview cache files remain under
// cacheDir/scratch/reversed/ because they are reproducible and safely cache-evictable.
val deleted = pruneStaleFilesIn(scratchDir, cutoff) +
pruneStaleFilesIn(File(scratchDir, REVERSED_SUBDIR), cutoff)
pruneStaleFilesIn(legacyCacheScratchDir, cutoff) +
pruneStaleFilesIn(reversedScratchDir, cutoff)
if (deleted > 0) Log.d(TAG, "Pruned $deleted stale scratch file(s)")
deleted
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ internal object ReverseCrashlytics {
.putString("mmr_failure_kind", failureKind.take(1024))
source?.let {
builder
.putBoolean("source_exists", it.exists())
.putLong("source_bytes", it.length())
.putString("source_name", it.name.take(1024))
}
Expand Down Expand Up @@ -198,6 +199,7 @@ internal object ReverseCrashlytics {
.putLong("trim_start_ms", trimStartMs)
.putLong("trim_end_ms", trimEndMs)
.putLong("trim_window_ms", (trimEndMs - trimStartMs).coerceAtLeast(0L))
.putBoolean("source_exists", source.exists())
.putLong("source_bytes", source.length())
.putString("source_name", source.name.take(1024))
if (diagnostics != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,9 @@ class OpenLoopViewModel(
private val _renderProgress = MutableStateFlow(0f)
val renderProgress: StateFlow<Float> = _renderProgress.asStateFlow()

/** Guards against repeated Save taps while promotion/enqueue/render is already active. */
private var saveInProgress = false

/** One-shot snackbar events (see [BoomerangEvent]); collected once by MainActivity. */
private val _events = Channel<BoomerangEvent>(Channel.BUFFERED)
val events: Flow<BoomerangEvent> = _events.receiveAsFlow()
Expand Down Expand Up @@ -346,7 +349,7 @@ class OpenLoopViewModel(

_uiState.value = OpenLoopUiState.Recording

// Per-capture scratch file (cacheDir/scratch/raw_<uuid>.mp4) instead of a single fixed path,
// Per-capture scratch file (filesDir/scratch/raw_<uuid>.mp4) instead of a single fixed path,
// so the captured clip has a stable identity for the Trim screen and back-to-back captures
// can't clobber each other.
val scratch = videoStorage.createScratchCapture()
Expand Down Expand Up @@ -1045,6 +1048,8 @@ class OpenLoopViewModel(
fun saveBoomerang() {
val editor = _editorState.value ?: return
val scratch = activeScratch ?: return
if (saveInProgress || _uiState.value is OpenLoopUiState.Processing) return
saveInProgress = true
val tab = _editorTabState.value
val mode = tab.mode

Expand Down Expand Up @@ -1121,6 +1126,7 @@ class OpenLoopViewModel(
// End the WorkManager observer without canceling this coroutine mid-collect.
renderObserveJob = null
activeRenderScratchUuid = null
saveInProgress = false
cancelReverseJob()
activeScratch = null
promotedRaw = null
Expand Down Expand Up @@ -1159,6 +1165,7 @@ class OpenLoopViewModel(
renderObserveJob?.cancel()
renderObserveJob = null
activeRenderScratchUuid = null
saveInProgress = false
_renderProgress.value = 0f
val editor = _editorState.value
val trimStartMs = editor?.trimStartMs ?: 0L
Expand Down Expand Up @@ -1250,6 +1257,7 @@ class OpenLoopViewModel(
renderObserveJob?.cancel()
renderObserveJob = null
activeRenderScratchUuid = null
saveInProgress = false
}

/** Clear the active editing session (after discard or navigation away). Does NOT touch on-disk files. */
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/res/xml/file_paths.xml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
FileProvider path map (slice 06). The <files-path> root is Context.getFilesDir(), so this exposes
filesDir/videos/ — persisted clips and rendered loops. Scratch clips (cacheDir/scratch/) are
filesDir/videos/ — persisted clips and rendered loops. Scratch clips (filesDir/scratch/) are
deliberately NOT listed, so in-flight captures can never leak through the share sheet.
-->
<paths>
Expand Down
Loading