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
|
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
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
|
# Gradle runs through the committed wrapper rather than a setup action. The
|
||||||
# wrapper verifies its own distribution against distributionSha256Sum, and
|
# wrapper verifies its own distribution against distributionSha256Sum, and
|
||||||
@@ -66,7 +66,7 @@ jobs:
|
|||||||
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: '17'
|
java-version: '25'
|
||||||
|
|
||||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ concurrency:
|
|||||||
# owner can repoint v4 at new code, so a tag reference is an open invitation to run
|
# 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
|
# whatever that repository contains tomorrow. The trailing comment records which
|
||||||
# release each hash corresponds to, since a bare hash is unreadable.
|
# 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:
|
env:
|
||||||
GRADLE_CACHE_PATHS: |
|
GRADLE_CACHE_PATHS: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
@@ -80,7 +88,7 @@ jobs:
|
|||||||
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
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.
|
# Gradle is invoked through the committed wrapper rather than a setup action.
|
||||||
# The wrapper verifies its own distribution against distributionSha256Sum, and
|
# The wrapper verifies its own distribution against distributionSha256Sum, and
|
||||||
@@ -100,6 +108,69 @@ jobs:
|
|||||||
name: unit-test-report
|
name: unit-test-report
|
||||||
path: app/build/reports/tests/
|
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.
|
# One runner per API level, across the whole supported range.
|
||||||
#
|
#
|
||||||
@@ -146,7 +217,7 @@ jobs:
|
|||||||
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: '17'
|
java-version: '25'
|
||||||
|
|
||||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
with:
|
with:
|
||||||
@@ -190,20 +261,12 @@ jobs:
|
|||||||
# dominate the APK, so shipping arm64 to an x86_64 emulator doubles the
|
# 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.
|
# 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
|
# Diagnostics live in .github/scripts/e2e-run.sh, not here. This action splits
|
||||||
# because a red instrumented run is otherwise near-impossible to read from a
|
# `script` on newlines and runs each line as its own `sh -c`, so a handler written
|
||||||
# log alone. The first reports what the guest actually got, so a wrong
|
# inline has to fit on ONE line -- which is how the previous version ended up as an
|
||||||
# emulator configuration is visible on a green run too; the crash dump runs
|
# unreadable chain of semicolons. One line invokes the script; the script can use
|
||||||
# only on failure, so a green run is unchanged.
|
# functions, and captures a hang as well as a failure. See its header.
|
||||||
#
|
script: bash .github/scripts/e2e-run.sh ${{ matrix.label }}
|
||||||
# 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; }
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
if: always()
|
if: always()
|
||||||
@@ -212,3 +275,25 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
app/build/reports/androidTests/
|
app/build/reports/androidTests/
|
||||||
app/build/outputs/androidTest-results/
|
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 {
|
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.
|
// 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 {
|
android {
|
||||||
@@ -58,8 +71,35 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_25
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
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 {
|
packaging {
|
||||||
@@ -76,6 +116,115 @@ kotlin {
|
|||||||
compilerOptions {}
|
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 {
|
dependencies {
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import android.media.MediaExtractor
|
|||||||
import android.media.MediaFormat
|
import android.media.MediaFormat
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.media3.common.MimeTypes
|
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
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.codec.AndroidDeviceCodecs
|
||||||
import org.libremediaconverter.convert.Media3Engine
|
import org.libremediaconverter.convert.Media3Engine
|
||||||
import org.libremediaconverter.convert.MediaProbe
|
import org.libremediaconverter.convert.MediaProbe
|
||||||
@@ -17,10 +20,6 @@ import org.libremediaconverter.model.ConversionRouter
|
|||||||
import org.libremediaconverter.model.OutputFormat
|
import org.libremediaconverter.model.OutputFormat
|
||||||
import org.libremediaconverter.model.QualityTier
|
import org.libremediaconverter.model.QualityTier
|
||||||
import org.libremediaconverter.model.VideoCodec
|
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
|
import java.io.File
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,6 +44,7 @@ import java.io.File
|
|||||||
class RealMediaBenchmark {
|
class RealMediaBenchmark {
|
||||||
|
|
||||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal storage, not the external files dir.
|
* Internal storage, not the external files dir.
|
||||||
*
|
*
|
||||||
@@ -55,8 +55,7 @@ class RealMediaBenchmark {
|
|||||||
*/
|
*/
|
||||||
private val samples: File get() = context.filesDir
|
private val samples: File get() = context.filesDir
|
||||||
|
|
||||||
private fun sample(name: String): File? =
|
private fun sample(name: String): File? = File(samples, name).takeIf { it.exists() && it.length() > 0 }
|
||||||
File(samples, name).takeIf { it.exists() && it.length() > 0 }
|
|
||||||
|
|
||||||
private fun durationMs(file: File): Long {
|
private fun durationMs(file: File): Long {
|
||||||
val extractor = MediaExtractor()
|
val extractor = MediaExtractor()
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import org.junit.Test
|
|||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.libremediaconverter.model.AudioCodec
|
import org.libremediaconverter.model.AudioCodec
|
||||||
import org.libremediaconverter.model.Container
|
import org.libremediaconverter.model.Container
|
||||||
import org.libremediaconverter.model.ConversionRequest
|
|
||||||
import org.libremediaconverter.model.Engine
|
import org.libremediaconverter.model.Engine
|
||||||
import org.libremediaconverter.model.InputKind
|
import org.libremediaconverter.model.InputKind
|
||||||
import org.libremediaconverter.model.OutputSpec
|
import org.libremediaconverter.model.OutputSpec
|
||||||
@@ -84,14 +83,13 @@ class RemuxTest {
|
|||||||
WorkManager.getInstance(context).cancelAllWork()
|
WorkManager.getInstance(context).cancelAllWork()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun asset(name: String): File =
|
private fun asset(name: String): File = File(context.cacheDir, name).also { file ->
|
||||||
File(context.cacheDir, name).also { file ->
|
file.delete()
|
||||||
file.delete()
|
staged += file
|
||||||
staged += file
|
InstrumentationRegistry.getInstrumentation().context.assets
|
||||||
InstrumentationRegistry.getInstrumentation().context.assets
|
.open(name)
|
||||||
.open(name)
|
.use { asset -> file.outputStream().use { asset.copyTo(it) } }
|
||||||
.use { asset -> file.outputStream().use { asset.copyTo(it) } }
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// --- probing ------------------------------------------------------------
|
// --- probing ------------------------------------------------------------
|
||||||
|
|
||||||
@@ -255,8 +253,7 @@ class RemuxTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun videoMimeOf(file: File): String? =
|
private fun videoMimeOf(file: File): String? = trackMimesOf(file).firstOrNull { it.startsWith("video/") }
|
||||||
trackMimesOf(file).firstOrNull { it.startsWith("video/") }
|
|
||||||
|
|
||||||
private fun durationMsOf(file: File): Long {
|
private fun durationMsOf(file: File): Long {
|
||||||
val extractor = MediaExtractor()
|
val extractor = MediaExtractor()
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ package org.libremediaconverter.fallback
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
import org.libremediaconverter.convert.ConversionDependencies
|
import org.libremediaconverter.convert.ConversionDependencies
|
||||||
import org.libremediaconverter.convert.HardwareTranscoder
|
import org.libremediaconverter.convert.HardwareTranscoder
|
||||||
import org.libremediaconverter.convert.OutputPublisher
|
import org.libremediaconverter.convert.OutputPublisher
|
||||||
import org.libremediaconverter.convert.SoftwareTranscoder
|
import org.libremediaconverter.convert.SoftwareTranscoder
|
||||||
import org.libremediaconverter.model.ConversionRequest
|
import org.libremediaconverter.model.ConversionRequest
|
||||||
import org.libremediaconverter.model.OutputFormat
|
|
||||||
import java.io.File
|
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
|
* 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.
|
* day are the ones that were never executed.
|
||||||
*/
|
*/
|
||||||
|
@UnstableApi
|
||||||
object FakeFailures {
|
object FakeFailures {
|
||||||
|
|
||||||
class ExplodingHardware(private val message: String = "hardware exploded") : HardwareTranscoder {
|
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.test.platform.app.InstrumentationRegistry
|
||||||
import androidx.work.WorkInfo
|
import androidx.work.WorkInfo
|
||||||
import androidx.work.WorkManager
|
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.flow.first
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
@@ -22,6 +15,13 @@ import org.junit.Assert.assertTrue
|
|||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
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
|
import java.io.File
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,14 +61,13 @@ class ForcedFailureTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun convertRequest(format: OutputFormat = OutputFormat.MP4_H265) =
|
private fun convertRequest(format: OutputFormat = OutputFormat.MP4_H265) = ConversionWorker.request(
|
||||||
ConversionWorker.request(
|
inputUri = Uri.fromFile(input),
|
||||||
inputUri = Uri.fromFile(input),
|
displayName = SAMPLE,
|
||||||
displayName = SAMPLE,
|
sizeBytes = input.length(),
|
||||||
sizeBytes = input.length(),
|
spec = format.spec,
|
||||||
spec = format.spec,
|
quality = QualityTier.FAST,
|
||||||
quality = QualityTier.FAST,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
// --- the dynamic fallback, forced rather than provoked -------------------
|
// --- the dynamic fallback, forced rather than provoked -------------------
|
||||||
|
|
||||||
@@ -179,11 +178,9 @@ class ForcedFailureTest {
|
|||||||
assertEquals(WorkInfo.State.FAILED, terminal?.state)
|
assertEquals(WorkInfo.State.FAILED, terminal?.state)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun assertTrue(message: String, condition: Boolean) =
|
private fun assertTrue(message: String, condition: Boolean) = org.junit.Assert.assertTrue(message, condition)
|
||||||
org.junit.Assert.assertTrue(message, condition)
|
|
||||||
|
|
||||||
private fun assertTrue(condition: Boolean, message: String) =
|
private fun assertTrue(condition: Boolean, message: String) = org.junit.Assert.assertTrue(message, condition)
|
||||||
org.junit.Assert.assertTrue(message, condition)
|
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val SAMPLE = "sample_h264.mp4"
|
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.test.platform.app.InstrumentationRegistry
|
||||||
import androidx.work.WorkInfo
|
import androidx.work.WorkInfo
|
||||||
import androidx.work.WorkManager
|
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.flow.first
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
@@ -18,6 +15,9 @@ import org.junit.Assert.assertTrue
|
|||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
|
import org.libremediaconverter.model.OutputFormat
|
||||||
|
import org.libremediaconverter.model.QualityTier
|
||||||
|
import org.libremediaconverter.work.ConversionWorker
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,10 +8,6 @@ import androidx.work.Data
|
|||||||
import androidx.work.OneTimeWorkRequestBuilder
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
import androidx.work.WorkInfo
|
import androidx.work.WorkInfo
|
||||||
import androidx.work.WorkManager
|
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.flow.first
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
@@ -21,6 +17,10 @@ import org.junit.Assert.assertTrue
|
|||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
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
|
import java.io.File
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ import android.media.MediaExtractor
|
|||||||
import android.media.MediaFormat
|
import android.media.MediaFormat
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
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 kotlinx.coroutines.runBlocking
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
@@ -14,6 +11,9 @@ import org.junit.Assert.assertTrue
|
|||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
|
import org.libremediaconverter.model.ConversionRequest
|
||||||
|
import org.libremediaconverter.model.OutputFormat
|
||||||
|
import org.libremediaconverter.model.QualityTier
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,8 +46,10 @@ class FFmpegEngineTest {
|
|||||||
outputs.forEach { it.delete() }
|
outputs.forEach { it.delete() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun outputFor(name: String) =
|
private fun outputFor(name: String) = File(context.cacheDir, name).also {
|
||||||
File(context.cacheDir, name).also { it.delete(); outputs += it }
|
it.delete()
|
||||||
|
outputs += it
|
||||||
|
}
|
||||||
|
|
||||||
private fun convert(format: OutputFormat, quality: QualityTier = QualityTier.BEST): File {
|
private fun convert(format: OutputFormat, quality: QualityTier = QualityTier.BEST): File {
|
||||||
val out = outputFor("out_${format.name.lowercase()}.${format.extension}")
|
val out = outputFor("out_${format.name.lowercase()}.${format.extension}")
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ import android.media.MediaFormat
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
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 kotlinx.coroutines.runBlocking
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
@@ -15,6 +12,9 @@ import org.junit.Assert.assertTrue
|
|||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
|
import org.libremediaconverter.convert.MediaProbe
|
||||||
|
import org.libremediaconverter.ffmpeg.ConcatEngine
|
||||||
|
import org.libremediaconverter.model.ConcatStrategy
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,8 +57,10 @@ class ConcatEngineTest {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun output(name: String) =
|
private fun output(name: String) = File(context.cacheDir, name).also {
|
||||||
File(context.cacheDir, name).also { it.delete(); staged += it }
|
it.delete()
|
||||||
|
staged += it
|
||||||
|
}
|
||||||
|
|
||||||
private fun durationMs(file: File): Long {
|
private fun durationMs(file: File): Long {
|
||||||
val extractor = MediaExtractor()
|
val extractor = MediaExtractor()
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
|||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import androidx.work.WorkInfo
|
import androidx.work.WorkInfo
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import org.libremediaconverter.model.ConcatStrategy
|
|
||||||
import org.libremediaconverter.work.ConcatWorker
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
@@ -17,6 +15,8 @@ import org.junit.Assert.assertTrue
|
|||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
|
import org.libremediaconverter.model.ConcatStrategy
|
||||||
|
import org.libremediaconverter.work.ConcatWorker
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,14 +8,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
|||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import androidx.work.WorkInfo
|
import androidx.work.WorkInfo
|
||||||
import androidx.work.WorkManager
|
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.flow.first
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
@@ -25,6 +17,14 @@ import org.junit.Assert.assertTrue
|
|||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
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
|
import java.io.File
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -170,8 +170,11 @@ class ConversionWorkerTest {
|
|||||||
terminal?.state,
|
terminal?.state,
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
if (hasHardwareHevc) "hardware HEVC present, expected the Media3 path"
|
if (hasHardwareHevc) {
|
||||||
else "no hardware HEVC encoder, expected the FFmpeg path",
|
"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,
|
if (hasHardwareHevc) Engine.MEDIA3.name else Engine.FFMPEG.name,
|
||||||
terminal?.outputData?.getString(ConversionWorker.KEY_ENGINE_USED),
|
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.WindowWidthSizeClass
|
||||||
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package org.libremediaconverter.codec
|
package org.libremediaconverter.codec
|
||||||
|
|
||||||
import android.media.MediaCodecInfo
|
|
||||||
import android.media.MediaCodecList
|
import android.media.MediaCodecList
|
||||||
import android.media.MediaFormat
|
import android.media.MediaFormat
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@@ -28,8 +27,7 @@ class AndroidDeviceCodecs private constructor(
|
|||||||
private val decodeMimes: Set<String>,
|
private val decodeMimes: Set<String>,
|
||||||
) : DeviceCodecs {
|
) : DeviceCodecs {
|
||||||
|
|
||||||
override fun canEncode(codec: VideoCodec): Boolean =
|
override fun canEncode(codec: VideoCodec): Boolean = mimeFor(codec)?.let { it in hardwareEncodeMimes } ?: true
|
||||||
mimeFor(codec)?.let { it in hardwareEncodeMimes } ?: true
|
|
||||||
|
|
||||||
override fun canDecode(codecName: String): Boolean {
|
override fun canDecode(codecName: String): Boolean {
|
||||||
// The platform already failed to parse this input, so there is nothing to
|
// The platform already failed to parse this input, so there is nothing to
|
||||||
@@ -46,8 +44,7 @@ class AndroidDeviceCodecs private constructor(
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var cached: AndroidDeviceCodecs? = null
|
private var cached: AndroidDeviceCodecs? = null
|
||||||
|
|
||||||
fun get(): AndroidDeviceCodecs =
|
fun get(): AndroidDeviceCodecs = cached ?: synchronized(this) { cached ?: probe().also { cached = it } }
|
||||||
cached ?: synchronized(this) { cached ?: probe().also { cached = it } }
|
|
||||||
|
|
||||||
private fun probe(): AndroidDeviceCodecs {
|
private fun probe(): AndroidDeviceCodecs {
|
||||||
val encoders = mutableSetOf<String>()
|
val encoders = mutableSetOf<String>()
|
||||||
@@ -104,7 +101,6 @@ class AndroidDeviceCodecs private constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Test seam: lets instrumented tests build a probe from explicit sets. */
|
/** Test seam: lets instrumented tests build a probe from explicit sets. */
|
||||||
fun forTesting(encoders: Set<String>, decoders: Set<String>) =
|
fun forTesting(encoders: Set<String>, decoders: Set<String>) = AndroidDeviceCodecs(encoders, decoders)
|
||||||
AndroidDeviceCodecs(encoders, decoders)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,17 +8,6 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.work.WorkInfo
|
import androidx.work.WorkInfo
|
||||||
import androidx.work.WorkManager
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -30,6 +19,17 @@ import kotlinx.coroutines.flow.stateIn
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
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.io.File
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -62,6 +62,7 @@ sealed interface ConversionState {
|
|||||||
data object Idle : ConversionState
|
data object Idle : ConversionState
|
||||||
data class Ready(val input: InputFile) : ConversionState
|
data class Ready(val input: InputFile) : ConversionState
|
||||||
data class Converting(val input: InputFile, val percent: Int) : ConversionState
|
data class Converting(val input: InputFile, val percent: Int) : ConversionState
|
||||||
|
|
||||||
/** Budget for foreground work ran out; WorkManager will retry when it can. */
|
/** Budget for foreground work ran out; WorkManager will retry when it can. */
|
||||||
data class Waiting(val input: InputFile) : ConversionState
|
data class Waiting(val input: InputFile) : ConversionState
|
||||||
data class Converted(
|
data class Converted(
|
||||||
@@ -102,20 +103,16 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, Validation.Valid)
|
}.stateIn(viewModelScope, SharingStarted.Eagerly, Validation.Valid)
|
||||||
|
|
||||||
fun setPreset(format: OutputFormat) = _settings.update { it.copy(spec = format.spec) }
|
fun setPreset(format: OutputFormat) = _settings.update { it.copy(spec = format.spec) }
|
||||||
fun setContainer(container: Container) =
|
fun setContainer(container: Container) = _settings.update { it.copy(spec = it.spec.copy(container = container)) }
|
||||||
_settings.update { it.copy(spec = it.spec.copy(container = container)) }
|
|
||||||
|
|
||||||
fun setVideoCodec(codec: VideoCodec) =
|
fun setVideoCodec(codec: VideoCodec) = _settings.update { it.copy(spec = it.spec.copy(videoCodec = codec)) }
|
||||||
_settings.update { it.copy(spec = it.spec.copy(videoCodec = codec)) }
|
|
||||||
|
|
||||||
fun setAudioCodec(codec: AudioCodec) =
|
fun setAudioCodec(codec: AudioCodec) = _settings.update { it.copy(spec = it.spec.copy(audioCodec = codec)) }
|
||||||
_settings.update { it.copy(spec = it.spec.copy(audioCodec = codec)) }
|
|
||||||
|
|
||||||
fun applySuggestion(spec: OutputSpec) = _settings.update { it.copy(spec = spec) }
|
fun applySuggestion(spec: OutputSpec) = _settings.update { it.copy(spec = spec) }
|
||||||
|
|
||||||
fun setQuality(quality: QualityTier) = _settings.update { it.copy(quality = quality) }
|
fun setQuality(quality: QualityTier) = _settings.update { it.copy(quality = quality) }
|
||||||
fun setEnginePreference(preference: EnginePreference) =
|
fun setEnginePreference(preference: EnginePreference) = _settings.update { it.copy(enginePreference = preference) }
|
||||||
_settings.update { it.copy(enginePreference = preference) }
|
|
||||||
|
|
||||||
fun onInputPicked(uri: Uri) {
|
fun onInputPicked(uri: Uri) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -202,7 +199,7 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
|
|
||||||
WorkInfo.State.FAILED -> ConversionState.Failed(
|
WorkInfo.State.FAILED -> ConversionState.Failed(
|
||||||
info.outputData.getString(ConversionWorker.KEY_ERROR)
|
info.outputData.getString(ConversionWorker.KEY_ERROR)
|
||||||
?: "Conversion failed."
|
?: "Conversion failed.",
|
||||||
)
|
)
|
||||||
|
|
||||||
WorkInfo.State.CANCELLED -> ConversionState.Ready(input)
|
WorkInfo.State.CANCELLED -> ConversionState.Ready(input)
|
||||||
@@ -229,7 +226,7 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
ConversionWorker.outputNameFor(
|
ConversionWorker.outputNameFor(
|
||||||
converted.input.displayName,
|
converted.input.displayName,
|
||||||
_settings.value.spec,
|
_settings.value.spec,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
_state.value = ConversionState.Failed(e.message ?: "Could not save the file.")
|
_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
|
_state.value = ConversionState.Idle
|
||||||
}
|
}
|
||||||
|
|
||||||
fun suggestedOutputName(): String =
|
fun suggestedOutputName(): String = ConversionWorker.outputNameFor(
|
||||||
ConversionWorker.outputNameFor(
|
currentInput()?.displayName ?: "output",
|
||||||
currentInput()?.displayName ?: "output",
|
_settings.value.spec,
|
||||||
_settings.value.spec,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
private fun ConversionState.probe(): InputProbe? = when (this) {
|
private fun ConversionState.probe(): InputProbe? = when (this) {
|
||||||
is ConversionState.Ready -> input.probe
|
is ConversionState.Ready -> input.probe
|
||||||
|
|||||||
@@ -34,9 +34,6 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
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.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
@@ -50,14 +47,14 @@ import org.libremediaconverter.model.OutputSpec
|
|||||||
import org.libremediaconverter.model.QualityTier
|
import org.libremediaconverter.model.QualityTier
|
||||||
import org.libremediaconverter.model.Validation
|
import org.libremediaconverter.model.Validation
|
||||||
import org.libremediaconverter.model.VideoCodec
|
import org.libremediaconverter.model.VideoCodec
|
||||||
|
import org.libremediaconverter.ui.PrimaryButtonHeight
|
||||||
|
import org.libremediaconverter.ui.ScreenPaddingHorizontal
|
||||||
|
import org.libremediaconverter.ui.ScreenPaddingVertical
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
@Composable
|
@Composable
|
||||||
fun ConverterScreen(
|
fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewModel = viewModel()) {
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
viewModel: ConversionViewModel = viewModel(),
|
|
||||||
) {
|
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val settings by viewModel.settings.collectAsStateWithLifecycle()
|
val settings by viewModel.settings.collectAsStateWithLifecycle()
|
||||||
val validation by viewModel.validation.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.
|
// only, offers no audio at all, and will not reliably surface .mkv/.flac/.webm.
|
||||||
// SAF needs no runtime permission.
|
// SAF needs no runtime permission.
|
||||||
val pickInput = rememberLauncherForActivityResult(
|
val pickInput = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.OpenDocument()
|
ActivityResultContracts.OpenDocument(),
|
||||||
) { uri -> uri?.let(viewModel::onInputPicked) }
|
) { uri -> uri?.let(viewModel::onInputPicked) }
|
||||||
|
|
||||||
val chooseDestination = rememberLauncherForActivityResult(
|
val chooseDestination = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.CreateDocument(settings.spec.mimeType)
|
ActivityResultContracts.CreateDocument(settings.spec.mimeType),
|
||||||
) { uri -> uri?.let(viewModel::save) }
|
) { uri -> uri?.let(viewModel::save) }
|
||||||
|
|
||||||
// Requested at the point of use rather than on first launch, so the ask carries its
|
// 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
|
// foreground service still runs, but its progress notification is confined to the
|
||||||
// Task Manager instead of the shade.
|
// Task Manager instead of the shade.
|
||||||
val requestNotifications = rememberLauncherForActivityResult(
|
val requestNotifications = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.RequestPermission()
|
ActivityResultContracts.RequestPermission(),
|
||||||
) { viewModel.convert() }
|
) { viewModel.convert() }
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
@@ -69,7 +69,10 @@ class Media3Engine(private val context: Context) : HardwareTranscoder {
|
|||||||
val plan = CopyPlanner.plan(request.spec, request.probe)
|
val plan = CopyPlanner.plan(request.spec, request.probe)
|
||||||
handler.post {
|
handler.post {
|
||||||
val transformer = runCatching { buildTransformer(plan, cont) }
|
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
|
// 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
|
// 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) }
|
runCatching { transformer.start(composition, output.absolutePath) }
|
||||||
.onFailure { cont.resumeWithException(it); return@post }
|
.onFailure {
|
||||||
|
cont.resumeWithException(it)
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
|
||||||
pollProgress(transformer, cont, onProgress)
|
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
|
* 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.
|
* writing MP4, which is what the old code did.
|
||||||
*/
|
*/
|
||||||
private fun buildTransformer(
|
private fun buildTransformer(plan: ConversionPlan, cont: CancellableContinuation<Unit>): Transformer {
|
||||||
plan: ConversionPlan,
|
|
||||||
cont: CancellableContinuation<Unit>,
|
|
||||||
): Transformer {
|
|
||||||
val muxerFactory = requireNotNull(Media3Muxers.factoryFor(plan.container)) {
|
val muxerFactory = requireNotNull(Media3Muxers.factoryFor(plan.container)) {
|
||||||
"Media3 cannot mux ${plan.container}; this job should have routed to FFmpeg."
|
"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)
|
if (cont.isActive) cont.resume(Unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onError(
|
override fun onError(composition: Composition, result: ExportResult, exception: ExportException) {
|
||||||
composition: Composition,
|
|
||||||
result: ExportResult,
|
|
||||||
exception: ExportException,
|
|
||||||
) {
|
|
||||||
if (cont.isActive) cont.resumeWithException(exception)
|
if (cont.isActive) cont.resumeWithException(exception)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -193,7 +192,6 @@ class Media3Engine(private val context: Context) : HardwareTranscoder {
|
|||||||
thread.quitSafely()
|
thread.quitSafely()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val PROGRESS_INTERVAL_MS = 250L
|
const val PROGRESS_INTERVAL_MS = 250L
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ object MediaProbe {
|
|||||||
mime.startsWith("audio/") && audio == null -> audio = shortName(mime)
|
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) {
|
} catch (e: Exception) {
|
||||||
Log.i(TAG, "Platform extractor could not read $uri.", e)
|
Log.i(TAG, "Platform extractor could not read $uri.", e)
|
||||||
null
|
null
|
||||||
@@ -162,7 +162,7 @@ object MediaProbe {
|
|||||||
container = containerFrom(formatName, video?.getCodec()),
|
container = containerFrom(formatName, video?.getCodec()),
|
||||||
videoCodec = video?.getCodec(),
|
videoCodec = video?.getCodec(),
|
||||||
audioCodec = audio?.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,
|
width = video?.getWidth()?.toInt() ?: 0,
|
||||||
height = video?.getHeight()?.toInt() ?: 0,
|
height = video?.getHeight()?.toInt() ?: 0,
|
||||||
isImage = isImageFormat(formatName),
|
isImage = isImageFormat(formatName),
|
||||||
@@ -284,4 +284,10 @@ object MediaProbe {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private const val TAG = "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
|
* 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.
|
* just barely fit is rejected rather than failing partway through.
|
||||||
*/
|
*/
|
||||||
open fun hasSpaceFor(bytes: Long): Boolean =
|
open fun hasSpaceFor(bytes: Long): Boolean = stagingDir.usableSpace > bytes + SPACE_HEADROOM_BYTES
|
||||||
stagingDir.usableSpace > bytes + SPACE_HEADROOM_BYTES
|
|
||||||
|
|
||||||
/** Copies a finished staging file into a user-chosen SAF destination. */
|
/** Copies a finished staging file into a user-chosen SAF destination. */
|
||||||
open fun publish(staged: File, destination: Uri) {
|
open fun publish(staged: File, destination: Uri) {
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ package org.libremediaconverter.convert
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import org.libremediaconverter.codec.AndroidDeviceCodecs
|
||||||
import org.libremediaconverter.ffmpeg.FFmpegEngine
|
import org.libremediaconverter.ffmpeg.FFmpegEngine
|
||||||
import org.libremediaconverter.model.ConversionRequest
|
import org.libremediaconverter.model.ConversionRequest
|
||||||
import org.libremediaconverter.model.DeviceCodecs
|
import org.libremediaconverter.model.DeviceCodecs
|
||||||
import org.libremediaconverter.model.OutputFormat
|
import org.libremediaconverter.model.OutputFormat
|
||||||
import org.libremediaconverter.codec.AndroidDeviceCodecs
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
/** The hardware conversion path. Implemented by [Media3Engine]. */
|
/** 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
|
* 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.
|
* worker's input Data by hand rather than through its request() helper.
|
||||||
*/
|
*/
|
||||||
|
@UnstableApi
|
||||||
object ConversionDependencies {
|
object ConversionDependencies {
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import android.util.Log
|
|||||||
import com.arthenica.ffmpegkit.FFmpegKit
|
import com.arthenica.ffmpegkit.FFmpegKit
|
||||||
import com.arthenica.ffmpegkit.FFmpegKitConfig
|
import com.arthenica.ffmpegkit.FFmpegKitConfig
|
||||||
import com.arthenica.ffmpegkit.ReturnCode
|
import com.arthenica.ffmpegkit.ReturnCode
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import org.libremediaconverter.convert.MediaProbe
|
import org.libremediaconverter.convert.MediaProbe
|
||||||
import org.libremediaconverter.model.ConcatPlanner
|
import org.libremediaconverter.model.ConcatPlanner
|
||||||
import org.libremediaconverter.model.ConcatStrategy
|
import org.libremediaconverter.model.ConcatStrategy
|
||||||
import org.libremediaconverter.model.OutputFormat
|
import org.libremediaconverter.model.OutputFormat
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.resumeWithException
|
import kotlin.coroutines.resumeWithException
|
||||||
@@ -27,11 +27,7 @@ class ConcatEngine(private val context: Context) {
|
|||||||
|
|
||||||
data class Result(val strategy: ConcatStrategy, val output: File)
|
data class Result(val strategy: ConcatStrategy, val output: File)
|
||||||
|
|
||||||
suspend fun join(
|
suspend fun join(inputs: List<Uri>, output: File, format: OutputFormat = OutputFormat.MP4_H264): Result {
|
||||||
inputs: List<Uri>,
|
|
||||||
output: File,
|
|
||||||
format: OutputFormat = OutputFormat.MP4_H264,
|
|
||||||
): Result {
|
|
||||||
require(inputs.size >= 2) { "Joining needs at least two files." }
|
require(inputs.size >= 2) { "Joining needs at least two files." }
|
||||||
|
|
||||||
val paths = inputs.map { uri ->
|
val paths = inputs.map { uri ->
|
||||||
@@ -70,8 +66,8 @@ class ConcatEngine(private val context: Context) {
|
|||||||
else -> cont.resumeWithException(
|
else -> cont.resumeWithException(
|
||||||
FFmpegEngine.FFmpegException(
|
FFmpegEngine.FFmpegException(
|
||||||
"Joining failed (${rc?.value}): " +
|
"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. */
|
/** Containers in the ISO base-media family, where HEVC needs the hvc1 brand. */
|
||||||
private val MP4_FAMILY = setOf(Container.MP4, Container.MOV)
|
private val MP4_FAMILY = setOf(Container.MP4, Container.MOV)
|
||||||
|
|
||||||
fun build(
|
fun build(request: ConversionRequest, inputPath: String, outputPath: String): List<String> {
|
||||||
request: ConversionRequest,
|
|
||||||
inputPath: String,
|
|
||||||
outputPath: String,
|
|
||||||
): List<String> {
|
|
||||||
val plan = CopyPlanner.plan(request.spec, request.probe)
|
val plan = CopyPlanner.plan(request.spec, request.probe)
|
||||||
return buildList {
|
return buildList {
|
||||||
add("-hide_banner")
|
add("-hide_banner")
|
||||||
// Overwrite: the output path is one we just created in our own cache.
|
// Overwrite: the output path is one we just created in our own cache.
|
||||||
add("-y")
|
add("-y")
|
||||||
add("-i"); add(inputPath)
|
add("-i")
|
||||||
|
add(inputPath)
|
||||||
|
|
||||||
if (request.spec.isImageOutput) {
|
if (request.spec.isImageOutput) {
|
||||||
addAll(imageArgs(request))
|
addAll(imageArgs(request))
|
||||||
@@ -86,7 +83,8 @@ object FFmpegCommandBuilder {
|
|||||||
"-vf",
|
"-vf",
|
||||||
"fps=12,scale=480:-1:flags=lanczos,split[a][b];" +
|
"fps=12,scale=480:-1:flags=lanczos,split[a][b];" +
|
||||||
"[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer",
|
"[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer",
|
||||||
"-loop", "0",
|
"-loop",
|
||||||
|
"0",
|
||||||
)
|
)
|
||||||
|
|
||||||
else -> listOf("-an", "-vf", "fps=1", "-vsync", "0")
|
else -> listOf("-an", "-vf", "fps=1", "-vsync", "0")
|
||||||
@@ -99,7 +97,8 @@ object FFmpegCommandBuilder {
|
|||||||
VideoPlan.Drop -> listOf("-vn")
|
VideoPlan.Drop -> listOf("-vn")
|
||||||
|
|
||||||
VideoPlan.Copy -> buildList {
|
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
|
// 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
|
// HEVC out of Matroska into MP4 otherwise produces a file Apple devices and many
|
||||||
// hardware players refuse, even though the samples are byte-identical.
|
// 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
|
// that hardware encoding is unlikely to work for that input. Fast therefore means a
|
||||||
// fast *preset*, not a different encoder.
|
// fast *preset*, not a different encoder.
|
||||||
VideoCodec.H265 -> listOf(
|
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.
|
// Without this, many players and Apple devices refuse HEVC in MP4.
|
||||||
"-tag:v", "hvc1",
|
"-tag:v",
|
||||||
|
"hvc1",
|
||||||
) + PIX_FMT
|
) + PIX_FMT
|
||||||
|
|
||||||
VideoCodec.VP9 -> buildList {
|
VideoCodec.VP9 -> buildList {
|
||||||
@@ -140,7 +145,12 @@ object FFmpegCommandBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
VideoCodec.H264 -> listOf(
|
VideoCodec.H264 -> listOf(
|
||||||
"-c:v", "libx264", "-crf", "$CRF_H264", "-preset", preset,
|
"-c:v",
|
||||||
|
"libx264",
|
||||||
|
"-crf",
|
||||||
|
"$CRF_H264",
|
||||||
|
"-preset",
|
||||||
|
preset,
|
||||||
) + PIX_FMT
|
) + PIX_FMT
|
||||||
|
|
||||||
// No silent substitution. A trailing `else -> libx264` would hand back H.264 for a
|
// 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.
|
// about rather than papering over.
|
||||||
VideoCodec.VP8, VideoCodec.AV1 -> error(
|
VideoCodec.VP8, VideoCodec.AV1 -> error(
|
||||||
"This app cannot encode ${codec.label}; it can only copy an existing " +
|
"This app cannot encode ${codec.label}; it can only copy an existing " +
|
||||||
"${codec.label} stream."
|
"${codec.label} stream.",
|
||||||
)
|
)
|
||||||
|
|
||||||
VideoCodec.COPY, VideoCodec.NONE -> error(
|
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()
|
return if (sourceIsHevc) listOf("-tag:v", "hvc1") else emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun audioArgs(plan: ConversionPlan): List<String> =
|
private fun audioArgs(plan: ConversionPlan): List<String> = when (val audio = plan.audio) {
|
||||||
when (val audio = plan.audio) {
|
AudioPlan.Drop -> listOf("-an")
|
||||||
AudioPlan.Drop -> listOf("-an")
|
AudioPlan.Copy -> listOf("-c:a", "copy")
|
||||||
AudioPlan.Copy -> listOf("-c:a", "copy")
|
is AudioPlan.Encode -> when (audio.codec) {
|
||||||
is AudioPlan.Encode -> when (audio.codec) {
|
AudioCodec.MP3 -> listOf("-c:a", "libmp3lame", "-q:a", "2")
|
||||||
AudioCodec.MP3 -> listOf("-c:a", "libmp3lame", "-q:a", "2")
|
AudioCodec.FLAC -> listOf("-c:a", "flac")
|
||||||
AudioCodec.FLAC -> listOf("-c:a", "flac")
|
AudioCodec.PCM -> listOf("-c:a", "pcm_s16le")
|
||||||
AudioCodec.PCM -> listOf("-c:a", "pcm_s16le")
|
AudioCodec.OPUS -> listOf("-c:a", "libopus", "-b:a", "128k")
|
||||||
AudioCodec.OPUS -> listOf("-c:a", "libopus", "-b:a", "128k")
|
AudioCodec.VORBIS -> listOf("-c:a", "libvorbis", "-q:a", "5")
|
||||||
AudioCodec.VORBIS -> listOf("-c:a", "libvorbis", "-q:a", "5")
|
else -> listOf("-c:a", "aac", "-b:a", "192k")
|
||||||
else -> listOf("-c:a", "aac", "-b:a", "192k")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun containerArgs(plan: ConversionPlan): List<String> = buildList {
|
private fun containerArgs(plan: ConversionPlan): List<String> = buildList {
|
||||||
// Name the muxer rather than letting FFmpeg infer it from the output path. Inference is
|
// 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
|
// 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.
|
// 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) {
|
if (plan.container in MP4_FAMILY) {
|
||||||
// Move the moov atom to the front so the file starts playing before it is
|
// 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
|
// fully downloaded. This is also the reason output never goes through a SAF
|
||||||
// file descriptor: faststart has to seek backwards to rewrite the header.
|
// 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,
|
format: OutputFormat,
|
||||||
): List<String> = when (strategy) {
|
): List<String> = when (strategy) {
|
||||||
ConcatStrategy.STREAM_COPY -> buildList {
|
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.
|
// -safe 0 permits absolute paths in the list file, which ours are.
|
||||||
add("-f"); add("concat")
|
add("-f")
|
||||||
add("-safe"); add("0")
|
add("concat")
|
||||||
add("-i"); add(listFile.absolutePath)
|
add("-safe")
|
||||||
add("-c"); add("copy")
|
add("0")
|
||||||
|
add("-i")
|
||||||
|
add(listFile.absolutePath)
|
||||||
|
add("-c")
|
||||||
|
add("copy")
|
||||||
if (format.container == org.libremediaconverter.model.Container.MP4) {
|
if (format.container == org.libremediaconverter.model.Container.MP4) {
|
||||||
add("-movflags"); add("+faststart")
|
add("-movflags")
|
||||||
|
add("+faststart")
|
||||||
}
|
}
|
||||||
add(output.absolutePath)
|
add(output.absolutePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
ConcatStrategy.REENCODE -> buildList {
|
ConcatStrategy.REENCODE -> buildList {
|
||||||
add("-hide_banner"); add("-y")
|
add("-hide_banner")
|
||||||
inputPaths.forEach { add("-i"); add(it) }
|
add("-y")
|
||||||
|
inputPaths.forEach {
|
||||||
|
add("-i")
|
||||||
|
add(it)
|
||||||
|
}
|
||||||
// Normalise every input to a common size and frame rate before joining,
|
// Normalise every input to a common size and frame rate before joining,
|
||||||
// otherwise the concat filter refuses mismatched inputs.
|
// otherwise the concat filter refuses mismatched inputs.
|
||||||
val filter = buildString {
|
val filter = buildString {
|
||||||
@@ -53,15 +63,23 @@ object FFmpegConcatCommand {
|
|||||||
inputPaths.indices.forEach { i -> append("[v$i][$i:a]") }
|
inputPaths.indices.forEach { i -> append("[v$i][$i:a]") }
|
||||||
append("concat=n=${inputPaths.size}:v=1:a=1[v][a]")
|
append("concat=n=${inputPaths.size}:v=1:a=1[v][a]")
|
||||||
}
|
}
|
||||||
add("-filter_complex"); add(filter)
|
add("-filter_complex")
|
||||||
add("-map"); add("[v]")
|
add(filter)
|
||||||
add("-map"); add("[a]")
|
add("-map")
|
||||||
add("-c:v"); add("libx264")
|
add("[v]")
|
||||||
add("-crf"); add("20")
|
add("-map")
|
||||||
add("-pix_fmt"); add("yuv420p")
|
add("[a]")
|
||||||
add("-c:a"); add("aac")
|
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) {
|
if (format.container == org.libremediaconverter.model.Container.MP4) {
|
||||||
add("-movflags"); add("+faststart")
|
add("-movflags")
|
||||||
|
add("+faststart")
|
||||||
}
|
}
|
||||||
add(output.absolutePath)
|
add(output.absolutePath)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import com.arthenica.ffmpegkit.FFmpegKit
|
|||||||
import com.arthenica.ffmpegkit.FFmpegKitConfig
|
import com.arthenica.ffmpegkit.FFmpegKitConfig
|
||||||
import com.arthenica.ffmpegkit.Level
|
import com.arthenica.ffmpegkit.Level
|
||||||
import com.arthenica.ffmpegkit.ReturnCode
|
import com.arthenica.ffmpegkit.ReturnCode
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import org.libremediaconverter.convert.SoftwareTranscoder
|
import org.libremediaconverter.convert.SoftwareTranscoder
|
||||||
import org.libremediaconverter.model.ConversionRequest
|
import org.libremediaconverter.model.ConversionRequest
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.resumeWithException
|
import kotlin.coroutines.resumeWithException
|
||||||
@@ -61,8 +61,8 @@ class FFmpegEngine : SoftwareTranscoder {
|
|||||||
"FFmpeg failed (${rc?.value}): " +
|
"FFmpeg failed (${rc?.value}): " +
|
||||||
completed.getFailStackTrace().orEmpty().ifBlank {
|
completed.getFailStackTrace().orEmpty().ifBlank {
|
||||||
completed.getAllLogsAsString(LOG_TAIL_LIMIT).orEmpty()
|
completed.getAllLogsAsString(LOG_TAIL_LIMIT).orEmpty()
|
||||||
}
|
},
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -33,18 +33,15 @@ import org.libremediaconverter.ui.ScreenPaddingVertical
|
|||||||
|
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
@Composable
|
@Composable
|
||||||
fun JoinScreen(
|
fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewModel()) {
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
viewModel: JoinViewModel = viewModel(),
|
|
||||||
) {
|
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val pickInputs = rememberLauncherForActivityResult(
|
val pickInputs = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.OpenMultipleDocuments()
|
ActivityResultContracts.OpenMultipleDocuments(),
|
||||||
) { uris -> if (uris.isNotEmpty()) viewModel.onInputsPicked(uris) }
|
) { uris -> if (uris.isNotEmpty()) viewModel.onInputsPicked(uris) }
|
||||||
|
|
||||||
val chooseDestination = rememberLauncherForActivityResult(
|
val chooseDestination = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.CreateDocument("video/mp4")
|
ActivityResultContracts.CreateDocument("video/mp4"),
|
||||||
) { uri -> uri?.let(viewModel::save) }
|
) { uri -> uri?.let(viewModel::save) }
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
@@ -8,10 +8,6 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
import androidx.work.WorkInfo
|
import androidx.work.WorkInfo
|
||||||
import androidx.work.WorkManager
|
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.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -19,6 +15,10 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
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.io.File
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -72,8 +72,11 @@ class JoinViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
_state.value = when (info.state) {
|
_state.value = when (info.state) {
|
||||||
WorkInfo.State.RUNNING, WorkInfo.State.BLOCKED -> JoinState.Joining(inputs)
|
WorkInfo.State.RUNNING, WorkInfo.State.BLOCKED -> JoinState.Joining(inputs)
|
||||||
WorkInfo.State.ENQUEUED ->
|
WorkInfo.State.ENQUEUED ->
|
||||||
if (info.runAttemptCount > 0) JoinState.Waiting(inputs)
|
if (info.runAttemptCount > 0) {
|
||||||
else JoinState.Joining(inputs)
|
JoinState.Waiting(inputs)
|
||||||
|
} else {
|
||||||
|
JoinState.Joining(inputs)
|
||||||
|
}
|
||||||
|
|
||||||
WorkInfo.State.SUCCEEDED -> {
|
WorkInfo.State.SUCCEEDED -> {
|
||||||
val path = info.outputData.getString(ConcatWorker.KEY_OUTPUT_PATH)
|
val path = info.outputData.getString(ConcatWorker.KEY_OUTPUT_PATH)
|
||||||
@@ -87,7 +90,7 @@ class JoinViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
WorkInfo.State.FAILED -> JoinState.Failed(
|
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)
|
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
|
// 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.
|
// any codec. That is what makes it the natural remux target.
|
||||||
Container.MKV to setOf(
|
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.WEBM to setOf(VideoCodec.VP8, VideoCodec.VP9, VideoCodec.AV1),
|
||||||
Container.MPEG_TS to setOf(VideoCodec.H264, VideoCodec.H265),
|
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.MP4 to setOf(AudioCodec.AAC, AudioCodec.MP3, AudioCodec.OPUS, AudioCodec.FLAC),
|
||||||
Container.MOV to setOf(AudioCodec.AAC, AudioCodec.MP3, AudioCodec.PCM),
|
Container.MOV to setOf(AudioCodec.AAC, AudioCodec.MP3, AudioCodec.PCM),
|
||||||
Container.MKV to setOf(
|
Container.MKV to setOf(
|
||||||
AudioCodec.AAC, AudioCodec.OPUS, AudioCodec.VORBIS,
|
AudioCodec.AAC,
|
||||||
AudioCodec.MP3, AudioCodec.FLAC, AudioCodec.PCM,
|
AudioCodec.OPUS,
|
||||||
|
AudioCodec.VORBIS,
|
||||||
|
AudioCodec.MP3,
|
||||||
|
AudioCodec.FLAC,
|
||||||
|
AudioCodec.PCM,
|
||||||
),
|
),
|
||||||
Container.WEBM to setOf(AudioCodec.OPUS, AudioCodec.VORBIS),
|
Container.WEBM to setOf(AudioCodec.OPUS, AudioCodec.VORBIS),
|
||||||
Container.MPEG_TS to setOf(AudioCodec.AAC, AudioCodec.MP3),
|
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. */
|
/** Vorbis is absent for the same reason: nothing here emits a Vorbis encoder. */
|
||||||
private val ENCODABLE_AUDIO = setOf(
|
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) {
|
fun accepts(container: Container, codec: VideoCodec, mode: CodecMode): Boolean = when (codec) {
|
||||||
@@ -249,9 +261,15 @@ object ContainerCapabilities {
|
|||||||
.filter { it != exclude }
|
.filter { it != exclude }
|
||||||
.distinct()
|
.distinct()
|
||||||
.filter { validate(it, probe).isValid }
|
.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. */
|
/** Best valid spec for this container, preserving as much of the request as possible. */
|
||||||
private fun repair(spec: OutputSpec, probe: InputProbe): OutputSpec? {
|
private fun repair(spec: OutputSpec, probe: InputProbe): OutputSpec? {
|
||||||
val container = spec.container
|
val container = spec.container
|
||||||
|
|||||||
@@ -57,7 +57,10 @@ object ConversionRouter {
|
|||||||
|
|
||||||
internal val MEDIA3_MUXABLE_AUDIO: Map<Container, Set<AudioCodec>> = mapOf(
|
internal val MEDIA3_MUXABLE_AUDIO: Map<Container, Set<AudioCodec>> = mapOf(
|
||||||
Container.MP4 to setOf(
|
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.
|
* A resolved conversion: no [VideoCodec.COPY] left to interpret, every track decided.
|
||||||
*/
|
*/
|
||||||
data class ConversionPlan(
|
data class ConversionPlan(val container: Container, val video: VideoPlan, val audio: AudioPlan) {
|
||||||
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. */
|
/** No track is re-encoded and at least one is copied: a container change and nothing more. */
|
||||||
val isPureRemux: Boolean
|
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)
|
(video is VideoPlan.Copy || audio is AudioPlan.Copy)
|
||||||
|
|
||||||
val hasVideo: Boolean get() = video != VideoPlan.Drop
|
val hasVideo: Boolean get() = video != VideoPlan.Drop
|
||||||
|
|||||||
@@ -45,11 +45,9 @@ enum class Container(
|
|||||||
/** Whether this container can hold a video track at all. */
|
/** Whether this container can hold a video track at all. */
|
||||||
val canHoldVideo: Boolean get() = videoExtension != null
|
val canHoldVideo: Boolean get() = videoExtension != null
|
||||||
|
|
||||||
fun extensionFor(hasVideo: Boolean): String =
|
fun extensionFor(hasVideo: Boolean): String = if (hasVideo) videoExtension ?: audioExtension else audioExtension
|
||||||
if (hasVideo) videoExtension ?: audioExtension else audioExtension
|
|
||||||
|
|
||||||
fun mimeTypeFor(hasVideo: Boolean): String =
|
fun mimeTypeFor(hasVideo: Boolean): String = if (hasVideo) videoMime ?: audioMime else audioMime
|
||||||
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
|
* now comes from [ContainerCapabilities] instead, which is explicit and unit-tested rather than
|
||||||
* implicit in which combinations someone remembered to enumerate.
|
* implicit in which combinations someone remembered to enumerate.
|
||||||
*/
|
*/
|
||||||
data class OutputSpec(
|
data class OutputSpec(val container: Container, val videoCodec: VideoCodec, val audioCodec: AudioCodec) {
|
||||||
val container: Container,
|
|
||||||
val videoCodec: VideoCodec,
|
|
||||||
val audioCodec: AudioCodec,
|
|
||||||
) {
|
|
||||||
/** Whether the output keeps a video track — the thing extension and MIME type turn on. */
|
/** Whether the output keeps a video track — the thing extension and MIME type turn on. */
|
||||||
val hasVideo: Boolean get() = videoCodec != VideoCodec.NONE
|
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. */
|
/** True when neither track is re-encoded, i.e. this is a pure container change. */
|
||||||
val isPureRemux: Boolean
|
val isPureRemux: Boolean
|
||||||
get() = videoCodec.isCopyOrAbsent() && audioCodec.isCopyOrAbsent() &&
|
get() = videoCodec.isCopyOrAbsent() &&
|
||||||
|
audioCodec.isCopyOrAbsent() &&
|
||||||
(videoCodec == VideoCodec.COPY || audioCodec == AudioCodec.COPY)
|
(videoCodec == VideoCodec.COPY || audioCodec == AudioCodec.COPY)
|
||||||
|
|
||||||
val extension: String get() = container.extensionFor(hasVideo)
|
val extension: String get() = container.extensionFor(hasVideo)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package org.libremediaconverter.ui.theme
|
package org.libremediaconverter.ui.theme
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import androidx.work.CoroutineWorker
|
|||||||
import androidx.work.Data
|
import androidx.work.Data
|
||||||
import androidx.work.ForegroundInfo
|
import androidx.work.ForegroundInfo
|
||||||
import androidx.work.OneTimeWorkRequestBuilder
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
import androidx.work.WorkInfo
|
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import androidx.work.workDataOf
|
import androidx.work.workDataOf
|
||||||
import org.libremediaconverter.convert.ConversionDependencies
|
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.
|
* concatenated; showing a fabricated percentage would be worse than showing none.
|
||||||
*/
|
*/
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
class ConcatWorker(
|
class ConcatWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
|
||||||
context: Context,
|
|
||||||
params: WorkerParameters,
|
|
||||||
) : CoroutineWorker(context, params) {
|
|
||||||
|
|
||||||
private val notifications = ConversionNotifications(applicationContext)
|
private val notifications = ConversionNotifications(applicationContext)
|
||||||
private val publisher = ConversionDependencies.publisher(applicationContext)
|
private val publisher = ConversionDependencies.publisher(applicationContext)
|
||||||
@@ -43,7 +39,7 @@ class ConcatWorker(
|
|||||||
}
|
}
|
||||||
val totalBytes = inputData.getLong(KEY_TOTAL_BYTES, 0L)
|
val totalBytes = inputData.getLong(KEY_TOTAL_BYTES, 0L)
|
||||||
val format = OutputFormat.valueOf(
|
val format = OutputFormat.valueOf(
|
||||||
inputData.getString(KEY_FORMAT) ?: OutputFormat.MP4_H264.name
|
inputData.getString(KEY_FORMAT) ?: OutputFormat.MP4_H264.name,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!publisher.hasSpaceFor(totalBytes)) {
|
if (!publisher.hasSpaceFor(totalBytes)) {
|
||||||
@@ -55,7 +51,7 @@ class ConcatWorker(
|
|||||||
NOTIFICATION_ID,
|
NOTIFICATION_ID,
|
||||||
notifications.build(id, "Joining ${uris.size} files", 0, indeterminate = true),
|
notifications.build(id, "Joining ${uris.size} files", 0, indeterminate = true),
|
||||||
ConversionForegroundType.current(),
|
ConversionForegroundType.current(),
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
val staged = publisher.createStagingFile("joined.${format.extension}")
|
val staged = publisher.createStagingFile("joined.${format.extension}")
|
||||||
@@ -65,7 +61,7 @@ class ConcatWorker(
|
|||||||
workDataOf(
|
workDataOf(
|
||||||
KEY_OUTPUT_PATH to staged.absolutePath,
|
KEY_OUTPUT_PATH to staged.absolutePath,
|
||||||
KEY_STRATEGY to result.strategy.name,
|
KEY_STRATEGY to result.strategy.name,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
staged.delete()
|
staged.delete()
|
||||||
@@ -99,18 +95,15 @@ class ConcatWorker(
|
|||||||
private const val NOTIFICATION_ID = 1002
|
private const val NOTIFICATION_ID = 1002
|
||||||
private const val TAG = "ConcatWorker"
|
private const val TAG = "ConcatWorker"
|
||||||
|
|
||||||
fun request(
|
fun request(inputs: List<Uri>, totalBytes: Long, format: OutputFormat = OutputFormat.MP4_H264) =
|
||||||
inputs: List<Uri>,
|
OneTimeWorkRequestBuilder<ConcatWorker>()
|
||||||
totalBytes: Long,
|
.setInputData(
|
||||||
format: OutputFormat = OutputFormat.MP4_H264,
|
Data.Builder()
|
||||||
) = OneTimeWorkRequestBuilder<ConcatWorker>()
|
.putStringArray(KEY_INPUT_URIS, inputs.map(Uri::toString).toTypedArray())
|
||||||
.setInputData(
|
.putLong(KEY_TOTAL_BYTES, totalBytes)
|
||||||
Data.Builder()
|
.putString(KEY_FORMAT, format.name)
|
||||||
.putStringArray(KEY_INPUT_URIS, inputs.map(Uri::toString).toTypedArray())
|
.build(),
|
||||||
.putLong(KEY_TOTAL_BYTES, totalBytes)
|
)
|
||||||
.putString(KEY_FORMAT, format.name)
|
.build()
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,15 @@ object ConversionForegroundType {
|
|||||||
* `mediaProcessing` constant does not exist to pass in the first place.
|
* `mediaProcessing` constant does not exist to pass in the first place.
|
||||||
*/
|
*/
|
||||||
fun current(): Int = when {
|
fun current(): Int = when {
|
||||||
Build.VERSION.SDK_INT >= 35 -> ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM ->
|
||||||
Build.VERSION.SDK_INT >= 34 -> ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING
|
||||||
else -> 0
|
|
||||||
|
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)
|
context.getString(R.string.notification_preparing)
|
||||||
} else {
|
} else {
|
||||||
context.getString(R.string.notification_progress, percent)
|
context.getString(R.string.notification_progress, percent)
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
.setSmallIcon(android.R.drawable.stat_sys_download)
|
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
// Progress updates far outpace what the UI can use; alerting once keeps
|
// Progress updates far outpace what the UI can use; alerting once keeps
|
||||||
// the system UI from being hammered.
|
// the system UI from being hammered.
|
||||||
.setOnlyAlertOnce(true)
|
.setOnlyAlertOnce(true)
|
||||||
.setProgress(100, percent, indeterminate)
|
.setProgress(PERCENT_MAX, percent, indeterminate)
|
||||||
.addAction(
|
.addAction(
|
||||||
android.R.drawable.ic_menu_close_clear_cancel,
|
android.R.drawable.ic_menu_close_clear_cancel,
|
||||||
context.getString(R.string.action_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
|
* notification appears only in the Task Manager rather than the shade — so
|
||||||
* progress silently vanishes from the user's point of view.
|
* progress silently vanishes from the user's point of view.
|
||||||
*/
|
*/
|
||||||
fun areEnabled(): Boolean =
|
fun areEnabled(): Boolean = context.getSystemService(NotificationManager::class.java)
|
||||||
context.getSystemService(NotificationManager::class.java)
|
.areNotificationsEnabled()
|
||||||
.areNotificationsEnabled()
|
.also { if (!it) Log.i(TAG, "Notifications disabled; progress will not be visible.") }
|
||||||
.also { if (!it) Log.i(TAG, "Notifications disabled; progress will not be visible.") }
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val CHANNEL_ID = "conversions"
|
const val CHANNEL_ID = "conversions"
|
||||||
private const val TAG = "ConversionNotifications"
|
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.Data
|
||||||
import androidx.work.ForegroundInfo
|
import androidx.work.ForegroundInfo
|
||||||
import androidx.work.OneTimeWorkRequestBuilder
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
import androidx.work.WorkInfo
|
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import androidx.work.workDataOf
|
import androidx.work.workDataOf
|
||||||
import com.arthenica.ffmpegkit.FFmpegKitConfig
|
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.
|
* with a short quota, which is the wrong shape for a multi-minute transcode.
|
||||||
*/
|
*/
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
class ConversionWorker(
|
class ConversionWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
|
||||||
context: Context,
|
|
||||||
params: WorkerParameters,
|
|
||||||
) : CoroutineWorker(context, params) {
|
|
||||||
|
|
||||||
private val notifications = ConversionNotifications(applicationContext)
|
private val notifications = ConversionNotifications(applicationContext)
|
||||||
|
|
||||||
// Resolved through ConversionDependencies so tests can force the failure paths.
|
// Resolved through ConversionDependencies so tests can force the failure paths.
|
||||||
private val publisher = ConversionDependencies.publisher(applicationContext)
|
private val publisher = ConversionDependencies.publisher(applicationContext)
|
||||||
|
|
||||||
@@ -56,10 +53,10 @@ class ConversionWorker(
|
|||||||
val sizeBytes = inputData.getLong(KEY_SIZE_BYTES, 0L)
|
val sizeBytes = inputData.getLong(KEY_SIZE_BYTES, 0L)
|
||||||
val spec = readSpec()
|
val spec = readSpec()
|
||||||
val quality = QualityTier.valueOf(
|
val quality = QualityTier.valueOf(
|
||||||
inputData.getString(KEY_QUALITY) ?: QualityTier.FAST.name
|
inputData.getString(KEY_QUALITY) ?: QualityTier.FAST.name,
|
||||||
)
|
)
|
||||||
val preference = EnginePreference.valueOf(
|
val preference = EnginePreference.valueOf(
|
||||||
inputData.getString(KEY_ENGINE_PREFERENCE) ?: EnginePreference.AUTO.name
|
inputData.getString(KEY_ENGINE_PREFERENCE) ?: EnginePreference.AUTO.name,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!publisher.hasSpaceFor(sizeBytes)) {
|
if (!publisher.hasSpaceFor(sizeBytes)) {
|
||||||
@@ -102,7 +99,7 @@ class ConversionWorker(
|
|||||||
KEY_OUTPUT_PATH to staged.absolutePath,
|
KEY_OUTPUT_PATH to staged.absolutePath,
|
||||||
KEY_ENGINE_USED to decision.engine.name,
|
KEY_ENGINE_USED to decision.engine.name,
|
||||||
KEY_ROUTE_REASON to decision.reason.explanation,
|
KEY_ROUTE_REASON to decision.reason.explanation,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
staged.delete()
|
staged.delete()
|
||||||
@@ -142,12 +139,7 @@ class ConversionWorker(
|
|||||||
runFFmpeg(request, inputUri, staged, displayName)
|
runFFmpeg(request, inputUri, staged, displayName)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun runFFmpeg(
|
private suspend fun runFFmpeg(request: ConversionRequest, inputUri: Uri, staged: File, displayName: String) {
|
||||||
request: ConversionRequest,
|
|
||||||
inputUri: Uri,
|
|
||||||
staged: File,
|
|
||||||
displayName: String,
|
|
||||||
) {
|
|
||||||
// FFmpeg needs a path. ffkitsaf bridges a content:// URI for reading; the read
|
// 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
|
// side is seekable for local providers, which is all the demuxer needs. Output
|
||||||
// still goes to a real cache path — see OutputPublisher.
|
// still goes to a real cache path — see OutputPublisher.
|
||||||
@@ -177,8 +169,7 @@ class ConversionWorker(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isCancellation(e: Throwable): Boolean =
|
private fun isCancellation(e: Throwable): Boolean = e is kotlinx.coroutines.CancellationException || isStopped
|
||||||
e is kotlinx.coroutines.CancellationException || isStopped
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Distinguishes a genuine failure from the foreground-service budget expiring.
|
* 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,
|
* later rather than tell the user the conversion failed — the work is still valid,
|
||||||
* there is simply no budget right now.
|
* there is simply no budget right now.
|
||||||
*/
|
*/
|
||||||
private fun handleTimeoutIfNeeded(cause: Throwable): Result =
|
private fun handleTimeoutIfNeeded(cause: Throwable): Result = when (FailureOutcome.forStopReason(stopReason)) {
|
||||||
when (FailureOutcome.forStopReason(stopReason)) {
|
FailureOutcome.RETRY -> {
|
||||||
FailureOutcome.RETRY -> {
|
Log.w(TAG, "Foreground service budget exhausted; will retry.", cause)
|
||||||
Log.w(TAG, "Foreground service budget exhausted; will retry.", cause)
|
Result.retry()
|
||||||
Result.retry()
|
|
||||||
}
|
|
||||||
FailureOutcome.FAIL -> {
|
|
||||||
Log.e(TAG, "Conversion failed.", cause)
|
|
||||||
Result.failure(workDataOf(KEY_ERROR to (cause.message ?: "Conversion failed.")))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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.
|
* Reads the output spec out of the worker's input Data.
|
||||||
@@ -223,19 +213,17 @@ class ConversionWorker(
|
|||||||
return OutputSpec(container, video, audio)
|
return OutputSpec(container, video, audio)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getForegroundInfo(): ForegroundInfo =
|
override suspend fun getForegroundInfo(): ForegroundInfo = foregroundInfo(
|
||||||
foregroundInfo(
|
inputData.getString(KEY_DISPLAY_NAME) ?: "input",
|
||||||
inputData.getString(KEY_DISPLAY_NAME) ?: "input",
|
percent = 0,
|
||||||
percent = 0,
|
indeterminate = true,
|
||||||
indeterminate = true,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
private fun foregroundInfo(title: String, percent: Int, indeterminate: Boolean) =
|
private fun foregroundInfo(title: String, percent: Int, indeterminate: Boolean) = ForegroundInfo(
|
||||||
ForegroundInfo(
|
NOTIFICATION_ID,
|
||||||
NOTIFICATION_ID,
|
notifications.build(id, title, percent, indeterminate),
|
||||||
notifications.build(id, title, percent, indeterminate),
|
ConversionForegroundType.current(),
|
||||||
ConversionForegroundType.current(),
|
)
|
||||||
)
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val KEY_INPUT_URI = "input_uri"
|
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
|
* yields `.mkv` or `.mka` and MP4 yields `.mp4` or `.m4a` without a preset having to
|
||||||
* enumerate both.
|
* enumerate both.
|
||||||
*/
|
*/
|
||||||
fun outputNameFor(inputName: String, spec: OutputSpec): String =
|
fun outputNameFor(inputName: String, spec: OutputSpec): String = inputName.substringBeforeLast('.', inputName) +
|
||||||
inputName.substringBeforeLast('.', inputName) +
|
"_converted.${spec.extension}"
|
||||||
"_converted.${spec.extension}"
|
|
||||||
|
|
||||||
fun request(
|
fun request(
|
||||||
inputUri: Uri,
|
inputUri: Uri,
|
||||||
@@ -285,7 +272,7 @@ class ConversionWorker(
|
|||||||
.putString(KEY_AUDIO_CODEC, spec.audioCodec.name)
|
.putString(KEY_AUDIO_CODEC, spec.audioCodec.name)
|
||||||
.putString(KEY_QUALITY, quality.name)
|
.putString(KEY_QUALITY, quality.name)
|
||||||
.putString(KEY_ENGINE_PREFERENCE, enginePreference.name)
|
.putString(KEY_ENGINE_PREFERENCE, enginePreference.name)
|
||||||
.build()
|
.build(),
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ enum class FailureOutcome {
|
|||||||
RETRY,
|
RETRY,
|
||||||
|
|
||||||
/** A genuine failure; report it to the user. */
|
/** A genuine failure; report it to the user. */
|
||||||
FAIL;
|
FAIL,
|
||||||
|
|
||||||
|
;
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun forStopReason(stopReason: Int): FailureOutcome =
|
fun forStopReason(stopReason: Int): FailureOutcome =
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
package org.libremediaconverter.ffmpeg
|
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.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
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 {
|
class FFmpegCommandBuilderTest {
|
||||||
|
|
||||||
@@ -94,8 +94,10 @@ class FFmpegCommandBuilderTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `every video encode path forces yuv420p`() {
|
fun `every video encode path forces yuv420p`() {
|
||||||
val videoFormats = listOf(
|
val videoFormats = listOf(
|
||||||
OutputFormat.MP4_H264, OutputFormat.MP4_H265,
|
OutputFormat.MP4_H264,
|
||||||
OutputFormat.MKV_H264, OutputFormat.MKV_H265,
|
OutputFormat.MP4_H265,
|
||||||
|
OutputFormat.MKV_H264,
|
||||||
|
OutputFormat.MKV_H265,
|
||||||
OutputFormat.WEBM_VP9,
|
OutputFormat.WEBM_VP9,
|
||||||
)
|
)
|
||||||
videoFormats.forEach { format ->
|
videoFormats.forEach { format ->
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
package org.libremediaconverter.ffmpeg
|
package org.libremediaconverter.ffmpeg
|
||||||
|
|
||||||
import org.libremediaconverter.model.ConcatStrategy
|
|
||||||
import org.libremediaconverter.model.OutputFormat
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import org.libremediaconverter.model.ConcatStrategy
|
||||||
|
import org.libremediaconverter.model.OutputFormat
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
class FFmpegConcatCommandTest {
|
class FFmpegConcatCommandTest {
|
||||||
@@ -31,7 +31,11 @@ class FFmpegConcatCommandTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `stream copy uses the concat demuxer and copies codecs`() {
|
fun `stream copy uses the concat demuxer and copies codecs`() {
|
||||||
val args = FFmpegConcatCommand.build(
|
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"))
|
assertTrue(args.contains("concat"))
|
||||||
assertEquals("copy", args[args.indexOf("-c") + 1])
|
assertEquals("copy", args[args.indexOf("-c") + 1])
|
||||||
@@ -42,7 +46,11 @@ class FFmpegConcatCommandTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `stream copy allows absolute paths in the list file`() {
|
fun `stream copy allows absolute paths in the list file`() {
|
||||||
val args = FFmpegConcatCommand.build(
|
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.
|
// Without -safe 0 the demuxer rejects the absolute paths we generate.
|
||||||
assertEquals("0", args[args.indexOf("-safe") + 1])
|
assertEquals("0", args[args.indexOf("-safe") + 1])
|
||||||
@@ -51,7 +59,11 @@ class FFmpegConcatCommandTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `re-encode passes every input separately and builds a filter graph`() {
|
fun `re-encode passes every input separately and builds a filter graph`() {
|
||||||
val args = FFmpegConcatCommand.build(
|
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" })
|
assertEquals(2, args.count { it == "-i" })
|
||||||
val filter = args[args.indexOf("-filter_complex") + 1]
|
val filter = args[args.indexOf("-filter_complex") + 1]
|
||||||
@@ -64,7 +76,11 @@ class FFmpegConcatCommandTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `re-encode maps the filter outputs rather than raw streams`() {
|
fun `re-encode maps the filter outputs rather than raw streams`() {
|
||||||
val args = FFmpegConcatCommand.build(
|
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("[v]"))
|
||||||
assertTrue(args.contains("[a]"))
|
assertTrue(args.contains("[a]"))
|
||||||
@@ -81,7 +97,11 @@ class FFmpegConcatCommandTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `mkv output does not get faststart`() {
|
fun `mkv output does not get faststart`() {
|
||||||
val args = FFmpegConcatCommand.build(
|
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"))
|
assertFalse(args.contains("+faststart"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package org.libremediaconverter.model
|
package org.libremediaconverter.model
|
||||||
|
|
||||||
import org.libremediaconverter.model.ConversionRouter.Reason
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import org.libremediaconverter.model.ConversionRouter.Reason
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One test per routing predicate.
|
* One test per routing predicate.
|
||||||
@@ -291,8 +291,12 @@ class ConversionRouterTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `the new containers are all FFmpeg-only`() {
|
fun `the new containers are all FFmpeg-only`() {
|
||||||
listOf(
|
listOf(
|
||||||
Container.MOV, Container.MKV, Container.MPEG_TS,
|
Container.MOV,
|
||||||
Container.AVI, Container.FLV, Container.ASF,
|
Container.MKV,
|
||||||
|
Container.MPEG_TS,
|
||||||
|
Container.AVI,
|
||||||
|
Container.FLV,
|
||||||
|
Container.ASF,
|
||||||
).forEach { container ->
|
).forEach { container ->
|
||||||
val d = route(
|
val d = route(
|
||||||
OutputSpec(container, VideoCodec.COPY, AudioCodec.COPY),
|
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.
|
// 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
|
#This file is generated by updateDaemonJvm
|
||||||
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/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/fa1e318c287360478e3c83a9a3ef1007/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/cf726b4a1c84b50457225f9bba6d7650/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/fa1e318c287360478e3c83a9a3ef1007/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/c2dd35c9d0aaf0ba6ad0791320f99dfc/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/e5810bd7fd1f8a586644409d395a7e55/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/cf726b4a1c84b50457225f9bba6d7650/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/fa1e318c287360478e3c83a9a3ef1007/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/7b3c4877c0749019e6805bb61e421497/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/d76df094a9cbbabd3b08251f9e61444a/redirect
|
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/aa99913fa6d767d899ffc96082da01ca/redirect
|
||||||
toolchainVersion=25
|
toolchainVersion=25
|
||||||
|
|||||||
+70
-23
@@ -1,31 +1,72 @@
|
|||||||
[versions]
|
[versions]
|
||||||
# Build tooling.
|
# Build tooling. Pinned, never floating. agp and kotlin are coupled (below); ksp is
|
||||||
# NOTE: AGP 9 has BUILT-IN Kotlin support. Applying org.jetbrains.kotlin.android
|
# staged for a later phase and unapplied today -- KSP2 versions independently of Kotlin.
|
||||||
# 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.
|
# 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"
|
agp = "9.3.1"
|
||||||
kotlin = "2.2.10"
|
kotlin = "2.4.10"
|
||||||
ksp = "2.3.11"
|
ksp = "2.3.11"
|
||||||
|
|
||||||
# AndroidX / Compose
|
# AndroidX / Compose -- floating on minor + patch. The prerelease guard in
|
||||||
composeBom = "2026.08.00"
|
# app/build.gradle.kts is what keeps `+` from selecting an alpha: several of these
|
||||||
coreKtx = "1.19.0"
|
# (lifecycle, navigation, work, datastore, annotation) publish alphas and RCs with
|
||||||
activityCompose = "1.13.0"
|
# version numbers ABOVE their newest stable, and Gradle's `+` would take them.
|
||||||
lifecycle = "2.11.0"
|
# Bare `+`, not "2026.+": the year is the major in this scheme (YYYY.MM.PP), so a
|
||||||
navigation = "2.9.8"
|
# 2026-prefixed float would quietly stop finding releases on 1 January and keep
|
||||||
work = "2.11.2"
|
# building green against a frozen BOM.
|
||||||
datastore = "1.2.1"
|
composeBom = "+"
|
||||||
media3 = "1.11.0"
|
coreKtx = "1.+"
|
||||||
room = "2.8.4"
|
activityCompose = "1.+"
|
||||||
documentfile = "1.1.0"
|
lifecycle = "2.+"
|
||||||
annotation = "1.10.0"
|
navigation = "2.+"
|
||||||
|
work = "2.+"
|
||||||
|
datastore = "1.+"
|
||||||
|
media3 = "1.+"
|
||||||
|
room = "2.+"
|
||||||
|
documentfile = "1.+"
|
||||||
|
annotation = "1.+"
|
||||||
|
|
||||||
# Test
|
# Test -- floating, same rules as above.
|
||||||
junit = "4.13.2"
|
junit = "4.+"
|
||||||
androidxJunit = "1.3.0"
|
androidxJunit = "1.+"
|
||||||
espressoCore = "3.7.0"
|
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"
|
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]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
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" }
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
# com.android.application and org.jetbrains.kotlin.plugin.compose are deliberately absent.
|
||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
# 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" }
|
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.
|
# 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
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionSha256Sum=553c78f50dafcd54d65b9a444649057857469edf836431389695608536d6b746
|
distributionSha256Sum=acd53f1edaf02f1a8ff99879f8a34b302661a057d9b063ae9e35b552f804d20a
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
|
retries=0
|
||||||
|
retryBackOffMs=500
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
# Darwin, MinGW, and NonStop.
|
# Darwin, MinGW, and NonStop.
|
||||||
#
|
#
|
||||||
# (3) This script is generated from the Groovy template
|
# (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.
|
# within the Gradle project.
|
||||||
#
|
#
|
||||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
@@ -114,7 +114,6 @@ case "$( uname )" in #(
|
|||||||
NONSTOP* ) nonstop=true ;;
|
NONSTOP* ) nonstop=true ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
CLASSPATH="\\\"\\\""
|
|
||||||
|
|
||||||
|
|
||||||
# Determine the Java command to use to start the JVM.
|
# 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
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
if "$cygwin" || "$msys" ; then
|
if "$cygwin" || "$msys" ; then
|
||||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
|
||||||
|
|
||||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
@@ -212,7 +210,6 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
|||||||
|
|
||||||
set -- \
|
set -- \
|
||||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
-classpath "$CLASSPATH" \
|
|
||||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
"$@"
|
"$@"
|
||||||
|
|
||||||
|
|||||||
Vendored
+82
-94
@@ -1,94 +1,82 @@
|
|||||||
@rem
|
@rem
|
||||||
@rem Copyright 2015 the original author or authors.
|
@rem Copyright 2015 the original author or authors.
|
||||||
@rem
|
@rem
|
||||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
@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 not use this file except in compliance with the License.
|
||||||
@rem You may obtain a copy of the License at
|
@rem You may obtain a copy of the License at
|
||||||
@rem
|
@rem
|
||||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
@rem
|
@rem
|
||||||
@rem Unless required by applicable law or agreed to in writing, software
|
@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 distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
@rem See the License for the specific language governing permissions and
|
@rem See the License for the specific language governing permissions and
|
||||||
@rem limitations under the License.
|
@rem limitations under the License.
|
||||||
@rem
|
@rem
|
||||||
@rem SPDX-License-Identifier: Apache-2.0
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
@rem
|
@rem
|
||||||
|
|
||||||
@if "%DEBUG%"=="" @echo off
|
@if "%DEBUG%"=="" @echo off
|
||||||
@rem ##########################################################################
|
@rem ##########################################################################
|
||||||
@rem
|
@rem
|
||||||
@rem Gradle startup script for Windows
|
@rem Gradle startup script for Windows
|
||||||
@rem
|
@rem
|
||||||
@rem ##########################################################################
|
@rem ##########################################################################
|
||||||
|
|
||||||
@rem Set local scope for the variables with windows NT shell
|
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||||
if "%OS%"=="Windows_NT" setlocal
|
setlocal EnableExtensions
|
||||||
|
|
||||||
set DIRNAME=%~dp0
|
set DIRNAME=%~dp0
|
||||||
if "%DIRNAME%"=="" set DIRNAME=.
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
@rem This is normally unused
|
@rem This is normally unused
|
||||||
set APP_BASE_NAME=%~n0
|
set APP_BASE_NAME=%~n0
|
||||||
set APP_HOME=%DIRNAME%
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
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.
|
@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"
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
@rem Find java.exe
|
@rem Find java.exe
|
||||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
set JAVA_EXE=java.exe
|
set JAVA_EXE=java.exe
|
||||||
%JAVA_EXE% -version >NUL 2>&1
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
if %ERRORLEVEL% equ 0 goto execute
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
echo. 1>&2
|
echo. 1>&2
|
||||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 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. 1>&2
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the 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
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
goto fail
|
"%COMSPEC%" /c exit 1
|
||||||
|
|
||||||
:findJavaFromJavaHome
|
:findJavaFromJavaHome
|
||||||
set JAVA_HOME=%JAVA_HOME:"=%
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
if exist "%JAVA_EXE%" goto execute
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
echo. 1>&2
|
echo. 1>&2
|
||||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
echo. 1>&2
|
echo. 1>&2
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the 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
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
goto fail
|
"%COMSPEC%" /c exit 1
|
||||||
|
|
||||||
:execute
|
:execute
|
||||||
@rem Setup the command line
|
@rem Setup the command line
|
||||||
|
|
||||||
set CLASSPATH=
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
@rem Execute Gradle
|
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||||
"%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" %*
|
@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
|
||||||
:end
|
|
||||||
@rem End local scope for the variables with windows NT shell
|
:exitWithErrorLevel
|
||||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||||
|
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||||
: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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user