Merge pull request #5 from JMR-dev/chore/lint-and-format-parity

Add lint, formatting and static analysis; move the toolchain to Java 25
This commit was merged in pull request #5.
This commit is contained in:
Jason Ross
2026-08-22 16:22:39 -05:00
committed by GitHub
52 changed files with 1211 additions and 470 deletions
+7
View File
@@ -0,0 +1,7 @@
root = true
[*.{kt,kts}]
ktlint_code_style = intellij_idea
max_line_length = 120
# @Composable functions are PascalCase by convention — don't flag them as bad function names.
ktlint_function_naming_ignore_when_annotated_with = Composable
+28
View File
@@ -0,0 +1,28 @@
# Normalize line endings across platforms.
* text=auto eol=lf
# Windows scripts must keep CRLF.
*.bat text eol=crlf
# The Gradle wrapper shell script must keep LF (it runs under sh on Unix/macOS).
gradlew text eol=lf
# Treat these as binary.
*.jar binary
*.ttf binary
*.otf binary
*.png binary
*.jpg binary
*.jpeg binary
*.webp binary
*.apk binary
*.aab binary
*.keystore binary
*.jks binary
# The FFmpeg archive under bin/ and the native libraries inside it. Without these a
# normalizing checkout would rewrite bytes that happen to look like line endings and
# corrupt the archive -- which the "FFmpeg binary" CI job exists to catch, but which
# is far cheaper to prevent here.
*.aar binary
*.so binary
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
#
# Runs the instrumented suite on an already-booted emulator, and makes a failure
# diagnosable without a re-run.
#
# Adapted from LibreMail's CI emulator instrumentation. Its lesson, learned there over
# several wedged merge queues, is that a red E2E leg with nothing but "exit 1" in the log
# costs more than the failure itself -- so every failure path here leaves evidence behind.
#
# WHY THIS IS A FILE rather than inline YAML: reactivecircus/android-emulator-runner splits
# its `script:` input on newlines and runs each line as its own `sh -c`. Shell functions,
# `if` blocks and traps cannot survive that, which is why the previous version had its whole
# failure handler crammed onto one unreadable line. One line calls this; this can breathe.
#
# Two failure shapes, deliberately handled differently:
#
# FAILED -- gradle returned non-zero. The reports say which test and why, so capture the
# device and runner state around it.
# WEDGED -- gradle never returned and the wrapper timeout killed it. There is no report at
# all, so the evidence has to be taken from the live device: what test was
# running, and what every process was doing. SIGQUIT is the important part -- ART
# dumps full thread stacks to logcat and /data/anr, which is how you tell a
# deadlocked test from a stuck MediaCodec from an emulator that stopped answering.
#
# Every probe is guarded with `|| true`. A diagnostic must never be the thing that turns a run
# red -- notably, a grep that matches nothing exits 1.
set -uo pipefail
LABEL="${1:-unknown}"
APP_ID="org.libremediaconverter"
TEST_ID="org.libremediaconverter.test"
TMP="${RUNNER_TEMP:-/tmp}"
LOGCAT_LOG="$TMP/logcat-api${LABEL}.txt"
DIAG_LOG="$TMP/diagnostics-api${LABEL}.txt"
WEDGE_LOG="$TMP/wedge-diagnostics-api${LABEL}.txt"
# ~5 min is a healthy leg (measured across API 33-36), and this wraps only the gradle client,
# a subset of that. 20 min is generous enough never to trip on a slow-but-working run, and far
# enough under the job's 60-min cap that a genuine wedge still leaves time to capture it.
WEDGE_TIMEOUT=1200
# Stream logcat from now until the step ends, into a file that survives to the artifact upload.
# Without this, a failure that happens on-device leaves nothing behind: `adb logcat -d` at the
# end only has whatever is still in the ring buffer, and a chatty test run evicts the cause.
echo "===== logcat (api${LABEL}) =====" >> "$LOGCAT_LOG"
adb logcat -v time >> "$LOGCAT_LOG" 2>&1 &
LOGCAT_PID=$!
dump_diagnostics() {
{
echo "===== E2E api${LABEL} failure diagnostics -- $(date -u +%FT%TZ) ====="
echo "--- adb devices ---"; adb devices -l 2>&1 || true
echo "--- guest memory ---"; adb shell cat /proc/meminfo 2>&1 | grep -E 'MemTotal|MemAvailable|SwapTotal' || true
echo "--- guest storage ---"; adb shell df /data 2>&1 || true
echo "--- is the app even installed? ---"; adb shell pm list packages 2>&1 | grep -a libremedia || true
echo "--- native crashes ---"; adb logcat -d -b crash 2>&1 | tail -80 || true
echo "--- runner: kvm ---"; ls -l /dev/kvm 2>&1 || true
echo "--- runner: memory ---"; free -h 2>&1 || true
echo "--- runner: disk ---"; df -h 2>&1 || true
} >> "$DIAG_LOG" 2>&1 || true
# Also to the step log, so the common case needs no artifact download.
echo "----- FAILURE SUMMARY (api${LABEL}) -----"
adb shell cat /proc/meminfo 2>&1 | grep -E 'MemTotal|MemAvailable' || true
echo "--- native crashes (tail 60) ---"
adb logcat -d -b crash 2>&1 | tail -60 || true
}
capture_wedge() {
{
echo "==================================================================="
echo "===== E2E WEDGE -- api${LABEL} -- $1"
echo "===== $(date -u +%FT%TZ) -- after ${WEDGE_TIMEOUT}s wrapper timeout"
echo "==================================================================="
# The single most useful line: which test was in flight when everything stopped.
echo "--- running/last instrumented test (logcat TestRunner) ---"
grep -a TestRunner "$LOGCAT_LOG" 2>/dev/null | tail -25 || true
echo "--- boot state ---"
adb shell getprop sys.boot_completed 2>&1 || true
echo "--- are the binder services published? ---"
for svc in input window activity media.player; do
echo " service check $svc:"; adb shell service check "$svc" 2>&1 || true
done
APP_PID="$(adb shell pidof "$APP_ID" 2>/dev/null | tr -d '\r')" || true
TEST_PID="$(adb shell pidof "$TEST_ID" 2>/dev/null | tr -d '\r')" || true
echo "--- pids --- app: ${APP_PID:-<none>} test: ${TEST_PID:-<none>}"
# SIGQUIT makes ART dump every thread's stack to logcat and /data/anr. This is what
# distinguishes a deadlocked test from a stuck native encode from a dead device.
echo "--- SIGQUIT thread dumps ---"
for pid in $APP_PID $TEST_PID; do
[ -n "$pid" ] && adb shell kill -3 "$pid" 2>&1 || true
done
sleep 5
echo "--- /data/anr/* ---"
adb shell 'cat /data/anr/* 2>/dev/null' 2>&1 || true
echo "--- dumpsys activity ---"; adb shell dumpsys activity 2>&1 || true
echo "--- dumpsys window ---"; adb shell dumpsys window 2>&1 || true
# FFmpeg and Media3 both run through MediaCodec; a wedged transcode shows up here.
echo "--- dumpsys media.player ---"; adb shell dumpsys media.player 2>&1 || true
echo "--- logcat -d (tail 400, includes the SIGQUIT dump) ---"
adb logcat -d 2>&1 | tail -400 || true
} >> "$WEDGE_LOG" 2>&1 || true
echo "::warning::E2E api${LABEL} WEDGED ($1) -- see the wedge-diagnostics-api${LABEL} artifact"
}
echo "::group::E2E api${LABEL}"
adb shell cat /proc/meminfo 2>&1 | grep -E 'MemTotal|MemAvailable|SwapTotal' || true
status=0
# -k 30s SIGKILLs a gradle client that ignores SIGTERM. The wrapper covers ONLY the foreground
# gradle client -- never the emulator, which the action owns -- so it cannot hang the leg.
timeout -k 30s "$WEDGE_TIMEOUT" \
./gradlew :app:connectedDebugAndroidTest -PabiFilters=x86_64 --stacktrace || status=$?
echo "::endgroup::"
if [ "$status" -eq 0 ]; then
kill "$LOGCAT_PID" 2>/dev/null || true
exit 0
fi
if [ "$status" -eq 124 ]; then
capture_wedge "api${LABEL}"
else
echo "::error::E2E api${LABEL} failed (exit $status)"
fi
dump_diagnostics
kill "$LOGCAT_PID" 2>/dev/null || true
exit "$status"
+2 -2
View File
@@ -29,7 +29,7 @@ jobs:
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: temurin
java-version: '17' # AGP 9 requires JDK 17
java-version: '25' # Matches the daemon JVM pinned in gradle/gradle-daemon-jvm.properties
# Gradle runs through the committed wrapper rather than a setup action. The
# wrapper verifies its own distribution against distributionSha256Sum, and
@@ -66,7 +66,7 @@ jobs:
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: temurin
java-version: '17'
java-version: '25'
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
+101 -16
View File
@@ -14,6 +14,14 @@ concurrency:
# owner can repoint v4 at new code, so a tag reference is an open invitation to run
# whatever that repository contains tomorrow. The trailing comment records which
# release each hash corresponds to, since a bare hash is unreadable.
# Nothing here writes: these jobs read the code, build it and attach reports. Declared
# explicitly rather than inherited from the repository default, for the same reason the
# action SHAs above are pinned -- the token's reach should be readable here, and a default
# that widens later should not silently widen these jobs with it. build.yml's release job
# makes the opposite declaration for the same reason.
permissions:
contents: read
env:
GRADLE_CACHE_PATHS: |
~/.gradle/caches
@@ -80,7 +88,7 @@ jobs:
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: temurin
java-version: '17' # AGP 9 will not run on anything older
java-version: '25' # Matches the daemon JVM pinned in gradle/gradle-daemon-jvm.properties
# Gradle is invoked through the committed wrapper rather than a setup action.
# The wrapper verifies its own distribution against distributionSha256Sum, and
@@ -100,6 +108,69 @@ jobs:
name: unit-test-report
path: app/build/reports/tests/
# Reported, not gated. A coverage floor is only meaningful against a measured
# baseline, and this is the thing that measures it -- currently 31% of lines. Once
# that number has settled, a jacocoTestCoverageVerification task can hold it.
- name: Coverage report
run: ./gradlew :app:jacocoTestReport
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: coverage-report
path: app/build/reports/jacoco/jacocoTestReport/
# ---------------------------------------------------------------------------
# Three tools, one job, because they answer three different questions and a
# developer wants all three answers at once rather than one per push.
#
# ktlint -- formatting. Owns it outright; detekt's formatting ruleset is off,
# so the two can never disagree about the same line.
# detekt -- static analysis. Its config lives in config/detekt/detekt.yml and
# overrides only the rules this codebase legitimately breaks.
# lint -- the Android-specific things neither of the others can see: opt-in
# markers, API-level misuse, manifest and resource problems.
#
# --continue is what makes it one round trip: a ktlint failure still lets detekt
# and lint report, so a red run hands over the whole list rather than the first
# item on it.
#
# No emulator and no FFmpeg archive needed, so this is the cheapest gate here and
# deliberately does not depend on the ffmpeg job.
# ---------------------------------------------------------------------------
static-analysis:
name: Static analysis
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: temurin
java-version: '25'
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ${{ env.GRADLE_CACHE_PATHS }}
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: gradle-${{ runner.os }}-
- name: ktlint, detekt and Android lint
run: ./gradlew :app:ktlintCheck :app:detekt :app:lintDebug --continue --stacktrace
# The XML matters as much as the HTML: it is the one that can be diffed between
# runs to see what a change actually moved.
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: static-analysis-reports
path: |
app/build/reports/ktlint/
app/build/reports/detekt/
app/build/reports/lint-results-debug.html
app/build/reports/lint-results-debug.xml
# ---------------------------------------------------------------------------
# One runner per API level, across the whole supported range.
#
@@ -146,7 +217,7 @@ jobs:
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: temurin
java-version: '17'
java-version: '25'
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
@@ -190,20 +261,12 @@ jobs:
# dominate the APK, so shipping arm64 to an x86_64 emulator doubles the
# install for code that can never run: 114 MB against 80 MB.
#
# The probe lines survive from diagnosing the API 37 crash and are kept
# because a red instrumented run is otherwise near-impossible to read from a
# log alone. The first reports what the guest actually got, so a wrong
# emulator configuration is visible on a green run too; the crash dump runs
# only on failure, so a green run is unchanged.
#
# Each line here is a separate `sh -c` -- the action splits the script on
# newlines -- so the failure handler has to stay on one line. The action does
# not pass ignoreReturnCode, so a non-zero line fails the job outright: the
# probe ends in `|| true` because a grep that matches nothing exits 1, and a
# diagnostic must never be the thing that turns a run red.
script: |
adb shell cat /proc/meminfo | grep -E 'MemTotal|MemAvailable|SwapTotal' || true
./gradlew :app:connectedDebugAndroidTest -PabiFilters=x86_64 || { echo "=== guest memory at failure ==="; adb shell cat /proc/meminfo | grep -E 'MemTotal|MemAvailable|SwapTotal'; echo "=== native crashes ==="; adb logcat -d -b crash | tail -60; exit 1; }
# Diagnostics live in .github/scripts/e2e-run.sh, not here. This action splits
# `script` on newlines and runs each line as its own `sh -c`, so a handler written
# inline has to fit on ONE line -- which is how the previous version ended up as an
# unreadable chain of semicolons. One line invokes the script; the script can use
# functions, and captures a hang as well as a failure. See its header.
script: bash .github/scripts/e2e-run.sh ${{ matrix.label }}
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
@@ -212,3 +275,25 @@ jobs:
path: |
app/build/reports/androidTests/
app/build/outputs/androidTest-results/
if-no-files-found: warn
# The streamed logcat and the failure dump. Uploaded always, because a leg that goes red
# once and green on re-run is exactly the one worth reading afterwards, and by then the
# emulator is long gone.
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: e2e-diagnostics-api${{ matrix.label }}
path: |
${{ runner.temp }}/logcat-api${{ matrix.label }}.txt
${{ runner.temp }}/diagnostics-api${{ matrix.label }}.txt
if-no-files-found: warn
# Only exists when the wrapper timeout tripped, so `ignore` keeps healthy runs quiet
# instead of warning on every green leg.
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: e2e-wedge-api${{ matrix.label }}
path: ${{ runner.temp }}/wedge-diagnostics-api${{ matrix.label }}.txt
if-no-files-found: ignore
+137
View File
@@ -0,0 +1,137 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
LibreMediaConverter is an Android media converter (Kotlin, Jetpack Compose, Material 3) with two
conversion engines: Media3 Transformer for the hardware path and FFmpeg for everything the platform
cannot do. See `@README.md` for the architecture and `@LICENSES/README.md` for the split license —
this file covers only what is not obvious from the code.
## Build, test, lint
**Everything is on Java 25** — daemon, CI, IDE, and the app's own bytecode. Four places say so and
they must not drift apart:
| Where | What sets it |
|---|---|
| Gradle daemon | `gradle/gradle-daemon-jvm.properties` → `toolchainVersion=25` |
| CI | `java-version: '25'` in both workflows |
| IDE | `.idea/misc.xml` |
| App bytecode | `compileOptions` in `app/build.gradle.kts` |
**Do not pick a JDK for the daemon — the repo does.** `gradle-daemon-jvm.properties` carries foojay
download URLs per platform, so Gradle provisions and runs the daemon on Java 25 regardless of what
`JAVA_HOME` says (that only sets the *launcher* — `./gradlew --version` prints both). Change it with
`./gradlew updateDaemonJvm --jvm-version=NN`, never by hand.
**Reaching 25 in the bytecode row took a deliberate build change.** AGP 9's built-in Kotlin compiles
with the KGP it bundles — 2.2.10 for AGP 9.3.1 — and that caps `jvmTarget` at 24. The root
`build.gradle.kts` puts KGP (and the lockstep Compose compiler plugin) on the buildscript classpath
so AGP picks up 2.4.10 instead, which supports up to 26. That is why the module applies
`com.android.application` and the Compose plugin by `id()` rather than from the catalog. Verified end
to end, not assumed: compiled classes report major version 69, D8 dexes them, and R8 minifies them.
Consequences worth knowing before touching any of it:
- Raising `kotlin` requires a matching `compose-compiler-gradle-plugin`; they are one version.
- Still **do not** apply `org.jetbrains.kotlin.android` — incompatible with AGP 9's DSL.
- Java 24 is *not* an option even though Kotlin allows it: Adoptium dropped the EOL non-LTS, so
there is no installable temurin-24. 25 is LTS and in the repo.
The Gradle wrapper does **not** float and cannot: `distributionUrl` names one archive and
`distributionSha256Sum` is that file's checksum. Bump it with `./gradlew wrapper --gradle-version X
--gradle-distribution-sha256-sum <sha>` so the two stay consistent.
```bash
./gradlew :app:assembleDebug # build debug APK
./gradlew :app:testDebugUnitTest # JVM unit tests
./gradlew :app:ktlintCheck # formatting
./gradlew :app:ktlintFormat # fix formatting in place
./gradlew :app:detekt # static analysis
./gradlew :app:lintDebug # Android lint
./gradlew :app:jacocoTestReport # coverage (XML+HTML under app/build/reports/jacoco/)
# single unit test:
./gradlew :app:testDebugUnitTest --tests "org.libremediaconverter.model.ConversionRouterTest"
```
CI's "Static analysis" gate is exactly `./gradlew :app:ktlintCheck :app:detekt :app:lintDebug
--continue`. Run it with `--continue` locally too: one round trip gives you all three lists instead
of the first one that fails.
**Before treating a change as done**, run: `assembleDebug` + `testDebugUnitTest` +
`compileDebugAndroidTestKotlin` + `ktlintCheck` + `detekt` + `lintDebug`.
`compileDebugAndroidTestKotlin` matters more here than it looks — the instrumented suite cannot run
on this machine (below), so without it an androidTest compile error is not discovered until CI.
ktlint and detekt also cover the `test`/`androidTest` source sets that `lintDebug` skips.
## Instrumented tests do not run locally
Two independent reasons, so do not spend time on either:
- **Emulators segfault on this host.** qemu dies on every AVD. Instrumented tests run on CI or on
the physical Pixel, never in a local emulator.
- **The API 37 image is broken.** `android-37.0` crash-loops surfaceflinger inside its own gralloc
mapper, so every test fails there regardless of this app. `docs/api-37-emulator-crash.md` records
the evidence and the ruled-out fixes; CI's matrix therefore stops at API 36 even though targetSdk
is 37. **API 37 needs a manual check on the Pixel 10 Pro XL before each release.**
On a device or emulator, build only the ABI it can execute:
```bash
./gradlew :app:connectedDebugAndroidTest -PabiFilters=x86_64
```
FFmpeg's native libraries dominate the APK, so shipping arm64 to an x86_64 emulator doubles the
install for code that can never run — and on API 37 the full APK does not fit at all.
## Conventions
- **ktlint owns formatting, detekt owns static analysis.** detekt's formatting ruleset is off, so
the two can never disagree about the same line. Never hand-fix a formatting complaint — run
`ktlintFormat`. Style is `intellij_idea` at 120 columns, set in `.editorconfig`.
- **detekt config is `config/detekt/detekt.yml`**, merged onto detekt's defaults
(`buildUponDefaultConfig = true`), so it carries only the rules this codebase legitimately
breaks — each with the reason written next to it. Relax a rule that way or fix the code; never a
bare `@Suppress`. Do not invent config keys: unknown ones are rejected.
- The `model` package is excluded from `ReturnCount` and `CyclomaticComplexMethod` only. It is the
decision layer, where one branch is one documented user-visible outcome and the metric counts
answers rather than complexity. Every other rule still applies there.
- **Coverage is reported, not gated** — currently ~31% of lines. A floor needs a baseline that has
settled first.
- `kotlin.code.style=official`. Gradle stays Kotlin DSL.
## Dependency versions
Libraries **float on minor + patch** (`coreKtx = "1.+"`). Three groups deliberately do not:
- **`agp`, `kotlin`, `ksp` are version-locked to each other.** AGP 9.3.1's POM declares
`kotlin-gradle-plugin` 2.2.10, and that is what AGP's built-in Kotlin compiles with. Android lint
will suggest Kotlin 2.4.10; taking it breaks the Compose compiler unless KGP is *also* forced onto
the root buildscript classpath. Move all three together, by hand, or none.
- **ktlint, detekt and JaCoCo are pinned.** A new rule in a linter makes files nobody touched stop
passing, so CI goes red on a PR whose diff cannot explain it. Upgrading them is its own commit:
run the tool, read the new findings, fix or relax them.
- **The FFmpeg AAR** is a committed file, not a coordinate.
**`+` does not mean "newest stable" on its own** — Gradle will happily resolve it to an alpha, and
androidx routinely publishes alphas numbered above the current stable (at last check: lifecycle,
navigation, work, datastore and annotation all did). The `componentSelection` block in
`app/build.gradle.kts` rejects prereleases, which is the only reason `2.+` means 2.11.0 rather than
2.12.0-alpha01. **Do not remove it.** To try a prerelease, name the exact version in the catalog —
that pins it, which is the right way round.
Because versions float, a build can change without a commit. `./gradlew :app:dependencies
--configuration debugRuntimeClasspath` shows what actually resolved.
## Traps
- **Do not apply `org.jetbrains.kotlin.android`.** AGP 9 has built-in Kotlin; applying the legacy
plugin fails the build. This is why `libs.versions.toml` pins `kotlin` to AGP's bundled KGP
version rather than the newest Kotlin release — the Compose compiler plugin must match it.
- **The FFmpeg AAR is committed** under `bin/`, deliberately. It is not on any Maven repo
(ffmpeg-kit was archived and delisted). Rebuilding per CI run made red builds ambiguous: broken
code, or a cross-compile that hiccuped? `bin/README.md` has provenance and how to regenerate it.
- **Anything touching Media3 carries `@UnstableApi`** rather than swallowing the marker with
`@OptIn`. Android lint's `UnsafeOptInUsageError` catches a missed one.
- **Release builds ship both ABIs.** `-PabiFilters` is a test-run override only; `build.yml`
verifies the released APK carries every ABI and that all native libraries are 16 KB aligned.
+153 -4
View File
@@ -1,7 +1,20 @@
import org.gradle.testing.jacoco.tasks.JacocoReport
plugins {
alias(libs.plugins.android.application)
// Applied by id: these two come from the root buildscript classpath, which is what
// overrides AGP's bundled Kotlin. See the comment in the root build file.
id("com.android.application")
// Required even under AGP 9: the Compose compiler plugin is NOT built in.
alias(libs.plugins.kotlin.compose)
id("org.jetbrains.kotlin.plugin.compose")
// Lint/format. Resolved from the Gradle Plugin Portal, not AGP's buildscript
// classpath -- neither is an Android plugin.
alias(libs.plugins.ktlint)
alias(libs.plugins.detekt)
// JaCoCo (Gradle built-in) instruments the JVM testDebugUnitTest task. Report only:
// there is deliberately no coverage gate, see the jacocoTestReport block below.
jacoco
}
android {
@@ -58,8 +71,35 @@ android {
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
sourceCompatibility = JavaVersion.VERSION_25
targetCompatibility = JavaVersion.VERSION_25
}
lint {
// ktlint and detekt both fail the build on any finding. Android lint by default
// aborts on errors only, so warnings would land in the report while the gate stayed
// green -- a gate that passes while the report has content is not a gate.
warningsAsErrors = true
// Already the default. Stated so a later edit cannot turn the gate off by accident.
abortOnError = true
// Dependency-freshness nags. These do not describe this code: they go red the day
// someone else publishes a release, which would turn a PR red for a reason its
// author cannot see in their own diff and cannot fix by changing anything they
// wrote. They also want the network at lint time. Upgrades are a deliberate act
// here -- the Kotlin version in particular is pinned to AGP's bundled KGP and is
// NOT free to follow the newest release -- so they are chosen, not nagged for.
disable += setOf("AndroidGradlePluginVersion", "NewerVersionAvailable", "GradleDependency")
// A real suggestion, deliberately not acted on in this commit. hasSpaceFor() reads
// File.usableSpace, which under-reports because it ignores cache the system could
// reclaim -- so the app can refuse a conversion it actually had room for.
// StorageManager.getAllocatableBytes is the better answer, but swapping it in
// changes when a job is rejected and can throw IOException, which is a behaviour
// change to a safety check and deserves its own commit and its own test rather
// than a drive-by in a tooling change. `informational` keeps it visible in every
// lint report instead of hiding it, while letting the gate pass until then.
informational += "UsableSpace"
}
packaging {
@@ -76,6 +116,115 @@ kotlin {
compilerOptions {}
}
// --- Prerelease guard for the floating dependency versions ------------------------------
// The library versions in libs.versions.toml float on minor + patch ("1.+"). Gradle resolves
// `+` to the highest version it finds, and it does NOT skip prereleases -- so without this,
// androidx would quietly hand the app an alpha. That is not hypothetical here: at the time
// of writing lifecycle, navigation, work, datastore and annotation ALL publish an alpha or
// rc numbered above their newest stable, so five of the floats would have moved onto
// unreleased code on the next build, with nothing in the diff to say so.
//
// Rejecting them here means "+" reads as "the newest RELEASED version", which is what
// floating was meant to buy.
//
// Note that this applies to STATIC versions too, not only floating ones: naming
// "2.12.0-alpha01" in the catalog does not get you that alpha, it fails to resolve. Verified,
// because the obvious assumption is the opposite. To take an androidx prerelease deliberately,
// drop the group from the guarded list below for as long as you need it.
// Groups whose versions this project actually floats. The guard applies to these and to
// nothing else, which is the whole point.
//
// The first version of this was a blanket rule over every group, and it broke every E2E job
// while every local check stayed green. AGP resolves its OWN tooling through this project's
// configurations, and the Unified Test Platform that runs connectedAndroidTest depends on
// com.google.testing.platform artifacts pinned at 0.0.9-alpha04. Rejecting those made
// :app:connectedDebugAndroidTest unresolvable. Nothing that runs without a device touches
// that configuration, so it passed here and failed on all four API levels at once.
//
// Scoping to the groups we float is also why detekt's alpha needs no exception any more: we
// do not float dev.detekt, so the guard has no opinion about it. An allowlist of exceptions
// would have needed a new entry every time AGP pulled in another prerelease tool.
val floatedGroupPrefixes = listOf("androidx.", "junit", "com.arthenica")
// Matches both spellings androidx and friends use: "-alpha01" and "-alpha.1".
val prereleaseMarker =
Regex("""[-.](alpha|beta|rc|eap|dev|snapshot|pre|m)[-.]?\d*$""", RegexOption.IGNORE_CASE)
configurations.configureEach {
resolutionStrategy {
componentSelection {
all {
val floated = floatedGroupPrefixes.any { candidate.group.startsWith(it) }
if (floated && prereleaseMarker.containsMatchIn(candidate.version)) {
reject("prerelease; floating versions take released builds only")
}
}
}
}
}
detekt {
// Merge the project overrides in config/detekt onto detekt's bundled defaults, so this
// repo's file only has to carry the rules it actually changes.
buildUponDefaultConfig = true
config.setFrom(rootProject.file("config/detekt/detekt.yml"))
}
// Pin the coverage agent rather than inheriting whatever Gradle bundles.
jacoco {
toolVersion = libs.versions.jacoco.get()
}
// --- Unit-test coverage -------------------------------------------------------------------
// Report only. There is deliberately no coverage gate: a floor is only meaningful against a
// measured baseline, and the JVM test stack here is still junit-only. This task produces the
// number a floor would need; add `jacocoTestCoverageVerification` once it is known.
// Generated code, stripped from the denominator so the percentage reflects hand-written Kotlin.
// No DI framework is in use, so there are no Hilt/Dagger patterns to exclude.
val jacocoGeneratedExcludes = listOf(
"**/R.class",
"**/R\$*.class",
"**/BuildConfig.*",
"**/Manifest*.*",
// Room lands in a later phase; its KSP output comes out the same door as hand-written code.
"**/*_Impl*",
// One per file with @Composable lambdas -- Compose compiler output, not written by anyone.
"**/ComposableSingletons*",
)
// AGP 9 compiles Kotlin through its built-in compiler, which writes here rather than to the
// classic `tmp/kotlin-classes/debug`. All hand-written code in this module is Kotlin, so the
// javac output (BuildConfig and R only) is not read at all.
val jacocoDebugKotlinClasses = layout.buildDirectory.dir(
"intermediates/built_in_kotlinc/debug/compileDebugKotlin/classes",
)
// Accept both the base `jacoco` plugin's default exec location and AGP's
// enableUnitTestCoverage one, so the wiring survives either being the source of truth.
val jacocoExecutionData = fileTree(layout.buildDirectory) {
include(
"jacoco/testDebugUnitTest.exec",
"outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec",
)
}
tasks.register<JacocoReport>("jacocoTestReport") {
// The exec data does not exist until the tests have run.
dependsOn("testDebugUnitTest")
group = "verification"
description = "Generates JaCoCo XML + HTML coverage for the debug JVM unit tests."
reports {
xml.required.set(true)
html.required.set(true)
}
classDirectories.setFrom(fileTree(jacocoDebugKotlinClasses) { exclude(jacocoGeneratedExcludes) })
sourceDirectories.setFrom(files("src/main/java"))
executionData.setFrom(jacocoExecutionData)
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
@@ -4,10 +4,13 @@ import android.media.MediaExtractor
import android.media.MediaFormat
import android.net.Uri
import android.util.Log
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.runBlocking
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.codec.AndroidDeviceCodecs
import org.libremediaconverter.convert.Media3Engine
import org.libremediaconverter.convert.MediaProbe
@@ -17,10 +20,6 @@ import org.libremediaconverter.model.ConversionRouter
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.model.VideoCodec
import kotlinx.coroutines.runBlocking
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
/**
@@ -45,6 +44,7 @@ import java.io.File
class RealMediaBenchmark {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
/**
* Internal storage, not the external files dir.
*
@@ -55,8 +55,7 @@ class RealMediaBenchmark {
*/
private val samples: File get() = context.filesDir
private fun sample(name: String): File? =
File(samples, name).takeIf { it.exists() && it.length() > 0 }
private fun sample(name: String): File? = File(samples, name).takeIf { it.exists() && it.length() > 0 }
private fun durationMs(file: File): Long {
val extractor = MediaExtractor()
@@ -20,7 +20,6 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.Engine
import org.libremediaconverter.model.InputKind
import org.libremediaconverter.model.OutputSpec
@@ -84,14 +83,13 @@ class RemuxTest {
WorkManager.getInstance(context).cancelAllWork()
}
private fun asset(name: String): File =
File(context.cacheDir, name).also { file ->
file.delete()
staged += file
InstrumentationRegistry.getInstrumentation().context.assets
.open(name)
.use { asset -> file.outputStream().use { asset.copyTo(it) } }
}
private fun asset(name: String): File = File(context.cacheDir, name).also { file ->
file.delete()
staged += file
InstrumentationRegistry.getInstrumentation().context.assets
.open(name)
.use { asset -> file.outputStream().use { asset.copyTo(it) } }
}
// --- probing ------------------------------------------------------------
@@ -255,8 +253,7 @@ class RemuxTest {
}
}
private fun videoMimeOf(file: File): String? =
trackMimesOf(file).firstOrNull { it.startsWith("video/") }
private fun videoMimeOf(file: File): String? = trackMimesOf(file).firstOrNull { it.startsWith("video/") }
private fun durationMsOf(file: File): Long {
val extractor = MediaExtractor()
@@ -2,12 +2,12 @@ package org.libremediaconverter.fallback
import android.content.Context
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import org.libremediaconverter.convert.ConversionDependencies
import org.libremediaconverter.convert.HardwareTranscoder
import org.libremediaconverter.convert.OutputPublisher
import org.libremediaconverter.convert.SoftwareTranscoder
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.OutputFormat
import java.io.File
/**
@@ -18,6 +18,7 @@ import java.io.File
* test run avoids. Without a way to inject failure, the branches a user meets on a bad
* day are the ones that were never executed.
*/
@UnstableApi
object FakeFailures {
class ExplodingHardware(private val message: String = "hardware exploded") : HardwareTranscoder {
@@ -6,13 +6,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.WorkInfo
import androidx.work.WorkManager
import org.libremediaconverter.convert.ConversionDependencies
import org.libremediaconverter.model.DeviceCodecs
import org.libremediaconverter.model.Engine
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.work.ConcatWorker
import org.libremediaconverter.work.ConversionWorker
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
@@ -22,6 +15,13 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.convert.ConversionDependencies
import org.libremediaconverter.model.DeviceCodecs
import org.libremediaconverter.model.Engine
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.work.ConcatWorker
import org.libremediaconverter.work.ConversionWorker
import java.io.File
/**
@@ -61,14 +61,13 @@ class ForcedFailureTest {
}
}
private fun convertRequest(format: OutputFormat = OutputFormat.MP4_H265) =
ConversionWorker.request(
inputUri = Uri.fromFile(input),
displayName = SAMPLE,
sizeBytes = input.length(),
spec = format.spec,
quality = QualityTier.FAST,
)
private fun convertRequest(format: OutputFormat = OutputFormat.MP4_H265) = ConversionWorker.request(
inputUri = Uri.fromFile(input),
displayName = SAMPLE,
sizeBytes = input.length(),
spec = format.spec,
quality = QualityTier.FAST,
)
// --- the dynamic fallback, forced rather than provoked -------------------
@@ -179,11 +178,9 @@ class ForcedFailureTest {
assertEquals(WorkInfo.State.FAILED, terminal?.state)
}
private fun assertTrue(message: String, condition: Boolean) =
org.junit.Assert.assertTrue(message, condition)
private fun assertTrue(message: String, condition: Boolean) = org.junit.Assert.assertTrue(message, condition)
private fun assertTrue(condition: Boolean, message: String) =
org.junit.Assert.assertTrue(message, condition)
private fun assertTrue(condition: Boolean, message: String) = org.junit.Assert.assertTrue(message, condition)
private companion object {
const val SAMPLE = "sample_h264.mp4"
@@ -6,9 +6,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.WorkInfo
import androidx.work.WorkManager
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.work.ConversionWorker
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
@@ -18,6 +15,9 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.work.ConversionWorker
import java.io.File
/**
@@ -8,10 +8,6 @@ import androidx.work.Data
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import org.libremediaconverter.convert.OutputPublisher
import org.libremediaconverter.ffmpeg.ConcatEngine
import org.libremediaconverter.work.ConcatWorker
import org.libremediaconverter.work.ConversionWorker
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
@@ -21,6 +17,10 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.convert.OutputPublisher
import org.libremediaconverter.ffmpeg.ConcatEngine
import org.libremediaconverter.work.ConcatWorker
import org.libremediaconverter.work.ConversionWorker
import java.io.File
/**
@@ -4,9 +4,6 @@ import android.media.MediaExtractor
import android.media.MediaFormat
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
@@ -14,6 +11,9 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import java.io.File
/**
@@ -46,8 +46,10 @@ class FFmpegEngineTest {
outputs.forEach { it.delete() }
}
private fun outputFor(name: String) =
File(context.cacheDir, name).also { it.delete(); outputs += it }
private fun outputFor(name: String) = File(context.cacheDir, name).also {
it.delete()
outputs += it
}
private fun convert(format: OutputFormat, quality: QualityTier = QualityTier.BEST): File {
val out = outputFor("out_${format.name.lowercase()}.${format.extension}")
@@ -5,9 +5,6 @@ import android.media.MediaFormat
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.libremediaconverter.convert.MediaProbe
import org.libremediaconverter.ffmpeg.ConcatEngine
import org.libremediaconverter.model.ConcatStrategy
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
@@ -15,6 +12,9 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.convert.MediaProbe
import org.libremediaconverter.ffmpeg.ConcatEngine
import org.libremediaconverter.model.ConcatStrategy
import java.io.File
/**
@@ -57,8 +57,10 @@ class ConcatEngineTest {
return out
}
private fun output(name: String) =
File(context.cacheDir, name).also { it.delete(); staged += it }
private fun output(name: String) = File(context.cacheDir, name).also {
it.delete()
staged += it
}
private fun durationMs(file: File): Long {
val extractor = MediaExtractor()
@@ -6,8 +6,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.WorkInfo
import androidx.work.WorkManager
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.work.ConcatWorker
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
@@ -17,6 +15,8 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.work.ConcatWorker
import java.io.File
/**
@@ -8,14 +8,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.WorkInfo
import androidx.work.WorkManager
import org.libremediaconverter.codec.AndroidDeviceCodecs
import org.libremediaconverter.model.Engine
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.OutputSpec
import org.libremediaconverter.model.VideoCodec
import org.libremediaconverter.model.QualityTier
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
@@ -25,6 +17,14 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.codec.AndroidDeviceCodecs
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.Engine
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.OutputSpec
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.model.VideoCodec
import java.io.File
/**
@@ -170,8 +170,11 @@ class ConversionWorkerTest {
terminal?.state,
)
assertEquals(
if (hasHardwareHevc) "hardware HEVC present, expected the Media3 path"
else "no hardware HEVC encoder, expected the FFmpeg path",
if (hasHardwareHevc) {
"hardware HEVC present, expected the Media3 path"
} else {
"no hardware HEVC encoder, expected the FFmpeg path"
},
if (hasHardwareHevc) Engine.MEDIA3.name else Engine.FFMPEG.name,
terminal?.outputData?.getString(ConversionWorker.KEY_ENGINE_USED),
)
@@ -18,8 +18,8 @@ import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSiz
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@@ -1,6 +1,5 @@
package org.libremediaconverter.codec
import android.media.MediaCodecInfo
import android.media.MediaCodecList
import android.media.MediaFormat
import android.util.Log
@@ -28,8 +27,7 @@ class AndroidDeviceCodecs private constructor(
private val decodeMimes: Set<String>,
) : DeviceCodecs {
override fun canEncode(codec: VideoCodec): Boolean =
mimeFor(codec)?.let { it in hardwareEncodeMimes } ?: true
override fun canEncode(codec: VideoCodec): Boolean = mimeFor(codec)?.let { it in hardwareEncodeMimes } ?: true
override fun canDecode(codecName: String): Boolean {
// The platform already failed to parse this input, so there is nothing to
@@ -46,8 +44,7 @@ class AndroidDeviceCodecs private constructor(
@Volatile
private var cached: AndroidDeviceCodecs? = null
fun get(): AndroidDeviceCodecs =
cached ?: synchronized(this) { cached ?: probe().also { cached = it } }
fun get(): AndroidDeviceCodecs = cached ?: synchronized(this) { cached ?: probe().also { cached = it } }
private fun probe(): AndroidDeviceCodecs {
val encoders = mutableSetOf<String>()
@@ -104,7 +101,6 @@ class AndroidDeviceCodecs private constructor(
}
/** Test seam: lets instrumented tests build a probe from explicit sets. */
fun forTesting(encoders: Set<String>, decoders: Set<String>) =
AndroidDeviceCodecs(encoders, decoders)
fun forTesting(encoders: Set<String>, decoders: Set<String>) = AndroidDeviceCodecs(encoders, decoders)
}
}
@@ -8,17 +8,6 @@ import androidx.lifecycle.viewModelScope
import androidx.media3.common.util.UnstableApi
import androidx.work.WorkInfo
import androidx.work.WorkManager
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.ContainerCapabilities
import org.libremediaconverter.model.EnginePreference
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.OutputSpec
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.model.Validation
import org.libremediaconverter.model.VideoCodec
import org.libremediaconverter.work.ConversionWorker
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -30,6 +19,17 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.ContainerCapabilities
import org.libremediaconverter.model.EnginePreference
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.OutputSpec
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.model.Validation
import org.libremediaconverter.model.VideoCodec
import org.libremediaconverter.work.ConversionWorker
import java.io.File
import java.util.UUID
@@ -62,6 +62,7 @@ sealed interface ConversionState {
data object Idle : ConversionState
data class Ready(val input: InputFile) : ConversionState
data class Converting(val input: InputFile, val percent: Int) : ConversionState
/** Budget for foreground work ran out; WorkManager will retry when it can. */
data class Waiting(val input: InputFile) : ConversionState
data class Converted(
@@ -102,20 +103,16 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) {
}.stateIn(viewModelScope, SharingStarted.Eagerly, Validation.Valid)
fun setPreset(format: OutputFormat) = _settings.update { it.copy(spec = format.spec) }
fun setContainer(container: Container) =
_settings.update { it.copy(spec = it.spec.copy(container = container)) }
fun setContainer(container: Container) = _settings.update { it.copy(spec = it.spec.copy(container = container)) }
fun setVideoCodec(codec: VideoCodec) =
_settings.update { it.copy(spec = it.spec.copy(videoCodec = codec)) }
fun setVideoCodec(codec: VideoCodec) = _settings.update { it.copy(spec = it.spec.copy(videoCodec = codec)) }
fun setAudioCodec(codec: AudioCodec) =
_settings.update { it.copy(spec = it.spec.copy(audioCodec = codec)) }
fun setAudioCodec(codec: AudioCodec) = _settings.update { it.copy(spec = it.spec.copy(audioCodec = codec)) }
fun applySuggestion(spec: OutputSpec) = _settings.update { it.copy(spec = spec) }
fun setQuality(quality: QualityTier) = _settings.update { it.copy(quality = quality) }
fun setEnginePreference(preference: EnginePreference) =
_settings.update { it.copy(enginePreference = preference) }
fun setEnginePreference(preference: EnginePreference) = _settings.update { it.copy(enginePreference = preference) }
fun onInputPicked(uri: Uri) {
viewModelScope.launch {
@@ -202,7 +199,7 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) {
WorkInfo.State.FAILED -> ConversionState.Failed(
info.outputData.getString(ConversionWorker.KEY_ERROR)
?: "Conversion failed."
?: "Conversion failed.",
)
WorkInfo.State.CANCELLED -> ConversionState.Ready(input)
@@ -229,7 +226,7 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) {
ConversionWorker.outputNameFor(
converted.input.displayName,
_settings.value.spec,
)
),
)
}.onFailure { e ->
_state.value = ConversionState.Failed(e.message ?: "Could not save the file.")
@@ -244,11 +241,10 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) {
_state.value = ConversionState.Idle
}
fun suggestedOutputName(): String =
ConversionWorker.outputNameFor(
currentInput()?.displayName ?: "output",
_settings.value.spec,
)
fun suggestedOutputName(): String = ConversionWorker.outputNameFor(
currentInput()?.displayName ?: "output",
_settings.value.spec,
)
private fun ConversionState.probe(): InputProbe? = when (this) {
is ConversionState.Ready -> input.probe
@@ -34,9 +34,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.libremediaconverter.ui.PrimaryButtonHeight
import org.libremediaconverter.ui.ScreenPaddingHorizontal
import org.libremediaconverter.ui.ScreenPaddingVertical
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.util.UnstableApi
@@ -50,14 +47,14 @@ import org.libremediaconverter.model.OutputSpec
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.model.Validation
import org.libremediaconverter.model.VideoCodec
import org.libremediaconverter.ui.PrimaryButtonHeight
import org.libremediaconverter.ui.ScreenPaddingHorizontal
import org.libremediaconverter.ui.ScreenPaddingVertical
import java.util.Locale
@UnstableApi
@Composable
fun ConverterScreen(
modifier: Modifier = Modifier,
viewModel: ConversionViewModel = viewModel(),
) {
fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewModel = viewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
val settings by viewModel.settings.collectAsStateWithLifecycle()
val validation by viewModel.validation.collectAsStateWithLifecycle()
@@ -66,11 +63,11 @@ fun ConverterScreen(
// only, offers no audio at all, and will not reliably surface .mkv/.flac/.webm.
// SAF needs no runtime permission.
val pickInput = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
ActivityResultContracts.OpenDocument(),
) { uri -> uri?.let(viewModel::onInputPicked) }
val chooseDestination = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument(settings.spec.mimeType)
ActivityResultContracts.CreateDocument(settings.spec.mimeType),
) { uri -> uri?.let(viewModel::save) }
// Requested at the point of use rather than on first launch, so the ask carries its
@@ -78,7 +75,7 @@ fun ConverterScreen(
// foreground service still runs, but its progress notification is confined to the
// Task Manager instead of the shade.
val requestNotifications = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
ActivityResultContracts.RequestPermission(),
) { viewModel.convert() }
Column(
@@ -69,7 +69,10 @@ class Media3Engine(private val context: Context) : HardwareTranscoder {
val plan = CopyPlanner.plan(request.spec, request.probe)
handler.post {
val transformer = runCatching { buildTransformer(plan, cont) }
.getOrElse { cont.resumeWithException(it); return@post }
.getOrElse {
cont.resumeWithException(it)
return@post
}
// Dropping the tracks the target does not have is what stops an audio-only export
// from carrying a re-encoded video track. Without setRemoveVideo, asking for M4A
@@ -92,7 +95,10 @@ class Media3Engine(private val context: Context) : HardwareTranscoder {
}
runCatching { transformer.start(composition, output.absolutePath) }
.onFailure { cont.resumeWithException(it); return@post }
.onFailure {
cont.resumeWithException(it)
return@post
}
pollProgress(transformer, cont, onProgress)
}
@@ -104,10 +110,7 @@ class Media3Engine(private val context: Context) : HardwareTranscoder {
* is supposed to have sent such a job to FFmpeg — so it fails loudly instead of quietly
* writing MP4, which is what the old code did.
*/
private fun buildTransformer(
plan: ConversionPlan,
cont: CancellableContinuation<Unit>,
): Transformer {
private fun buildTransformer(plan: ConversionPlan, cont: CancellableContinuation<Unit>): Transformer {
val muxerFactory = requireNotNull(Media3Muxers.factoryFor(plan.container)) {
"Media3 cannot mux ${plan.container}; this job should have routed to FFmpeg."
}
@@ -128,11 +131,7 @@ class Media3Engine(private val context: Context) : HardwareTranscoder {
if (cont.isActive) cont.resume(Unit)
}
override fun onError(
composition: Composition,
result: ExportResult,
exception: ExportException,
) {
override fun onError(composition: Composition, result: ExportResult, exception: ExportException) {
if (cont.isActive) cont.resumeWithException(exception)
}
})
@@ -193,7 +192,6 @@ class Media3Engine(private val context: Context) : HardwareTranscoder {
thread.quitSafely()
}
private companion object {
const val PROGRESS_INTERVAL_MS = 250L
}
@@ -115,7 +115,7 @@ object MediaProbe {
mime.startsWith("audio/") && audio == null -> audio = shortName(mime)
}
}
Extracted(video, audio, durationUs / 1000, width, height)
Extracted(video, audio, durationUs / US_PER_MS, width, height)
} catch (e: Exception) {
Log.i(TAG, "Platform extractor could not read $uri.", e)
null
@@ -162,7 +162,7 @@ object MediaProbe {
container = containerFrom(formatName, video?.getCodec()),
videoCodec = video?.getCodec(),
audioCodec = audio?.getCodec(),
durationMs = info.getDuration()?.toDoubleOrNull()?.times(1000)?.toLong() ?: 0L,
durationMs = info.getDuration()?.toDoubleOrNull()?.times(MS_PER_SECOND)?.toLong() ?: 0L,
width = video?.getWidth()?.toInt() ?: 0,
height = video?.getHeight()?.toInt() ?: 0,
isImage = isImageFormat(formatName),
@@ -284,4 +284,10 @@ object MediaProbe {
}
private const val TAG = "MediaProbe"
/** MediaExtractor reports KEY_DURATION in microseconds; InputProbe carries milliseconds. */
private const val US_PER_MS = 1000
/** MediaMetadataRetriever's ffprobe-style duration is in seconds, as a decimal string. */
private const val MS_PER_SECOND = 1000
}
@@ -32,8 +32,7 @@ open class OutputPublisher(private val context: Context) {
* Staging means peak usage is roughly input + output at once, so a job that would
* just barely fit is rejected rather than failing partway through.
*/
open fun hasSpaceFor(bytes: Long): Boolean =
stagingDir.usableSpace > bytes + SPACE_HEADROOM_BYTES
open fun hasSpaceFor(bytes: Long): Boolean = stagingDir.usableSpace > bytes + SPACE_HEADROOM_BYTES
/** Copies a finished staging file into a user-chosen SAF destination. */
open fun publish(staged: File, destination: Uri) {
@@ -2,11 +2,12 @@ package org.libremediaconverter.convert
import android.content.Context
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import org.libremediaconverter.codec.AndroidDeviceCodecs
import org.libremediaconverter.ffmpeg.FFmpegEngine
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.DeviceCodecs
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.codec.AndroidDeviceCodecs
import java.io.File
/** The hardware conversion path. Implemented by [Media3Engine]. */
@@ -58,6 +59,7 @@ interface SoftwareTranscoder {
* with a content URI pointing at a provider that does not exist, or by constructing a
* worker's input Data by hand rather than through its request() helper.
*/
@UnstableApi
object ConversionDependencies {
@Volatile
@@ -6,11 +6,11 @@ import android.util.Log
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.FFmpegKitConfig
import com.arthenica.ffmpegkit.ReturnCode
import kotlinx.coroutines.suspendCancellableCoroutine
import org.libremediaconverter.convert.MediaProbe
import org.libremediaconverter.model.ConcatPlanner
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.model.OutputFormat
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.File
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@@ -27,11 +27,7 @@ class ConcatEngine(private val context: Context) {
data class Result(val strategy: ConcatStrategy, val output: File)
suspend fun join(
inputs: List<Uri>,
output: File,
format: OutputFormat = OutputFormat.MP4_H264,
): Result {
suspend fun join(inputs: List<Uri>, output: File, format: OutputFormat = OutputFormat.MP4_H264): Result {
require(inputs.size >= 2) { "Joining needs at least two files." }
val paths = inputs.map { uri ->
@@ -70,8 +66,8 @@ class ConcatEngine(private val context: Context) {
else -> cont.resumeWithException(
FFmpegEngine.FFmpegException(
"Joining failed (${rc?.value}): " +
completed.getAllLogsAsString(LOG_TAIL_LIMIT).orEmpty()
)
completed.getAllLogsAsString(LOG_TAIL_LIMIT).orEmpty(),
),
)
}
}
@@ -53,17 +53,14 @@ object FFmpegCommandBuilder {
/** Containers in the ISO base-media family, where HEVC needs the hvc1 brand. */
private val MP4_FAMILY = setOf(Container.MP4, Container.MOV)
fun build(
request: ConversionRequest,
inputPath: String,
outputPath: String,
): List<String> {
fun build(request: ConversionRequest, inputPath: String, outputPath: String): List<String> {
val plan = CopyPlanner.plan(request.spec, request.probe)
return buildList {
add("-hide_banner")
// Overwrite: the output path is one we just created in our own cache.
add("-y")
add("-i"); add(inputPath)
add("-i")
add(inputPath)
if (request.spec.isImageOutput) {
addAll(imageArgs(request))
@@ -86,7 +83,8 @@ object FFmpegCommandBuilder {
"-vf",
"fps=12,scale=480:-1:flags=lanczos,split[a][b];" +
"[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer",
"-loop", "0",
"-loop",
"0",
)
else -> listOf("-an", "-vf", "fps=1", "-vsync", "0")
@@ -99,7 +97,8 @@ object FFmpegCommandBuilder {
VideoPlan.Drop -> listOf("-vn")
VideoPlan.Copy -> buildList {
add("-c:v"); add("copy")
add("-c:v")
add("copy")
// The hvc1 brand matters on the copy path too, not just when encoding: remuxing
// HEVC out of Matroska into MP4 otherwise produces a file Apple devices and many
// hardware players refuse, even though the samples are byte-identical.
@@ -128,9 +127,15 @@ object FFmpegCommandBuilder {
// that hardware encoding is unlikely to work for that input. Fast therefore means a
// fast *preset*, not a different encoder.
VideoCodec.H265 -> listOf(
"-c:v", "libx265", "-crf", "$CRF_H265", "-preset", preset,
"-c:v",
"libx265",
"-crf",
"$CRF_H265",
"-preset",
preset,
// Without this, many players and Apple devices refuse HEVC in MP4.
"-tag:v", "hvc1",
"-tag:v",
"hvc1",
) + PIX_FMT
VideoCodec.VP9 -> buildList {
@@ -140,7 +145,12 @@ object FFmpegCommandBuilder {
}
VideoCodec.H264 -> listOf(
"-c:v", "libx264", "-crf", "$CRF_H264", "-preset", preset,
"-c:v",
"libx264",
"-crf",
"$CRF_H264",
"-preset",
preset,
) + PIX_FMT
// No silent substitution. A trailing `else -> libx264` would hand back H.264 for a
@@ -151,11 +161,11 @@ object FFmpegCommandBuilder {
// about rather than papering over.
VideoCodec.VP8, VideoCodec.AV1 -> error(
"This app cannot encode ${codec.label}; it can only copy an existing " +
"${codec.label} stream."
"${codec.label} stream.",
)
VideoCodec.COPY, VideoCodec.NONE -> error(
"encodeVideo called for $codec, which is not an encode"
"encodeVideo called for $codec, which is not an encode",
)
}
}
@@ -167,31 +177,32 @@ object FFmpegCommandBuilder {
return if (sourceIsHevc) listOf("-tag:v", "hvc1") else emptyList()
}
private fun audioArgs(plan: ConversionPlan): List<String> =
when (val audio = plan.audio) {
AudioPlan.Drop -> listOf("-an")
AudioPlan.Copy -> listOf("-c:a", "copy")
is AudioPlan.Encode -> when (audio.codec) {
AudioCodec.MP3 -> listOf("-c:a", "libmp3lame", "-q:a", "2")
AudioCodec.FLAC -> listOf("-c:a", "flac")
AudioCodec.PCM -> listOf("-c:a", "pcm_s16le")
AudioCodec.OPUS -> listOf("-c:a", "libopus", "-b:a", "128k")
AudioCodec.VORBIS -> listOf("-c:a", "libvorbis", "-q:a", "5")
else -> listOf("-c:a", "aac", "-b:a", "192k")
}
private fun audioArgs(plan: ConversionPlan): List<String> = when (val audio = plan.audio) {
AudioPlan.Drop -> listOf("-an")
AudioPlan.Copy -> listOf("-c:a", "copy")
is AudioPlan.Encode -> when (audio.codec) {
AudioCodec.MP3 -> listOf("-c:a", "libmp3lame", "-q:a", "2")
AudioCodec.FLAC -> listOf("-c:a", "flac")
AudioCodec.PCM -> listOf("-c:a", "pcm_s16le")
AudioCodec.OPUS -> listOf("-c:a", "libopus", "-b:a", "128k")
AudioCodec.VORBIS -> listOf("-c:a", "libvorbis", "-q:a", "5")
else -> listOf("-c:a", "aac", "-b:a", "192k")
}
}
private fun containerArgs(plan: ConversionPlan): List<String> = buildList {
// Name the muxer rather than letting FFmpeg infer it from the output path. Inference is
// unreliable for MPEG-TS and ASF, and the app now lets the user pick a container
// independently of the preset that used to imply it.
add("-f"); add(plan.container.ffmpegFormat)
add("-f")
add(plan.container.ffmpegFormat)
if (plan.container in MP4_FAMILY) {
// Move the moov atom to the front so the file starts playing before it is
// fully downloaded. This is also the reason output never goes through a SAF
// file descriptor: faststart has to seek backwards to rewrite the header.
add("-movflags"); add("+faststart")
add("-movflags")
add("+faststart")
}
}
@@ -28,21 +28,31 @@ object FFmpegConcatCommand {
format: OutputFormat,
): List<String> = when (strategy) {
ConcatStrategy.STREAM_COPY -> buildList {
add("-hide_banner"); add("-y")
add("-hide_banner")
add("-y")
// -safe 0 permits absolute paths in the list file, which ours are.
add("-f"); add("concat")
add("-safe"); add("0")
add("-i"); add(listFile.absolutePath)
add("-c"); add("copy")
add("-f")
add("concat")
add("-safe")
add("0")
add("-i")
add(listFile.absolutePath)
add("-c")
add("copy")
if (format.container == org.libremediaconverter.model.Container.MP4) {
add("-movflags"); add("+faststart")
add("-movflags")
add("+faststart")
}
add(output.absolutePath)
}
ConcatStrategy.REENCODE -> buildList {
add("-hide_banner"); add("-y")
inputPaths.forEach { add("-i"); add(it) }
add("-hide_banner")
add("-y")
inputPaths.forEach {
add("-i")
add(it)
}
// Normalise every input to a common size and frame rate before joining,
// otherwise the concat filter refuses mismatched inputs.
val filter = buildString {
@@ -53,15 +63,23 @@ object FFmpegConcatCommand {
inputPaths.indices.forEach { i -> append("[v$i][$i:a]") }
append("concat=n=${inputPaths.size}:v=1:a=1[v][a]")
}
add("-filter_complex"); add(filter)
add("-map"); add("[v]")
add("-map"); add("[a]")
add("-c:v"); add("libx264")
add("-crf"); add("20")
add("-pix_fmt"); add("yuv420p")
add("-c:a"); add("aac")
add("-filter_complex")
add(filter)
add("-map")
add("[v]")
add("-map")
add("[a]")
add("-c:v")
add("libx264")
add("-crf")
add("20")
add("-pix_fmt")
add("yuv420p")
add("-c:a")
add("aac")
if (format.container == org.libremediaconverter.model.Container.MP4) {
add("-movflags"); add("+faststart")
add("-movflags")
add("+faststart")
}
add(output.absolutePath)
}
@@ -5,9 +5,9 @@ import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.FFmpegKitConfig
import com.arthenica.ffmpegkit.Level
import com.arthenica.ffmpegkit.ReturnCode
import kotlinx.coroutines.suspendCancellableCoroutine
import org.libremediaconverter.convert.SoftwareTranscoder
import org.libremediaconverter.model.ConversionRequest
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.File
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@@ -61,8 +61,8 @@ class FFmpegEngine : SoftwareTranscoder {
"FFmpeg failed (${rc?.value}): " +
completed.getFailStackTrace().orEmpty().ifBlank {
completed.getAllLogsAsString(LOG_TAIL_LIMIT).orEmpty()
}
)
},
),
)
}
},
@@ -33,18 +33,15 @@ import org.libremediaconverter.ui.ScreenPaddingVertical
@UnstableApi
@Composable
fun JoinScreen(
modifier: Modifier = Modifier,
viewModel: JoinViewModel = viewModel(),
) {
fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
val pickInputs = rememberLauncherForActivityResult(
ActivityResultContracts.OpenMultipleDocuments()
ActivityResultContracts.OpenMultipleDocuments(),
) { uris -> if (uris.isNotEmpty()) viewModel.onInputsPicked(uris) }
val chooseDestination = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("video/mp4")
ActivityResultContracts.CreateDocument("video/mp4"),
) { uri -> uri?.let(viewModel::save) }
Column(
@@ -8,10 +8,6 @@ import androidx.lifecycle.viewModelScope
import androidx.media3.common.util.UnstableApi
import androidx.work.WorkInfo
import androidx.work.WorkManager
import org.libremediaconverter.convert.InputFile
import org.libremediaconverter.convert.OutputPublisher
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.work.ConcatWorker
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -19,6 +15,10 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.libremediaconverter.convert.InputFile
import org.libremediaconverter.convert.OutputPublisher
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.work.ConcatWorker
import java.io.File
import java.util.UUID
@@ -72,8 +72,11 @@ class JoinViewModel(app: Application) : AndroidViewModel(app) {
_state.value = when (info.state) {
WorkInfo.State.RUNNING, WorkInfo.State.BLOCKED -> JoinState.Joining(inputs)
WorkInfo.State.ENQUEUED ->
if (info.runAttemptCount > 0) JoinState.Waiting(inputs)
else JoinState.Joining(inputs)
if (info.runAttemptCount > 0) {
JoinState.Waiting(inputs)
} else {
JoinState.Joining(inputs)
}
WorkInfo.State.SUCCEEDED -> {
val path = info.outputData.getString(ConcatWorker.KEY_OUTPUT_PATH)
@@ -87,7 +90,7 @@ class JoinViewModel(app: Application) : AndroidViewModel(app) {
}
WorkInfo.State.FAILED -> JoinState.Failed(
info.outputData.getString(ConcatWorker.KEY_ERROR) ?: "Joining failed."
info.outputData.getString(ConcatWorker.KEY_ERROR) ?: "Joining failed.",
)
WorkInfo.State.CANCELLED -> JoinState.Ready(inputs)
@@ -27,7 +27,11 @@ object ContainerCapabilities {
// Matroska is the permissive one: it is a general-purpose container and takes essentially
// any codec. That is what makes it the natural remux target.
Container.MKV to setOf(
VideoCodec.H264, VideoCodec.H265, VideoCodec.VP8, VideoCodec.VP9, VideoCodec.AV1,
VideoCodec.H264,
VideoCodec.H265,
VideoCodec.VP8,
VideoCodec.VP9,
VideoCodec.AV1,
),
Container.WEBM to setOf(VideoCodec.VP8, VideoCodec.VP9, VideoCodec.AV1),
Container.MPEG_TS to setOf(VideoCodec.H264, VideoCodec.H265),
@@ -48,8 +52,12 @@ object ContainerCapabilities {
Container.MP4 to setOf(AudioCodec.AAC, AudioCodec.MP3, AudioCodec.OPUS, AudioCodec.FLAC),
Container.MOV to setOf(AudioCodec.AAC, AudioCodec.MP3, AudioCodec.PCM),
Container.MKV to setOf(
AudioCodec.AAC, AudioCodec.OPUS, AudioCodec.VORBIS,
AudioCodec.MP3, AudioCodec.FLAC, AudioCodec.PCM,
AudioCodec.AAC,
AudioCodec.OPUS,
AudioCodec.VORBIS,
AudioCodec.MP3,
AudioCodec.FLAC,
AudioCodec.PCM,
),
Container.WEBM to setOf(AudioCodec.OPUS, AudioCodec.VORBIS),
Container.MPEG_TS to setOf(AudioCodec.AAC, AudioCodec.MP3),
@@ -75,7 +83,11 @@ object ContainerCapabilities {
/** Vorbis is absent for the same reason: nothing here emits a Vorbis encoder. */
private val ENCODABLE_AUDIO = setOf(
AudioCodec.AAC, AudioCodec.OPUS, AudioCodec.MP3, AudioCodec.FLAC, AudioCodec.PCM,
AudioCodec.AAC,
AudioCodec.OPUS,
AudioCodec.MP3,
AudioCodec.FLAC,
AudioCodec.PCM,
)
fun accepts(container: Container, codec: VideoCodec, mode: CodecMode): Boolean = when (codec) {
@@ -249,9 +261,15 @@ object ContainerCapabilities {
.filter { it != exclude }
.distinct()
.filter { validate(it, probe).isValid }
.take(3)
.take(MAX_SUGGESTIONS)
}
/**
* How many alternatives an [Validation.Invalid] offers. Enough to show a real choice,
* few enough that the error stays readable.
*/
private const val MAX_SUGGESTIONS = 3
/** Best valid spec for this container, preserving as much of the request as possible. */
private fun repair(spec: OutputSpec, probe: InputProbe): OutputSpec? {
val container = spec.container
@@ -57,7 +57,10 @@ object ConversionRouter {
internal val MEDIA3_MUXABLE_AUDIO: Map<Container, Set<AudioCodec>> = mapOf(
Container.MP4 to setOf(
AudioCodec.AAC, AudioCodec.OPUS, AudioCodec.VORBIS, AudioCodec.PCM,
AudioCodec.AAC,
AudioCodec.OPUS,
AudioCodec.VORBIS,
AudioCodec.PCM,
),
)
@@ -18,14 +18,11 @@ sealed interface AudioPlan {
/**
* A resolved conversion: no [VideoCodec.COPY] left to interpret, every track decided.
*/
data class ConversionPlan(
val container: Container,
val video: VideoPlan,
val audio: AudioPlan,
) {
data class ConversionPlan(val container: Container, val video: VideoPlan, val audio: AudioPlan) {
/** No track is re-encoded and at least one is copied: a container change and nothing more. */
val isPureRemux: Boolean
get() = video !is VideoPlan.Encode && audio !is AudioPlan.Encode &&
get() = video !is VideoPlan.Encode &&
audio !is AudioPlan.Encode &&
(video is VideoPlan.Copy || audio is AudioPlan.Copy)
val hasVideo: Boolean get() = video != VideoPlan.Drop
@@ -45,11 +45,9 @@ enum class Container(
/** Whether this container can hold a video track at all. */
val canHoldVideo: Boolean get() = videoExtension != null
fun extensionFor(hasVideo: Boolean): String =
if (hasVideo) videoExtension ?: audioExtension else audioExtension
fun extensionFor(hasVideo: Boolean): String = if (hasVideo) videoExtension ?: audioExtension else audioExtension
fun mimeTypeFor(hasVideo: Boolean): String =
if (hasVideo) videoMime ?: audioMime else audioMime
fun mimeTypeFor(hasVideo: Boolean): String = if (hasVideo) videoMime ?: audioMime else audioMime
}
/**
@@ -88,11 +86,7 @@ enum class AudioCodec(val label: String) {
* now comes from [ContainerCapabilities] instead, which is explicit and unit-tested rather than
* implicit in which combinations someone remembered to enumerate.
*/
data class OutputSpec(
val container: Container,
val videoCodec: VideoCodec,
val audioCodec: AudioCodec,
) {
data class OutputSpec(val container: Container, val videoCodec: VideoCodec, val audioCodec: AudioCodec) {
/** Whether the output keeps a video track — the thing extension and MIME type turn on. */
val hasVideo: Boolean get() = videoCodec != VideoCodec.NONE
@@ -104,7 +98,8 @@ data class OutputSpec(
/** True when neither track is re-encoded, i.e. this is a pure container change. */
val isPureRemux: Boolean
get() = videoCodec.isCopyOrAbsent() && audioCodec.isCopyOrAbsent() &&
get() = videoCodec.isCopyOrAbsent() &&
audioCodec.isCopyOrAbsent() &&
(videoCodec == VideoCodec.COPY || audioCodec == AudioCodec.COPY)
val extension: String get() = container.extensionFor(hasVideo)
@@ -1,6 +1,5 @@
package org.libremediaconverter.ui.theme
import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
@@ -8,7 +8,6 @@ import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ForegroundInfo
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import org.libremediaconverter.convert.ConversionDependencies
@@ -27,10 +26,7 @@ import org.libremediaconverter.model.OutputFormat
* concatenated; showing a fabricated percentage would be worse than showing none.
*/
@UnstableApi
class ConcatWorker(
context: Context,
params: WorkerParameters,
) : CoroutineWorker(context, params) {
class ConcatWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
private val notifications = ConversionNotifications(applicationContext)
private val publisher = ConversionDependencies.publisher(applicationContext)
@@ -43,7 +39,7 @@ class ConcatWorker(
}
val totalBytes = inputData.getLong(KEY_TOTAL_BYTES, 0L)
val format = OutputFormat.valueOf(
inputData.getString(KEY_FORMAT) ?: OutputFormat.MP4_H264.name
inputData.getString(KEY_FORMAT) ?: OutputFormat.MP4_H264.name,
)
if (!publisher.hasSpaceFor(totalBytes)) {
@@ -55,7 +51,7 @@ class ConcatWorker(
NOTIFICATION_ID,
notifications.build(id, "Joining ${uris.size} files", 0, indeterminate = true),
ConversionForegroundType.current(),
)
),
)
val staged = publisher.createStagingFile("joined.${format.extension}")
@@ -65,7 +61,7 @@ class ConcatWorker(
workDataOf(
KEY_OUTPUT_PATH to staged.absolutePath,
KEY_STRATEGY to result.strategy.name,
)
),
)
} catch (e: Throwable) {
staged.delete()
@@ -99,18 +95,15 @@ class ConcatWorker(
private const val NOTIFICATION_ID = 1002
private const val TAG = "ConcatWorker"
fun request(
inputs: List<Uri>,
totalBytes: Long,
format: OutputFormat = OutputFormat.MP4_H264,
) = OneTimeWorkRequestBuilder<ConcatWorker>()
.setInputData(
Data.Builder()
.putStringArray(KEY_INPUT_URIS, inputs.map(Uri::toString).toTypedArray())
.putLong(KEY_TOTAL_BYTES, totalBytes)
.putString(KEY_FORMAT, format.name)
.build()
)
.build()
fun request(inputs: List<Uri>, totalBytes: Long, format: OutputFormat = OutputFormat.MP4_H264) =
OneTimeWorkRequestBuilder<ConcatWorker>()
.setInputData(
Data.Builder()
.putStringArray(KEY_INPUT_URIS, inputs.map(Uri::toString).toTypedArray())
.putLong(KEY_TOTAL_BYTES, totalBytes)
.putString(KEY_FORMAT, format.name)
.build(),
)
.build()
}
}
@@ -30,8 +30,15 @@ object ConversionForegroundType {
* `mediaProcessing` constant does not exist to pass in the first place.
*/
fun current(): Int = when {
Build.VERSION.SDK_INT >= 35 -> ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING
Build.VERSION.SDK_INT >= 34 -> ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
else -> 0
Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM ->
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING
Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE ->
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
else -> NO_TYPE_REQUIRED
}
/** API 33 wants no type at all, and `ForegroundInfo` reads 0 as exactly that. */
private const val NO_TYPE_REQUIRED = 0
}
@@ -35,14 +35,14 @@ class ConversionNotifications(private val context: Context) {
context.getString(R.string.notification_preparing)
} else {
context.getString(R.string.notification_progress, percent)
}
},
)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setOngoing(true)
// Progress updates far outpace what the UI can use; alerting once keeps
// the system UI from being hammered.
.setOnlyAlertOnce(true)
.setProgress(100, percent, indeterminate)
.setProgress(PERCENT_MAX, percent, indeterminate)
.addAction(
android.R.drawable.ic_menu_close_clear_cancel,
context.getString(R.string.action_cancel),
@@ -57,13 +57,15 @@ class ConversionNotifications(private val context: Context) {
* notification appears only in the Task Manager rather than the shade — so
* progress silently vanishes from the user's point of view.
*/
fun areEnabled(): Boolean =
context.getSystemService(NotificationManager::class.java)
.areNotificationsEnabled()
.also { if (!it) Log.i(TAG, "Notifications disabled; progress will not be visible.") }
fun areEnabled(): Boolean = context.getSystemService(NotificationManager::class.java)
.areNotificationsEnabled()
.also { if (!it) Log.i(TAG, "Notifications disabled; progress will not be visible.") }
companion object {
const val CHANNEL_ID = "conversions"
private const val TAG = "ConversionNotifications"
/** `setProgress` takes a max and a current; progress is reported as a percentage. */
private const val PERCENT_MAX = 100
}
}
@@ -8,7 +8,6 @@ import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.ForegroundInfo
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import com.arthenica.ffmpegkit.FFmpegKitConfig
@@ -40,12 +39,10 @@ import java.io.File
* with a short quota, which is the wrong shape for a multi-minute transcode.
*/
@UnstableApi
class ConversionWorker(
context: Context,
params: WorkerParameters,
) : CoroutineWorker(context, params) {
class ConversionWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
private val notifications = ConversionNotifications(applicationContext)
// Resolved through ConversionDependencies so tests can force the failure paths.
private val publisher = ConversionDependencies.publisher(applicationContext)
@@ -56,10 +53,10 @@ class ConversionWorker(
val sizeBytes = inputData.getLong(KEY_SIZE_BYTES, 0L)
val spec = readSpec()
val quality = QualityTier.valueOf(
inputData.getString(KEY_QUALITY) ?: QualityTier.FAST.name
inputData.getString(KEY_QUALITY) ?: QualityTier.FAST.name,
)
val preference = EnginePreference.valueOf(
inputData.getString(KEY_ENGINE_PREFERENCE) ?: EnginePreference.AUTO.name
inputData.getString(KEY_ENGINE_PREFERENCE) ?: EnginePreference.AUTO.name,
)
if (!publisher.hasSpaceFor(sizeBytes)) {
@@ -102,7 +99,7 @@ class ConversionWorker(
KEY_OUTPUT_PATH to staged.absolutePath,
KEY_ENGINE_USED to decision.engine.name,
KEY_ROUTE_REASON to decision.reason.explanation,
)
),
)
} catch (e: Throwable) {
staged.delete()
@@ -142,12 +139,7 @@ class ConversionWorker(
runFFmpeg(request, inputUri, staged, displayName)
}
private suspend fun runFFmpeg(
request: ConversionRequest,
inputUri: Uri,
staged: File,
displayName: String,
) {
private suspend fun runFFmpeg(request: ConversionRequest, inputUri: Uri, staged: File, displayName: String) {
// FFmpeg needs a path. ffkitsaf bridges a content:// URI for reading; the read
// side is seekable for local providers, which is all the demuxer needs. Output
// still goes to a real cache path — see OutputPublisher.
@@ -177,8 +169,7 @@ class ConversionWorker(
}
}
private fun isCancellation(e: Throwable): Boolean =
e is kotlinx.coroutines.CancellationException || isStopped
private fun isCancellation(e: Throwable): Boolean = e is kotlinx.coroutines.CancellationException || isStopped
/**
* Distinguishes a genuine failure from the foreground-service budget expiring.
@@ -189,17 +180,16 @@ class ConversionWorker(
* later rather than tell the user the conversion failed — the work is still valid,
* there is simply no budget right now.
*/
private fun handleTimeoutIfNeeded(cause: Throwable): Result =
when (FailureOutcome.forStopReason(stopReason)) {
FailureOutcome.RETRY -> {
Log.w(TAG, "Foreground service budget exhausted; will retry.", cause)
Result.retry()
}
FailureOutcome.FAIL -> {
Log.e(TAG, "Conversion failed.", cause)
Result.failure(workDataOf(KEY_ERROR to (cause.message ?: "Conversion failed.")))
}
private fun handleTimeoutIfNeeded(cause: Throwable): Result = when (FailureOutcome.forStopReason(stopReason)) {
FailureOutcome.RETRY -> {
Log.w(TAG, "Foreground service budget exhausted; will retry.", cause)
Result.retry()
}
FailureOutcome.FAIL -> {
Log.e(TAG, "Conversion failed.", cause)
Result.failure(workDataOf(KEY_ERROR to (cause.message ?: "Conversion failed.")))
}
}
/**
* Reads the output spec out of the worker's input Data.
@@ -223,19 +213,17 @@ class ConversionWorker(
return OutputSpec(container, video, audio)
}
override suspend fun getForegroundInfo(): ForegroundInfo =
foregroundInfo(
inputData.getString(KEY_DISPLAY_NAME) ?: "input",
percent = 0,
indeterminate = true,
)
override suspend fun getForegroundInfo(): ForegroundInfo = foregroundInfo(
inputData.getString(KEY_DISPLAY_NAME) ?: "input",
percent = 0,
indeterminate = true,
)
private fun foregroundInfo(title: String, percent: Int, indeterminate: Boolean) =
ForegroundInfo(
NOTIFICATION_ID,
notifications.build(id, title, percent, indeterminate),
ConversionForegroundType.current(),
)
private fun foregroundInfo(title: String, percent: Int, indeterminate: Boolean) = ForegroundInfo(
NOTIFICATION_ID,
notifications.build(id, title, percent, indeterminate),
ConversionForegroundType.current(),
)
companion object {
const val KEY_INPUT_URI = "input_uri"
@@ -263,9 +251,8 @@ class ConversionWorker(
* yields `.mkv` or `.mka` and MP4 yields `.mp4` or `.m4a` without a preset having to
* enumerate both.
*/
fun outputNameFor(inputName: String, spec: OutputSpec): String =
inputName.substringBeforeLast('.', inputName) +
"_converted.${spec.extension}"
fun outputNameFor(inputName: String, spec: OutputSpec): String = inputName.substringBeforeLast('.', inputName) +
"_converted.${spec.extension}"
fun request(
inputUri: Uri,
@@ -285,7 +272,7 @@ class ConversionWorker(
.putString(KEY_AUDIO_CODEC, spec.audioCodec.name)
.putString(KEY_QUALITY, quality.name)
.putString(KEY_ENGINE_PREFERENCE, enginePreference.name)
.build()
.build(),
)
.build()
}
@@ -16,7 +16,9 @@ enum class FailureOutcome {
RETRY,
/** A genuine failure; report it to the user. */
FAIL;
FAIL,
;
companion object {
fun forStopReason(stopReason: Int): FailureOutcome =
@@ -1,17 +1,17 @@
package org.libremediaconverter.ffmpeg
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.OutputSpec
import org.libremediaconverter.model.VideoCodec
import org.libremediaconverter.model.QualityTier
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.OutputSpec
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.model.VideoCodec
class FFmpegCommandBuilderTest {
@@ -94,8 +94,10 @@ class FFmpegCommandBuilderTest {
@Test
fun `every video encode path forces yuv420p`() {
val videoFormats = listOf(
OutputFormat.MP4_H264, OutputFormat.MP4_H265,
OutputFormat.MKV_H264, OutputFormat.MKV_H265,
OutputFormat.MP4_H264,
OutputFormat.MP4_H265,
OutputFormat.MKV_H264,
OutputFormat.MKV_H265,
OutputFormat.WEBM_VP9,
)
videoFormats.forEach { format ->
@@ -1,11 +1,11 @@
package org.libremediaconverter.ffmpeg
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.model.OutputFormat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.model.OutputFormat
import java.io.File
class FFmpegConcatCommandTest {
@@ -31,7 +31,11 @@ class FFmpegConcatCommandTest {
@Test
fun `stream copy uses the concat demuxer and copies codecs`() {
val args = FFmpegConcatCommand.build(
ConcatStrategy.STREAM_COPY, inputs, listFile, output, OutputFormat.MP4_H264,
ConcatStrategy.STREAM_COPY,
inputs,
listFile,
output,
OutputFormat.MP4_H264,
)
assertTrue(args.contains("concat"))
assertEquals("copy", args[args.indexOf("-c") + 1])
@@ -42,7 +46,11 @@ class FFmpegConcatCommandTest {
@Test
fun `stream copy allows absolute paths in the list file`() {
val args = FFmpegConcatCommand.build(
ConcatStrategy.STREAM_COPY, inputs, listFile, output, OutputFormat.MP4_H264,
ConcatStrategy.STREAM_COPY,
inputs,
listFile,
output,
OutputFormat.MP4_H264,
)
// Without -safe 0 the demuxer rejects the absolute paths we generate.
assertEquals("0", args[args.indexOf("-safe") + 1])
@@ -51,7 +59,11 @@ class FFmpegConcatCommandTest {
@Test
fun `re-encode passes every input separately and builds a filter graph`() {
val args = FFmpegConcatCommand.build(
ConcatStrategy.REENCODE, inputs, listFile, output, OutputFormat.MP4_H264,
ConcatStrategy.REENCODE,
inputs,
listFile,
output,
OutputFormat.MP4_H264,
)
assertEquals(2, args.count { it == "-i" })
val filter = args[args.indexOf("-filter_complex") + 1]
@@ -64,7 +76,11 @@ class FFmpegConcatCommandTest {
@Test
fun `re-encode maps the filter outputs rather than raw streams`() {
val args = FFmpegConcatCommand.build(
ConcatStrategy.REENCODE, inputs, listFile, output, OutputFormat.MP4_H264,
ConcatStrategy.REENCODE,
inputs,
listFile,
output,
OutputFormat.MP4_H264,
)
assertTrue(args.contains("[v]"))
assertTrue(args.contains("[a]"))
@@ -81,7 +97,11 @@ class FFmpegConcatCommandTest {
@Test
fun `mkv output does not get faststart`() {
val args = FFmpegConcatCommand.build(
ConcatStrategy.STREAM_COPY, inputs, listFile, File("/cache/j.mkv"), OutputFormat.MKV_H264,
ConcatStrategy.STREAM_COPY,
inputs,
listFile,
File("/cache/j.mkv"),
OutputFormat.MKV_H264,
)
assertFalse(args.contains("+faststart"))
}
@@ -1,8 +1,8 @@
package org.libremediaconverter.model
import org.libremediaconverter.model.ConversionRouter.Reason
import org.junit.Assert.assertEquals
import org.junit.Test
import org.libremediaconverter.model.ConversionRouter.Reason
/**
* One test per routing predicate.
@@ -291,8 +291,12 @@ class ConversionRouterTest {
@Test
fun `the new containers are all FFmpeg-only`() {
listOf(
Container.MOV, Container.MKV, Container.MPEG_TS,
Container.AVI, Container.FLV, Container.ASF,
Container.MOV,
Container.MKV,
Container.MPEG_TS,
Container.AVI,
Container.FLV,
Container.ASF,
).forEach { container ->
val d = route(
OutputSpec(container, VideoCodec.COPY, AudioCodec.COPY),
+23 -3
View File
@@ -1,4 +1,24 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
}
//
// AGP 9 uses "built-in Kotlin": it compiles Kotlin itself, with the KGP it bundles (2.2.10 for
// AGP 9.3.1, per its POM). That version caps jvmTarget at 24. Putting a newer KGP on this single
// buildscript classpath is what raises the ceiling -- AGP's built-in Kotlin then compiles with
// 2.4.10 instead. The Compose compiler plugin must match KGP exactly, so it moves in lockstep.
//
// This is why the module below applies these two by id() rather than alias(): the plugins come
// from here, not from the version catalog's plugin resolution. The catalog still carries the
// version numbers, so there is exactly one place to edit.
//
// We still do NOT apply org.jetbrains.kotlin.android -- that plugin is incompatible with AGP 9's
// built-in-Kotlin DSL and fails the build.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:${libs.versions.agp.get()}")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${libs.versions.kotlin.get()}")
classpath("org.jetbrains.kotlin:compose-compiler-gradle-plugin:${libs.versions.kotlin.get()}")
}
}
+104
View File
@@ -0,0 +1,104 @@
# Project overrides merged onto detekt's bundled defaults (buildUponDefaultConfig = true in
# app/build.gradle.kts). Only rules that need tuning for this project appear here.
#
# Guiding principle: Compose UI intentionally breaks several OOP-era metrics, and a few default
# thresholds are stricter than this project's chosen style. Those are relaxed here with a reason.
# Genuine smells (swallowed exceptions, an over-complex condition, a misnamed file) are fixed in
# the code, not silenced.
#
# Formatting is deliberately absent: ktlint owns it. detekt's formatting ruleset is not enabled,
# so the two tools can never disagree about the same line.
complexity:
LongMethod:
# Declarative @Composable functions are read top-to-bottom and are legitimately long.
ignoreAnnotated: ['Composable']
CyclomaticComplexMethod:
# Branchy layout code (when/if inside a UI tree) isn't algorithmic complexity.
ignoreAnnotated: ['Composable']
# A flat `when` used as a lookup table scores one point per entry, so MediaProbe's
# demuxer-name -> Container map reads as complexity 21 while having no nesting, no
# state and nothing to follow. That is the metric measuring table size. A `when` with
# real logic in its branches still counts.
ignoreSingleWhenExpression: true
ignoreSimpleWhenEntries: true
# Same reason the model package is excluded from ReturnCount below: there, one branch
# is one documented outcome. ConversionRouter.route scores 17 because the app can give
# 17 distinct answers to "which engine, and why", not because it is hard to follow --
# it is a flat guard chain with a comment per rule. Only this metric and ReturnCount
# are relaxed for that package; LongMethod, NestedBlockDepth, ComplexCondition and the
# rest still apply there.
excludes: ['**/model/**']
TooManyFunctions:
# Screen files group many small @Composable helpers next to their screen, and the codec /
# container matrix files are intentionally operation-rich cohesive APIs. detekt's default of
# 11 is far too low for either. Files past ~40 functions still flag as genuinely bloated.
ignoreAnnotated: ['Composable']
allowedFunctionsPerFile: 40
allowedFunctionsPerClass: 40
allowedFunctionsPerInterface: 40
# ContainerCapabilities is a single object holding the container x codec matrix and the
# queries over it. Splitting it to satisfy a count would scatter one table across files.
allowedFunctionsPerObject: 40
allowedFunctionsPerEnum: 40
naming:
FunctionNaming:
# @Composable functions are PascalCase by Compose convention.
ignoreAnnotated: ['Composable']
style:
MagicNumber:
# dp / sp / duration literals are idiomatic inline in Compose.
ignoreAnnotated: ['Composable']
ignorePropertyDeclaration: true
ignoreNamedArgument: true
# SI thresholds in the byte formatter. Each literal sits on the same line as the unit
# string it belongs to -- `bytes >= 1_000_000 -> "%.1f MB"` -- so a BYTES_PER_MB pair
# (Long and Double, since the compare and the divide need different types) would add
# six names and say nothing the line does not already say. Counts, limits and tuning
# values still flag; these are unit boundaries.
ignoreNumbers:
- '-1'
- '0'
- '1'
- '2'
- '1e3'
- '1e6'
- '1e9'
- '1_000'
- '1_000_000'
- '1_000_000_000'
ReturnCount:
# Allow guard-clause-style early returns; detekt's default of 2 is overly strict.
max: 4
excludeGuardClauses: true
# The model package IS the decision layer: ConversionRouter picks an engine,
# ContainerCapabilities validates a container x codec pair, the planners pick a
# strategy. Each `return` there is one specific, user-visible reason, and the count
# equals the number of reasons the app can give -- ConversionRouter.route even
# documents that their ORDER is what decides which message the user sees. Collapsing
# them into one exit would bury that. Everywhere else -- UI, workers, engines -- the
# limit still applies.
excludes: ['**/model/**']
LoopWithTooManyJumpStatements:
# Clear early-continue / early-return loops read fine; the default of 1 is strict.
maxJumpCount: 3
ThrowsCount:
# Guard-clause throws don't count; allow a few more for functions validating several
# preconditions.
excludeGuardClauses: true
max: 3
exceptions:
TooGenericExceptionCaught:
# The engine boundaries catch broadly on purpose. MediaProbe drives the platform
# extractor and MediaMetadataRetriever over arbitrary user files; ConversionWorker and
# ConcatWorker wrap Media3 and FFmpeg. All three sit in front of native code that
# reports a malformed file as anything from IllegalArgumentException to
# IllegalStateException to a bare RuntimeException, and the list is not documented.
# Enumerating it would mean guessing, and a guess that is wrong crashes the app on a
# file it could have simply reported as unreadable. Every one of these catches logs
# and handles -- falls back to FFmpeg, or fails the job with a reason -- and the
# SwallowedException rule stays active to keep it that way.
active: false
+10 -10
View File
@@ -1,12 +1,12 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/c2dd35c9d0aaf0ba6ad0791320f99dfc/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/e5810bd7fd1f8a586644409d395a7e55/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7b3c4877c0749019e6805bb61e421497/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/d76df094a9cbbabd3b08251f9e61444a/redirect
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/539ea72837c6244075f8ee037babc29e/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/876dbbf00bf4ef6190f7da074cee903a/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/539ea72837c6244075f8ee037babc29e/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/876dbbf00bf4ef6190f7da074cee903a/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/1df82b76031285ef2e6a4f59ffe9299d/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/4d0277c49991306ac78048580fe34bcf/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/539ea72837c6244075f8ee037babc29e/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/876dbbf00bf4ef6190f7da074cee903a/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/a9fa3b809e484a1e1ac4308a5dda661a/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/aa99913fa6d767d899ffc96082da01ca/redirect
toolchainVersion=25
+70 -23
View File
@@ -1,31 +1,72 @@
[versions]
# Build tooling.
# NOTE: AGP 9 has BUILT-IN Kotlin support. Applying org.jetbrains.kotlin.android
# FAILS the build. AGP 9.3.1 brings kotlin-gradle-plugin 2.2.10 transitively, so the
# Compose compiler plugin below must match that version, not the newest Kotlin release.
# Build tooling. Pinned, never floating. agp and kotlin are coupled (below); ksp is
# staged for a later phase and unapplied today -- KSP2 versions independently of Kotlin.
#
# AGP 9 has BUILT-IN Kotlin: it compiles Kotlin itself rather than delegating to the
# org.jetbrains.kotlin.android plugin, which is incompatible with its DSL and fails the
# build if applied. DO NOT add it.
#
# By default AGP compiles with the KGP it bundles -- 2.2.10 for AGP 9.3.1, per its POM --
# and that version caps jvmTarget at 24, which would cap the app's bytecode below the JDK
# everything else runs on. The root build.gradle.kts therefore puts KGP on the buildscript
# classpath explicitly, which AGP's built-in Kotlin picks up instead. That is the only
# reason `kotlin` here can be ahead of what AGP ships.
#
# The Compose compiler plugin is versioned in lockstep with Kotlin and is read from this
# same `kotlin` entry, so the two cannot drift. Raising `kotlin` means checking that a
# matching compose-compiler-gradle-plugin exists.
agp = "9.3.1"
kotlin = "2.2.10"
kotlin = "2.4.10"
ksp = "2.3.11"
# AndroidX / Compose
composeBom = "2026.08.00"
coreKtx = "1.19.0"
activityCompose = "1.13.0"
lifecycle = "2.11.0"
navigation = "2.9.8"
work = "2.11.2"
datastore = "1.2.1"
media3 = "1.11.0"
room = "2.8.4"
documentfile = "1.1.0"
annotation = "1.10.0"
# AndroidX / Compose -- floating on minor + patch. The prerelease guard in
# app/build.gradle.kts is what keeps `+` from selecting an alpha: several of these
# (lifecycle, navigation, work, datastore, annotation) publish alphas and RCs with
# version numbers ABOVE their newest stable, and Gradle's `+` would take them.
# Bare `+`, not "2026.+": the year is the major in this scheme (YYYY.MM.PP), so a
# 2026-prefixed float would quietly stop finding releases on 1 January and keep
# building green against a frozen BOM.
composeBom = "+"
coreKtx = "1.+"
activityCompose = "1.+"
lifecycle = "2.+"
navigation = "2.+"
work = "2.+"
datastore = "1.+"
media3 = "1.+"
room = "2.+"
documentfile = "1.+"
annotation = "1.+"
# Test
junit = "4.13.2"
androidxJunit = "1.3.0"
espressoCore = "3.7.0"
# Test -- floating, same rules as above.
junit = "4.+"
androidxJunit = "1.+"
espressoCore = "3.+"
# PINNED, unlike its neighbours. Under semver a 0.x minor is allowed to break, and
# this library is load-bearing exactly where breakage is hardest to see: the wrapper
# reaches for smartexception.java.Exceptions only when an FFmpeg call FAILS, so a
# moved class surfaces as an R8 missing-class error at release time, or as a crash on
# the error path -- the least-exercised code in the app. Bump it deliberately.
smartException = "0.2.1"
# Lint/format. PINNED, deliberately, while the libraries above float.
#
# A library bump that misbehaves usually still compiles. An analysis-tool bump does
# something worse: a new rule in ktlint or detekt makes files nobody touched stop
# passing, so CI goes red on a PR whose diff cannot explain it. Upgrading these is
# therefore an act with its own commit -- run the tool, read the new findings, fix or
# relax them -- which is exactly the reviewable step floating is meant to skip.
#
# ktlint owns formatting; detekt owns static analysis (its formatting ruleset stays
# off, so the two can never disagree about the same line).
# detekt 2.0 is the only line with Gradle 9 support -- stable 1.23.x tops out at
# Gradle 8.12, and this project is on 9.7.1.
ktlint = "14.2.0"
detekt = "2.0.0-alpha.6"
# JaCoCo coverage agent. Pinned rather than inheriting whatever Gradle 9.7.1 bundles,
# so the agent version that reads Kotlin 2.2.10 bytecode is stated, not implied.
jacoco = "0.8.15"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
@@ -75,7 +116,13 @@ androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "a
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
# com.android.application and org.jetbrains.kotlin.plugin.compose are deliberately absent.
# They come from the root buildscript classpath (see build.gradle.kts) so that a newer KGP
# can override AGP's bundled one, and the module applies them by id(). An alias here would
# be dead weight that reads like the source of truth.
#
# ktlint and detekt are ordinary plugin-portal resolutions and stay aliases.
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
detekt = { id = "dev.detekt", version.ref = "detekt" }
# DO NOT add org.jetbrains.kotlin.android — AGP 9 built-in Kotlin makes it a build failure.
Binary file not shown.
+4 -3
View File
@@ -1,9 +1,10 @@
#Wed Aug 19 15:18:44 CDT 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=553c78f50dafcd54d65b9a444649057857469edf836431389695608536d6b746
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
distributionSha256Sum=acd53f1edaf02f1a8ff99879f8a34b302661a057d9b063ae9e35b552f804d20a
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+1 -4
View File
@@ -57,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@@ -114,7 +114,6 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
@@ -172,7 +171,6 @@ fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -212,7 +210,6 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
Vendored
+82 -94
View File
@@ -1,94 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute Gradle
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%