dd2fcf0a191d8683d2dc1e13e4b261f1799c6503
20
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
dd2fcf0a19 |
Rename the project to LibreMediaConverter
Done now rather than later: the application ID is permanent once published -- Play treats a change as an entirely different app -- so this is the last cheap moment to choose it. applicationId / namespace dev.jasonmross.mediaconverter -> org.libremediaconverter source tree java/dev/jasonmross/mediaconverter -> java/org/libremediaconverter gradle project AndroidMediaConverter -> LibreMediaConverter theme Theme.MediaConverter -> Theme.LibreMediaConverter compose theme MediaConverterTheme -> LibreMediaConverterTheme display name "Media Converter" -> "LibreMediaConverter" org.* rather than dev.jasonmross.* because "Libre" signals a project rather than a personal app, and a project-owned namespace lets maintainership move later without the identifier contradicting reality. The source trees moved with git mv so history follows the files instead of showing 42 deletions beside 42 additions. Verified after the rename: 66 unit tests, and 40 instrumented tests on an API 36 emulator, 0 failures. The built APK reports org.libremediaconverter, and no stale jasonmross, AndroidMediaConverter or MediaConverterTheme identifiers remain anywhere in the tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
128763e99c |
Commit the FFmpeg binary so test runs stop depending on a rebuild
CI rebuilt FFmpeg on every cold cache, which made results ambiguous: a red run could mean the code was broken or that a forty-minute cross-compile of FFmpeg, x264, x265 and SVT-AV1 had hiccuped. Those are not the same signal, and only one of them is worth a developer's attention. The archive is now checked in under bin/, so a failing run points at code. It also removes roughly forty minutes from a cold run and lets a fresh clone build without a container toolchain. bin/README.md records provenance -- upstream tag, FFmpeg version, NDK, ABIs, SHA-256 and the full configure line read back out of the shipped libavutil -- so the binary is auditable rather than opaque. The recipe in tools/ffmpeg remains the authority: this archive is its output, and is also what satisfies the GPL corresponding-source obligation. The status check is now seven independent runners: one validating the archive, one for the JVM tests, and one per API level from 33 to 37. The FFmpeg job verifies rather than builds. It asserts native libraries are present for both ABIs and that every one is 16 KB aligned, which is a Play requirement that is easy to lose in a rebuild and expensive to discover at submission. Checking for file existence alone would not do: a Git LFS pointer checked out without LFS passes that and then surfaces as an obscure linker error much later. It is a separate job rather than a step in each emulator run so a bad archive reports once, clearly, instead of five confusing emulator failures. build.yml no longer builds FFmpeg either, and keeps only its post-merge and release duties. Two costs, deliberately accepted. The repository goes from about 1 MB to 35 MB, and every future rebuild adds another 35 MB blob to history permanently, so bin/README.md says to regenerate only when the FFmpeg version or the configure flags actually change. And F-Droid's scanner flags checked-in native libraries, so submitting there needs a scandelete entry for bin/ -- noted in bin/README.md, and nothing prevents a from-source build. Verified against the relocated archive: 66 unit tests, and 40 instrumented tests on an API 36 emulator, 0 failures. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
54d6e9c57b |
Add a pull-request status check across API 33-37
Runs the JVM tests and the instrumented suite on every pull request to main, with one emulator job per supported API level. The matrix is the whole range rather than a single level because the foreground-service type differs across it -- none below 34, dataSync at 34, mediaProcessing from 35 -- so testing one level would leave two thirds of that branch unexercised. Running the range locally is what caught a test that had baked in an assumption about the host's encoders. API 37 needs its image level stated separately. It is published as android-37.0, not android-37, so a plain integer resolves to nothing and the image download silently finds no package. FFmpeg is built once and shared. The AAR is not committed -- 35 MB of native code, and F-Droid strips checked-in binaries -- but every job needs it, since the app compiles against it and the instrumented tests exercise it for real. Building it is a full cross-compile of FFmpeg, x264, x265 and SVT-AV1, so it is cached on the contents of tools/ffmpeg, which is what actually determines the output. The job also asserts the AAR carries native libraries for both ABIs: a truncated or stub archive would otherwise pass a file-exists check and send the matrix off to fail confusingly five times over. fail-fast is off. Knowing whether a failure is universal or specific to one API level is most of the diagnosis. build.yml no longer runs on pull requests. It triggered on every PR with no branch filter, so both workflows would have run, and its unit job falls back to a stub AAR -- a weaker check that could mask a compile break the real one would catch. It keeps its post-merge and release duties. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a172cb137f |
Pin the fallback test's device profile so it is not machine-dependent
Running the suite across API 33, 34, 35 and 36 emulators failed the same test on all four, while it passed on a Pixel 10 Pro XL. The assertion was "the hardware path should have been attempted", and the routing was correct in both cases: those emulators expose no hardware H.264 encoder, so the router sent the job straight to FFmpeg and Media3 was never called. That is the same mistake made earlier with routesAFastMp4JobToMedia3 -- baking an assumption about the host's encoders into a test. A result that flips with the machine says nothing about the code. This test is about the fallback mechanism rather than about routing, so it now pins DeviceCodecs.PERMISSIVE through the existing seam and the router's choice becomes deterministic. Routing itself is covered separately, by tests that derive their expectation from the device. Verified on emulators for API 33, 34, 35 and 36: 40 instrumented tests each, 0 failures, 2 skipped, with the only skips being the opt-in benchmark. That also exercises all three foreground-service regimes for the first time -- no type below 34, dataSync at 34, mediaProcessing from 35 -- which had previously only ever run at API 37. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
99bd961022 |
Force the last five failure branches
The previous commit called five branches unforceable. That was wrong on all five, and the reasoning behind it was lazy rather than investigated. Three were described as needing a SAF grant revoked mid-job. They do not: a content URI whose authority does not exist reaches exactly the same null branch as one whose permission was withdrawn, and constructing one is a single line. That now covers the conversion input, the join inputs, and the publish destination. One was described as unreachable through ConcatWorker.request(). True, but irrelevant -- a worker is just input Data, and WorkManager will run one built without the input array. That is also what a version-skewed queue entry would look like after an app update, so it is worth covering rather than dismissing. One was described as Media3-internal. An output path whose parent directory does not exist makes Transformer.start() fail synchronously, and the engine has to surface that as a rejected suspension. The test asserts specifically that it is not a timeout: hanging would be far worse than throwing, because the worker would sit holding a foreground service indefinitely. The assertions check outcomes rather than exception types. Whether a resolver returns null or throws is a provider implementation detail; what matters is that the job fails cleanly and carries a message, instead of taking down the worker. Every failure branch in the conversion and join paths is now forced. 40 instrumented tests on a Pixel 10 Pro XL and 66 unit tests, 0 failures, with the only skips being the opt-in benchmark. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
83fb55515b |
Add a seam for forcing failure paths, and force thirteen of them
Error handling was the least-tested code in the app. It only runs when something goes wrong, which is exactly what a healthy test run avoids, so the branches a user meets on a bad day were the ones that had never executed. An audit put it at 4 of 18 fallback branches actually forced by a test. There was also no mechanism to do better: ConversionWorker constructed Media3Engine and FFmpegEngine directly, so no test could make either of them fail. Media3Engine and FFmpegEngine now implement HardwareTranscoder and SoftwareTranscoder, and ConversionDependencies holds the factories. Workers are built by WorkManager and the app deliberately carries no DI framework, so a small settable holder is the least machinery that does the job. The foreground-service timeout needed different treatment. Its trigger is the six-hour-per-day budget expiring, which no test can reach, so the decision moved out of the worker into FailureOutcome. The rule is now verified on the JVM across every WorkInfo stop reason, including that only the timeout earns a retry -- retrying a user cancellation would ignore the user, and retrying a constraint failure would spin. Thirteen branches are now forced, including both free-space prechecks, both engines failing, a missing input, and the router bypassing hardware entirely for MP3. The dynamic fallback is covered twice over: once by injection, and once by HardwareFallbackTest driving it with genuinely undecodable 4:4:4 footage. Injection alone would prove the plumbing without proving the condition ever arises in reality. Five remain unforced and are listed in ConversionDependencies' documentation. They need a SAF grant to be revoked mid-job, which is not something a test can arrange. Media3EngineTest now asserts against the muxed file rather than the engine's own ExportResult, which is a stronger check anyway: a result object can report success for a file that will not play. 35 instrumented tests on a Pixel 10 Pro XL and 66 unit tests, 0 failures. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
356c04d137 |
Make the 4:4:4 fallback regression run without manual setup
The test covering the runtime fallback read its input from the app's internal storage, which only ever contained a file because it had been piped in by hand with run-as. On a fresh checkout it hit assumeTrue and skipped -- silently, while still counting toward the suite total. A regression test that skips is worse than no test, because the number reads as coverage. It now ships its own fixture: three seconds of H.264 High 4:4:4 Predictive, 76 KB. Producing it needed x264, which the host toolchain cannot supply -- Fedora's ffmpeg carries openh264, which is Constrained Baseline only and cannot even decode 4:4:4 -- so it was generated with ffmpeg-full inside the existing FFmpeg build container. The command is recorded in the test's own documentation so the fixture can be regenerated rather than trusted blindly. Verified by deleting the hand-staged files first and running the suite clean: the fallback test executes, Media3 fails to decode as expected, and the worker completes the conversion through FFmpeg. It is no longer among the skips. The benchmark stays opt-in and is now documented as such. It needs real long-form media that does not belong in the repository, and its numbers should not be mistaken for something the suite verifies. 29 instrumented tests on a Pixel 10 Pro XL: 0 failures, 2 skipped, and both skips are the benchmark by design. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ec6e37ad08 |
Stop selecting FFmpeg's MediaCodec encoders, and pin the pixel format
Testing on a Pixel 10 Pro XL with real footage found two defects that neither the emulator nor any unit test could surface. The sample is H.264 High 4:4:4 Predictive (avc1.F4001F). Media3 cannot decode it on any device: hardware AVC decoders implement High 4:2:0, and Android's software c2.google.avc.decoder does not cover 4:4:4 either, so the export dies with a codec exception. The static routing rules cannot predict this -- the container is MP4, the codec is "h264", and the device reports AVC decode and encode -- so it is exactly the case the runtime fallback exists for. Until a file like this was tried on hardware, that fallback had never actually fired. Following it through exposed the two bugs behind it. First, only one video encode path named a pixel format. FFmpeg decodes 4:4:4 to yuv444p and hands those frames to encoders that cannot accept them; naming yuv420p makes it insert the conversion instead. Every path now does. Second, and not fixed by that: hevc_mediacodec still failed with "Error submitting video frame to the encoder". That is the second distinct failure from FFmpeg's MediaCodec wrappers in one session -- the first being that on a device with no hardware encoder they silently bind to the platform software codec and crawl while presenting as the fast path. They are undocumented, per-device flaky, and duplicate badly something Media3 already does properly. A job only reaches FFmpeg because Media3 could not handle it, which is itself evidence hardware encode is unlikely to work for that input. So FFmpeg now always encodes in software, and Fast means a fast preset rather than a different encoder: libx264/libx265 at veryfast against medium, both with CRF. With that, the fallback completes and the file converts. Measured on the Pixel: AV1 1080p through the hardware path runs at 7.9x realtime, confirming the figure the two-engine design was based on; software x264 CRF at 720p runs at 4.4x. Hardware encoders present are av01, avc and hevc -- AV1 encode included, which is still rare. 29 instrumented tests pass on device, 63 unit tests on the JVM. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
64e146ce5d |
Cover the join path with device tests
The join flow was implemented but had never run end to end: unit tests covered the planner and the argument shapes, neither of which can tell you what FFmpeg actually does with real files. Three fixtures make the strategy decision testable. clip_a and clip_b match in codec, resolution and frame rate; clip_c deliberately differs in both resolution and frame rate. Without a genuinely mismatched input there is no way to prove the re-encode branch is ever taken. The tests assert which strategy ran, not merely that output appeared. That distinction is the whole point here: the concat demuxer does not reliably reject mismatched inputs, so a naive implementation produces a file whose later segments are garbled while still exiting successfully. Each test also checks the output is long enough to contain both inputs, since a truncated join is exactly what a wrong stream copy looks like. Also covered: the list file is cleaned up, fewer than two inputs is refused, the probe distinguishes the clips the planner depends on, and the chosen strategy reaches the UI through WorkManager -- it is what tells the user whether their files were copied losslessly or re-encoded. Measured on an API 37 emulator, the two paths differ by roughly thirty times on the same pair of clips: 0.026s to stream copy against 0.829s to re-encode. That gap is itself evidence the planner is not quietly re-encoding everything. 25 instrumented tests now pass, up from 17. 65 unit tests unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e5f274183c |
Match the Join tab to the converter's empty state
Applies the same treatment to the Join tab: label and button centred on both axes, button filling the width at 56dp tall, and the same asymmetric screen padding. An empty state that looks different depending on which tab you are on reads as a bug rather than as variety. The two shared dimensions move into ui/Dimens.kt rather than being duplicated per screen, so the tabs cannot drift apart later. Verified on an API 37 emulator: both tabs now present an identical empty state. 65 unit and 17 instrumented tests still pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a8fa3aecdf |
Centre the empty state and widen its primary button
Splits the converter screen into a fixed header and a body region that behaves differently per state. The empty state centres its label and button on both axes in whatever space is left; the working states keep scrolling, since format pickers and progress can exceed the screen and centring content that overflows would push it out of reach. The primary button now fills the width and stands 56dp tall rather than the Material default of 40dp, so it reads as the main affordance instead of a small control adrift in an otherwise empty screen. Horizontal screen padding drops to 16dp while vertical stays at 24dp, which is what lets a full-width button sit close to both edges. Both dimensions are named constants rather than inline numbers, because the same treatment is applied to the primary action in every other state. No theme changes were needed. Dark mode already tracked the device: the Compose theme reads isSystemInDarkTheme() and the activity theme has a values-night variant. Verified on an API 37 emulator by toggling `cmd uimode night` and capturing both -- mean luminance 245/255 in light against 18/255 in dark, with the button recolouring and status bar icons inverting correctly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2fcacd199c |
Use a real software encoder when the device has none in hardware
Running the device suite for the first time surfaced a defect the unit tests could not: FFmpeg's *_mediacodec wrappers do not fail on a device without a hardware encoder. They quietly bind to the platform's software codec (c2.android.hevc.encoder on the test emulator) and encode far slower than libx264 or libx265 would, while still presenting as the fast path. A three second 320x240 clip did not finish inside a three minute timeout. The Fast tier now checks whether a hardware encoder actually exists for the target codec, and falls back to libx264/libx265 on -preset veryfast when one does not. That is both quicker and honest about what it is doing, and the distinction between Fast and Best survives the fallback: veryfast against medium, rather than both collapsing to the same slow path. The routing itself was already correct. The capability probe rightly rejects c2.android.* as software-only, so the job was correctly sent to FFmpeg -- the test asserting Media3 was encoding an assumption about the machine it ran on. It now derives its expectation from the device, so it means the same thing on hardware with a real encoder and on an emulator without one. Verified on an API 37 emulator: 17 of 17 instrumented tests pass in 4.2s, against six minutes of timeouts before. 65 unit tests still pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
438f21783f |
Make the foreground-service test read the running API level
The three foreground-service-type regimes (none at 33, dataSync at 34, mediaProcessing at 35+) are the reason ConversionForegroundType exists, so the test derives its expectation from Build.VERSION rather than pinning one value. The same test then means something on any device in the supported range instead of only on the one it was written against. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
bb969ece64 |
Enable R8 and add release, store and F-Droid infrastructure
Turning on R8 immediately surfaced a latent runtime bug: the ffmpeg-kit-next wrapper references com.arthenica.smartexception.java.Exceptions from AbstractSession.fail() in eighteen places, but a local .aar carries no transitive dependencies, so nothing was pulling it in. Debug builds tolerate this through lazy class loading -- the class is only touched on an error path -- so it would have shipped as a crash the first time an FFmpeg conversion failed. Declared explicitly now. Keep rules cover the JNI boundary. The native library resolves classes and methods by name, which R8 cannot see, so without them the FFmpeg calls fail with NoSuchMethodError in release builds only. Workers are kept too, since WorkManager reconstructs them reflectively from a class name persisted in its database, and a rename breaks jobs enqueued before the update. Verified on the produced artifacts rather than assumed: all 22 native libraries survive minification and every one is still 16 KB aligned inside the APK. Release is 82 MB against 115 MB for debug; the AAB is 40 MB and Play splits it per ABI. The privacy policy lists every permission, including the three WorkManager adds automatically (WAKE_LOCK, RECEIVE_BOOT_COMPLETED, ACCESS_NETWORK_STATE). Checking the merged manifest showed those, and a policy that omitted them would look dishonest to anyone who inspected the app. INTERNET is genuinely absent, so "files stay on the device" is enforced by the OS rather than a promise. CI runs unit tests on every push and builds the FFmpeg AAR only for release tags, since that is a full cross-compile. Releases attach the FFmpeg corresponding source next to the APK: GPL-3.0 requires it, and FFmpeg's instruction to host it "on the same webserver" cannot be satisfied by a Play listing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0c352cdcfb |
Route conversions between hardware and software engines
Adds the second engine and the rules that choose between them, turning a
video transcoder into a converter.
The router encodes capability boundaries, not preferences. Media3 handles
what it genuinely can and FFmpeg takes the rest:
MKV, and any container Media3 cannot mux
MP3, which Android cannot encode at any API level -- a platform gap
rather than a Media3 limitation
GIF and PNG frame sequences, which have no Media3 muxer
VP9 and AV1 targets: Transformer.setVideoMimeType accepts only
H.263/H.264/H.265/MP4V, so the WebM muxer has no encoder behind it
inputs with no platform decoder, since Transformer ignores ExoPlayer's
bundled software decoders and the dav1d extension does not rescue it
the Best quality tier, because CRF and two-pass come from x264/x265 and
no Android hardware encoder exposes either
Rule order matters and is deliberate: specific reasons are checked before
general ones because the reason is shown to the user. "Android has no
encoder for this format" is actionable for MP3; "this container needs
FFmpeg" is not. A test caught the original ordering getting this backwards.
Hardware support is vendor-declared and, per the platform's own docs,
"cannot be tested for correctness", so the static rules are backed by a
dynamic fallback: a Media3 export that fails is retried on FFmpeg rather
than surfaced as a failed conversion.
Joining files chooses between a stream copy and a re-encode by inspecting
the inputs. The concat demuxer requires matching codec, resolution and
timebase, and does not reliably fail when they differ -- it can emit a file
whose later segments are garbled. Unknown properties count as a mismatch,
because two nulls are not evidence of agreement.
The UI surfaces the routing decision rather than hiding it, so a slow job
explains itself, and offers a per-job engine override.
Navigation is adaptive: a bottom bar on phones, a side rail on wider
screens. Not cosmetic -- from targetSdk 37 Android ignores screenOrientation
and resizableActivity on displays at least 600dp wide, with no opt-out, so
the app is resized whether or not it is ready.
62 unit tests cover the routing matrix, the FFmpeg argument builder and the
concat planner on the JVM, against fabricated device profiles so branches
like "this device cannot encode HEVC" are reachable without that hardware.
Device-level tests for the FFmpeg formats are written but not yet run; the
emulator in this environment will not stay up.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
e84ab6a89d |
Run conversions as durable foreground work
Conversions now go through WorkManager instead of a viewModelScope coroutine,
so a job outlives the ViewModel, survives process death, and keeps running
when the user leaves the app.
The foreground service type needs three branches across the supported range,
which is why ConversionForegroundType exists rather than a constant:
API 33 no type is required at all
API 34 a type is mandatory, but mediaProcessing does not exist yet, so
dataSync is the only sensible fit
API 35+ mediaProcessing, whose own documentation describes it as
"converting media to different formats"
The manifest declares both types on WorkManager's SystemForegroundService
via tools:node="merge" -- setForeground runs *that* service, not one of
ours, so declaring the type on an app-owned service would have no effect.
The manifest cannot branch on API level, so the runtime picks which type is
actually passed.
Both types share a budget of six hours per twenty-four across the whole app.
When it runs out WorkManager reports STOP_REASON_FOREGROUND_SERVICE_TIMEOUT,
which the worker translates into Result.retry() rather than a failure: the
work is still valid, there is simply no budget right now. The UI surfaces
that as a distinct Waiting state that explains the pause instead of showing
an error.
Expedited work is deliberately not used. It maps to JobScheduler expedited
jobs with a short quota, which is the wrong shape for a multi-minute
transcode.
POST_NOTIFICATIONS is requested when the user taps Convert, not on first
launch, so the ask arrives with visible justification. The conversion starts
either way -- without the permission the foreground service still runs, but
its progress notification is confined to the Task Manager rather than the
shade. Notification updates are throttled to roughly one per second because
progress updates arrive far faster than the system UI can absorb.
Tests run against the real WorkManager rather than a test double,
specifically so setForeground and the declared service type are exercised on
a device that enforces them. Verified on an API 37 emulator: the service
starts, and logcat shows no type or permission exceptions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
b082cea889 |
Add containerized FFmpeg build producing a 16 KB-aligned GPL AAR
There is no usable prebuilt FFmpeg for Android any more. arthenica/ffmpeg-kit is archived and its binaries were deleted from Maven Central, so every com.arthenica:ffmpeg-kit-* coordinate 404s and all of its release tags have zero assets. Maven Central's search index still lists the old versions, which misleads; the files behind those entries are gone. The successor, ffmpeg-kit-next, is source-only by design. Building it ourselves is the only remaining option, not a preference. ffmpeg-kit-next is Nix-only -- there is no plain android.sh, only nix-android.sh and a flake -- so the toolchain lives in a container rather than on the developer's machine. The recipe doubles as the reproducibility artifact F-Droid expects and as the GPL corresponding-source obligation. Four problems this path hits, none of them documented upstream: - The nixos/nix base image already ships bash, coreutils and git; installing them collides with the existing profile entries and fails the image build. - Upstream scripts use #!/bin/bash but the image provides only /bin/sh, so start-android.sh dies with "cannot execute: required file not found" after the entire toolchain has been built. - Gradle's AAPT2 comes from Maven as a prebuilt binary linked against FHS paths that do not exist under Nix, failing with "Daemon startup failed" after the whole native build succeeds. Nixpkgs' Android SDK ships an already-patched aapt2, so Gradle is pointed at that. - A bare '*.aar' find also collects every AAR Gradle unpacked into its own caches, so the copy is scoped to the ffmpeg-kit outputs. The NDK stays at r27d as the flake pins it. Do not "upgrade" to r28+: android/jni/Android.mk applies -Wl,-z,max-page-size=16384 manually precisely because r27 predates automatic alignment, and the result is verified 16 KB compliant as-is. Verified against the produced artifact: every .so on both ABIs reports LOAD align 0x4000, libraries are separate rather than a static monolith as the GPL relinking obligation requires, and the embedded configure line confirms --enable-gpl --enable-version3 with x264, x265, SVT-AV1, LAME, libass and the MediaCodec wrappers. Note that --enable-small and --enable-lto internalize symbols, so absence from strings output proves nothing; check the configure line instead. The 35 MB AAR itself is gitignored. F-Droid strips checked-in prebuilt native libraries, and the recipe is the artifact of record. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e4efa8a74a |
Add Media3 hardware conversion path with end-to-end tests
First working conversion: SAF input -> hardware transcode -> staged cache file -> SAF export, driven from a Compose screen. Media3 Transformer is the engine for this path rather than FFmpeg. It is Apache-2.0, needs no native build, consumes content:// URIs directly, and runs MediaCodec decode -> GL surface -> MediaCodec encode without frames round-tripping through the CPU. FFmpeg remains necessary for the long tail (MP3, GIF, MKV, exotic containers) but is not the right tool here. Two hazards are designed against rather than discovered later: Transformer must be driven from a single thread that has a Looper, and start()/cancel() throw IllegalStateException from anywhere else. The Looper it binds to is whichever the Builder saw, silently falling back to the main one. A WorkManager Worker runs on a Looper-less executor thread, so the naive arrangement builds against the main Looper and then throws on start. Media3Engine owns a dedicated HandlerThread, passes its Looper explicitly, and marshals every call onto it, so callers get a plain suspending function and cannot reintroduce the bug. Media3EngineTest covers this directly by driving a conversion from a Looper-less thread. Output never goes through a SAF file descriptor. MP4 faststart rewrites the moov atom at the end and needs to seek backwards, which a SAF fd does not reliably support. OutputPublisher stages to app-private cache, a real POSIX path, and copies out afterwards. That costs transient double disk usage, so it checks free space before starting. Input uses ACTION_OPEN_DOCUMENT rather than the photo picker: the picker is images and video only, offers no audio at all, and does not reliably surface .mkv/.flac/.webm. SAF needs no runtime permission. Tests run on an API 37 emulator and assert the output codec by reading the muxed file with MediaExtractor, so a silent fallback to H.264 fails rather than passing. Progress reporting is deliberately not asserted as non-empty: a 3 s fixture can finish inside one 250 ms poll tick, which would be an intermittent failure rather than a real defect. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
48f65f0941 |
Replace scaffold with Compose/Material 3 base and project identity
The generated scaffold was a Views-based Material 2 shell with no activity,
no Kotlin sources, and a placeholder package. Replace it with the real base
the conversion work builds on.
Build configuration, with three AGP 9 specifics that contradict most
tutorials still in circulation:
- AGP 9 has built-in Kotlin. Applying org.jetbrains.kotlin.android now fails
the build, so the absence of that plugin is deliberate, not an oversight.
- The Compose compiler plugin is still separate and must be applied, pinned
to 2.2.10 to match the kotlin-gradle-plugin AGP 9.3.1 brings transitively.
Pinning it to the newest Kotlin release instead would mismatch.
- android.kotlinOptions {} was removed; jvm configuration moves to a
top-level kotlin { compilerOptions {} }.
Java compatibility goes 11 -> 17 (AGP 9 requires JDK 17), abiFilters
restrict packaging to arm64-v8a and x86_64, and jniLibs packaging is set
uncompressed so the APK zip-aligns native libraries on 16 KB boundaries.
Every dependency version in the catalog was checked to resolve against
Google Maven rather than copied from documentation. Note that KSP has moved
to standalone versioning (2.3.11) and no longer uses the old
<kotlin>-<ksp> scheme; it is catalogued but left unapplied until Room lands.
Set applicationId to dev.jasonmross.mediaconverter. com.example.* is
rejected by the Play Console, and the application ID is permanent once
published, so it has to be right before the first upload. The display name
is just a string resource and stays changeable.
Document the split license posture: source is MIT, but the distributed
binary will be GPL-3.0 because it bundles FFmpeg built with x264/x265.
LICENSES/README.md records why, including that libass is ISC rather than
GPL, so subtitle burn-in is not what forces the GPL choice.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
18ae2cff80 | initial commit |