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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ Before anything destructive (deleting files, overwriting code, sending communica

### Definition of Done — required before "done" or "Ready for PR"

**Production zero-error rule (non-negotiable):** OpenLoop is live in Production and reachable by billions of users. **Any error the agent encounters while working — a failing test, a compile error, a lint error, a crash — MUST be resolved before a PR is created, even pre-existing failures the agent did not introduce.** "Not my change" is never a reason to open a PR on a red baseline; fix it as part of the work, or stop and escalate to the owner for explicit direction. A PR is opened only from a fully green state (clean debug + release build, 0 test failures, 0 new lint errors). Full policy in **[`docs/DEFINITION_OF_DONE.md`](docs/DEFINITION_OF_DONE.md)**.

A change is **not done because it compiles.** Before calling any non-trivial change done or opening a PR, clear the verification gate in **[`docs/DEFINITION_OF_DONE.md`](docs/DEFINITION_OF_DONE.md)**: baseline → clean build (debug **and** release) genuinely green → requirement checks (e.g. 16 KB `zipalign`) → unit + instrumented tests with 0 failures → **static analysis clean** (Android Lint reports zero *new* errors via `./gradlew :app:lintDebug`, and IDE "Inspect Code" / `inspect.bat` run locally — see **[`docs/STATIC_ANALYSIS.md`](docs/STATIC_ANALYSIS.md)**) → **actually run the app on an emulator, launch it, and capture a screenshot as proof** → honestly state what could not be verified + a manual QA checklist → attach the screenshot to the PR. "Genuinely green" = `BUILD SUCCESSFUL` **and** exit code 0 **and** zero `e:` errors (never trust a `| tail`-masked exit code). This is the standard, not a nice-to-have.

### Note-taking
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,44 @@ class VideoReverserTest {
}
}

/**
* The fix for Crashlytics 47233ad7 (LG LM-X540, Android 10): when the device's hardware AVC codec
* rejects start with `IllegalArgumentException: start failed`, [VideoReverser] must retry once on
* the **software** encoder + decoder and still produce a valid reversed clip — on any manufacturer,
* not just Samsung.
*
* The 3-device emulator sweep never exercised this branch (emulators already use the software
* codec, so nothing ever rejects start). This test closes that gap by injecting the exact failure
* on attempt 0 via [VideoReverser.attemptHook], then asserting (a) a valid playable output still
* comes out, and (b) the retry actually flipped to the software encoder AND decoder.
*/
@Test
fun reverse_recoversFromCodecStartFailure_viaSoftwareFallback() = runBlocking {
val attempts = mutableListOf<Triple<Int, Boolean, Boolean>>()
reverser.attemptHook = { attempt, preferSoftwareEncoder, preferSoftwareDecoder ->
attempts.add(Triple(attempt, preferSoftwareEncoder, preferSoftwareDecoder))
// Simulate the LG hardware codec refusing to start on the first attempt only.
if (attempt == 0) throw IllegalArgumentException("start failed")
}

val output = reverser.reverse(fixture, 0L, durationMs)

// (a) Recovery produced a real, valid, playable reversed clip — not an empty shell.
val validation = ReverseOutputValidator.validateReversedOutput(output)
assertTrue("expected a valid recovered reverse, got ${validation.reason}", validation.valid)
assertTrue("expected >=1 muxed sample, got ${validation.sampleCount}", validation.sampleCount >= 1)

// (b) It recovered via the software-codec retry: attempt 0 failed on the default path, and the
// retry (attempt 1) forced BOTH the software encoder and the software decoder.
assertTrue("expected the reverse to retry, got attempts=$attempts", attempts.size >= 2)
assertEquals("first attempt index", 0, attempts[0].first)
assertTrue("first attempt must not force the software decoder", !attempts[0].third)
val retry = attempts[1]
assertEquals("retry must be attempt index 1", 1, retry.first)
assertTrue("retry must force the software ENCODER", retry.second)
assertTrue("retry must force the software DECODER", retry.third)
}

// ── Fixture acquisition ──────────────────────────────────────────────────────────────────────

/** Bundled asset → synthetic generation → skip. Never returns a non-existent file. */
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.github.stozo04.openloop.media

