From b5a7ffb7bd572c50c75053d4638118d6b6ab4847 Mon Sep 17 00:00:00 2001 From: Siarhei Bakatsiuk Date: Fri, 24 Jul 2026 09:53:56 +0300 Subject: [PATCH 1/6] Verify native downloads against committed SHA-256 baselines FetchJars.sh previously trusted TLS alone: no digest check on ~217 MB of native code that gets linked into consumers' apps, even though the iOS and Mac repos treat a checksum mismatch as fatal. Every download is now verified against build/checksums/.sha256 before it replaces anything, and --verify re-checks files already on disk (CI runs it on cache hits, so a stale or poisoned cache fails the build instead of getting packed). Baselines are recorded once per upstream line by build/update-checksums.sh, preferring Maven Central's .sha256 sidecars over locally computed hashes; all three published lines (6.0.3, 7.1.6, 8.1.7) are recorded, and the 8.1.7 digests match the .aars the green e2e runs used. Also folded in: - smart-exception version now lives once in Directory.Build.props (SmartExceptionVersion); the csproj and FetchJars.sh both read it, closing the one spot in the pipeline where versions could silently drift. - curl --retry 3 on all fetches, so a transient blip no longer aborts the run. - NativeVersionMapTests no longer hardcodes the mapping row count (adding a line used to fail CI until the test was edited); instead a new test requires every native-versions.tsv row to have a complete checksum baseline, which is the invariant that actually matters. Co-Authored-By: Claude Fable 5 --- .github/workflows/build.yml | 5 + Directory.Build.props | 7 + README.md | 4 +- build/checksums/6.0.3.sha256 | 10 ++ build/checksums/7.1.6.sha256 | 10 ++ build/checksums/8.1.7.sha256 | 10 ++ build/update-checksums.sh | 104 +++++++++++++++ .../FFmpegKit.Android.csproj | 4 +- src/FFmpegKit.Android/Jars/FetchJars.sh | 126 +++++++++++++++--- .../NativeVersionMapTests.cs | 38 +++++- 10 files changed, 295 insertions(+), 23 deletions(-) create mode 100644 build/checksums/6.0.3.sha256 create mode 100644 build/checksums/7.1.6.sha256 create mode 100644 build/checksums/8.1.7.sha256 create mode 100755 build/update-checksums.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 01cfb59..79d74e4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -106,6 +106,11 @@ jobs: if: steps.jars.outputs.cache-hit != 'true' run: ./src/FFmpegKit.Android/Jars/FetchJars.sh "${{ steps.native.outputs.version }}" + # Runs on cache hits too: the fetch already verifies fresh downloads, but a restored + # cache is only as trustworthy as its last write. Seconds of hashing per build. + - name: Verify FFmpegKit binaries + run: ./src/FFmpegKit.Android/Jars/FetchJars.sh --verify "${{ steps.native.outputs.version }}" + - name: Pack all variants run: ./src/FFmpegKit.Android/BuildNugets.sh "${{ inputs.version }}" "${{ steps.native.outputs.version }}" diff --git a/Directory.Build.props b/Directory.Build.props index 3d5683a..bd0a72f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -16,6 +16,13 @@ 8.1.7 $(FFmpegVersion) + + 0.2.1 + Video diff --git a/README.md b/README.md index 31fac9f..09583d4 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ So the `.aar` files come from the community fork, via Maven Central — see [`Fe The version is set by `FFmpegKitNativeVersion` in [`Directory.Build.props`](Directory.Build.props), which `FetchJars.sh` reads, so the download and the `.aar` the project expects cannot drift apart. +Every download is verified against a SHA-256 baseline committed at [`build/checksums/`](build/checksums) — one file per FFmpegKit line, recorded once with [`build/update-checksums.sh`](build/update-checksums.sh) (from Maven Central's own `.sha256` sidecars where published) and enforced on every fetch, locally and in CI. A corrupted or substituted artifact fails the fetch instead of ending up inside a shipped package. + The fork currently publishes three lines, each with all eight variants: | FFmpegKit | Bundled FFmpeg | ABIs | minSdk | @@ -239,7 +241,7 @@ Python 3 is also needed, for the package merge step. ./src/FFmpegKit.Android/BuildNugets.sh 8.1.2.4-rc.1 # ...or with an explicit package version ``` -`FetchJars.sh` reads the FFmpegKit version from `FFmpegKitNativeVersion` in `Directory.Build.props`, the same property the `.csproj` uses to pick the `.aar`, so the two cannot drift apart. Pass a version to override it (`./FetchJars.sh 8.2.0`) when trying a newer upstream build before committing to it. +`FetchJars.sh` reads the FFmpegKit version from `FFmpegKitNativeVersion` in `Directory.Build.props`, the same property the `.csproj` uses to pick the `.aar`, so the two cannot drift apart. Pass a version to override it (`./FetchJars.sh 8.2.0`) when trying a newer upstream build before committing to it — record its checksum baseline first with `./build/update-checksums.sh 8.2.0`, since the fetch refuses to run against a line that has no committed baseline. `./FetchJars.sh --verify` re-checks the files already on disk without downloading anything. ### A single variant diff --git a/build/checksums/6.0.3.sha256 b/build/checksums/6.0.3.sha256 new file mode 100644 index 0000000..fe0648b --- /dev/null +++ b/build/checksums/6.0.3.sha256 @@ -0,0 +1,10 @@ +c6df4ed69ab9e4717fc487afe86223033cb56ec81e492e45c9258a7e647f6426 ffmpeg-kit-audio-6.0.3.aar +21240a3e9c4004fba6fa71adcab0da8fd70be1e2407ff1127dbea30c745ddd7d ffmpeg-kit-full-6.0.3.aar +80b069ac31afe428f50f8f6332aef88ced20afc4d03daf78582e62a327ab8e7b ffmpeg-kit-full-gpl-6.0.3.aar +c77da985b4ae175fefbd6ea3fd2a7048081f0e4859b9649e9707d34b32ae28f9 ffmpeg-kit-https-6.0.3.aar +3022679a5911860343bd2070203660bad30c56ab5ab670ce72e7cb44cd3fd0c6 ffmpeg-kit-https-gpl-6.0.3.aar +0cd6912de6ab35b958847bfd6ffa0b214ec1d11cd5020e4244e058f6761c8c55 ffmpeg-kit-min-6.0.3.aar +f23d29e3f70eb1d59721ba16e57c01d9bc50a45f19e16a027a2a719a62cde0fc ffmpeg-kit-min-gpl-6.0.3.aar +9522f17d5323e9f6333c85b3717e6777d1f9c205c29932c19ecc62bc3d3e16d0 ffmpeg-kit-video-6.0.3.aar +1cad0fb4dfa01755a014331b5ed199281d2c3fab5aca5c9d7abd0b41d0ec3f7b smart-exception-common-0.2.1.jar +5b96aaa5f191dedbef72fb0c38f1a2b01807920afc0d92a75a2acd6e0cc7703c smart-exception-java-0.2.1.jar diff --git a/build/checksums/7.1.6.sha256 b/build/checksums/7.1.6.sha256 new file mode 100644 index 0000000..f90f4a5 --- /dev/null +++ b/build/checksums/7.1.6.sha256 @@ -0,0 +1,10 @@ +870d2c110b05a280bf28c430e92650fa82e00bb77de5d5a85bc7e94c9d1ebad6 ffmpeg-kit-audio-7.1.6.aar +149fe79f8009d0b849818ccf370d9c4637a1198bbef4d5b2e63204dab5e2401d ffmpeg-kit-full-7.1.6.aar +0aa3e5f568274950a5524f336d9c141c539bc07a296ba5ec7fd42f098cd3a244 ffmpeg-kit-full-gpl-7.1.6.aar +e80e5ee52248a3dcb1dda0524dd784fe4bbb212dcd4d575e82e15d21a002d860 ffmpeg-kit-https-7.1.6.aar +afa1e52f817cc8d555f9ec7d6f4611347bcf686027b7a4f0325613759c8f4f92 ffmpeg-kit-https-gpl-7.1.6.aar +f6ce02f57dadd8e6ccc407f0ed0c916567714c0db8b170eda33d9b6654ac14e1 ffmpeg-kit-min-7.1.6.aar +fcf7fb115c7c930a907cc173441a445e0bae025e914579002ee9cc0245b3b773 ffmpeg-kit-min-gpl-7.1.6.aar +c2e53d6c33e195a663152b4f135a3bae96d6bf0232ef62a532eadb827f9dc803 ffmpeg-kit-video-7.1.6.aar +1cad0fb4dfa01755a014331b5ed199281d2c3fab5aca5c9d7abd0b41d0ec3f7b smart-exception-common-0.2.1.jar +5b96aaa5f191dedbef72fb0c38f1a2b01807920afc0d92a75a2acd6e0cc7703c smart-exception-java-0.2.1.jar diff --git a/build/checksums/8.1.7.sha256 b/build/checksums/8.1.7.sha256 new file mode 100644 index 0000000..4366356 --- /dev/null +++ b/build/checksums/8.1.7.sha256 @@ -0,0 +1,10 @@ +9b277de25934594635a495e4fda164b902309219a2f538827f5ecc7ed77229d6 ffmpeg-kit-audio-8.1.7.aar +c3cbc81d498175fd2aa69ee2dfe7dafbf519052a96283c2568fe5b3b16618456 ffmpeg-kit-full-8.1.7.aar +374d3734755fd4a4f2241da23b93ab0b3bcae9bd39f01cf3bf29a5731914658b ffmpeg-kit-full-gpl-8.1.7.aar +07d321247016c6a46cc81ad2c1cfb58c2503d575e6d3daebe310ff7efa538630 ffmpeg-kit-https-8.1.7.aar +48425b8deb0b885fe2f6bfdb4fc027029e072789d181935d60b538d03b5fa1b2 ffmpeg-kit-https-gpl-8.1.7.aar +591ddcf3698028e838b854dc775e60aedaf43fffd59689cb59b8af632aba3d76 ffmpeg-kit-min-8.1.7.aar +3698fe62678cbc6c7d3c2b124ce28f4f064d0f18e1ecb414a513ed9909eee73e ffmpeg-kit-min-gpl-8.1.7.aar +d37050bc77f3a2dac30c27a0ce464d499cabbb71634fb71704aa447f39db9309 ffmpeg-kit-video-8.1.7.aar +1cad0fb4dfa01755a014331b5ed199281d2c3fab5aca5c9d7abd0b41d0ec3f7b smart-exception-common-0.2.1.jar +5b96aaa5f191dedbef72fb0c38f1a2b01807920afc0d92a75a2acd6e0cc7703c smart-exception-java-0.2.1.jar diff --git a/build/update-checksums.sh b/build/update-checksums.sh new file mode 100755 index 0000000..0dbc561 --- /dev/null +++ b/build/update-checksums.sh @@ -0,0 +1,104 @@ +#!/bin/sh + +set -e + +# Records the SHA-256 baseline that Jars/FetchJars.sh verifies every download against, +# one file per FFmpegKit line: build/checksums/.sha256. +# +# For the .aar files the digest is taken from Maven Central's .sha256 sidecar when one is +# published, so the recorded value comes from repository metadata rather than whatever this +# machine happened to download. When no sidecar exists the artifact itself is downloaded and +# hashed - trust-on-first-use, which still pins every later fetch to today's bytes. The +# smart-exception jars come from GitHub releases, which publish no digests, so they are +# always download-and-hash. +# +# Run this once when adopting a new upstream line (a new native-versions.tsv row), review the +# diff, and commit the result. It is deliberately not run by CI: the whole point is that CI +# verifies against a baseline a human recorded. +# +# Usage: +# build/update-checksums.sh # version from Directory.Build.props +# build/update-checksums.sh 7.1.6 # an older line, e.g. when adding a native-versions.tsv row + +cd "$(dirname "$0")/.." + +PROPS="Directory.Build.props" +MAVEN_BASE="https://repo1.maven.org/maven2/dev/ffmpegkit-maintained" + +read_property() { + sed -n "s:.*<$1>\(.*\).*:\1:p" "$PROPS" | head -1 +} + +require_version() { + if [ -z "$2" ]; then + echo "error: could not read $1 from $PROPS" >&2 + exit 1 + fi + case "$2" in + *[!A-Za-z0-9._-]*) + echo "error: invalid $1 '$2'" >&2 + exit 1 + ;; + esac +} + +FFMPEG_KIT_VERSION="$1" +if [ -z "$FFMPEG_KIT_VERSION" ]; then + FFMPEG_KIT_VERSION=$(read_property FFmpegKitNativeVersion) +fi +require_version FFmpegKitNativeVersion "$FFMPEG_KIT_VERSION" + +SMART_EXCEPTION_VERSION=$(read_property SmartExceptionVersion) +require_version SmartExceptionVersion "$SMART_EXCEPTION_VERSION" + +OUT="build/checksums/$FFMPEG_KIT_VERSION.sha256" +mkdir -p build/checksums + +STAGING=$(mktemp -d) +trap 'rm -rf "$STAGING"' EXIT + +sha256_of() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + sha256sum "$1" | awk '{print $1}' + fi +} + +# $1 = url, $2 = file name. Prints the digest. +digest_of() { + # A sidecar is a few dozen bytes; only fall back to the full artifact when it is absent + # or malformed. + sidecar=$(curl -fsL --retry 3 --retry-delay 2 "$1.sha256" 2>/dev/null | awk '{print $1}' | tr '[:upper:]' '[:lower:]') || sidecar="" + case "$sidecar" in + *[!0-9a-f]* | "") ;; + *) + if [ ${#sidecar} -eq 64 ]; then + echo "$sidecar" + return + fi + ;; + esac + + curl -fL --retry 3 --retry-delay 2 -o "$STAGING/$2" "$1" >&2 + sha256_of "$STAGING/$2" +} + +: > "$STAGING/baseline" + +for artifact in common java; do + name="smart-exception-$artifact-$SMART_EXCEPTION_VERSION.jar" + url="https://github.com/tanersener/smart-exception/releases/download/v$SMART_EXCEPTION_VERSION/$name" + printf '%s %s\n' "$(digest_of "$url" "$name")" "$name" >> "$STAGING/baseline" + echo "==> $name" +done + +for variant in audio full full-gpl https https-gpl min min-gpl video; do + name="ffmpeg-kit-$variant-$FFMPEG_KIT_VERSION.aar" + url="$MAVEN_BASE/ffmpeg-kit-$variant/$FFMPEG_KIT_VERSION/$name" + printf '%s %s\n' "$(digest_of "$url" "$name")" "$name" >> "$STAGING/baseline" + echo "==> $name" +done + +sort -k2 "$STAGING/baseline" > "$OUT" +echo "==> wrote $OUT" diff --git a/src/FFmpegKit.Android/FFmpegKit.Android.csproj b/src/FFmpegKit.Android/FFmpegKit.Android.csproj index 92b43ad..9a8828f 100644 --- a/src/FFmpegKit.Android/FFmpegKit.Android.csproj +++ b/src/FFmpegKit.Android/FFmpegKit.Android.csproj @@ -92,8 +92,8 @@ embedded here or every FFmpeg call throws NoClassDefFoundError on device. Bind="false": consumers only need the classes at runtime, not a C# binding for them. --> - - + + diff --git a/src/FFmpegKit.Android/Jars/FetchJars.sh b/src/FFmpegKit.Android/Jars/FetchJars.sh index 5b4fd74..b1e25e6 100755 --- a/src/FFmpegKit.Android/Jars/FetchJars.sh +++ b/src/FFmpegKit.Android/Jars/FetchJars.sh @@ -9,43 +9,127 @@ set -e # (no prebuilt binaries since v6.1.0). The .aar files below instead come from the # community-maintained Maven Central mirror at dev.ffmpegkit-maintained. # +# Every downloaded file is verified against the SHA-256 baseline committed at +# build/checksums/.sha256. These are tens of megabytes of native code +# that get linked into consumers' apps: a corrupted or substituted artifact must fail +# here, not at packaging time or - worse - silently on devices. Baselines are recorded +# by build/update-checksums.sh when a new upstream line is adopted. +# # Usage: -# ./FetchJars.sh # version from Directory.Build.props -# ./FetchJars.sh 8.2.0 # override, e.g. to try a newer upstream build before committing to it +# ./FetchJars.sh # version from Directory.Build.props +# ./FetchJars.sh 8.2.0 # override, e.g. to try a newer upstream build +# ./FetchJars.sh --verify # no downloads: check the files already present here +# ./FetchJars.sh --verify 8.2.0 cd "$(dirname "$0")" ROOT="$(cd ../../.. && pwd)" PROPS="$ROOT/Directory.Build.props" MAVEN_BASE="https://repo1.maven.org/maven2/dev/ffmpegkit-maintained" -SMART_EXCEPTION_VERSION="0.2.1" + +VERIFY_ONLY=0 +FFMPEG_KIT_VERSION="" +for arg in "$@"; do + case "$arg" in + --verify) VERIFY_ONLY=1 ;; + -*) echo "error: unknown option '$arg'" >&2; exit 1 ;; + *) FFMPEG_KIT_VERSION="$arg" ;; + esac +done + +read_property() { + sed -n "s:.*<$1>\(.*\).*:\1:p" "$PROPS" | head -1 +} + +# Versions are interpolated into URLs and file names, so reject anything exotic up front. +require_version() { + if [ -z "$2" ]; then + echo "error: could not read $1 from $PROPS" >&2 + exit 1 + fi + case "$2" in + *[!A-Za-z0-9._-]*) + echo "error: invalid $1 '$2'" >&2 + exit 1 + ;; + esac +} # Read the version from Directory.Build.props rather than repeating it here: the .aar file names # are baked into the .csproj via FFmpegKitNativeVersion, so a second copy that drifts out of sync # fails the build with a confusing "file not found" on an .aar nobody downloaded. -FFMPEG_KIT_VERSION="$1" if [ -z "$FFMPEG_KIT_VERSION" ]; then - FFMPEG_KIT_VERSION=$(sed -n 's:.*\(.*\).*:\1:p' "$PROPS" | head -1) + FFMPEG_KIT_VERSION=$(read_property FFmpegKitNativeVersion) fi +require_version FFmpegKitNativeVersion "$FFMPEG_KIT_VERSION" -if [ -z "$FFMPEG_KIT_VERSION" ]; then - echo "error: could not read FFmpegKitNativeVersion from $PROPS" >&2 +# The smart-exception version lives in Directory.Build.props too: the .csproj embeds the jars +# via the same property, so there is exactly one place to bump and nothing to drift. A wrong +# pairing here fails only on device, with NoClassDefFoundError. +SMART_EXCEPTION_VERSION=$(read_property SmartExceptionVersion) +require_version SmartExceptionVersion "$SMART_EXCEPTION_VERSION" + +CHECKSUMS="$ROOT/build/checksums/$FFMPEG_KIT_VERSION.sha256" +if [ ! -f "$CHECKSUMS" ]; then + echo "error: no checksum baseline for FFmpegKit $FFMPEG_KIT_VERSION" >&2 + echo " expected $CHECKSUMS" >&2 + echo " run build/update-checksums.sh $FFMPEG_KIT_VERSION to record one" >&2 exit 1 fi -# The version is interpolated into URLs and file names, so reject anything exotic up front. -case "$FFMPEG_KIT_VERSION" in - *[!A-Za-z0-9._-]*) - echo "error: invalid version '$FFMPEG_KIT_VERSION'" >&2 +sha256_of() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + else + sha256sum "$1" | awk '{print $1}' + fi +} + +# $1 = path to check, $2 = file name as recorded in the baseline +verify_file() { + want=$(awk -v f="$2" '$2 == f { print $1; exit }' "$CHECKSUMS") + if [ -z "$want" ]; then + echo "error: $CHECKSUMS has no entry for $2" >&2 + echo " run build/update-checksums.sh $FFMPEG_KIT_VERSION to record it" >&2 + exit 1 + fi + got=$(sha256_of "$1") + if [ "$got" != "$want" ]; then + echo "error: SHA-256 mismatch for $2" >&2 + echo " expected $want" >&2 + echo " got $got" >&2 exit 1 - ;; -esac + fi +} + +# Everything one FFmpegKit line consists of: the eight variant .aars plus the two +# smart-exception jars FFmpegKitConfig's static initialiser needs at runtime. +expected_files() { + for variant in audio full full-gpl https https-gpl min min-gpl video; do + echo "ffmpeg-kit-$variant-$FFMPEG_KIT_VERSION.aar" + done + for artifact in common java; do + echo "smart-exception-$artifact-$SMART_EXCEPTION_VERSION.jar" + done +} + +if [ "$VERIFY_ONLY" = 1 ]; then + for file in $(expected_files); do + if [ ! -f "$file" ]; then + echo "error: $file is missing - run FetchJars.sh to download it" >&2 + exit 1 + fi + verify_file "$file" "$file" + done + echo "==> verified $(expected_files | wc -l | tr -d ' ') files against $CHECKSUMS" + exit 0 +fi echo "==> fetching FFmpegKit $FFMPEG_KIT_VERSION" -# Downloaded into a staging directory and moved into place only once every file has arrived. -# Deleting first and downloading second leaves the working copy with no binaries at all when a -# version turns out not to exist, which costs a 200 MB re-download to recover from. +# Downloaded into a staging directory and moved into place only once every file has arrived and +# verified. Deleting first and downloading second leaves the working copy with no binaries at all +# when a version turns out not to exist, which costs a 200 MB re-download to recover from. STAGING=$(mktemp -d) trap 'rm -rf "$STAGING"' EXIT @@ -53,17 +137,21 @@ trap 'rm -rf "$STAGING"' EXIT # is not bundled in the .aar and is not declared in its .pom. Without these two jars every # FFmpeg call fails at runtime with NoClassDefFoundError. for artifact in common java; do - curl -fL --output-dir "$STAGING" -O \ + curl -fL --retry 3 --retry-delay 2 --output-dir "$STAGING" -O \ "https://github.com/tanersener/smart-exception/releases/download/v$SMART_EXCEPTION_VERSION/smart-exception-$artifact-$SMART_EXCEPTION_VERSION.jar" done for variant in audio full full-gpl https https-gpl min min-gpl video; do - curl -fL --output-dir "$STAGING" -O \ + curl -fL --retry 3 --retry-delay 2 --output-dir "$STAGING" -O \ "$MAVEN_BASE/ffmpeg-kit-$variant/$FFMPEG_KIT_VERSION/ffmpeg-kit-$variant-$FFMPEG_KIT_VERSION.aar" done +for file in $(expected_files); do + verify_file "$STAGING/$file" "$file" +done + rm -f ./*.jar rm -f ./*.aar mv "$STAGING"/*.jar "$STAGING"/*.aar . -echo "==> fetched $(ls ./*.aar | wc -l | tr -d ' ') .aar and $(ls ./*.jar | wc -l | tr -d ' ') .jar files" +echo "==> fetched and verified $(ls ./*.aar | wc -l | tr -d ' ') .aar and $(ls ./*.jar | wc -l | tr -d ' ') .jar files" diff --git a/tests/FFmpegKit.Android.PackageTests/NativeVersionMapTests.cs b/tests/FFmpegKit.Android.PackageTests/NativeVersionMapTests.cs index d90c0e7..dabd788 100644 --- a/tests/FFmpegKit.Android.PackageTests/NativeVersionMapTests.cs +++ b/tests/FFmpegKit.Android.PackageTests/NativeVersionMapTests.cs @@ -46,7 +46,43 @@ private static string ReadProperty(string name) [Fact] public void Mapping_lists_every_supported_build() { - Assert.Equal(3, Mapping.Count); + // Append-only: lines that have shipped stay in the mapping so their tags remain + // releasable. New rows are expected over time, so this is a floor, not an exact count. + Assert.True(Mapping.Count >= 3, $"native-versions.tsv lists {Mapping.Count} rows; the 6.x/7.x/8.x lines must not be removed."); + } + + [Fact] + public void Every_mapping_row_has_a_checksum_baseline() + { + // FetchJars.sh refuses to download a line that has no committed SHA-256 baseline, so a + // native-versions.tsv row without one is unreleasable. build/update-checksums.sh records + // a baseline in one command; this test is the reminder to run it. + var smartException = ReadProperty("SmartExceptionVersion"); + var variants = new[] { "audio", "full", "full-gpl", "https", "https-gpl", "min", "min-gpl", "video" }; + + foreach (var (_, ffmpegKit) in Mapping) + { + var path = Path.Combine(RepoRoot, "build", "checksums", $"{ffmpegKit}.sha256"); + Assert.True(File.Exists(path), $"Missing checksum baseline {path}; run build/update-checksums.sh {ffmpegKit}."); + + var entries = File.ReadAllLines(path) + .Where(line => line.Trim().Length > 0) + .Select(line => + { + var match = Regex.Match(line, "^([0-9a-f]{64}) (\\S+)$"); + Assert.True(match.Success, $"{path}: malformed line '{line}'."); + return match.Groups[2].Value; + }) + .ToHashSet(); + + foreach (var variant in variants) + { + Assert.Contains($"ffmpeg-kit-{variant}-{ffmpegKit}.aar", entries); + } + + Assert.Contains($"smart-exception-common-{smartException}.jar", entries); + Assert.Contains($"smart-exception-java-{smartException}.jar", entries); + } } [Fact] From 98273cedd4a46b6ca53ca7e98802351676b2e796 Mon Sep 17 00:00:00 2001 From: Siarhei Bakatsiuk Date: Fri, 24 Jul 2026 10:12:32 +0300 Subject: [PATCH 2/6] Surface the 64-bit-only and API 24+ constraints at build time A consuming app that includes 32-bit RuntimeIdentifiers builds, packages and installs without complaint, then crashes loading libffmpegkit.so on 32-bit devices - the .aar simply has no armeabi-v7a/x86 libraries to miss at build time. Every package now ships a build/buildTransitive .targets file that warns from the consuming app's own build: - FFMPEGKIT001: SupportedOSPlatformVersion below 24. The manifest merger already hard-errors on this (the .aar declares minSdk 24), but with a java-worded AMM0000; this precedes it with an actionable .NET message, and still fires when the merge error is silenced via tools:overrideLibrary. - FFMPEGKIT002: 32-bit Android runtime identifiers in the build. Warnings, not errors - an app may gate FFmpegKit calls at runtime - each suppressible individually via NoWarn. Hooked BeforeTargets=PrepareForBuild (BeforeTargets=Build would run after the failing packaging chain) and gated to the RID-agnostic pass so multi-ABI builds warn once, not once per ABI. Verified against a scratch app consuming the packed nupkg: both warnings fire ahead of the AMM0000 error at minSdk 21, FFMPEGKIT002 alone with a succeeding build at minSdk 24, and a package-layout test now pins the file's presence in both build/ and buildTransitive/ for all eight variants. Co-Authored-By: Claude Fable 5 --- README.md | 2 + .../FFmpegKit.Android.csproj | 9 ++++ .../FFmpegKit.Net.Android.targets | 52 +++++++++++++++++++ .../PackageLayoutTests.cs | 24 +++++++++ 4 files changed, 87 insertions(+) create mode 100644 src/FFmpegKit.Android/FFmpegKit.Net.Android.targets diff --git a/README.md b/README.md index 09583d4..d694363 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ The fork currently publishes three lines, each with all eight variants: None of them ship 32-bit binaries, so dropping back to an older line does not restore `armeabi-v7a` or `x86` support. +Both constraints are surfaced at build time in consuming apps by a small `.targets` file every package carries: **`FFMPEGKIT001`** warns when `SupportedOSPlatformVersion` is below 24, **`FFMPEGKIT002`** when the build includes 32-bit runtime identifiers (`android-arm`, `android-x86`) — each otherwise only fails on a device, when `libffmpegkit.so` does not load. Both are ordinary MSBuild warnings, suppressible per project via `NoWarn` for apps that gate FFmpegKit usage at runtime. + ### Releasing an older line Tags name the FFmpeg version: **`v6.1.6.4` publishes `6.1.6.4`** and builds against the FFmpegKit release that packages FFmpeg 6.1.6, looked up in [`build/native-versions.tsv`](build/native-versions.tsv). The fourth component is the binding revision and any prerelease suffix is ignored when resolving (`v8.1.2.4-rc.1` → FFmpeg `8.1.2`). A tag naming a version that is not in the mapping fails the release with the list of known builds, so an old-style tag like `v8.1.7.4` cannot be published by mistake. No branch or `Directory.Build.props` edit is needed. diff --git a/src/FFmpegKit.Android/FFmpegKit.Android.csproj b/src/FFmpegKit.Android/FFmpegKit.Android.csproj index 9a8828f..bd3eaaa 100644 --- a/src/FFmpegKit.Android/FFmpegKit.Android.csproj +++ b/src/FFmpegKit.Android/FFmpegKit.Android.csproj @@ -117,6 +117,15 @@ + + + + diff --git a/src/FFmpegKit.Android/FFmpegKit.Net.Android.targets b/src/FFmpegKit.Android/FFmpegKit.Net.Android.targets new file mode 100644 index 0000000..7a9a294 --- /dev/null +++ b/src/FFmpegKit.Android/FFmpegKit.Net.Android.targets @@ -0,0 +1,52 @@ + + + + + + + + + + <_FFmpegKitNetAndroidRidRaw Include="$(RuntimeIdentifiers)" /> + + + + + + + + <_FFmpegKitNetAndroid32BitRid Include="@(_FFmpegKitNetAndroidRid)" + Condition=" '%(Identity)' == 'android-arm' or '%(Identity)' == 'android-x86' " /> + + + + + + + diff --git a/tests/FFmpegKit.Android.PackageTests/PackageLayoutTests.cs b/tests/FFmpegKit.Android.PackageTests/PackageLayoutTests.cs index f086382..63ae7e8 100644 --- a/tests/FFmpegKit.Android.PackageTests/PackageLayoutTests.cs +++ b/tests/FFmpegKit.Android.PackageTests/PackageLayoutTests.cs @@ -88,6 +88,30 @@ public void Package_embeds_the_smart_exception_classes_needed_at_runtime(string } } + [Theory] + [MemberData(nameof(Packages.Variants), MemberType = typeof(Packages))] + public void Package_ships_the_constraint_check_targets(string variant) + { + using var package = Packages.OpenPackage(variant); + + // The native constraints (64-bit only, API 24+) fail at app runtime if this file stops + // being packed - nothing else in the pipeline would notice. buildTransitive matters most: + // apps normally reference FFmpegKit.Net., not this binding directly. + foreach (var folder in new[] { "build", "buildTransitive" }) + { + var path = $"{folder}/{Packages.PackageId(variant)}.targets"; + var entry = package.GetEntry(path); + Assert.True(entry is not null, $"{Packages.PackageId(variant)} is missing '{path}'."); + + using var reader = new StreamReader(entry!.Open()); + var content = reader.ReadToEnd(); + + Assert.Contains("FFMPEGKIT001", content); + Assert.Contains("FFMPEGKIT002", content); + Assert.Contains("AndroidApplication", content); + } + } + [Theory] [MemberData(nameof(Packages.Variants), MemberType = typeof(Packages))] public void Package_declares_the_expected_nuspec_metadata(string variant) From d0e8c56faf88d2c4b06c6a664eb07a30073a18bb Mon Sep 17 00:00:00 2001 From: Siarhei Bakatsiuk Date: Fri, 24 Jul 2026 10:20:49 +0300 Subject: [PATCH 3/6] Annotate the hand-written Additions, add Disable*Callback helpers The binding project ships with Nullable disabled because the generated surface has no annotations, which also stripped them from the hand-written Additions - the part of the API consumers touch most. Every Additions file now opts in with #nullable enable; MediaValues' parsers take nullable inputs, matching the Java strings and boxed Longs that actually arrive. Clearing the global callbacks previously required knowing the magic incantation EnableLogCallback((ILogCallback)null!) - the delegate overloads deliberately reject null. DisableLogCallback()/DisableStatisticsCallback() say the same thing by name; the device tests now use them. Co-Authored-By: Claude Fable 5 --- src/FFmpegKit.Android/Additions/Callbacks.cs | 2 ++ src/FFmpegKit.Android/Additions/Enums.cs | 2 ++ .../Additions/FFmpegKit.Async.cs | 2 ++ .../Additions/FFmpegKitConfig.Callbacks.cs | 21 +++++++++++++++++-- .../Additions/FFmpegKitConfig.Saf.cs | 2 ++ .../Additions/FFmpegProgress.cs | 2 ++ .../Additions/FFprobeKit.Async.cs | 2 ++ .../Additions/MediaInformation.Typed.cs | 2 ++ .../Additions/MediaValues.cs | 18 +++++++++------- .../SmokeTests.cs | 2 +- 10 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/FFmpegKit.Android/Additions/Callbacks.cs b/src/FFmpegKit.Android/Additions/Callbacks.cs index 0d24aec..f0060f0 100644 --- a/src/FFmpegKit.Android/Additions/Callbacks.cs +++ b/src/FFmpegKit.Android/Additions/Callbacks.cs @@ -1,5 +1,7 @@ using System; +#nullable enable + namespace Ffmpegkit.Droid { // Adapters adapting a delegate to FFmpegKit's single-method Java callback interfaces. diff --git a/src/FFmpegKit.Android/Additions/Enums.cs b/src/FFmpegKit.Android/Additions/Enums.cs index 7e51ea5..4e33364 100644 --- a/src/FFmpegKit.Android/Additions/Enums.cs +++ b/src/FFmpegKit.Android/Additions/Enums.cs @@ -1,5 +1,7 @@ using System; +#nullable enable + namespace Ffmpegkit.Droid { /// Managed counterpart of the Java SessionState enum. diff --git a/src/FFmpegKit.Android/Additions/FFmpegKit.Async.cs b/src/FFmpegKit.Android/Additions/FFmpegKit.Async.cs index fcffe4e..b49accb 100644 --- a/src/FFmpegKit.Android/Additions/FFmpegKit.Async.cs +++ b/src/FFmpegKit.Android/Additions/FFmpegKit.Async.cs @@ -2,6 +2,8 @@ using System.Threading; using System.Threading.Tasks; +#nullable enable + namespace Ffmpegkit.Droid { public partial class FFmpegKit diff --git a/src/FFmpegKit.Android/Additions/FFmpegKitConfig.Callbacks.cs b/src/FFmpegKit.Android/Additions/FFmpegKitConfig.Callbacks.cs index 0a226b3..45de192 100644 --- a/src/FFmpegKit.Android/Additions/FFmpegKitConfig.Callbacks.cs +++ b/src/FFmpegKit.Android/Additions/FFmpegKitConfig.Callbacks.cs @@ -1,5 +1,10 @@ using System; +// The generated binding compiles with Nullable disabled and carries no annotations; these +// hand-written additions are annotation-correct, so nullable-enabled consumers get real +// nullability information for at least the surface they touch most. +#nullable enable + namespace Ffmpegkit.Droid { public partial class FFmpegKitConfig @@ -11,8 +16,8 @@ public partial class FFmpegKitConfig /// an FFmpegKit worker thread — marshal to the UI thread before touching UI. /// /// The delegate is held by FFmpegKit until it is replaced, so anything it captures stays - /// alive too — avoid capturing an Activity. There is no Disable method: clear it by - /// calling EnableLogCallback((ILogCallback)null). + /// alive too — avoid capturing an Activity. Clear it with + /// . /// /// public static void EnableLogCallback (Action logCallback) @@ -36,5 +41,17 @@ public static void EnableStatisticsCallback (Action statisticsCallba EnableStatisticsCallback (new ActionStatisticsCallback (statisticsCallback)); } + + /// Stops routing FFmpeg log lines to a previously enabled callback. + /// + /// The delegate overload above deliberately rejects null; the native way to clear the + /// hook is passing a null interface, which nullable-enabled callers could only write as + /// EnableLogCallback ((ILogCallback) null!). This says the same thing by name. + /// + public static void DisableLogCallback () => EnableLogCallback ((ILogCallback) null!); + + /// Stops routing FFmpeg statistics samples to a previously enabled callback. + /// See . + public static void DisableStatisticsCallback () => EnableStatisticsCallback ((IStatisticsCallback) null!); } } diff --git a/src/FFmpegKit.Android/Additions/FFmpegKitConfig.Saf.cs b/src/FFmpegKit.Android/Additions/FFmpegKitConfig.Saf.cs index df8c5bc..e2ab5c1 100644 --- a/src/FFmpegKit.Android/Additions/FFmpegKitConfig.Saf.cs +++ b/src/FFmpegKit.Android/Additions/FFmpegKitConfig.Saf.cs @@ -1,5 +1,7 @@ using System; +#nullable enable + namespace Ffmpegkit.Droid { public partial class FFmpegKitConfig diff --git a/src/FFmpegKit.Android/Additions/FFmpegProgress.cs b/src/FFmpegKit.Android/Additions/FFmpegProgress.cs index ab4a0a1..c1f168e 100644 --- a/src/FFmpegKit.Android/Additions/FFmpegProgress.cs +++ b/src/FFmpegKit.Android/Additions/FFmpegProgress.cs @@ -1,5 +1,7 @@ using System; +#nullable enable + namespace Ffmpegkit.Droid { /// diff --git a/src/FFmpegKit.Android/Additions/FFprobeKit.Async.cs b/src/FFmpegKit.Android/Additions/FFprobeKit.Async.cs index 6033360..efdf85c 100644 --- a/src/FFmpegKit.Android/Additions/FFprobeKit.Async.cs +++ b/src/FFmpegKit.Android/Additions/FFprobeKit.Async.cs @@ -1,6 +1,8 @@ using System; using System.Threading.Tasks; +#nullable enable + namespace Ffmpegkit.Droid { public partial class FFprobeKit diff --git a/src/FFmpegKit.Android/Additions/MediaInformation.Typed.cs b/src/FFmpegKit.Android/Additions/MediaInformation.Typed.cs index 0083226..9ead18f 100644 --- a/src/FFmpegKit.Android/Additions/MediaInformation.Typed.cs +++ b/src/FFmpegKit.Android/Additions/MediaInformation.Typed.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +#nullable enable + namespace Ffmpegkit.Droid { public partial class MediaInformation diff --git a/src/FFmpegKit.Android/Additions/MediaValues.cs b/src/FFmpegKit.Android/Additions/MediaValues.cs index 9e6b084..fc9ec0d 100644 --- a/src/FFmpegKit.Android/Additions/MediaValues.cs +++ b/src/FFmpegKit.Android/Additions/MediaValues.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.Globalization; +#nullable enable + namespace Ffmpegkit.Droid { /// @@ -17,23 +19,23 @@ namespace Ffmpegkit.Droid /// internal static class MediaValues { - internal static double? Seconds (string value) => Number (value); + internal static double? Seconds (string? value) => Number (value); - internal static long? Integer (string value) => + internal static long? Integer (string? value) => long.TryParse (value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) ? parsed : null; - internal static double? Number (string value) => + internal static double? Number (string? value) => double.TryParse (value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) ? parsed : null; - internal static int? Int32 (Java.Lang.Long value) => + internal static int? Int32 (Java.Lang.Long? value) => value is null ? null : checked ((int)value.LongValue ()); /// Parses a rational such as "30/1" or "30000/1001", as FFprobe reports rates. - internal static double? Rational (string value) + internal static double? Rational (string? value) { if (string.IsNullOrWhiteSpace (value)) return null; @@ -51,7 +53,7 @@ internal static class MediaValues } /// Flattens a JSON object into string values, so callers need no Java JSON type. - internal static IReadOnlyDictionary ToDictionary (Org.Json.JSONObject json) + internal static IReadOnlyDictionary ToDictionary (Org.Json.JSONObject? json) { var values = new Dictionary (StringComparer.OrdinalIgnoreCase); @@ -67,7 +69,9 @@ internal static IReadOnlyDictionary ToDictionary (Org.Json.JSONO if (string.IsNullOrEmpty (key)) continue; - var value = json.OptString (key, null); + // Mono.Android annotates the fallback as non-nullable, but OptString handles null + // fine and it is what distinguishes "absent" from a legitimately empty value. + var value = json.OptString (key, null!); if (value is not null) values[key] = value; diff --git a/tests/FFmpegKit.Android.DeviceTests/SmokeTests.cs b/tests/FFmpegKit.Android.DeviceTests/SmokeTests.cs index 5a46aaf..099e9e6 100644 --- a/tests/FFmpegKit.Android.DeviceTests/SmokeTests.cs +++ b/tests/FFmpegKit.Android.DeviceTests/SmokeTests.cs @@ -212,7 +212,7 @@ private static void LogDelegateReceivesOutput(string workingDirectory) } finally { - FFmpegKitConfig.EnableLogCallback((ILogCallback)null!); + FFmpegKitConfig.DisableLogCallback(); } Assert(Volatile.Read(ref lines) > 0, "The log delegate never fired."); From c2effa0002869449afc7e7c2675065d92c451e41 Mon Sep 17 00:00:00 2001 From: Siarhei Bakatsiuk Date: Fri, 24 Jul 2026 10:27:28 +0300 Subject: [PATCH 4/6] Docs polish: quickstart alias hint, badge anchors, 8.1.2.5 release notes The quickstart showed bare FFmpegKit.Execute(...) with the namespace-shadowing caveat explained only in the migration section further down - both first-party consumers hit exactly that collision. The hint now sits at the point of use. The two badge anchors pointed at headings that do not exist (#packages, #licence); curated notes cover the next binding revision. Co-Authored-By: Claude Fable 5 --- README.md | 10 ++++++++-- docs/release-notes/8.1.2.5.md | 23 +++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 docs/release-notes/8.1.2.5.md diff --git a/README.md b/README.md index d694363..1ce7e9b 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ [![NuGet](https://img.shields.io/nuget/v/FFmpegKit.Net.Video.Android?label=nuget)](https://www.nuget.org/packages/FFmpegKit.Net.Video.Android) [![release](https://github.com/sbokatuk/FFmpegKit.Android/actions/workflows/release.yml/badge.svg)](https://github.com/sbokatuk/FFmpegKit.Android/actions/workflows/release.yml) -[![Targets: net8.0 | net9.0 | net10.0](https://img.shields.io/badge/targets-net8.0%20%7C%20net9.0%20%7C%20net10.0-512BD4)](#packages) +[![Targets: net8.0 | net9.0 | net10.0](https://img.shields.io/badge/targets-net8.0%20%7C%20net9.0%20%7C%20net10.0-512BD4)](#installation) [![ffmpeg 8.1.2](https://img.shields.io/badge/ffmpeg-8.1.2-632CA6)](#about) -[![Licence: MIT AND LGPL-3.0 or GPL-3.0](https://img.shields.io/badge/licence-MIT%20AND%20LGPL--3.0%20or%20GPL--3.0-orange)](#licence) +[![Licence: MIT AND LGPL-3.0 or GPL-3.0](https://img.shields.io/badge/licence-MIT%20AND%20LGPL--3.0%20or%20GPL--3.0-orange)](#license) .NET for Android and .NET MAUI bindings for the native **FFmpegKit** library. @@ -152,6 +152,12 @@ Execute your FFmpeg command FFmpegKit.Execute("-i input.mov -c:v libx264 output.mp4"); ``` +> If your app's own root namespace starts with `FFmpegKit` (say, `FFmpegKit.MyApp`), the bare +> `FFmpegKit` above resolves to your namespace instead of the class and fails to compile — add +> `using FFmpeg = Ffmpegkit.Droid.FFmpegKit;` and call `FFmpeg.Execute(...)`, as the sample and +> the device tests do. The [migration notes](#installation) explain why the namespace is not +> `FFmpegKit.*` itself. + More examples and usage can be found in the [original FFmpegKit wiki](https://github.com/arthenica/ffmpeg-kit/wiki/Android). That repository is archived, but the Java API it documents is the one these bindings expose, so it remains the reference. ### Beyond the generated binding diff --git a/docs/release-notes/8.1.2.5.md b/docs/release-notes/8.1.2.5.md new file mode 100644 index 0000000..7cd04d3 --- /dev/null +++ b/docs/release-notes/8.1.2.5.md @@ -0,0 +1,23 @@ +## What's changed + +Binding revision 5 of FFmpeg `8.1.2` (FFmpegKit `8.1.7`). The native binaries are unchanged; everything below is binding, packaging and pipeline work. + +### Build-time constraint checks + +Every package now ships a small `build`/`buildTransitive` `.targets` file imported into consuming **apps**: + +- **`FFMPEGKIT001`** warns when `SupportedOSPlatformVersion` is below 24 — ahead of the manifest merger's java-worded `AMM0000` error, and still there if that error is silenced with `tools:overrideLibrary`. +- **`FFMPEGKIT002`** warns when the build includes 32-bit runtime identifiers (`android-arm`, `android-x86`). There are no 32-bit native libraries in any variant or version; without this warning the failure is `libffmpegkit.so` not loading on a 32-bit device. + +Both are ordinary MSBuild warnings with their own codes, so either can be suppressed per project via `NoWarn` by apps that gate FFmpegKit usage at runtime. Library projects are not affected. + +### API + +- The hand-written additions — async wrappers, typed media information, lambda callbacks, SAF helpers — now carry **nullable reference annotations** (`#nullable enable` per file). The generated binding surface remains unannotated, faithfully to the Java API. +- **`FFmpegKitConfig.DisableLogCallback()`** and **`DisableStatisticsCallback()`** replace the `EnableLogCallback((ILogCallback)null!)` incantation as the way to clear the global callbacks. + +### Supply chain + +- Every native download is now **verified against a SHA-256 baseline** committed at `build/checksums/`, one file per FFmpegKit line, recorded from Maven Central's own `.sha256` sidecars where published (`build/update-checksums.sh`). A corrupted or substituted artifact fails the fetch — locally and in CI, which also re-verifies restored caches. +- The `smart-exception` runtime jars' version now lives once in `Directory.Build.props`; the fetch script and the project file both read it, so the pairing can no longer drift and fail only on device. +- Downloads retry on transient network errors. From adbd6e7992d22e8fbfbc720a27c1cf7812f22e39 Mon Sep 17 00:00:00 2001 From: Siarhei Bakatsiuk Date: Fri, 24 Jul 2026 11:56:02 +0300 Subject: [PATCH 5/6] Document the Disable*Callback helpers in the README Co-Authored-By: Claude Fable 5 --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 1ce7e9b..f8aa191 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,8 @@ stream.IsVideo / IsAudio FFmpegKitConfig.EnableLogCallback(log => Debug.WriteLine(log.Message)); ``` +Clear either hook by name — `FFmpegKitConfig.DisableLogCallback()` / `DisableStatisticsCallback()` — instead of the `EnableLogCallback((ILogCallback)null)` incantation. The delegates are held by FFmpegKit until cleared, so anything they capture stays alive too. + **Use managed enums.** `SessionState` and `Level` are Java enums, so they cannot be used in a `switch`, and comparing them with `==` compares managed peer references rather than the underlying constants. `ToManaged()` converts at the boundary. ### Working with user-picked files From e33ffebfc33844eca6d69b759c4eb3a2caaf5d49 Mon Sep 17 00:00:00 2001 From: Siarhei Bakatsiuk Date: Fri, 24 Jul 2026 12:15:23 +0300 Subject: [PATCH 6/6] Sample: add the cancellation demo The iOS sample demonstrates cancelling a running command; this one did not, despite the binding carrying the same CancellationToken support. Cancel button + token wiring, and a cancelled run now reports as cancelled rather than as a failure. Co-Authored-By: Claude Fable 5 --- .../FFmpegKit.Android.Example/MainPage.xaml | 7 +++++ .../MainPage.xaml.cs | 26 ++++++++++++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/samples/FFmpegKit.Android.Example/MainPage.xaml b/samples/FFmpegKit.Android.Example/MainPage.xaml index f1dd9e0..063ccad 100644 --- a/samples/FFmpegKit.Android.Example/MainPage.xaml +++ b/samples/FFmpegKit.Android.Example/MainPage.xaml @@ -27,6 +27,13 @@ Clicked="OnConvertClicked" HorizontalOptions="Fill" /> +