diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..23bf3b9 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..979f0c8 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.github/scripts/e2e-run.sh b/.github/scripts/e2e-run.sh new file mode 100755 index 0000000..b2486a1 --- /dev/null +++ b/.github/scripts/e2e-run.sh @@ -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:-} test: ${TEST_PID:-}" + # 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" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 55336f7..fbc65f3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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: diff --git a/.github/workflows/status_check.yml b/.github/workflows/status_check.yml index b3d0597..171163b 100644 --- a/.github/workflows/status_check.yml +++ b/.github/workflows/status_check.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..80c0667 --- /dev/null +++ b/CLAUDE.md @@ -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 ` 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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e2b917c..c5ab3ed 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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("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) diff --git a/app/src/androidTest/java/org/libremediaconverter/bench/RealMediaBenchmark.kt b/app/src/androidTest/java/org/libremediaconverter/bench/RealMediaBenchmark.kt index 887e811..3787388 100644 --- a/app/src/androidTest/java/org/libremediaconverter/bench/RealMediaBenchmark.kt +++ b/app/src/androidTest/java/org/libremediaconverter/bench/RealMediaBenchmark.kt @@ -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() diff --git a/app/src/androidTest/java/org/libremediaconverter/convert/RemuxTest.kt b/app/src/androidTest/java/org/libremediaconverter/convert/RemuxTest.kt index 827a212..ee6a988 100644 --- a/app/src/androidTest/java/org/libremediaconverter/convert/RemuxTest.kt +++ b/app/src/androidTest/java/org/libremediaconverter/convert/RemuxTest.kt @@ -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() diff --git a/app/src/androidTest/java/org/libremediaconverter/fallback/FakeFailures.kt b/app/src/androidTest/java/org/libremediaconverter/fallback/FakeFailures.kt index 3171d9a..7e40bf5 100644 --- a/app/src/androidTest/java/org/libremediaconverter/fallback/FakeFailures.kt +++ b/app/src/androidTest/java/org/libremediaconverter/fallback/FakeFailures.kt @@ -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 { diff --git a/app/src/androidTest/java/org/libremediaconverter/fallback/ForcedFailureTest.kt b/app/src/androidTest/java/org/libremediaconverter/fallback/ForcedFailureTest.kt index 618308a..4601c91 100644 --- a/app/src/androidTest/java/org/libremediaconverter/fallback/ForcedFailureTest.kt +++ b/app/src/androidTest/java/org/libremediaconverter/fallback/ForcedFailureTest.kt @@ -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" diff --git a/app/src/androidTest/java/org/libremediaconverter/fallback/HardwareFallbackTest.kt b/app/src/androidTest/java/org/libremediaconverter/fallback/HardwareFallbackTest.kt index 97c64f3..b05f5b9 100644 --- a/app/src/androidTest/java/org/libremediaconverter/fallback/HardwareFallbackTest.kt +++ b/app/src/androidTest/java/org/libremediaconverter/fallback/HardwareFallbackTest.kt @@ -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 /** diff --git a/app/src/androidTest/java/org/libremediaconverter/fallback/UnopenableUriTest.kt b/app/src/androidTest/java/org/libremediaconverter/fallback/UnopenableUriTest.kt index 4b1084d..7009e62 100644 --- a/app/src/androidTest/java/org/libremediaconverter/fallback/UnopenableUriTest.kt +++ b/app/src/androidTest/java/org/libremediaconverter/fallback/UnopenableUriTest.kt @@ -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 /** diff --git a/app/src/androidTest/java/org/libremediaconverter/ffmpeg/FFmpegEngineTest.kt b/app/src/androidTest/java/org/libremediaconverter/ffmpeg/FFmpegEngineTest.kt index 40264a2..79ad243 100644 --- a/app/src/androidTest/java/org/libremediaconverter/ffmpeg/FFmpegEngineTest.kt +++ b/app/src/androidTest/java/org/libremediaconverter/ffmpeg/FFmpegEngineTest.kt @@ -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}") diff --git a/app/src/androidTest/java/org/libremediaconverter/join/ConcatEngineTest.kt b/app/src/androidTest/java/org/libremediaconverter/join/ConcatEngineTest.kt index b9c703c..745b8aa 100644 --- a/app/src/androidTest/java/org/libremediaconverter/join/ConcatEngineTest.kt +++ b/app/src/androidTest/java/org/libremediaconverter/join/ConcatEngineTest.kt @@ -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() diff --git a/app/src/androidTest/java/org/libremediaconverter/join/ConcatWorkerTest.kt b/app/src/androidTest/java/org/libremediaconverter/join/ConcatWorkerTest.kt index 84bfdba..a4e2b6e 100644 --- a/app/src/androidTest/java/org/libremediaconverter/join/ConcatWorkerTest.kt +++ b/app/src/androidTest/java/org/libremediaconverter/join/ConcatWorkerTest.kt @@ -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 /** diff --git a/app/src/androidTest/java/org/libremediaconverter/work/ConversionWorkerTest.kt b/app/src/androidTest/java/org/libremediaconverter/work/ConversionWorkerTest.kt index 9b4d5dd..b8b5b2a 100644 --- a/app/src/androidTest/java/org/libremediaconverter/work/ConversionWorkerTest.kt +++ b/app/src/androidTest/java/org/libremediaconverter/work/ConversionWorkerTest.kt @@ -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), ) diff --git a/app/src/main/java/org/libremediaconverter/MainActivity.kt b/app/src/main/java/org/libremediaconverter/MainActivity.kt index 14139ae..6fd219f 100644 --- a/app/src/main/java/org/libremediaconverter/MainActivity.kt +++ b/app/src/main/java/org/libremediaconverter/MainActivity.kt @@ -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 diff --git a/app/src/main/java/org/libremediaconverter/codec/AndroidDeviceCodecs.kt b/app/src/main/java/org/libremediaconverter/codec/AndroidDeviceCodecs.kt index de630bb..85475a3 100644 --- a/app/src/main/java/org/libremediaconverter/codec/AndroidDeviceCodecs.kt +++ b/app/src/main/java/org/libremediaconverter/codec/AndroidDeviceCodecs.kt @@ -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, ) : 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() @@ -104,7 +101,6 @@ class AndroidDeviceCodecs private constructor( } /** Test seam: lets instrumented tests build a probe from explicit sets. */ - fun forTesting(encoders: Set, decoders: Set) = - AndroidDeviceCodecs(encoders, decoders) + fun forTesting(encoders: Set, decoders: Set) = AndroidDeviceCodecs(encoders, decoders) } } diff --git a/app/src/main/java/org/libremediaconverter/convert/ConversionViewModel.kt b/app/src/main/java/org/libremediaconverter/convert/ConversionViewModel.kt index e53a045..4e2ed51 100644 --- a/app/src/main/java/org/libremediaconverter/convert/ConversionViewModel.kt +++ b/app/src/main/java/org/libremediaconverter/convert/ConversionViewModel.kt @@ -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 diff --git a/app/src/main/java/org/libremediaconverter/convert/ConverterScreen.kt b/app/src/main/java/org/libremediaconverter/convert/ConverterScreen.kt index 12c999f..a68838d 100644 --- a/app/src/main/java/org/libremediaconverter/convert/ConverterScreen.kt +++ b/app/src/main/java/org/libremediaconverter/convert/ConverterScreen.kt @@ -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( diff --git a/app/src/main/java/org/libremediaconverter/convert/Media3Engine.kt b/app/src/main/java/org/libremediaconverter/convert/Media3Engine.kt index 71d1b79..fb25f71 100644 --- a/app/src/main/java/org/libremediaconverter/convert/Media3Engine.kt +++ b/app/src/main/java/org/libremediaconverter/convert/Media3Engine.kt @@ -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, - ): Transformer { + private fun buildTransformer(plan: ConversionPlan, cont: CancellableContinuation): 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 } diff --git a/app/src/main/java/org/libremediaconverter/convert/MediaProbe.kt b/app/src/main/java/org/libremediaconverter/convert/MediaProbe.kt index c4f73c7..0fa80ae 100644 --- a/app/src/main/java/org/libremediaconverter/convert/MediaProbe.kt +++ b/app/src/main/java/org/libremediaconverter/convert/MediaProbe.kt @@ -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 } diff --git a/app/src/main/java/org/libremediaconverter/convert/OutputPublisher.kt b/app/src/main/java/org/libremediaconverter/convert/OutputPublisher.kt index af8973e..f0cbe30 100644 --- a/app/src/main/java/org/libremediaconverter/convert/OutputPublisher.kt +++ b/app/src/main/java/org/libremediaconverter/convert/OutputPublisher.kt @@ -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) { diff --git a/app/src/main/java/org/libremediaconverter/convert/Transcoders.kt b/app/src/main/java/org/libremediaconverter/convert/Transcoders.kt index ea7f810..b70b9ae 100644 --- a/app/src/main/java/org/libremediaconverter/convert/Transcoders.kt +++ b/app/src/main/java/org/libremediaconverter/convert/Transcoders.kt @@ -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 diff --git a/app/src/main/java/org/libremediaconverter/ffmpeg/ConcatEngine.kt b/app/src/main/java/org/libremediaconverter/ffmpeg/ConcatEngine.kt index 038df54..35ea0de 100644 --- a/app/src/main/java/org/libremediaconverter/ffmpeg/ConcatEngine.kt +++ b/app/src/main/java/org/libremediaconverter/ffmpeg/ConcatEngine.kt @@ -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, - output: File, - format: OutputFormat = OutputFormat.MP4_H264, - ): Result { + suspend fun join(inputs: List, 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(), + ), ) } } diff --git a/app/src/main/java/org/libremediaconverter/ffmpeg/FFmpegCommandBuilder.kt b/app/src/main/java/org/libremediaconverter/ffmpeg/FFmpegCommandBuilder.kt index 4dde3ba..a06206b 100644 --- a/app/src/main/java/org/libremediaconverter/ffmpeg/FFmpegCommandBuilder.kt +++ b/app/src/main/java/org/libremediaconverter/ffmpeg/FFmpegCommandBuilder.kt @@ -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 { + fun build(request: ConversionRequest, inputPath: String, outputPath: String): List { 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 = - 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 = 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 = 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") } } diff --git a/app/src/main/java/org/libremediaconverter/ffmpeg/FFmpegConcatCommand.kt b/app/src/main/java/org/libremediaconverter/ffmpeg/FFmpegConcatCommand.kt index 4b35fb0..ec939b6 100644 --- a/app/src/main/java/org/libremediaconverter/ffmpeg/FFmpegConcatCommand.kt +++ b/app/src/main/java/org/libremediaconverter/ffmpeg/FFmpegConcatCommand.kt @@ -28,21 +28,31 @@ object FFmpegConcatCommand { format: OutputFormat, ): List = 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) } diff --git a/app/src/main/java/org/libremediaconverter/ffmpeg/FFmpegEngine.kt b/app/src/main/java/org/libremediaconverter/ffmpeg/FFmpegEngine.kt index 6205b52..f830ca8 100644 --- a/app/src/main/java/org/libremediaconverter/ffmpeg/FFmpegEngine.kt +++ b/app/src/main/java/org/libremediaconverter/ffmpeg/FFmpegEngine.kt @@ -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() - } - ) + }, + ), ) } }, diff --git a/app/src/main/java/org/libremediaconverter/join/JoinScreen.kt b/app/src/main/java/org/libremediaconverter/join/JoinScreen.kt index e344054..e5b6707 100644 --- a/app/src/main/java/org/libremediaconverter/join/JoinScreen.kt +++ b/app/src/main/java/org/libremediaconverter/join/JoinScreen.kt @@ -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( diff --git a/app/src/main/java/org/libremediaconverter/join/JoinViewModel.kt b/app/src/main/java/org/libremediaconverter/join/JoinViewModel.kt index 52c192c..2d09e3f 100644 --- a/app/src/main/java/org/libremediaconverter/join/JoinViewModel.kt +++ b/app/src/main/java/org/libremediaconverter/join/JoinViewModel.kt @@ -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) diff --git a/app/src/main/java/org/libremediaconverter/model/ContainerCapabilities.kt b/app/src/main/java/org/libremediaconverter/model/ContainerCapabilities.kt index e5c3644..538b33f 100644 --- a/app/src/main/java/org/libremediaconverter/model/ContainerCapabilities.kt +++ b/app/src/main/java/org/libremediaconverter/model/ContainerCapabilities.kt @@ -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 diff --git a/app/src/main/java/org/libremediaconverter/model/ConversionRouter.kt b/app/src/main/java/org/libremediaconverter/model/ConversionRouter.kt index 31af371..7b423c2 100644 --- a/app/src/main/java/org/libremediaconverter/model/ConversionRouter.kt +++ b/app/src/main/java/org/libremediaconverter/model/ConversionRouter.kt @@ -57,7 +57,10 @@ object ConversionRouter { internal val MEDIA3_MUXABLE_AUDIO: Map> = mapOf( Container.MP4 to setOf( - AudioCodec.AAC, AudioCodec.OPUS, AudioCodec.VORBIS, AudioCodec.PCM, + AudioCodec.AAC, + AudioCodec.OPUS, + AudioCodec.VORBIS, + AudioCodec.PCM, ), ) diff --git a/app/src/main/java/org/libremediaconverter/model/CopyPlanner.kt b/app/src/main/java/org/libremediaconverter/model/CopyPlanner.kt index 3542944..fc6aed4 100644 --- a/app/src/main/java/org/libremediaconverter/model/CopyPlanner.kt +++ b/app/src/main/java/org/libremediaconverter/model/CopyPlanner.kt @@ -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 diff --git a/app/src/main/java/org/libremediaconverter/model/OutputFormat.kt b/app/src/main/java/org/libremediaconverter/model/OutputFormat.kt index 1ff906e..8e5b23f 100644 --- a/app/src/main/java/org/libremediaconverter/model/OutputFormat.kt +++ b/app/src/main/java/org/libremediaconverter/model/OutputFormat.kt @@ -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) diff --git a/app/src/main/java/org/libremediaconverter/ui/theme/Theme.kt b/app/src/main/java/org/libremediaconverter/ui/theme/Theme.kt index 856247a..704cb6e 100644 --- a/app/src/main/java/org/libremediaconverter/ui/theme/Theme.kt +++ b/app/src/main/java/org/libremediaconverter/ui/theme/Theme.kt @@ -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 diff --git a/app/src/main/java/org/libremediaconverter/work/ConcatWorker.kt b/app/src/main/java/org/libremediaconverter/work/ConcatWorker.kt index 7b20b6b..8511d25 100644 --- a/app/src/main/java/org/libremediaconverter/work/ConcatWorker.kt +++ b/app/src/main/java/org/libremediaconverter/work/ConcatWorker.kt @@ -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, - totalBytes: Long, - format: OutputFormat = OutputFormat.MP4_H264, - ) = OneTimeWorkRequestBuilder() - .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, totalBytes: Long, format: OutputFormat = OutputFormat.MP4_H264) = + OneTimeWorkRequestBuilder() + .setInputData( + Data.Builder() + .putStringArray(KEY_INPUT_URIS, inputs.map(Uri::toString).toTypedArray()) + .putLong(KEY_TOTAL_BYTES, totalBytes) + .putString(KEY_FORMAT, format.name) + .build(), + ) + .build() } } diff --git a/app/src/main/java/org/libremediaconverter/work/ConversionForegroundType.kt b/app/src/main/java/org/libremediaconverter/work/ConversionForegroundType.kt index 1ac7cbc..12ee27c 100644 --- a/app/src/main/java/org/libremediaconverter/work/ConversionForegroundType.kt +++ b/app/src/main/java/org/libremediaconverter/work/ConversionForegroundType.kt @@ -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 } diff --git a/app/src/main/java/org/libremediaconverter/work/ConversionNotifications.kt b/app/src/main/java/org/libremediaconverter/work/ConversionNotifications.kt index e2b78b8..de3ddcf 100644 --- a/app/src/main/java/org/libremediaconverter/work/ConversionNotifications.kt +++ b/app/src/main/java/org/libremediaconverter/work/ConversionNotifications.kt @@ -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 } } diff --git a/app/src/main/java/org/libremediaconverter/work/ConversionWorker.kt b/app/src/main/java/org/libremediaconverter/work/ConversionWorker.kt index ea28f12..4b835fd 100644 --- a/app/src/main/java/org/libremediaconverter/work/ConversionWorker.kt +++ b/app/src/main/java/org/libremediaconverter/work/ConversionWorker.kt @@ -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() } diff --git a/app/src/main/java/org/libremediaconverter/work/FailureOutcome.kt b/app/src/main/java/org/libremediaconverter/work/FailureOutcome.kt index 123cc36..ab84b96 100644 --- a/app/src/main/java/org/libremediaconverter/work/FailureOutcome.kt +++ b/app/src/main/java/org/libremediaconverter/work/FailureOutcome.kt @@ -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 = diff --git a/app/src/test/java/org/libremediaconverter/ffmpeg/FFmpegCommandBuilderTest.kt b/app/src/test/java/org/libremediaconverter/ffmpeg/FFmpegCommandBuilderTest.kt index f119133..936717a 100644 --- a/app/src/test/java/org/libremediaconverter/ffmpeg/FFmpegCommandBuilderTest.kt +++ b/app/src/test/java/org/libremediaconverter/ffmpeg/FFmpegCommandBuilderTest.kt @@ -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 -> diff --git a/app/src/test/java/org/libremediaconverter/ffmpeg/FFmpegConcatCommandTest.kt b/app/src/test/java/org/libremediaconverter/ffmpeg/FFmpegConcatCommandTest.kt index b596435..c5b853d 100644 --- a/app/src/test/java/org/libremediaconverter/ffmpeg/FFmpegConcatCommandTest.kt +++ b/app/src/test/java/org/libremediaconverter/ffmpeg/FFmpegConcatCommandTest.kt @@ -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")) } diff --git a/app/src/test/java/org/libremediaconverter/model/ConversionRouterTest.kt b/app/src/test/java/org/libremediaconverter/model/ConversionRouterTest.kt index 8dfbb95..f762f14 100644 --- a/app/src/test/java/org/libremediaconverter/model/ConversionRouterTest.kt +++ b/app/src/test/java/org/libremediaconverter/model/ConversionRouterTest.kt @@ -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), diff --git a/build.gradle.kts b/build.gradle.kts index 3756278..31a6e3e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 -} \ No newline at end of file +// +// 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()}") + } +} diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml new file mode 100644 index 0000000..c4d98f7 --- /dev/null +++ b/config/detekt/detekt.yml @@ -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 diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties index fa4ed51..ebdb796 100644 --- a/gradle/gradle-daemon-jvm.properties +++ b/gradle/gradle-daemon-jvm.properties @@ -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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bdba997..ffde3ac 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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. diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 8bdaf60..b1b8ef5 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 482bf41..760c459 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew index ef07e01..b9bb139 100755 --- a/gradlew +++ b/gradlew @@ -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" \ "$@" diff --git a/gradlew.bat b/gradlew.bat index 5eed7ee..24c62d5 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -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%