import android.media.MediaCodec
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.isActive
import kotlin.coroutines.coroutineContext
Expand Down Expand Up @@ -65,3 +66,47 @@ internal fun shouldRetryMediaCodecContention(
if (failedAttemptIndex >= maxAttempts - 1 || !isMediaCodecLifecycleFailure(error)) return false
return samsung || isMediaCodecSurfaceReleasedFailure(error)
}

/**
* A [MediaCodec] that fails to **initialize** — `configure()` / `start()` rejecting the request —
* as opposed to a benign teardown ([isMediaCodecLifecycleFailure]) or a healthy-codec data problem.
*
* The motivating signature is `IllegalArgumentException: start failed` (LG LM-X540 / Android 10,
* Crashlytics 47233ad7): "start failed" is the JNI message `android.media.MediaCodec.native_start()`
* passes to `throwExceptionAsNecessary`, and a `BAD_VALUE` native status surfaces it as an
* [IllegalArgumentException]. On a low-end (often MediaTek) device under memory pressure the *hardware*
* AVC codec can reject start outright; the software codec usually still comes up. These failures are
* NOT Samsung-specific, so [shouldRetryMediaCodecContention] never recovered them — the reverse died
* on the first attempt with no fallback.
*
* [MediaCodec.CodecException] (the documented codec-error type) also counts, except when it presents
* as a benign lifecycle teardown (already filtered out above).
*/
internal fun isMediaCodecInitializationFailure(error: Throwable): Boolean {
if (isMediaCodecLifecycleFailure(error)) return false
return when (error) {
is MediaCodec.CodecException -> true
is IllegalArgumentException, is IllegalStateException -> {
val message = error.message.orEmpty()
message.contains("start failed", ignoreCase = true) ||
message.contains("configure failed", ignoreCase = true) ||
message.contains("codec failed", ignoreCase = true) ||
message.contains("Failed to initialize", ignoreCase = true) ||
message.contains("Error initializing", ignoreCase = true)
}
else -> false
}
}

