Merge pull request #5 from JMR-dev/chore/lint-and-format-parity
Add lint, formatting and static analysis; move the toolchain to Java 25
This commit was merged in pull request #5.
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
Executable
+129
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Runs the instrumented suite on an already-booted emulator, and makes a failure
|
||||
# diagnosable without a re-run.
|
||||
#
|
||||
# Adapted from LibreMail's CI emulator instrumentation. Its lesson, learned there over
|
||||
# several wedged merge queues, is that a red E2E leg with nothing but "exit 1" in the log
|
||||
# costs more than the failure itself -- so every failure path here leaves evidence behind.
|
||||
#
|
||||
# WHY THIS IS A FILE rather than inline YAML: reactivecircus/android-emulator-runner splits
|
||||
# its `script:` input on newlines and runs each line as its own `sh -c`. Shell functions,
|
||||
# `if` blocks and traps cannot survive that, which is why the previous version had its whole
|
||||
# failure handler crammed onto one unreadable line. One line calls this; this can breathe.
|
||||
#
|
||||
# Two failure shapes, deliberately handled differently:
|
||||
#
|
||||
# FAILED -- gradle returned non-zero. The reports say which test and why, so capture the
|
||||
# device and runner state around it.
|
||||
# WEDGED -- gradle never returned and the wrapper timeout killed it. There is no report at
|
||||
# all, so the evidence has to be taken from the live device: what test was
|
||||
# running, and what every process was doing. SIGQUIT is the important part -- ART
|
||||
# dumps full thread stacks to logcat and /data/anr, which is how you tell a
|
||||
# deadlocked test from a stuck MediaCodec from an emulator that stopped answering.
|
||||
#
|
||||
# Every probe is guarded with `|| true`. A diagnostic must never be the thing that turns a run
|
||||
# red -- notably, a grep that matches nothing exits 1.
|
||||
set -uo pipefail
|
||||
|
||||
LABEL="${1:-unknown}"
|
||||
APP_ID="org.libremediaconverter"
|
||||
TEST_ID="org.libremediaconverter.test"
|
||||
|
||||
TMP="${RUNNER_TEMP:-/tmp}"
|
||||
LOGCAT_LOG="$TMP/logcat-api${LABEL}.txt"
|
||||
DIAG_LOG="$TMP/diagnostics-api${LABEL}.txt"
|
||||
WEDGE_LOG="$TMP/wedge-diagnostics-api${LABEL}.txt"
|
||||
|
||||
# ~5 min is a healthy leg (measured across API 33-36), and this wraps only the gradle client,
|
||||
# a subset of that. 20 min is generous enough never to trip on a slow-but-working run, and far
|
||||
# enough under the job's 60-min cap that a genuine wedge still leaves time to capture it.
|
||||
WEDGE_TIMEOUT=1200
|
||||
|
||||
# Stream logcat from now until the step ends, into a file that survives to the artifact upload.
|
||||
# Without this, a failure that happens on-device leaves nothing behind: `adb logcat -d` at the
|
||||
# end only has whatever is still in the ring buffer, and a chatty test run evicts the cause.
|
||||
echo "===== logcat (api${LABEL}) =====" >> "$LOGCAT_LOG"
|
||||
adb logcat -v time >> "$LOGCAT_LOG" 2>&1 &
|
||||
LOGCAT_PID=$!
|
||||
|
||||
dump_diagnostics() {
|
||||
{
|
||||
echo "===== E2E api${LABEL} failure diagnostics -- $(date -u +%FT%TZ) ====="
|
||||
echo "--- adb devices ---"; adb devices -l 2>&1 || true
|
||||
echo "--- guest memory ---"; adb shell cat /proc/meminfo 2>&1 | grep -E 'MemTotal|MemAvailable|SwapTotal' || true
|
||||
echo "--- guest storage ---"; adb shell df /data 2>&1 || true
|
||||
echo "--- is the app even installed? ---"; adb shell pm list packages 2>&1 | grep -a libremedia || true
|
||||
echo "--- native crashes ---"; adb logcat -d -b crash 2>&1 | tail -80 || true
|
||||
echo "--- runner: kvm ---"; ls -l /dev/kvm 2>&1 || true
|
||||
echo "--- runner: memory ---"; free -h 2>&1 || true
|
||||
echo "--- runner: disk ---"; df -h 2>&1 || true
|
||||
} >> "$DIAG_LOG" 2>&1 || true
|
||||
|
||||
# Also to the step log, so the common case needs no artifact download.
|
||||
echo "----- FAILURE SUMMARY (api${LABEL}) -----"
|
||||
adb shell cat /proc/meminfo 2>&1 | grep -E 'MemTotal|MemAvailable' || true
|
||||
echo "--- native crashes (tail 60) ---"
|
||||
adb logcat -d -b crash 2>&1 | tail -60 || true
|
||||
}
|
||||
|
||||
capture_wedge() {
|
||||
{
|
||||
echo "==================================================================="
|
||||
echo "===== E2E WEDGE -- api${LABEL} -- $1"
|
||||
echo "===== $(date -u +%FT%TZ) -- after ${WEDGE_TIMEOUT}s wrapper timeout"
|
||||
echo "==================================================================="
|
||||
# The single most useful line: which test was in flight when everything stopped.
|
||||
echo "--- running/last instrumented test (logcat TestRunner) ---"
|
||||
grep -a TestRunner "$LOGCAT_LOG" 2>/dev/null | tail -25 || true
|
||||
echo "--- boot state ---"
|
||||
adb shell getprop sys.boot_completed 2>&1 || true
|
||||
echo "--- are the binder services published? ---"
|
||||
for svc in input window activity media.player; do
|
||||
echo " service check $svc:"; adb shell service check "$svc" 2>&1 || true
|
||||
done
|
||||
APP_PID="$(adb shell pidof "$APP_ID" 2>/dev/null | tr -d '\r')" || true
|
||||
TEST_PID="$(adb shell pidof "$TEST_ID" 2>/dev/null | tr -d '\r')" || true
|
||||
echo "--- pids --- app: ${APP_PID:-<none>} test: ${TEST_PID:-<none>}"
|
||||
# SIGQUIT makes ART dump every thread's stack to logcat and /data/anr. This is what
|
||||
# distinguishes a deadlocked test from a stuck native encode from a dead device.
|
||||
echo "--- SIGQUIT thread dumps ---"
|
||||
for pid in $APP_PID $TEST_PID; do
|
||||
[ -n "$pid" ] && adb shell kill -3 "$pid" 2>&1 || true
|
||||
done
|
||||
sleep 5
|
||||
echo "--- /data/anr/* ---"
|
||||
adb shell 'cat /data/anr/* 2>/dev/null' 2>&1 || true
|
||||
echo "--- dumpsys activity ---"; adb shell dumpsys activity 2>&1 || true
|
||||
echo "--- dumpsys window ---"; adb shell dumpsys window 2>&1 || true
|
||||
# FFmpeg and Media3 both run through MediaCodec; a wedged transcode shows up here.
|
||||
echo "--- dumpsys media.player ---"; adb shell dumpsys media.player 2>&1 || true
|
||||
echo "--- logcat -d (tail 400, includes the SIGQUIT dump) ---"
|
||||
adb logcat -d 2>&1 | tail -400 || true
|
||||
} >> "$WEDGE_LOG" 2>&1 || true
|
||||
echo "::warning::E2E api${LABEL} WEDGED ($1) -- see the wedge-diagnostics-api${LABEL} artifact"
|
||||
}
|
||||
|
||||
echo "::group::E2E api${LABEL}"
|
||||
adb shell cat /proc/meminfo 2>&1 | grep -E 'MemTotal|MemAvailable|SwapTotal' || true
|
||||
|
||||
status=0
|
||||
# -k 30s SIGKILLs a gradle client that ignores SIGTERM. The wrapper covers ONLY the foreground
|
||||
# gradle client -- never the emulator, which the action owns -- so it cannot hang the leg.
|
||||
timeout -k 30s "$WEDGE_TIMEOUT" \
|
||||
./gradlew :app:connectedDebugAndroidTest -PabiFilters=x86_64 --stacktrace || status=$?
|
||||
echo "::endgroup::"
|
||||
|
||||
if [ "$status" -eq 0 ]; then
|
||||
kill "$LOGCAT_PID" 2>/dev/null || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$status" -eq 124 ]; then
|
||||
capture_wedge "api${LABEL}"
|
||||
else
|
||||
echo "::error::E2E api${LABEL} failed (exit $status)"
|
||||
fi
|
||||
dump_diagnostics
|
||||
kill "$LOGCAT_PID" 2>/dev/null || true
|
||||
exit "$status"
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
LibreMediaConverter is an Android media converter (Kotlin, Jetpack Compose, Material 3) with two
|
||||
conversion engines: Media3 Transformer for the hardware path and FFmpeg for everything the platform
|
||||
cannot do. See `@README.md` for the architecture and `@LICENSES/README.md` for the split license —
|
||||
this file covers only what is not obvious from the code.
|
||||
|
||||
## Build, test, lint
|
||||
|
||||
**Everything is on Java 25** — daemon, CI, IDE, and the app's own bytecode. Four places say so and
|
||||
they must not drift apart:
|
||||
|
||||
| Where | What sets it |
|
||||
|---|---|
|
||||
| Gradle daemon | `gradle/gradle-daemon-jvm.properties` → `toolchainVersion=25` |
|
||||
| CI | `java-version: '25'` in both workflows |
|
||||
| IDE | `.idea/misc.xml` |
|
||||
| App bytecode | `compileOptions` in `app/build.gradle.kts` |
|
||||
|
||||
**Do not pick a JDK for the daemon — the repo does.** `gradle-daemon-jvm.properties` carries foojay
|
||||
download URLs per platform, so Gradle provisions and runs the daemon on Java 25 regardless of what
|
||||
`JAVA_HOME` says (that only sets the *launcher* — `./gradlew --version` prints both). Change it with
|
||||
`./gradlew updateDaemonJvm --jvm-version=NN`, never by hand.
|
||||
|
||||
**Reaching 25 in the bytecode row took a deliberate build change.** AGP 9's built-in Kotlin compiles
|
||||
with the KGP it bundles — 2.2.10 for AGP 9.3.1 — and that caps `jvmTarget` at 24. The root
|
||||
`build.gradle.kts` puts KGP (and the lockstep Compose compiler plugin) on the buildscript classpath
|
||||
so AGP picks up 2.4.10 instead, which supports up to 26. That is why the module applies
|
||||
`com.android.application` and the Compose plugin by `id()` rather than from the catalog. Verified end
|
||||
to end, not assumed: compiled classes report major version 69, D8 dexes them, and R8 minifies them.
|
||||
|
||||
Consequences worth knowing before touching any of it:
|
||||
|
||||
- Raising `kotlin` requires a matching `compose-compiler-gradle-plugin`; they are one version.
|
||||
- Still **do not** apply `org.jetbrains.kotlin.android` — incompatible with AGP 9's DSL.
|
||||
- Java 24 is *not* an option even though Kotlin allows it: Adoptium dropped the EOL non-LTS, so
|
||||
there is no installable temurin-24. 25 is LTS and in the repo.
|
||||
|
||||
The Gradle wrapper does **not** float and cannot: `distributionUrl` names one archive and
|
||||
`distributionSha256Sum` is that file's checksum. Bump it with `./gradlew wrapper --gradle-version X
|
||||
--gradle-distribution-sha256-sum <sha>` so the two stay consistent.
|
||||
|
||||
```bash
|
||||
./gradlew :app:assembleDebug # build debug APK
|
||||
./gradlew :app:testDebugUnitTest # JVM unit tests
|
||||
./gradlew :app:ktlintCheck # formatting
|
||||
./gradlew :app:ktlintFormat # fix formatting in place
|
||||
./gradlew :app:detekt # static analysis
|
||||
./gradlew :app:lintDebug # Android lint
|
||||
./gradlew :app:jacocoTestReport # coverage (XML+HTML under app/build/reports/jacoco/)
|
||||
# single unit test:
|
||||
./gradlew :app:testDebugUnitTest --tests "org.libremediaconverter.model.ConversionRouterTest"
|
||||
```
|
||||
|
||||
CI's "Static analysis" gate is exactly `./gradlew :app:ktlintCheck :app:detekt :app:lintDebug
|
||||
--continue`. Run it with `--continue` locally too: one round trip gives you all three lists instead
|
||||
of the first one that fails.
|
||||
|
||||
**Before treating a change as done**, run: `assembleDebug` + `testDebugUnitTest` +
|
||||
`compileDebugAndroidTestKotlin` + `ktlintCheck` + `detekt` + `lintDebug`.
|
||||
`compileDebugAndroidTestKotlin` matters more here than it looks — the instrumented suite cannot run
|
||||
on this machine (below), so without it an androidTest compile error is not discovered until CI.
|
||||
ktlint and detekt also cover the `test`/`androidTest` source sets that `lintDebug` skips.
|
||||
|
||||
## Instrumented tests do not run locally
|
||||
|
||||
Two independent reasons, so do not spend time on either:
|
||||
|
||||
- **Emulators segfault on this host.** qemu dies on every AVD. Instrumented tests run on CI or on
|
||||
the physical Pixel, never in a local emulator.
|
||||
- **The API 37 image is broken.** `android-37.0` crash-loops surfaceflinger inside its own gralloc
|
||||
mapper, so every test fails there regardless of this app. `docs/api-37-emulator-crash.md` records
|
||||
the evidence and the ruled-out fixes; CI's matrix therefore stops at API 36 even though targetSdk
|
||||
is 37. **API 37 needs a manual check on the Pixel 10 Pro XL before each release.**
|
||||
|
||||
On a device or emulator, build only the ABI it can execute:
|
||||
|
||||
```bash
|
||||
./gradlew :app:connectedDebugAndroidTest -PabiFilters=x86_64
|
||||
```
|
||||
|
||||
FFmpeg's native libraries dominate the APK, so shipping arm64 to an x86_64 emulator doubles the
|
||||
install for code that can never run — and on API 37 the full APK does not fit at all.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **ktlint owns formatting, detekt owns static analysis.** detekt's formatting ruleset is off, so
|
||||
the two can never disagree about the same line. Never hand-fix a formatting complaint — run
|
||||
`ktlintFormat`. Style is `intellij_idea` at 120 columns, set in `.editorconfig`.
|
||||
- **detekt config is `config/detekt/detekt.yml`**, merged onto detekt's defaults
|
||||
(`buildUponDefaultConfig = true`), so it carries only the rules this codebase legitimately
|
||||
breaks — each with the reason written next to it. Relax a rule that way or fix the code; never a
|
||||
bare `@Suppress`. Do not invent config keys: unknown ones are rejected.
|
||||
- The `model` package is excluded from `ReturnCount` and `CyclomaticComplexMethod` only. It is the
|
||||
decision layer, where one branch is one documented user-visible outcome and the metric counts
|
||||
answers rather than complexity. Every other rule still applies there.
|
||||
- **Coverage is reported, not gated** — currently ~31% of lines. A floor needs a baseline that has
|
||||
settled first.
|
||||
- `kotlin.code.style=official`. Gradle stays Kotlin DSL.
|
||||
|
||||
## Dependency versions
|
||||
|
||||
Libraries **float on minor + patch** (`coreKtx = "1.+"`). Three groups deliberately do not:
|
||||
|
||||
- **`agp`, `kotlin`, `ksp` are version-locked to each other.** AGP 9.3.1's POM declares
|
||||
`kotlin-gradle-plugin` 2.2.10, and that is what AGP's built-in Kotlin compiles with. Android lint
|
||||
will suggest Kotlin 2.4.10; taking it breaks the Compose compiler unless KGP is *also* forced onto
|
||||
the root buildscript classpath. Move all three together, by hand, or none.
|
||||
- **ktlint, detekt and JaCoCo are pinned.** A new rule in a linter makes files nobody touched stop
|
||||
passing, so CI goes red on a PR whose diff cannot explain it. Upgrading them is its own commit:
|
||||
run the tool, read the new findings, fix or relax them.
|
||||
- **The FFmpeg AAR** is a committed file, not a coordinate.
|
||||
|
||||
**`+` does not mean "newest stable" on its own** — Gradle will happily resolve it to an alpha, and
|
||||
androidx routinely publishes alphas numbered above the current stable (at last check: lifecycle,
|
||||
navigation, work, datastore and annotation all did). The `componentSelection` block in
|
||||
`app/build.gradle.kts` rejects prereleases, which is the only reason `2.+` means 2.11.0 rather than
|
||||
2.12.0-alpha01. **Do not remove it.** To try a prerelease, name the exact version in the catalog —
|
||||
that pins it, which is the right way round.
|
||||
|
||||
Because versions float, a build can change without a commit. `./gradlew :app:dependencies
|
||||
--configuration debugRuntimeClasspath` shows what actually resolved.
|
||||
|
||||
## Traps
|
||||
|
||||
- **Do not apply `org.jetbrains.kotlin.android`.** AGP 9 has built-in Kotlin; applying the legacy
|
||||
plugin fails the build. This is why `libs.versions.toml` pins `kotlin` to AGP's bundled KGP
|
||||
version rather than the newest Kotlin release — the Compose compiler plugin must match it.
|
||||
- **The FFmpeg AAR is committed** under `bin/`, deliberately. It is not on any Maven repo
|
||||
(ffmpeg-kit was archived and delisted). Rebuilding per CI run made red builds ambiguous: broken
|
||||
code, or a cross-compile that hiccuped? `bin/README.md` has provenance and how to regenerate it.
|
||||
- **Anything touching Media3 carries `@UnstableApi`** rather than swallowing the marker with
|
||||
`@OptIn`. Android lint's `UnsafeOptInUsageError` catches a missed one.
|
||||
- **Release builds ship both ABIs.** `-PabiFilters` is a test-run override only; `build.yml`
|
||||
verifies the released APK carries every ABI and that all native libraries are 16 KB aligned.
|
||||
+153
-4
@@ -1,7 +1,20 @@
|
||||
import org.gradle.testing.jacoco.tasks.JacocoReport
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
// Applied by id: these two come from the root buildscript classpath, which is what
|
||||
// overrides AGP's bundled Kotlin. See the comment in the root build file.
|
||||
id("com.android.application")
|
||||
// Required even under AGP 9: the Compose compiler plugin is NOT built in.
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
|
||||
// Lint/format. Resolved from the Gradle Plugin Portal, not AGP's buildscript
|
||||
// classpath -- neither is an Android plugin.
|
||||
alias(libs.plugins.ktlint)
|
||||
alias(libs.plugins.detekt)
|
||||
|
||||
// JaCoCo (Gradle built-in) instruments the JVM testDebugUnitTest task. Report only:
|
||||
// there is deliberately no coverage gate, see the jacocoTestReport block below.
|
||||
jacoco
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -58,8 +71,35 @@ android {
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
sourceCompatibility = JavaVersion.VERSION_25
|
||||
targetCompatibility = JavaVersion.VERSION_25
|
||||
}
|
||||
|
||||
lint {
|
||||
// ktlint and detekt both fail the build on any finding. Android lint by default
|
||||
// aborts on errors only, so warnings would land in the report while the gate stayed
|
||||
// green -- a gate that passes while the report has content is not a gate.
|
||||
warningsAsErrors = true
|
||||
// Already the default. Stated so a later edit cannot turn the gate off by accident.
|
||||
abortOnError = true
|
||||
|
||||
// Dependency-freshness nags. These do not describe this code: they go red the day
|
||||
// someone else publishes a release, which would turn a PR red for a reason its
|
||||
// author cannot see in their own diff and cannot fix by changing anything they
|
||||
// wrote. They also want the network at lint time. Upgrades are a deliberate act
|
||||
// here -- the Kotlin version in particular is pinned to AGP's bundled KGP and is
|
||||
// NOT free to follow the newest release -- so they are chosen, not nagged for.
|
||||
disable += setOf("AndroidGradlePluginVersion", "NewerVersionAvailable", "GradleDependency")
|
||||
|
||||
// A real suggestion, deliberately not acted on in this commit. hasSpaceFor() reads
|
||||
// File.usableSpace, which under-reports because it ignores cache the system could
|
||||
// reclaim -- so the app can refuse a conversion it actually had room for.
|
||||
// StorageManager.getAllocatableBytes is the better answer, but swapping it in
|
||||
// changes when a job is rejected and can throw IOException, which is a behaviour
|
||||
// change to a safety check and deserves its own commit and its own test rather
|
||||
// than a drive-by in a tooling change. `informational` keeps it visible in every
|
||||
// lint report instead of hiding it, while letting the gate pass until then.
|
||||
informational += "UsableSpace"
|
||||
}
|
||||
|
||||
packaging {
|
||||
@@ -76,6 +116,115 @@ kotlin {
|
||||
compilerOptions {}
|
||||
}
|
||||
|
||||
// --- Prerelease guard for the floating dependency versions ------------------------------
|
||||
// The library versions in libs.versions.toml float on minor + patch ("1.+"). Gradle resolves
|
||||
// `+` to the highest version it finds, and it does NOT skip prereleases -- so without this,
|
||||
// androidx would quietly hand the app an alpha. That is not hypothetical here: at the time
|
||||
// of writing lifecycle, navigation, work, datastore and annotation ALL publish an alpha or
|
||||
// rc numbered above their newest stable, so five of the floats would have moved onto
|
||||
// unreleased code on the next build, with nothing in the diff to say so.
|
||||
//
|
||||
// Rejecting them here means "+" reads as "the newest RELEASED version", which is what
|
||||
// floating was meant to buy.
|
||||
//
|
||||
// Note that this applies to STATIC versions too, not only floating ones: naming
|
||||
// "2.12.0-alpha01" in the catalog does not get you that alpha, it fails to resolve. Verified,
|
||||
// because the obvious assumption is the opposite. To take an androidx prerelease deliberately,
|
||||
// drop the group from the guarded list below for as long as you need it.
|
||||
// Groups whose versions this project actually floats. The guard applies to these and to
|
||||
// nothing else, which is the whole point.
|
||||
//
|
||||
// The first version of this was a blanket rule over every group, and it broke every E2E job
|
||||
// while every local check stayed green. AGP resolves its OWN tooling through this project's
|
||||
// configurations, and the Unified Test Platform that runs connectedAndroidTest depends on
|
||||
// com.google.testing.platform artifacts pinned at 0.0.9-alpha04. Rejecting those made
|
||||
// :app:connectedDebugAndroidTest unresolvable. Nothing that runs without a device touches
|
||||
// that configuration, so it passed here and failed on all four API levels at once.
|
||||
//
|
||||
// Scoping to the groups we float is also why detekt's alpha needs no exception any more: we
|
||||
// do not float dev.detekt, so the guard has no opinion about it. An allowlist of exceptions
|
||||
// would have needed a new entry every time AGP pulled in another prerelease tool.
|
||||
val floatedGroupPrefixes = listOf("androidx.", "junit", "com.arthenica")
|
||||
|
||||
// Matches both spellings androidx and friends use: "-alpha01" and "-alpha.1".
|
||||
val prereleaseMarker =
|
||||
Regex("""[-.](alpha|beta|rc|eap|dev|snapshot|pre|m)[-.]?\d*$""", RegexOption.IGNORE_CASE)
|
||||
|
||||
configurations.configureEach {
|
||||
resolutionStrategy {
|
||||
componentSelection {
|
||||
all {
|
||||
val floated = floatedGroupPrefixes.any { candidate.group.startsWith(it) }
|
||||
if (floated && prereleaseMarker.containsMatchIn(candidate.version)) {
|
||||
reject("prerelease; floating versions take released builds only")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
detekt {
|
||||
// Merge the project overrides in config/detekt onto detekt's bundled defaults, so this
|
||||
// repo's file only has to carry the rules it actually changes.
|
||||
buildUponDefaultConfig = true
|
||||
config.setFrom(rootProject.file("config/detekt/detekt.yml"))
|
||||
}
|
||||
|
||||
// Pin the coverage agent rather than inheriting whatever Gradle bundles.
|
||||
jacoco {
|
||||
toolVersion = libs.versions.jacoco.get()
|
||||
}
|
||||
|
||||
// --- Unit-test coverage -------------------------------------------------------------------
|
||||
// Report only. There is deliberately no coverage gate: a floor is only meaningful against a
|
||||
// measured baseline, and the JVM test stack here is still junit-only. This task produces the
|
||||
// number a floor would need; add `jacocoTestCoverageVerification` once it is known.
|
||||
|
||||
// Generated code, stripped from the denominator so the percentage reflects hand-written Kotlin.
|
||||
// No DI framework is in use, so there are no Hilt/Dagger patterns to exclude.
|
||||
val jacocoGeneratedExcludes = listOf(
|
||||
"**/R.class",
|
||||
"**/R\$*.class",
|
||||
"**/BuildConfig.*",
|
||||
"**/Manifest*.*",
|
||||
// Room lands in a later phase; its KSP output comes out the same door as hand-written code.
|
||||
"**/*_Impl*",
|
||||
// One per file with @Composable lambdas -- Compose compiler output, not written by anyone.
|
||||
"**/ComposableSingletons*",
|
||||
)
|
||||
|
||||
// AGP 9 compiles Kotlin through its built-in compiler, which writes here rather than to the
|
||||
// classic `tmp/kotlin-classes/debug`. All hand-written code in this module is Kotlin, so the
|
||||
// javac output (BuildConfig and R only) is not read at all.
|
||||
val jacocoDebugKotlinClasses = layout.buildDirectory.dir(
|
||||
"intermediates/built_in_kotlinc/debug/compileDebugKotlin/classes",
|
||||
)
|
||||
|
||||
// Accept both the base `jacoco` plugin's default exec location and AGP's
|
||||
// enableUnitTestCoverage one, so the wiring survives either being the source of truth.
|
||||
val jacocoExecutionData = fileTree(layout.buildDirectory) {
|
||||
include(
|
||||
"jacoco/testDebugUnitTest.exec",
|
||||
"outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec",
|
||||
)
|
||||
}
|
||||
|
||||
tasks.register<JacocoReport>("jacocoTestReport") {
|
||||
// The exec data does not exist until the tests have run.
|
||||
dependsOn("testDebugUnitTest")
|
||||
group = "verification"
|
||||
description = "Generates JaCoCo XML + HTML coverage for the debug JVM unit tests."
|
||||
|
||||
reports {
|
||||
xml.required.set(true)
|
||||
html.required.set(true)
|
||||
}
|
||||
|
||||
classDirectories.setFrom(fileTree(jacocoDebugKotlinClasses) { exclude(jacocoGeneratedExcludes) })
|
||||
sourceDirectories.setFrom(files("src/main/java"))
|
||||
executionData.setFrom(jacocoExecutionData)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
|
||||
@@ -4,10 +4,13 @@ import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.codec.AndroidDeviceCodecs
|
||||
import org.libremediaconverter.convert.Media3Engine
|
||||
import org.libremediaconverter.convert.MediaProbe
|
||||
@@ -17,10 +20,6 @@ import org.libremediaconverter.model.ConversionRouter
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
@@ -45,6 +44,7 @@ import java.io.File
|
||||
class RealMediaBenchmark {
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
/**
|
||||
* Internal storage, not the external files dir.
|
||||
*
|
||||
@@ -55,8 +55,7 @@ class RealMediaBenchmark {
|
||||
*/
|
||||
private val samples: File get() = context.filesDir
|
||||
|
||||
private fun sample(name: String): File? =
|
||||
File(samples, name).takeIf { it.exists() && it.length() > 0 }
|
||||
private fun sample(name: String): File? = File(samples, name).takeIf { it.exists() && it.length() > 0 }
|
||||
|
||||
private fun durationMs(file: File): Long {
|
||||
val extractor = MediaExtractor()
|
||||
|
||||
@@ -20,7 +20,6 @@ import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.Engine
|
||||
import org.libremediaconverter.model.InputKind
|
||||
import org.libremediaconverter.model.OutputSpec
|
||||
@@ -84,14 +83,13 @@ class RemuxTest {
|
||||
WorkManager.getInstance(context).cancelAllWork()
|
||||
}
|
||||
|
||||
private fun asset(name: String): File =
|
||||
File(context.cacheDir, name).also { file ->
|
||||
file.delete()
|
||||
staged += file
|
||||
InstrumentationRegistry.getInstrumentation().context.assets
|
||||
.open(name)
|
||||
.use { asset -> file.outputStream().use { asset.copyTo(it) } }
|
||||
}
|
||||
private fun asset(name: String): File = File(context.cacheDir, name).also { file ->
|
||||
file.delete()
|
||||
staged += file
|
||||
InstrumentationRegistry.getInstrumentation().context.assets
|
||||
.open(name)
|
||||
.use { asset -> file.outputStream().use { asset.copyTo(it) } }
|
||||
}
|
||||
|
||||
// --- probing ------------------------------------------------------------
|
||||
|
||||
@@ -255,8 +253,7 @@ class RemuxTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun videoMimeOf(file: File): String? =
|
||||
trackMimesOf(file).firstOrNull { it.startsWith("video/") }
|
||||
private fun videoMimeOf(file: File): String? = trackMimesOf(file).firstOrNull { it.startsWith("video/") }
|
||||
|
||||
private fun durationMsOf(file: File): Long {
|
||||
val extractor = MediaExtractor()
|
||||
|
||||
@@ -2,12 +2,12 @@ package org.libremediaconverter.fallback
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import org.libremediaconverter.convert.ConversionDependencies
|
||||
import org.libremediaconverter.convert.HardwareTranscoder
|
||||
import org.libremediaconverter.convert.OutputPublisher
|
||||
import org.libremediaconverter.convert.SoftwareTranscoder
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
@@ -18,6 +18,7 @@ import java.io.File
|
||||
* test run avoids. Without a way to inject failure, the branches a user meets on a bad
|
||||
* day are the ones that were never executed.
|
||||
*/
|
||||
@UnstableApi
|
||||
object FakeFailures {
|
||||
|
||||
class ExplodingHardware(private val message: String = "hardware exploded") : HardwareTranscoder {
|
||||
|
||||
@@ -6,13 +6,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import org.libremediaconverter.convert.ConversionDependencies
|
||||
import org.libremediaconverter.model.DeviceCodecs
|
||||
import org.libremediaconverter.model.Engine
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
import org.libremediaconverter.work.ConversionWorker
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
@@ -22,6 +15,13 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.convert.ConversionDependencies
|
||||
import org.libremediaconverter.model.DeviceCodecs
|
||||
import org.libremediaconverter.model.Engine
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
import org.libremediaconverter.work.ConversionWorker
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
@@ -61,14 +61,13 @@ class ForcedFailureTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun convertRequest(format: OutputFormat = OutputFormat.MP4_H265) =
|
||||
ConversionWorker.request(
|
||||
inputUri = Uri.fromFile(input),
|
||||
displayName = SAMPLE,
|
||||
sizeBytes = input.length(),
|
||||
spec = format.spec,
|
||||
quality = QualityTier.FAST,
|
||||
)
|
||||
private fun convertRequest(format: OutputFormat = OutputFormat.MP4_H265) = ConversionWorker.request(
|
||||
inputUri = Uri.fromFile(input),
|
||||
displayName = SAMPLE,
|
||||
sizeBytes = input.length(),
|
||||
spec = format.spec,
|
||||
quality = QualityTier.FAST,
|
||||
)
|
||||
|
||||
// --- the dynamic fallback, forced rather than provoked -------------------
|
||||
|
||||
@@ -179,11 +178,9 @@ class ForcedFailureTest {
|
||||
assertEquals(WorkInfo.State.FAILED, terminal?.state)
|
||||
}
|
||||
|
||||
private fun assertTrue(message: String, condition: Boolean) =
|
||||
org.junit.Assert.assertTrue(message, condition)
|
||||
private fun assertTrue(message: String, condition: Boolean) = org.junit.Assert.assertTrue(message, condition)
|
||||
|
||||
private fun assertTrue(condition: Boolean, message: String) =
|
||||
org.junit.Assert.assertTrue(message, condition)
|
||||
private fun assertTrue(condition: Boolean, message: String) = org.junit.Assert.assertTrue(message, condition)
|
||||
|
||||
private companion object {
|
||||
const val SAMPLE = "sample_h264.mp4"
|
||||
|
||||
@@ -6,9 +6,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import org.libremediaconverter.work.ConversionWorker
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
@@ -18,6 +15,9 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import org.libremediaconverter.work.ConversionWorker
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,10 +8,6 @@ import androidx.work.Data
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import org.libremediaconverter.convert.OutputPublisher
|
||||
import org.libremediaconverter.ffmpeg.ConcatEngine
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
import org.libremediaconverter.work.ConversionWorker
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
@@ -21,6 +17,10 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.convert.OutputPublisher
|
||||
import org.libremediaconverter.ffmpeg.ConcatEngine
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
import org.libremediaconverter.work.ConversionWorker
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,9 +4,6 @@ import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -14,6 +11,9 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
@@ -46,8 +46,10 @@ class FFmpegEngineTest {
|
||||
outputs.forEach { it.delete() }
|
||||
}
|
||||
|
||||
private fun outputFor(name: String) =
|
||||
File(context.cacheDir, name).also { it.delete(); outputs += it }
|
||||
private fun outputFor(name: String) = File(context.cacheDir, name).also {
|
||||
it.delete()
|
||||
outputs += it
|
||||
}
|
||||
|
||||
private fun convert(format: OutputFormat, quality: QualityTier = QualityTier.BEST): File {
|
||||
val out = outputFor("out_${format.name.lowercase()}.${format.extension}")
|
||||
|
||||
@@ -5,9 +5,6 @@ import android.media.MediaFormat
|
||||
import android.net.Uri
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.libremediaconverter.convert.MediaProbe
|
||||
import org.libremediaconverter.ffmpeg.ConcatEngine
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -15,6 +12,9 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.convert.MediaProbe
|
||||
import org.libremediaconverter.ffmpeg.ConcatEngine
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
@@ -57,8 +57,10 @@ class ConcatEngineTest {
|
||||
return out
|
||||
}
|
||||
|
||||
private fun output(name: String) =
|
||||
File(context.cacheDir, name).also { it.delete(); staged += it }
|
||||
private fun output(name: String) = File(context.cacheDir, name).also {
|
||||
it.delete()
|
||||
staged += it
|
||||
}
|
||||
|
||||
private fun durationMs(file: File): Long {
|
||||
val extractor = MediaExtractor()
|
||||
|
||||
@@ -6,8 +6,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
@@ -17,6 +15,8 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,14 +8,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import org.libremediaconverter.codec.AndroidDeviceCodecs
|
||||
import org.libremediaconverter.model.Engine
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.OutputSpec
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
@@ -25,6 +17,14 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.codec.AndroidDeviceCodecs
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.Engine
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.OutputSpec
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
@@ -170,8 +170,11 @@ class ConversionWorkerTest {
|
||||
terminal?.state,
|
||||
)
|
||||
assertEquals(
|
||||
if (hasHardwareHevc) "hardware HEVC present, expected the Media3 path"
|
||||
else "no hardware HEVC encoder, expected the FFmpeg path",
|
||||
if (hasHardwareHevc) {
|
||||
"hardware HEVC present, expected the Media3 path"
|
||||
} else {
|
||||
"no hardware HEVC encoder, expected the FFmpeg path"
|
||||
},
|
||||
if (hasHardwareHevc) Engine.MEDIA3.name else Engine.FFMPEG.name,
|
||||
terminal?.outputData?.getString(ConversionWorker.KEY_ENGINE_USED),
|
||||
)
|
||||
|
||||
@@ -18,8 +18,8 @@ import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSiz
|
||||
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
|
||||
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.libremediaconverter.codec
|
||||
|
||||
import android.media.MediaCodecInfo
|
||||
import android.media.MediaCodecList
|
||||
import android.media.MediaFormat
|
||||
import android.util.Log
|
||||
@@ -28,8 +27,7 @@ class AndroidDeviceCodecs private constructor(
|
||||
private val decodeMimes: Set<String>,
|
||||
) : DeviceCodecs {
|
||||
|
||||
override fun canEncode(codec: VideoCodec): Boolean =
|
||||
mimeFor(codec)?.let { it in hardwareEncodeMimes } ?: true
|
||||
override fun canEncode(codec: VideoCodec): Boolean = mimeFor(codec)?.let { it in hardwareEncodeMimes } ?: true
|
||||
|
||||
override fun canDecode(codecName: String): Boolean {
|
||||
// The platform already failed to parse this input, so there is nothing to
|
||||
@@ -46,8 +44,7 @@ class AndroidDeviceCodecs private constructor(
|
||||
@Volatile
|
||||
private var cached: AndroidDeviceCodecs? = null
|
||||
|
||||
fun get(): AndroidDeviceCodecs =
|
||||
cached ?: synchronized(this) { cached ?: probe().also { cached = it } }
|
||||
fun get(): AndroidDeviceCodecs = cached ?: synchronized(this) { cached ?: probe().also { cached = it } }
|
||||
|
||||
private fun probe(): AndroidDeviceCodecs {
|
||||
val encoders = mutableSetOf<String>()
|
||||
@@ -104,7 +101,6 @@ class AndroidDeviceCodecs private constructor(
|
||||
}
|
||||
|
||||
/** Test seam: lets instrumented tests build a probe from explicit sets. */
|
||||
fun forTesting(encoders: Set<String>, decoders: Set<String>) =
|
||||
AndroidDeviceCodecs(encoders, decoders)
|
||||
fun forTesting(encoders: Set<String>, decoders: Set<String>) = AndroidDeviceCodecs(encoders, decoders)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,17 +8,6 @@ import androidx.lifecycle.viewModelScope
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.ContainerCapabilities
|
||||
import org.libremediaconverter.model.EnginePreference
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.OutputSpec
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import org.libremediaconverter.model.Validation
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import org.libremediaconverter.work.ConversionWorker
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -30,6 +19,17 @@ import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.ContainerCapabilities
|
||||
import org.libremediaconverter.model.EnginePreference
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.OutputSpec
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import org.libremediaconverter.model.Validation
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import org.libremediaconverter.work.ConversionWorker
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
@@ -62,6 +62,7 @@ sealed interface ConversionState {
|
||||
data object Idle : ConversionState
|
||||
data class Ready(val input: InputFile) : ConversionState
|
||||
data class Converting(val input: InputFile, val percent: Int) : ConversionState
|
||||
|
||||
/** Budget for foreground work ran out; WorkManager will retry when it can. */
|
||||
data class Waiting(val input: InputFile) : ConversionState
|
||||
data class Converted(
|
||||
@@ -102,20 +103,16 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, Validation.Valid)
|
||||
|
||||
fun setPreset(format: OutputFormat) = _settings.update { it.copy(spec = format.spec) }
|
||||
fun setContainer(container: Container) =
|
||||
_settings.update { it.copy(spec = it.spec.copy(container = container)) }
|
||||
fun setContainer(container: Container) = _settings.update { it.copy(spec = it.spec.copy(container = container)) }
|
||||
|
||||
fun setVideoCodec(codec: VideoCodec) =
|
||||
_settings.update { it.copy(spec = it.spec.copy(videoCodec = codec)) }
|
||||
fun setVideoCodec(codec: VideoCodec) = _settings.update { it.copy(spec = it.spec.copy(videoCodec = codec)) }
|
||||
|
||||
fun setAudioCodec(codec: AudioCodec) =
|
||||
_settings.update { it.copy(spec = it.spec.copy(audioCodec = codec)) }
|
||||
fun setAudioCodec(codec: AudioCodec) = _settings.update { it.copy(spec = it.spec.copy(audioCodec = codec)) }
|
||||
|
||||
fun applySuggestion(spec: OutputSpec) = _settings.update { it.copy(spec = spec) }
|
||||
|
||||
fun setQuality(quality: QualityTier) = _settings.update { it.copy(quality = quality) }
|
||||
fun setEnginePreference(preference: EnginePreference) =
|
||||
_settings.update { it.copy(enginePreference = preference) }
|
||||
fun setEnginePreference(preference: EnginePreference) = _settings.update { it.copy(enginePreference = preference) }
|
||||
|
||||
fun onInputPicked(uri: Uri) {
|
||||
viewModelScope.launch {
|
||||
@@ -202,7 +199,7 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
WorkInfo.State.FAILED -> ConversionState.Failed(
|
||||
info.outputData.getString(ConversionWorker.KEY_ERROR)
|
||||
?: "Conversion failed."
|
||||
?: "Conversion failed.",
|
||||
)
|
||||
|
||||
WorkInfo.State.CANCELLED -> ConversionState.Ready(input)
|
||||
@@ -229,7 +226,7 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) {
|
||||
ConversionWorker.outputNameFor(
|
||||
converted.input.displayName,
|
||||
_settings.value.spec,
|
||||
)
|
||||
),
|
||||
)
|
||||
}.onFailure { e ->
|
||||
_state.value = ConversionState.Failed(e.message ?: "Could not save the file.")
|
||||
@@ -244,11 +241,10 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) {
|
||||
_state.value = ConversionState.Idle
|
||||
}
|
||||
|
||||
fun suggestedOutputName(): String =
|
||||
ConversionWorker.outputNameFor(
|
||||
currentInput()?.displayName ?: "output",
|
||||
_settings.value.spec,
|
||||
)
|
||||
fun suggestedOutputName(): String = ConversionWorker.outputNameFor(
|
||||
currentInput()?.displayName ?: "output",
|
||||
_settings.value.spec,
|
||||
)
|
||||
|
||||
private fun ConversionState.probe(): InputProbe? = when (this) {
|
||||
is ConversionState.Ready -> input.probe
|
||||
|
||||
@@ -34,9 +34,6 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.libremediaconverter.ui.PrimaryButtonHeight
|
||||
import org.libremediaconverter.ui.ScreenPaddingHorizontal
|
||||
import org.libremediaconverter.ui.ScreenPaddingVertical
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
@@ -50,14 +47,14 @@ import org.libremediaconverter.model.OutputSpec
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import org.libremediaconverter.model.Validation
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import org.libremediaconverter.ui.PrimaryButtonHeight
|
||||
import org.libremediaconverter.ui.ScreenPaddingHorizontal
|
||||
import org.libremediaconverter.ui.ScreenPaddingVertical
|
||||
import java.util.Locale
|
||||
|
||||
@UnstableApi
|
||||
@Composable
|
||||
fun ConverterScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: ConversionViewModel = viewModel(),
|
||||
) {
|
||||
fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewModel = viewModel()) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val settings by viewModel.settings.collectAsStateWithLifecycle()
|
||||
val validation by viewModel.validation.collectAsStateWithLifecycle()
|
||||
@@ -66,11 +63,11 @@ fun ConverterScreen(
|
||||
// only, offers no audio at all, and will not reliably surface .mkv/.flac/.webm.
|
||||
// SAF needs no runtime permission.
|
||||
val pickInput = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.OpenDocument()
|
||||
ActivityResultContracts.OpenDocument(),
|
||||
) { uri -> uri?.let(viewModel::onInputPicked) }
|
||||
|
||||
val chooseDestination = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.CreateDocument(settings.spec.mimeType)
|
||||
ActivityResultContracts.CreateDocument(settings.spec.mimeType),
|
||||
) { uri -> uri?.let(viewModel::save) }
|
||||
|
||||
// Requested at the point of use rather than on first launch, so the ask carries its
|
||||
@@ -78,7 +75,7 @@ fun ConverterScreen(
|
||||
// foreground service still runs, but its progress notification is confined to the
|
||||
// Task Manager instead of the shade.
|
||||
val requestNotifications = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { viewModel.convert() }
|
||||
|
||||
Column(
|
||||
|
||||
@@ -69,7 +69,10 @@ class Media3Engine(private val context: Context) : HardwareTranscoder {
|
||||
val plan = CopyPlanner.plan(request.spec, request.probe)
|
||||
handler.post {
|
||||
val transformer = runCatching { buildTransformer(plan, cont) }
|
||||
.getOrElse { cont.resumeWithException(it); return@post }
|
||||
.getOrElse {
|
||||
cont.resumeWithException(it)
|
||||
return@post
|
||||
}
|
||||
|
||||
// Dropping the tracks the target does not have is what stops an audio-only export
|
||||
// from carrying a re-encoded video track. Without setRemoveVideo, asking for M4A
|
||||
@@ -92,7 +95,10 @@ class Media3Engine(private val context: Context) : HardwareTranscoder {
|
||||
}
|
||||
|
||||
runCatching { transformer.start(composition, output.absolutePath) }
|
||||
.onFailure { cont.resumeWithException(it); return@post }
|
||||
.onFailure {
|
||||
cont.resumeWithException(it)
|
||||
return@post
|
||||
}
|
||||
|
||||
pollProgress(transformer, cont, onProgress)
|
||||
}
|
||||
@@ -104,10 +110,7 @@ class Media3Engine(private val context: Context) : HardwareTranscoder {
|
||||
* is supposed to have sent such a job to FFmpeg — so it fails loudly instead of quietly
|
||||
* writing MP4, which is what the old code did.
|
||||
*/
|
||||
private fun buildTransformer(
|
||||
plan: ConversionPlan,
|
||||
cont: CancellableContinuation<Unit>,
|
||||
): Transformer {
|
||||
private fun buildTransformer(plan: ConversionPlan, cont: CancellableContinuation<Unit>): Transformer {
|
||||
val muxerFactory = requireNotNull(Media3Muxers.factoryFor(plan.container)) {
|
||||
"Media3 cannot mux ${plan.container}; this job should have routed to FFmpeg."
|
||||
}
|
||||
@@ -128,11 +131,7 @@ class Media3Engine(private val context: Context) : HardwareTranscoder {
|
||||
if (cont.isActive) cont.resume(Unit)
|
||||
}
|
||||
|
||||
override fun onError(
|
||||
composition: Composition,
|
||||
result: ExportResult,
|
||||
exception: ExportException,
|
||||
) {
|
||||
override fun onError(composition: Composition, result: ExportResult, exception: ExportException) {
|
||||
if (cont.isActive) cont.resumeWithException(exception)
|
||||
}
|
||||
})
|
||||
@@ -193,7 +192,6 @@ class Media3Engine(private val context: Context) : HardwareTranscoder {
|
||||
thread.quitSafely()
|
||||
}
|
||||
|
||||
|
||||
private companion object {
|
||||
const val PROGRESS_INTERVAL_MS = 250L
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ object MediaProbe {
|
||||
mime.startsWith("audio/") && audio == null -> audio = shortName(mime)
|
||||
}
|
||||
}
|
||||
Extracted(video, audio, durationUs / 1000, width, height)
|
||||
Extracted(video, audio, durationUs / US_PER_MS, width, height)
|
||||
} catch (e: Exception) {
|
||||
Log.i(TAG, "Platform extractor could not read $uri.", e)
|
||||
null
|
||||
@@ -162,7 +162,7 @@ object MediaProbe {
|
||||
container = containerFrom(formatName, video?.getCodec()),
|
||||
videoCodec = video?.getCodec(),
|
||||
audioCodec = audio?.getCodec(),
|
||||
durationMs = info.getDuration()?.toDoubleOrNull()?.times(1000)?.toLong() ?: 0L,
|
||||
durationMs = info.getDuration()?.toDoubleOrNull()?.times(MS_PER_SECOND)?.toLong() ?: 0L,
|
||||
width = video?.getWidth()?.toInt() ?: 0,
|
||||
height = video?.getHeight()?.toInt() ?: 0,
|
||||
isImage = isImageFormat(formatName),
|
||||
@@ -284,4 +284,10 @@ object MediaProbe {
|
||||
}
|
||||
|
||||
private const val TAG = "MediaProbe"
|
||||
|
||||
/** MediaExtractor reports KEY_DURATION in microseconds; InputProbe carries milliseconds. */
|
||||
private const val US_PER_MS = 1000
|
||||
|
||||
/** MediaMetadataRetriever's ffprobe-style duration is in seconds, as a decimal string. */
|
||||
private const val MS_PER_SECOND = 1000
|
||||
}
|
||||
|
||||
@@ -32,8 +32,7 @@ open class OutputPublisher(private val context: Context) {
|
||||
* Staging means peak usage is roughly input + output at once, so a job that would
|
||||
* just barely fit is rejected rather than failing partway through.
|
||||
*/
|
||||
open fun hasSpaceFor(bytes: Long): Boolean =
|
||||
stagingDir.usableSpace > bytes + SPACE_HEADROOM_BYTES
|
||||
open fun hasSpaceFor(bytes: Long): Boolean = stagingDir.usableSpace > bytes + SPACE_HEADROOM_BYTES
|
||||
|
||||
/** Copies a finished staging file into a user-chosen SAF destination. */
|
||||
open fun publish(staged: File, destination: Uri) {
|
||||
|
||||
@@ -2,11 +2,12 @@ package org.libremediaconverter.convert
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import org.libremediaconverter.codec.AndroidDeviceCodecs
|
||||
import org.libremediaconverter.ffmpeg.FFmpegEngine
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.DeviceCodecs
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.codec.AndroidDeviceCodecs
|
||||
import java.io.File
|
||||
|
||||
/** The hardware conversion path. Implemented by [Media3Engine]. */
|
||||
@@ -58,6 +59,7 @@ interface SoftwareTranscoder {
|
||||
* with a content URI pointing at a provider that does not exist, or by constructing a
|
||||
* worker's input Data by hand rather than through its request() helper.
|
||||
*/
|
||||
@UnstableApi
|
||||
object ConversionDependencies {
|
||||
|
||||
@Volatile
|
||||
|
||||
@@ -6,11 +6,11 @@ import android.util.Log
|
||||
import com.arthenica.ffmpegkit.FFmpegKit
|
||||
import com.arthenica.ffmpegkit.FFmpegKitConfig
|
||||
import com.arthenica.ffmpegkit.ReturnCode
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import org.libremediaconverter.convert.MediaProbe
|
||||
import org.libremediaconverter.model.ConcatPlanner
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import java.io.File
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
@@ -27,11 +27,7 @@ class ConcatEngine(private val context: Context) {
|
||||
|
||||
data class Result(val strategy: ConcatStrategy, val output: File)
|
||||
|
||||
suspend fun join(
|
||||
inputs: List<Uri>,
|
||||
output: File,
|
||||
format: OutputFormat = OutputFormat.MP4_H264,
|
||||
): Result {
|
||||
suspend fun join(inputs: List<Uri>, output: File, format: OutputFormat = OutputFormat.MP4_H264): Result {
|
||||
require(inputs.size >= 2) { "Joining needs at least two files." }
|
||||
|
||||
val paths = inputs.map { uri ->
|
||||
@@ -70,8 +66,8 @@ class ConcatEngine(private val context: Context) {
|
||||
else -> cont.resumeWithException(
|
||||
FFmpegEngine.FFmpegException(
|
||||
"Joining failed (${rc?.value}): " +
|
||||
completed.getAllLogsAsString(LOG_TAIL_LIMIT).orEmpty()
|
||||
)
|
||||
completed.getAllLogsAsString(LOG_TAIL_LIMIT).orEmpty(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,17 +53,14 @@ object FFmpegCommandBuilder {
|
||||
/** Containers in the ISO base-media family, where HEVC needs the hvc1 brand. */
|
||||
private val MP4_FAMILY = setOf(Container.MP4, Container.MOV)
|
||||
|
||||
fun build(
|
||||
request: ConversionRequest,
|
||||
inputPath: String,
|
||||
outputPath: String,
|
||||
): List<String> {
|
||||
fun build(request: ConversionRequest, inputPath: String, outputPath: String): List<String> {
|
||||
val plan = CopyPlanner.plan(request.spec, request.probe)
|
||||
return buildList {
|
||||
add("-hide_banner")
|
||||
// Overwrite: the output path is one we just created in our own cache.
|
||||
add("-y")
|
||||
add("-i"); add(inputPath)
|
||||
add("-i")
|
||||
add(inputPath)
|
||||
|
||||
if (request.spec.isImageOutput) {
|
||||
addAll(imageArgs(request))
|
||||
@@ -86,7 +83,8 @@ object FFmpegCommandBuilder {
|
||||
"-vf",
|
||||
"fps=12,scale=480:-1:flags=lanczos,split[a][b];" +
|
||||
"[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer",
|
||||
"-loop", "0",
|
||||
"-loop",
|
||||
"0",
|
||||
)
|
||||
|
||||
else -> listOf("-an", "-vf", "fps=1", "-vsync", "0")
|
||||
@@ -99,7 +97,8 @@ object FFmpegCommandBuilder {
|
||||
VideoPlan.Drop -> listOf("-vn")
|
||||
|
||||
VideoPlan.Copy -> buildList {
|
||||
add("-c:v"); add("copy")
|
||||
add("-c:v")
|
||||
add("copy")
|
||||
// The hvc1 brand matters on the copy path too, not just when encoding: remuxing
|
||||
// HEVC out of Matroska into MP4 otherwise produces a file Apple devices and many
|
||||
// hardware players refuse, even though the samples are byte-identical.
|
||||
@@ -128,9 +127,15 @@ object FFmpegCommandBuilder {
|
||||
// that hardware encoding is unlikely to work for that input. Fast therefore means a
|
||||
// fast *preset*, not a different encoder.
|
||||
VideoCodec.H265 -> listOf(
|
||||
"-c:v", "libx265", "-crf", "$CRF_H265", "-preset", preset,
|
||||
"-c:v",
|
||||
"libx265",
|
||||
"-crf",
|
||||
"$CRF_H265",
|
||||
"-preset",
|
||||
preset,
|
||||
// Without this, many players and Apple devices refuse HEVC in MP4.
|
||||
"-tag:v", "hvc1",
|
||||
"-tag:v",
|
||||
"hvc1",
|
||||
) + PIX_FMT
|
||||
|
||||
VideoCodec.VP9 -> buildList {
|
||||
@@ -140,7 +145,12 @@ object FFmpegCommandBuilder {
|
||||
}
|
||||
|
||||
VideoCodec.H264 -> listOf(
|
||||
"-c:v", "libx264", "-crf", "$CRF_H264", "-preset", preset,
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-crf",
|
||||
"$CRF_H264",
|
||||
"-preset",
|
||||
preset,
|
||||
) + PIX_FMT
|
||||
|
||||
// No silent substitution. A trailing `else -> libx264` would hand back H.264 for a
|
||||
@@ -151,11 +161,11 @@ object FFmpegCommandBuilder {
|
||||
// about rather than papering over.
|
||||
VideoCodec.VP8, VideoCodec.AV1 -> error(
|
||||
"This app cannot encode ${codec.label}; it can only copy an existing " +
|
||||
"${codec.label} stream."
|
||||
"${codec.label} stream.",
|
||||
)
|
||||
|
||||
VideoCodec.COPY, VideoCodec.NONE -> error(
|
||||
"encodeVideo called for $codec, which is not an encode"
|
||||
"encodeVideo called for $codec, which is not an encode",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -167,31 +177,32 @@ object FFmpegCommandBuilder {
|
||||
return if (sourceIsHevc) listOf("-tag:v", "hvc1") else emptyList()
|
||||
}
|
||||
|
||||
private fun audioArgs(plan: ConversionPlan): List<String> =
|
||||
when (val audio = plan.audio) {
|
||||
AudioPlan.Drop -> listOf("-an")
|
||||
AudioPlan.Copy -> listOf("-c:a", "copy")
|
||||
is AudioPlan.Encode -> when (audio.codec) {
|
||||
AudioCodec.MP3 -> listOf("-c:a", "libmp3lame", "-q:a", "2")
|
||||
AudioCodec.FLAC -> listOf("-c:a", "flac")
|
||||
AudioCodec.PCM -> listOf("-c:a", "pcm_s16le")
|
||||
AudioCodec.OPUS -> listOf("-c:a", "libopus", "-b:a", "128k")
|
||||
AudioCodec.VORBIS -> listOf("-c:a", "libvorbis", "-q:a", "5")
|
||||
else -> listOf("-c:a", "aac", "-b:a", "192k")
|
||||
}
|
||||
private fun audioArgs(plan: ConversionPlan): List<String> = when (val audio = plan.audio) {
|
||||
AudioPlan.Drop -> listOf("-an")
|
||||
AudioPlan.Copy -> listOf("-c:a", "copy")
|
||||
is AudioPlan.Encode -> when (audio.codec) {
|
||||
AudioCodec.MP3 -> listOf("-c:a", "libmp3lame", "-q:a", "2")
|
||||
AudioCodec.FLAC -> listOf("-c:a", "flac")
|
||||
AudioCodec.PCM -> listOf("-c:a", "pcm_s16le")
|
||||
AudioCodec.OPUS -> listOf("-c:a", "libopus", "-b:a", "128k")
|
||||
AudioCodec.VORBIS -> listOf("-c:a", "libvorbis", "-q:a", "5")
|
||||
else -> listOf("-c:a", "aac", "-b:a", "192k")
|
||||
}
|
||||
}
|
||||
|
||||
private fun containerArgs(plan: ConversionPlan): List<String> = buildList {
|
||||
// Name the muxer rather than letting FFmpeg infer it from the output path. Inference is
|
||||
// unreliable for MPEG-TS and ASF, and the app now lets the user pick a container
|
||||
// independently of the preset that used to imply it.
|
||||
add("-f"); add(plan.container.ffmpegFormat)
|
||||
add("-f")
|
||||
add(plan.container.ffmpegFormat)
|
||||
|
||||
if (plan.container in MP4_FAMILY) {
|
||||
// Move the moov atom to the front so the file starts playing before it is
|
||||
// fully downloaded. This is also the reason output never goes through a SAF
|
||||
// file descriptor: faststart has to seek backwards to rewrite the header.
|
||||
add("-movflags"); add("+faststart")
|
||||
add("-movflags")
|
||||
add("+faststart")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,21 +28,31 @@ object FFmpegConcatCommand {
|
||||
format: OutputFormat,
|
||||
): List<String> = when (strategy) {
|
||||
ConcatStrategy.STREAM_COPY -> buildList {
|
||||
add("-hide_banner"); add("-y")
|
||||
add("-hide_banner")
|
||||
add("-y")
|
||||
// -safe 0 permits absolute paths in the list file, which ours are.
|
||||
add("-f"); add("concat")
|
||||
add("-safe"); add("0")
|
||||
add("-i"); add(listFile.absolutePath)
|
||||
add("-c"); add("copy")
|
||||
add("-f")
|
||||
add("concat")
|
||||
add("-safe")
|
||||
add("0")
|
||||
add("-i")
|
||||
add(listFile.absolutePath)
|
||||
add("-c")
|
||||
add("copy")
|
||||
if (format.container == org.libremediaconverter.model.Container.MP4) {
|
||||
add("-movflags"); add("+faststart")
|
||||
add("-movflags")
|
||||
add("+faststart")
|
||||
}
|
||||
add(output.absolutePath)
|
||||
}
|
||||
|
||||
ConcatStrategy.REENCODE -> buildList {
|
||||
add("-hide_banner"); add("-y")
|
||||
inputPaths.forEach { add("-i"); add(it) }
|
||||
add("-hide_banner")
|
||||
add("-y")
|
||||
inputPaths.forEach {
|
||||
add("-i")
|
||||
add(it)
|
||||
}
|
||||
// Normalise every input to a common size and frame rate before joining,
|
||||
// otherwise the concat filter refuses mismatched inputs.
|
||||
val filter = buildString {
|
||||
@@ -53,15 +63,23 @@ object FFmpegConcatCommand {
|
||||
inputPaths.indices.forEach { i -> append("[v$i][$i:a]") }
|
||||
append("concat=n=${inputPaths.size}:v=1:a=1[v][a]")
|
||||
}
|
||||
add("-filter_complex"); add(filter)
|
||||
add("-map"); add("[v]")
|
||||
add("-map"); add("[a]")
|
||||
add("-c:v"); add("libx264")
|
||||
add("-crf"); add("20")
|
||||
add("-pix_fmt"); add("yuv420p")
|
||||
add("-c:a"); add("aac")
|
||||
add("-filter_complex")
|
||||
add(filter)
|
||||
add("-map")
|
||||
add("[v]")
|
||||
add("-map")
|
||||
add("[a]")
|
||||
add("-c:v")
|
||||
add("libx264")
|
||||
add("-crf")
|
||||
add("20")
|
||||
add("-pix_fmt")
|
||||
add("yuv420p")
|
||||
add("-c:a")
|
||||
add("aac")
|
||||
if (format.container == org.libremediaconverter.model.Container.MP4) {
|
||||
add("-movflags"); add("+faststart")
|
||||
add("-movflags")
|
||||
add("+faststart")
|
||||
}
|
||||
add(output.absolutePath)
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ import com.arthenica.ffmpegkit.FFmpegKit
|
||||
import com.arthenica.ffmpegkit.FFmpegKitConfig
|
||||
import com.arthenica.ffmpegkit.Level
|
||||
import com.arthenica.ffmpegkit.ReturnCode
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import org.libremediaconverter.convert.SoftwareTranscoder
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import java.io.File
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
@@ -61,8 +61,8 @@ class FFmpegEngine : SoftwareTranscoder {
|
||||
"FFmpeg failed (${rc?.value}): " +
|
||||
completed.getFailStackTrace().orEmpty().ifBlank {
|
||||
completed.getAllLogsAsString(LOG_TAIL_LIMIT).orEmpty()
|
||||
}
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -33,18 +33,15 @@ import org.libremediaconverter.ui.ScreenPaddingVertical
|
||||
|
||||
@UnstableApi
|
||||
@Composable
|
||||
fun JoinScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: JoinViewModel = viewModel(),
|
||||
) {
|
||||
fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewModel()) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
val pickInputs = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.OpenMultipleDocuments()
|
||||
ActivityResultContracts.OpenMultipleDocuments(),
|
||||
) { uris -> if (uris.isNotEmpty()) viewModel.onInputsPicked(uris) }
|
||||
|
||||
val chooseDestination = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.CreateDocument("video/mp4")
|
||||
ActivityResultContracts.CreateDocument("video/mp4"),
|
||||
) { uri -> uri?.let(viewModel::save) }
|
||||
|
||||
Column(
|
||||
|
||||
@@ -8,10 +8,6 @@ import androidx.lifecycle.viewModelScope
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import org.libremediaconverter.convert.InputFile
|
||||
import org.libremediaconverter.convert.OutputPublisher
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -19,6 +15,10 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.libremediaconverter.convert.InputFile
|
||||
import org.libremediaconverter.convert.OutputPublisher
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
@@ -72,8 +72,11 @@ class JoinViewModel(app: Application) : AndroidViewModel(app) {
|
||||
_state.value = when (info.state) {
|
||||
WorkInfo.State.RUNNING, WorkInfo.State.BLOCKED -> JoinState.Joining(inputs)
|
||||
WorkInfo.State.ENQUEUED ->
|
||||
if (info.runAttemptCount > 0) JoinState.Waiting(inputs)
|
||||
else JoinState.Joining(inputs)
|
||||
if (info.runAttemptCount > 0) {
|
||||
JoinState.Waiting(inputs)
|
||||
} else {
|
||||
JoinState.Joining(inputs)
|
||||
}
|
||||
|
||||
WorkInfo.State.SUCCEEDED -> {
|
||||
val path = info.outputData.getString(ConcatWorker.KEY_OUTPUT_PATH)
|
||||
@@ -87,7 +90,7 @@ class JoinViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
|
||||
WorkInfo.State.FAILED -> JoinState.Failed(
|
||||
info.outputData.getString(ConcatWorker.KEY_ERROR) ?: "Joining failed."
|
||||
info.outputData.getString(ConcatWorker.KEY_ERROR) ?: "Joining failed.",
|
||||
)
|
||||
|
||||
WorkInfo.State.CANCELLED -> JoinState.Ready(inputs)
|
||||
|
||||
@@ -27,7 +27,11 @@ object ContainerCapabilities {
|
||||
// Matroska is the permissive one: it is a general-purpose container and takes essentially
|
||||
// any codec. That is what makes it the natural remux target.
|
||||
Container.MKV to setOf(
|
||||
VideoCodec.H264, VideoCodec.H265, VideoCodec.VP8, VideoCodec.VP9, VideoCodec.AV1,
|
||||
VideoCodec.H264,
|
||||
VideoCodec.H265,
|
||||
VideoCodec.VP8,
|
||||
VideoCodec.VP9,
|
||||
VideoCodec.AV1,
|
||||
),
|
||||
Container.WEBM to setOf(VideoCodec.VP8, VideoCodec.VP9, VideoCodec.AV1),
|
||||
Container.MPEG_TS to setOf(VideoCodec.H264, VideoCodec.H265),
|
||||
@@ -48,8 +52,12 @@ object ContainerCapabilities {
|
||||
Container.MP4 to setOf(AudioCodec.AAC, AudioCodec.MP3, AudioCodec.OPUS, AudioCodec.FLAC),
|
||||
Container.MOV to setOf(AudioCodec.AAC, AudioCodec.MP3, AudioCodec.PCM),
|
||||
Container.MKV to setOf(
|
||||
AudioCodec.AAC, AudioCodec.OPUS, AudioCodec.VORBIS,
|
||||
AudioCodec.MP3, AudioCodec.FLAC, AudioCodec.PCM,
|
||||
AudioCodec.AAC,
|
||||
AudioCodec.OPUS,
|
||||
AudioCodec.VORBIS,
|
||||
AudioCodec.MP3,
|
||||
AudioCodec.FLAC,
|
||||
AudioCodec.PCM,
|
||||
),
|
||||
Container.WEBM to setOf(AudioCodec.OPUS, AudioCodec.VORBIS),
|
||||
Container.MPEG_TS to setOf(AudioCodec.AAC, AudioCodec.MP3),
|
||||
@@ -75,7 +83,11 @@ object ContainerCapabilities {
|
||||
|
||||
/** Vorbis is absent for the same reason: nothing here emits a Vorbis encoder. */
|
||||
private val ENCODABLE_AUDIO = setOf(
|
||||
AudioCodec.AAC, AudioCodec.OPUS, AudioCodec.MP3, AudioCodec.FLAC, AudioCodec.PCM,
|
||||
AudioCodec.AAC,
|
||||
AudioCodec.OPUS,
|
||||
AudioCodec.MP3,
|
||||
AudioCodec.FLAC,
|
||||
AudioCodec.PCM,
|
||||
)
|
||||
|
||||
fun accepts(container: Container, codec: VideoCodec, mode: CodecMode): Boolean = when (codec) {
|
||||
@@ -249,9 +261,15 @@ object ContainerCapabilities {
|
||||
.filter { it != exclude }
|
||||
.distinct()
|
||||
.filter { validate(it, probe).isValid }
|
||||
.take(3)
|
||||
.take(MAX_SUGGESTIONS)
|
||||
}
|
||||
|
||||
/**
|
||||
* How many alternatives an [Validation.Invalid] offers. Enough to show a real choice,
|
||||
* few enough that the error stays readable.
|
||||
*/
|
||||
private const val MAX_SUGGESTIONS = 3
|
||||
|
||||
/** Best valid spec for this container, preserving as much of the request as possible. */
|
||||
private fun repair(spec: OutputSpec, probe: InputProbe): OutputSpec? {
|
||||
val container = spec.container
|
||||
|
||||
@@ -57,7 +57,10 @@ object ConversionRouter {
|
||||
|
||||
internal val MEDIA3_MUXABLE_AUDIO: Map<Container, Set<AudioCodec>> = mapOf(
|
||||
Container.MP4 to setOf(
|
||||
AudioCodec.AAC, AudioCodec.OPUS, AudioCodec.VORBIS, AudioCodec.PCM,
|
||||
AudioCodec.AAC,
|
||||
AudioCodec.OPUS,
|
||||
AudioCodec.VORBIS,
|
||||
AudioCodec.PCM,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -18,14 +18,11 @@ sealed interface AudioPlan {
|
||||
/**
|
||||
* A resolved conversion: no [VideoCodec.COPY] left to interpret, every track decided.
|
||||
*/
|
||||
data class ConversionPlan(
|
||||
val container: Container,
|
||||
val video: VideoPlan,
|
||||
val audio: AudioPlan,
|
||||
) {
|
||||
data class ConversionPlan(val container: Container, val video: VideoPlan, val audio: AudioPlan) {
|
||||
/** No track is re-encoded and at least one is copied: a container change and nothing more. */
|
||||
val isPureRemux: Boolean
|
||||
get() = video !is VideoPlan.Encode && audio !is AudioPlan.Encode &&
|
||||
get() = video !is VideoPlan.Encode &&
|
||||
audio !is AudioPlan.Encode &&
|
||||
(video is VideoPlan.Copy || audio is AudioPlan.Copy)
|
||||
|
||||
val hasVideo: Boolean get() = video != VideoPlan.Drop
|
||||
|
||||
@@ -45,11 +45,9 @@ enum class Container(
|
||||
/** Whether this container can hold a video track at all. */
|
||||
val canHoldVideo: Boolean get() = videoExtension != null
|
||||
|
||||
fun extensionFor(hasVideo: Boolean): String =
|
||||
if (hasVideo) videoExtension ?: audioExtension else audioExtension
|
||||
fun extensionFor(hasVideo: Boolean): String = if (hasVideo) videoExtension ?: audioExtension else audioExtension
|
||||
|
||||
fun mimeTypeFor(hasVideo: Boolean): String =
|
||||
if (hasVideo) videoMime ?: audioMime else audioMime
|
||||
fun mimeTypeFor(hasVideo: Boolean): String = if (hasVideo) videoMime ?: audioMime else audioMime
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,11 +86,7 @@ enum class AudioCodec(val label: String) {
|
||||
* now comes from [ContainerCapabilities] instead, which is explicit and unit-tested rather than
|
||||
* implicit in which combinations someone remembered to enumerate.
|
||||
*/
|
||||
data class OutputSpec(
|
||||
val container: Container,
|
||||
val videoCodec: VideoCodec,
|
||||
val audioCodec: AudioCodec,
|
||||
) {
|
||||
data class OutputSpec(val container: Container, val videoCodec: VideoCodec, val audioCodec: AudioCodec) {
|
||||
/** Whether the output keeps a video track — the thing extension and MIME type turn on. */
|
||||
val hasVideo: Boolean get() = videoCodec != VideoCodec.NONE
|
||||
|
||||
@@ -104,7 +98,8 @@ data class OutputSpec(
|
||||
|
||||
/** True when neither track is re-encoded, i.e. this is a pure container change. */
|
||||
val isPureRemux: Boolean
|
||||
get() = videoCodec.isCopyOrAbsent() && audioCodec.isCopyOrAbsent() &&
|
||||
get() = videoCodec.isCopyOrAbsent() &&
|
||||
audioCodec.isCopyOrAbsent() &&
|
||||
(videoCodec == VideoCodec.COPY || audioCodec == AudioCodec.COPY)
|
||||
|
||||
val extension: String get() = container.extensionFor(hasVideo)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.libremediaconverter.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
|
||||
@@ -8,7 +8,6 @@ import androidx.work.CoroutineWorker
|
||||
import androidx.work.Data
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.workDataOf
|
||||
import org.libremediaconverter.convert.ConversionDependencies
|
||||
@@ -27,10 +26,7 @@ import org.libremediaconverter.model.OutputFormat
|
||||
* concatenated; showing a fabricated percentage would be worse than showing none.
|
||||
*/
|
||||
@UnstableApi
|
||||
class ConcatWorker(
|
||||
context: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(context, params) {
|
||||
class ConcatWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
|
||||
|
||||
private val notifications = ConversionNotifications(applicationContext)
|
||||
private val publisher = ConversionDependencies.publisher(applicationContext)
|
||||
@@ -43,7 +39,7 @@ class ConcatWorker(
|
||||
}
|
||||
val totalBytes = inputData.getLong(KEY_TOTAL_BYTES, 0L)
|
||||
val format = OutputFormat.valueOf(
|
||||
inputData.getString(KEY_FORMAT) ?: OutputFormat.MP4_H264.name
|
||||
inputData.getString(KEY_FORMAT) ?: OutputFormat.MP4_H264.name,
|
||||
)
|
||||
|
||||
if (!publisher.hasSpaceFor(totalBytes)) {
|
||||
@@ -55,7 +51,7 @@ class ConcatWorker(
|
||||
NOTIFICATION_ID,
|
||||
notifications.build(id, "Joining ${uris.size} files", 0, indeterminate = true),
|
||||
ConversionForegroundType.current(),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
val staged = publisher.createStagingFile("joined.${format.extension}")
|
||||
@@ -65,7 +61,7 @@ class ConcatWorker(
|
||||
workDataOf(
|
||||
KEY_OUTPUT_PATH to staged.absolutePath,
|
||||
KEY_STRATEGY to result.strategy.name,
|
||||
)
|
||||
),
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
staged.delete()
|
||||
@@ -99,18 +95,15 @@ class ConcatWorker(
|
||||
private const val NOTIFICATION_ID = 1002
|
||||
private const val TAG = "ConcatWorker"
|
||||
|
||||
fun request(
|
||||
inputs: List<Uri>,
|
||||
totalBytes: Long,
|
||||
format: OutputFormat = OutputFormat.MP4_H264,
|
||||
) = OneTimeWorkRequestBuilder<ConcatWorker>()
|
||||
.setInputData(
|
||||
Data.Builder()
|
||||
.putStringArray(KEY_INPUT_URIS, inputs.map(Uri::toString).toTypedArray())
|
||||
.putLong(KEY_TOTAL_BYTES, totalBytes)
|
||||
.putString(KEY_FORMAT, format.name)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
fun request(inputs: List<Uri>, totalBytes: Long, format: OutputFormat = OutputFormat.MP4_H264) =
|
||||
OneTimeWorkRequestBuilder<ConcatWorker>()
|
||||
.setInputData(
|
||||
Data.Builder()
|
||||
.putStringArray(KEY_INPUT_URIS, inputs.map(Uri::toString).toTypedArray())
|
||||
.putLong(KEY_TOTAL_BYTES, totalBytes)
|
||||
.putString(KEY_FORMAT, format.name)
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,8 +30,15 @@ object ConversionForegroundType {
|
||||
* `mediaProcessing` constant does not exist to pass in the first place.
|
||||
*/
|
||||
fun current(): Int = when {
|
||||
Build.VERSION.SDK_INT >= 35 -> ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING
|
||||
Build.VERSION.SDK_INT >= 34 -> ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
else -> 0
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM ->
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING
|
||||
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE ->
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
|
||||
else -> NO_TYPE_REQUIRED
|
||||
}
|
||||
|
||||
/** API 33 wants no type at all, and `ForegroundInfo` reads 0 as exactly that. */
|
||||
private const val NO_TYPE_REQUIRED = 0
|
||||
}
|
||||
|
||||
@@ -35,14 +35,14 @@ class ConversionNotifications(private val context: Context) {
|
||||
context.getString(R.string.notification_preparing)
|
||||
} else {
|
||||
context.getString(R.string.notification_progress, percent)
|
||||
}
|
||||
},
|
||||
)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
.setOngoing(true)
|
||||
// Progress updates far outpace what the UI can use; alerting once keeps
|
||||
// the system UI from being hammered.
|
||||
.setOnlyAlertOnce(true)
|
||||
.setProgress(100, percent, indeterminate)
|
||||
.setProgress(PERCENT_MAX, percent, indeterminate)
|
||||
.addAction(
|
||||
android.R.drawable.ic_menu_close_clear_cancel,
|
||||
context.getString(R.string.action_cancel),
|
||||
@@ -57,13 +57,15 @@ class ConversionNotifications(private val context: Context) {
|
||||
* notification appears only in the Task Manager rather than the shade — so
|
||||
* progress silently vanishes from the user's point of view.
|
||||
*/
|
||||
fun areEnabled(): Boolean =
|
||||
context.getSystemService(NotificationManager::class.java)
|
||||
.areNotificationsEnabled()
|
||||
.also { if (!it) Log.i(TAG, "Notifications disabled; progress will not be visible.") }
|
||||
fun areEnabled(): Boolean = context.getSystemService(NotificationManager::class.java)
|
||||
.areNotificationsEnabled()
|
||||
.also { if (!it) Log.i(TAG, "Notifications disabled; progress will not be visible.") }
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "conversions"
|
||||
private const val TAG = "ConversionNotifications"
|
||||
|
||||
/** `setProgress` takes a max and a current; progress is reported as a percentage. */
|
||||
private const val PERCENT_MAX = 100
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import androidx.work.CoroutineWorker
|
||||
import androidx.work.Data
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.workDataOf
|
||||
import com.arthenica.ffmpegkit.FFmpegKitConfig
|
||||
@@ -40,12 +39,10 @@ import java.io.File
|
||||
* with a short quota, which is the wrong shape for a multi-minute transcode.
|
||||
*/
|
||||
@UnstableApi
|
||||
class ConversionWorker(
|
||||
context: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(context, params) {
|
||||
class ConversionWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
|
||||
|
||||
private val notifications = ConversionNotifications(applicationContext)
|
||||
|
||||
// Resolved through ConversionDependencies so tests can force the failure paths.
|
||||
private val publisher = ConversionDependencies.publisher(applicationContext)
|
||||
|
||||
@@ -56,10 +53,10 @@ class ConversionWorker(
|
||||
val sizeBytes = inputData.getLong(KEY_SIZE_BYTES, 0L)
|
||||
val spec = readSpec()
|
||||
val quality = QualityTier.valueOf(
|
||||
inputData.getString(KEY_QUALITY) ?: QualityTier.FAST.name
|
||||
inputData.getString(KEY_QUALITY) ?: QualityTier.FAST.name,
|
||||
)
|
||||
val preference = EnginePreference.valueOf(
|
||||
inputData.getString(KEY_ENGINE_PREFERENCE) ?: EnginePreference.AUTO.name
|
||||
inputData.getString(KEY_ENGINE_PREFERENCE) ?: EnginePreference.AUTO.name,
|
||||
)
|
||||
|
||||
if (!publisher.hasSpaceFor(sizeBytes)) {
|
||||
@@ -102,7 +99,7 @@ class ConversionWorker(
|
||||
KEY_OUTPUT_PATH to staged.absolutePath,
|
||||
KEY_ENGINE_USED to decision.engine.name,
|
||||
KEY_ROUTE_REASON to decision.reason.explanation,
|
||||
)
|
||||
),
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
staged.delete()
|
||||
@@ -142,12 +139,7 @@ class ConversionWorker(
|
||||
runFFmpeg(request, inputUri, staged, displayName)
|
||||
}
|
||||
|
||||
private suspend fun runFFmpeg(
|
||||
request: ConversionRequest,
|
||||
inputUri: Uri,
|
||||
staged: File,
|
||||
displayName: String,
|
||||
) {
|
||||
private suspend fun runFFmpeg(request: ConversionRequest, inputUri: Uri, staged: File, displayName: String) {
|
||||
// FFmpeg needs a path. ffkitsaf bridges a content:// URI for reading; the read
|
||||
// side is seekable for local providers, which is all the demuxer needs. Output
|
||||
// still goes to a real cache path — see OutputPublisher.
|
||||
@@ -177,8 +169,7 @@ class ConversionWorker(
|
||||
}
|
||||
}
|
||||
|
||||
private fun isCancellation(e: Throwable): Boolean =
|
||||
e is kotlinx.coroutines.CancellationException || isStopped
|
||||
private fun isCancellation(e: Throwable): Boolean = e is kotlinx.coroutines.CancellationException || isStopped
|
||||
|
||||
/**
|
||||
* Distinguishes a genuine failure from the foreground-service budget expiring.
|
||||
@@ -189,17 +180,16 @@ class ConversionWorker(
|
||||
* later rather than tell the user the conversion failed — the work is still valid,
|
||||
* there is simply no budget right now.
|
||||
*/
|
||||
private fun handleTimeoutIfNeeded(cause: Throwable): Result =
|
||||
when (FailureOutcome.forStopReason(stopReason)) {
|
||||
FailureOutcome.RETRY -> {
|
||||
Log.w(TAG, "Foreground service budget exhausted; will retry.", cause)
|
||||
Result.retry()
|
||||
}
|
||||
FailureOutcome.FAIL -> {
|
||||
Log.e(TAG, "Conversion failed.", cause)
|
||||
Result.failure(workDataOf(KEY_ERROR to (cause.message ?: "Conversion failed.")))
|
||||
}
|
||||
private fun handleTimeoutIfNeeded(cause: Throwable): Result = when (FailureOutcome.forStopReason(stopReason)) {
|
||||
FailureOutcome.RETRY -> {
|
||||
Log.w(TAG, "Foreground service budget exhausted; will retry.", cause)
|
||||
Result.retry()
|
||||
}
|
||||
FailureOutcome.FAIL -> {
|
||||
Log.e(TAG, "Conversion failed.", cause)
|
||||
Result.failure(workDataOf(KEY_ERROR to (cause.message ?: "Conversion failed.")))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the output spec out of the worker's input Data.
|
||||
@@ -223,19 +213,17 @@ class ConversionWorker(
|
||||
return OutputSpec(container, video, audio)
|
||||
}
|
||||
|
||||
override suspend fun getForegroundInfo(): ForegroundInfo =
|
||||
foregroundInfo(
|
||||
inputData.getString(KEY_DISPLAY_NAME) ?: "input",
|
||||
percent = 0,
|
||||
indeterminate = true,
|
||||
)
|
||||
override suspend fun getForegroundInfo(): ForegroundInfo = foregroundInfo(
|
||||
inputData.getString(KEY_DISPLAY_NAME) ?: "input",
|
||||
percent = 0,
|
||||
indeterminate = true,
|
||||
)
|
||||
|
||||
private fun foregroundInfo(title: String, percent: Int, indeterminate: Boolean) =
|
||||
ForegroundInfo(
|
||||
NOTIFICATION_ID,
|
||||
notifications.build(id, title, percent, indeterminate),
|
||||
ConversionForegroundType.current(),
|
||||
)
|
||||
private fun foregroundInfo(title: String, percent: Int, indeterminate: Boolean) = ForegroundInfo(
|
||||
NOTIFICATION_ID,
|
||||
notifications.build(id, title, percent, indeterminate),
|
||||
ConversionForegroundType.current(),
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val KEY_INPUT_URI = "input_uri"
|
||||
@@ -263,9 +251,8 @@ class ConversionWorker(
|
||||
* yields `.mkv` or `.mka` and MP4 yields `.mp4` or `.m4a` without a preset having to
|
||||
* enumerate both.
|
||||
*/
|
||||
fun outputNameFor(inputName: String, spec: OutputSpec): String =
|
||||
inputName.substringBeforeLast('.', inputName) +
|
||||
"_converted.${spec.extension}"
|
||||
fun outputNameFor(inputName: String, spec: OutputSpec): String = inputName.substringBeforeLast('.', inputName) +
|
||||
"_converted.${spec.extension}"
|
||||
|
||||
fun request(
|
||||
inputUri: Uri,
|
||||
@@ -285,7 +272,7 @@ class ConversionWorker(
|
||||
.putString(KEY_AUDIO_CODEC, spec.audioCodec.name)
|
||||
.putString(KEY_QUALITY, quality.name)
|
||||
.putString(KEY_ENGINE_PREFERENCE, enginePreference.name)
|
||||
.build()
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ enum class FailureOutcome {
|
||||
RETRY,
|
||||
|
||||
/** A genuine failure; report it to the user. */
|
||||
FAIL;
|
||||
FAIL,
|
||||
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun forStopReason(stopReason: Int): FailureOutcome =
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
package org.libremediaconverter.ffmpeg
|
||||
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.OutputSpec
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.OutputSpec
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
|
||||
class FFmpegCommandBuilderTest {
|
||||
|
||||
@@ -94,8 +94,10 @@ class FFmpegCommandBuilderTest {
|
||||
@Test
|
||||
fun `every video encode path forces yuv420p`() {
|
||||
val videoFormats = listOf(
|
||||
OutputFormat.MP4_H264, OutputFormat.MP4_H265,
|
||||
OutputFormat.MKV_H264, OutputFormat.MKV_H265,
|
||||
OutputFormat.MP4_H264,
|
||||
OutputFormat.MP4_H265,
|
||||
OutputFormat.MKV_H264,
|
||||
OutputFormat.MKV_H265,
|
||||
OutputFormat.WEBM_VP9,
|
||||
)
|
||||
videoFormats.forEach { format ->
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package org.libremediaconverter.ffmpeg
|
||||
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.libremediaconverter.model.ConcatStrategy
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import java.io.File
|
||||
|
||||
class FFmpegConcatCommandTest {
|
||||
@@ -31,7 +31,11 @@ class FFmpegConcatCommandTest {
|
||||
@Test
|
||||
fun `stream copy uses the concat demuxer and copies codecs`() {
|
||||
val args = FFmpegConcatCommand.build(
|
||||
ConcatStrategy.STREAM_COPY, inputs, listFile, output, OutputFormat.MP4_H264,
|
||||
ConcatStrategy.STREAM_COPY,
|
||||
inputs,
|
||||
listFile,
|
||||
output,
|
||||
OutputFormat.MP4_H264,
|
||||
)
|
||||
assertTrue(args.contains("concat"))
|
||||
assertEquals("copy", args[args.indexOf("-c") + 1])
|
||||
@@ -42,7 +46,11 @@ class FFmpegConcatCommandTest {
|
||||
@Test
|
||||
fun `stream copy allows absolute paths in the list file`() {
|
||||
val args = FFmpegConcatCommand.build(
|
||||
ConcatStrategy.STREAM_COPY, inputs, listFile, output, OutputFormat.MP4_H264,
|
||||
ConcatStrategy.STREAM_COPY,
|
||||
inputs,
|
||||
listFile,
|
||||
output,
|
||||
OutputFormat.MP4_H264,
|
||||
)
|
||||
// Without -safe 0 the demuxer rejects the absolute paths we generate.
|
||||
assertEquals("0", args[args.indexOf("-safe") + 1])
|
||||
@@ -51,7 +59,11 @@ class FFmpegConcatCommandTest {
|
||||
@Test
|
||||
fun `re-encode passes every input separately and builds a filter graph`() {
|
||||
val args = FFmpegConcatCommand.build(
|
||||
ConcatStrategy.REENCODE, inputs, listFile, output, OutputFormat.MP4_H264,
|
||||
ConcatStrategy.REENCODE,
|
||||
inputs,
|
||||
listFile,
|
||||
output,
|
||||
OutputFormat.MP4_H264,
|
||||
)
|
||||
assertEquals(2, args.count { it == "-i" })
|
||||
val filter = args[args.indexOf("-filter_complex") + 1]
|
||||
@@ -64,7 +76,11 @@ class FFmpegConcatCommandTest {
|
||||
@Test
|
||||
fun `re-encode maps the filter outputs rather than raw streams`() {
|
||||
val args = FFmpegConcatCommand.build(
|
||||
ConcatStrategy.REENCODE, inputs, listFile, output, OutputFormat.MP4_H264,
|
||||
ConcatStrategy.REENCODE,
|
||||
inputs,
|
||||
listFile,
|
||||
output,
|
||||
OutputFormat.MP4_H264,
|
||||
)
|
||||
assertTrue(args.contains("[v]"))
|
||||
assertTrue(args.contains("[a]"))
|
||||
@@ -81,7 +97,11 @@ class FFmpegConcatCommandTest {
|
||||
@Test
|
||||
fun `mkv output does not get faststart`() {
|
||||
val args = FFmpegConcatCommand.build(
|
||||
ConcatStrategy.STREAM_COPY, inputs, listFile, File("/cache/j.mkv"), OutputFormat.MKV_H264,
|
||||
ConcatStrategy.STREAM_COPY,
|
||||
inputs,
|
||||
listFile,
|
||||
File("/cache/j.mkv"),
|
||||
OutputFormat.MKV_H264,
|
||||
)
|
||||
assertFalse(args.contains("+faststart"))
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package org.libremediaconverter.model
|
||||
|
||||
import org.libremediaconverter.model.ConversionRouter.Reason
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.libremediaconverter.model.ConversionRouter.Reason
|
||||
|
||||
/**
|
||||
* One test per routing predicate.
|
||||
@@ -291,8 +291,12 @@ class ConversionRouterTest {
|
||||
@Test
|
||||
fun `the new containers are all FFmpeg-only`() {
|
||||
listOf(
|
||||
Container.MOV, Container.MKV, Container.MPEG_TS,
|
||||
Container.AVI, Container.FLV, Container.ASF,
|
||||
Container.MOV,
|
||||
Container.MKV,
|
||||
Container.MPEG_TS,
|
||||
Container.AVI,
|
||||
Container.FLV,
|
||||
Container.ASF,
|
||||
).forEach { container ->
|
||||
val d = route(
|
||||
OutputSpec(container, VideoCodec.COPY, AudioCodec.COPY),
|
||||
|
||||
+23
-3
@@ -1,4 +1,24 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
}
|
||||
//
|
||||
// AGP 9 uses "built-in Kotlin": it compiles Kotlin itself, with the KGP it bundles (2.2.10 for
|
||||
// AGP 9.3.1, per its POM). That version caps jvmTarget at 24. Putting a newer KGP on this single
|
||||
// buildscript classpath is what raises the ceiling -- AGP's built-in Kotlin then compiles with
|
||||
// 2.4.10 instead. The Compose compiler plugin must match KGP exactly, so it moves in lockstep.
|
||||
//
|
||||
// This is why the module below applies these two by id() rather than alias(): the plugins come
|
||||
// from here, not from the version catalog's plugin resolution. The catalog still carries the
|
||||
// version numbers, so there is exactly one place to edit.
|
||||
//
|
||||
// We still do NOT apply org.jetbrains.kotlin.android -- that plugin is incompatible with AGP 9's
|
||||
// built-in-Kotlin DSL and fails the build.
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle:${libs.versions.agp.get()}")
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${libs.versions.kotlin.get()}")
|
||||
classpath("org.jetbrains.kotlin:compose-compiler-gradle-plugin:${libs.versions.kotlin.get()}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -1,12 +1,12 @@
|
||||
#This file is generated by updateDaemonJvm
|
||||
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
|
||||
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
|
||||
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
|
||||
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
|
||||
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/c2dd35c9d0aaf0ba6ad0791320f99dfc/redirect
|
||||
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/e5810bd7fd1f8a586644409d395a7e55/redirect
|
||||
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
|
||||
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
|
||||
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7b3c4877c0749019e6805bb61e421497/redirect
|
||||
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/d76df094a9cbbabd3b08251f9e61444a/redirect
|
||||
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/539ea72837c6244075f8ee037babc29e/redirect
|
||||
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/876dbbf00bf4ef6190f7da074cee903a/redirect
|
||||
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/539ea72837c6244075f8ee037babc29e/redirect
|
||||
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/876dbbf00bf4ef6190f7da074cee903a/redirect
|
||||
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/1df82b76031285ef2e6a4f59ffe9299d/redirect
|
||||
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/4d0277c49991306ac78048580fe34bcf/redirect
|
||||
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/539ea72837c6244075f8ee037babc29e/redirect
|
||||
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/876dbbf00bf4ef6190f7da074cee903a/redirect
|
||||
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/a9fa3b809e484a1e1ac4308a5dda661a/redirect
|
||||
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/aa99913fa6d767d899ffc96082da01ca/redirect
|
||||
toolchainVersion=25
|
||||
|
||||
+70
-23
@@ -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.
|
||||
|
||||
Vendored
BIN
Binary file not shown.
+4
-3
@@ -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
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
@@ -114,7 +114,6 @@ case "$( uname )" in #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
@@ -172,7 +171,6 @@ fi
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
@@ -212,7 +210,6 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
|
||||
Vendored
+82
-94
@@ -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%
|
||||
|
||||
Reference in New Issue
Block a user