/**
* Whether [VideoReverser.reverse] should retry the failed pass with **software** codecs after a
* codec-initialization failure ([isMediaCodecInitializationFailure]). Unlike
* [shouldRetryMediaCodecContention], this is device-agnostic — the hardware codec rejecting
* `start()` is the failure, so the only useful next move is the software encoder + decoder, on any
* manufacturer. Retries only while an attempt remains (mirrors the contention/zero-frame guards).
*/
internal fun shouldRetryReverseWithSoftwareCodec(
error: Throwable,
failedAttemptIndex: Int,
maxAttempts: Int,
): Boolean = failedAttemptIndex < maxAttempts - 1 && isMediaCodecInitializationFailure(error)
68 changes: 58 additions & 10 deletions app/src/main/java/io/github/stozo04/openloop/media/VideoReverser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import android.media.MediaMuxer
import android.os.Build
import android.util.Log
import android.view.Surface
import androidx.annotation.VisibleForTesting
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
Expand Down Expand Up @@ -52,6 +53,16 @@ class VideoReverser(
@Volatile
private var lastSurfaceEncoderName: String? = null

/**
* Test-only fault-injection seam. Invoked at the start of every [reverse] attempt with the
* attempt index and the software-codec preferences that attempt will use. An instrumented test
* throws `IllegalArgumentException("start failed")` from here on attempt 0 to simulate the LG
* LM-X540 hardware-codec start rejection (Crashlytics 47233ad7), then asserts the reverse retries
* on the software encoder + decoder and still produces a valid clip. Always null in production.
*/
@VisibleForTesting
internal var attemptHook: ((attempt: Int, preferSoftwareEncoder: Boolean, preferSoftwareDecoder: Boolean) -> Unit)? = null

/**
* Reverse [source] over `[trimStartMs, trimEndMs]`, returning the reversed MP4 [File].
* Throws [java.io.IOException] / [MediaCodec.CodecException] on a pipeline failure (the caller
Expand Down Expand Up @@ -103,24 +114,36 @@ class VideoReverser(
// HW-encoder attempt will wedge again — skip straight to the software encoder instead of
// paying a doomed attempt + retry delay on each preview/save (S23/API 33, RESEARCH.md §7c).
var zeroFrameFailure = zeroFrameEncoderWedgeSticky
// Set after a codec-initialization failure (e.g. LG LM-X540 `IllegalArgumentException:
// start failed`): the device's HW AVC codec rejected start/configure, so the retry forces
// BOTH the software encoder AND the software decoder, on any manufacturer (Crashlytics
// 47233ad7). Distinct from zeroFrameFailure (encoder-only flip) and the Samsung contention
// retry (same codecs, just a settle delay).
var softwareCodecFallback = false
try {
for (attempt in 0 until maxAttempts) {
// Zero-frame retry pairing (spec §5.8, revised on-device 2026-06-04): on the S23/API 33
// the SW-decoder→HW-encoder pairing starves pass 2 (0 samples) and the HW-decoder→
// HW-encoder pairing throws CodecException 0xe at queueInputBuffer — so the retry
// flips the ENCODER to software (decoder stays per normal selection). The contention
// retry path is unchanged.
val preferSoftwareEncoder = zeroFrameFailure
val preferSoftwareEncoder = zeroFrameFailure || softwareCodecFallback
val preferSoftwareDecoder = softwareCodecFallback
try {
coroutineContext.ensureActive()
attemptHook?.invoke(attempt, preferSoftwareEncoder, preferSoftwareDecoder)
if (attempt > 0) {
lastSurfaceEncoderName = null
intermediate.delete()
output.delete()
ReversePreviewLog.i(
if (preferSoftwareEncoder) "reverse.zero_frame_retry" else "reverse.contention_retry",
when {
softwareCodecFallback -> "reverse.software_codec_retry"
preferSoftwareEncoder -> "reverse.zero_frame_retry"
else -> "reverse.contention_retry"
},
"attempt=${attempt + 1}/$maxAttempts delayMs=${SAMSUNG_CODEC_CONTENTION_RETRY.inWholeMilliseconds} " +
"preferSoftwareEncoder=$preferSoftwareEncoder",
"preferSoftwareEncoder=$preferSoftwareEncoder preferSoftwareDecoder=$preferSoftwareDecoder",
)
delay(SAMSUNG_CODEC_CONTENTION_RETRY)
} else {
Expand All @@ -131,7 +154,9 @@ class VideoReverser(
delay(PRE_REVERSE_CODEC_SETTLE)
}
ReversePreviewLog.d("reverse.pass1.start", "dest=${intermediate.name}")
transcodeToAllKeyframes(source, trimStartMs, trimEndMs, intermediate, preferSoftwareEncoder) { frac ->
transcodeToAllKeyframes(
source, trimStartMs, trimEndMs, intermediate, preferSoftwareEncoder, preferSoftwareDecoder,
) { frac ->
onProgress(frac * 0.5f)
}
ReversePreviewLog.i("reverse.pass1.done", "intermediateBytes=${intermediate.length()}")
Expand All @@ -140,7 +165,7 @@ class VideoReverser(
"reverse.pass2.start",
"dest=${output.name} pass1Encoder=$lastSurfaceEncoderName",
)
reverseAllKeyframeVideo(intermediate, output, preferSoftwareEncoder) { frac ->
reverseAllKeyframeVideo(intermediate, output, preferSoftwareEncoder, preferSoftwareDecoder) { frac ->
onProgress(0.5f + frac * 0.5f)
}
// A pass 2 starved of frames exits "cleanly" with a sample-less shell that the
Expand Down Expand Up @@ -180,6 +205,16 @@ class VideoReverser(
zeroFrameEncoderWedgeSticky = true
continue
}
// HW codec rejected configure/start (e.g. `IllegalArgumentException: start
// failed`) — retry once on the software encoder + decoder, any device.
if (shouldRetryReverseWithSoftwareCodec(t, attempt, maxAttempts)) {
softwareCodecFallback = true
ReversePreviewLog.i(
"reverse.software_codec_fallback",
"after ${t.javaClass.simpleName}: ${t.message}",
)
continue
}
if (!shouldRetryMediaCodecContention(t, attempt, maxAttempts, isSamsungDevice())) {
throw t
}
Expand Down Expand Up @@ -219,6 +254,7 @@ class VideoReverser(
trimEndMs: Long,
dest: File,
preferSoftwareEncoder: Boolean = false,
preferSoftwareDecoder: Boolean = false,
onProgress: (Float) -> Unit,
) {
val extractor = MediaExtractor()
Expand Down Expand Up @@ -284,6 +320,7 @@ class VideoReverser(
decoderMime = inputFormat.getString(MediaFormat.KEY_MIME)!!,
decoderFormat = inputFormat,
preferSoftwareEncoder = preferSoftwareEncoder,
preferSoftwareDecoder = preferSoftwareDecoder,
)
decoder = pipeline.decoder
encoder = pipeline.encoder
Expand Down Expand Up @@ -339,6 +376,7 @@ class VideoReverser(
source: File,
dest: File,
preferSoftwareEncoder: Boolean = false,
preferSoftwareDecoder: Boolean = false,
onProgress: (Float) -> Unit,
) {
val extractor = MediaExtractor()
Expand Down Expand Up @@ -400,6 +438,7 @@ class VideoReverser(
decoderMime = inputFormat.getString(MediaFormat.KEY_MIME)!!,
decoderFormat = inputFormat,
preferSoftwareEncoder = preferSoftwareEncoder,
preferSoftwareDecoder = preferSoftwareDecoder,
)
decoder = pass2Pipeline.decoder
encoder = pass2Pipeline.encoder
Expand Down Expand Up @@ -812,6 +851,7 @@ class VideoReverser(
decoderMime: String,
decoderFormat: MediaFormat,
preferSoftwareEncoder: Boolean = false,
preferSoftwareDecoder: Boolean = false,
): SurfaceCodecPipeline {
var lastFailure: Throwable? = null
repeat(SURFACE_CODEC_PIPELINE_MAX_ATTEMPTS) { attempt ->
Expand All @@ -828,7 +868,7 @@ class VideoReverser(
lastFailure = IOException("encoder input surface invalid after createInputSurface")
return@repeat
}
val decoder = openAvcDecoderForReverse(decoderMime, decoderFormat, surface)
val decoder = openAvcDecoderForReverse(decoderMime, decoderFormat, surface, preferSoftwareDecoder)
return SurfaceCodecPipeline(decoder, encoder, surface)
} catch (t: Throwable) {
lastFailure = t
Expand Down Expand Up @@ -922,23 +962,31 @@ class VideoReverser(

/**
* On Samsung, avoid [MediaCodec.createDecoderByType] defaulting to Exynos while ExoPlayer or pass 1
* still holds codec slots — pair software Google decoder with [openSurfaceAvcEncoder].
* still holds codec slots — pair software Google decoder with [openSurfaceAvcEncoder]. The same
* software try-order is used on ANY device when [preferSoftwareDecoder] is set, i.e. after a
* hardware codec-init failure tripped the software fallback (LG LM-X540 `IllegalArgumentException:
* start failed`, Crashlytics 47233ad7) — the HW decoder rejected start, so the software one is the
* only remaining option.
*
* Note (spec §5.8, on-device 2026-06-04): swapping THIS side to the platform decoder was tried
* for the S23 zero-frame wedge and failed harder (CodecException 0xe at queueInputBuffer with
* the HW decoder feeding the HW encoder surface) — the working fallback flips the ENCODER to
* software instead; the carve-out here stays unconditional.
* the HW decoder feeding the HW encoder surface) — that zero-frame fallback flips only the ENCODER
* to software. The init-failure fallback here additionally forces the software decoder because the
* decoder's own start is what failed.
*/
private fun openAvcDecoderForReverse(
mime: String,
format: MediaFormat,
outputSurface: Surface,
preferSoftwareDecoder: Boolean = false,
): MediaCodec {
val installedDecoders = MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos
.filter { !it.isEncoder && it.supportedTypes.any { t -> t.equals(mime, ignoreCase = true) } }
.map { it.name }
.toSet()
if (isSamsungDevice()) {
// Software-decoder carve-out: always on Samsung (Exynos contention), and on any device once a
// codec-init failure has tripped the software fallback (LG LM-X540 `start failed`, 47233ad7).
if (isSamsungDevice() || preferSoftwareDecoder) {
for (name in samsungSoftwareAvcDecoderTryOrder(installedDecoders)) {
var decoder: MediaCodec? = null
try {
Expand Down
Loading