Commit Graph
26 Commits
Author SHA1 Message Date
JMR-devandClaude Opus 5 a3c835b7c9 Keep the picker test off the API 37 gating leg, having measured why
The API 37 emulator images abort surfaceflinger inside the guest's Gralloc5 mapper,
init SIGKILLs zygote with it, and the framework restarts under the run. run-e2e.sh
and the CI leg disable SystemUI to remove the trigger -- but that removes the IDLE
one, RegionSamplingThread's nav-bar luma sampling. Driving DocumentsUI and rotating
the display are not idle. They are the first things in this suite that generate
surface traffic of their own.

Both tests were measured on android-37.0 under swangle_indirect with SystemUI
disabled and verified quiet, and measured SEPARATELY -- inferring the second from
the first is the mistake docs/api-37-emulator-crash.md opens by correcting. They
fail in the two shapes a framework restart produces:

  thePickedInputSurvivesARealRotation
    INSTRUMENTATION_ABORTED: System has crashed.
    Expected 59 tests, received 50
    (5 hasReadColorBufferDma aborts; the framework dies DURING the test, so six
     later tests never run and the XML carries a failure with no text at all)

  pickingAFileThroughTheSystemPickerFillsInTheFileCard
    androidx.test.uiautomator.StaleObjectException
      at androidx.test.uiautomator.UiObject2.click(UiObject2.java:526)
    (3 aborts; the picker's root node was rebuilt between finding it and tapping it)

Both pass on API 33 and API 36 locally -- whole suite, 59/0/0/2 on each -- which is
the same evidence pattern that made the Media3EngineTest pair the image rather than
the app.

So the class carries @FailsOnEmulatorApi37 and runs on the advisory leg.

THREE PLACES SAID "nothing in this suite touches system UI", and that is what makes
the SystemUI-disable deviation defensible. It is no longer true of the suite, and all
three are corrected rather than left to rot -- the workflow comment, run-e2e.sh's
header, and the doc. The rule they state is being APPLIED, not broken: the thing that
depends on system UI is excluded from the leg that cannot be trusted for it.

Two consequences stated rather than left to be discovered:

- run-e2e.sh applies no annotation filter, unlike CI, so a local `run-e2e.sh 37`
  reports these two on top of the Media3 pair AND DOES NOT FINISH. Its totals come
  back short and which later tests ran is arbitrary. The summary row now says so;
  it previously promised "exactly two failures", which would have read as a
  regression in someone else's diff.
- The advisory job is still named "E2E API 37 Media3 hardware transcode", and half
  of what it now runs is neither. Renaming a check touches branch protection, so it
  is deliberately not done here; the doc records the staleness and the revisit
  trigger now says the marker covers two unrelated bugs that can go green apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 20:58:34 -05:00
JMR-devandClaude Opus 5 650ca8fca3 Pick a file the way a user does, then rotate the phone
Two things nothing in this repo asserted, and they are one test class because
separately the second one asserts nothing new.

THE PICKER. ConverterScreen opens SAF with a MIME filter, and a filter is a thing
that can hide the user's file. Narrow it and the app still builds, still renders,
and still passes every JVM test -- the user taps "Choose file" and gets an empty
picker. The round trip now runs for real: DocumentsUI is driven with UiAutomator to
a fixture root, and the app is asserted to come back with the file.

The file card's name is not the only assertion, because a name proves less than it
looks: it comes from a metadata query, which a URI with no read grant answers just
as well. The "Container: MP4" detail row only appears once something has opened the
file and read its header, so it is what says the picker handed back a URI the app
can USE.

THE ROTATION. MainActivity declares no configChanges, and ConversionViewModel holds
the picked file in a plain MutableStateFlow with NO SavedStateHandle behind it.
Nothing persists it. The only thing that carries it across a rotation is the
ViewModelStore the Activity retains -- which no test anywhere asserted.

Two guards run before that assertion, because both ways it could pass while proving
nothing are silent: the display rotation really changed, and MainActivity really was
a different instance afterwards. Without the second one this is a recomposition test
wearing a rotation's name.

MUTATIONS, RUN RATHER THAN ASSERTED, on a local API 34 emulator.

Narrowing the filter to arrayOf("application/x-lmc-no-such-type") takes the fixture
root out of the picker entirely -- DocumentsUI matches the request against
Root.COLUMN_MIME_TYPES and drops roots that cannot answer -- and both tests fail:

  java.lang.IllegalArgumentException: the system picker never showed
      BySelector [TEXT='\QLMC R38 fixtures\E']

Making the ViewModel composition-scoped fails ONLY the rotation test:

  androidx.compose.ui.test.ComposeTimeoutException: Condition (a node tagged
      converter.fileCard.name exists) still not satisfied after 30000 ms

and :app:testDebugUnitTest stays BUILD SUCCESSFUL under it. That divergence is what
#64 exists to establish and what its own comment doubted; the PR body has the
verdict and why the doubt was reasonable.

THE PROVIDER HAD TO BE JAVA. It is the only Java file in the module. A
manifest-declared provider is a component of the instrumentation PACKAGE, so the
system starts a plain org.libremediaconverter.test process for it with only the test
APK on its dex path -- and the test APK is built without the Kotlin stdlib, because
the app APK has it and duplicating it is what checkDebugAndroidTestDuplicateClasses
prevents. The Kotlin draft died on its first query:

  java.lang.NoClassDefFoundError: Failed resolution of: Lkotlin/jvm/internal/Intrinsics;
      at org.libremediaconverter.saf.FixtureDocumentsProvider.queryDocument

The compiler emits that reference for the null checks on nearly every function, so
no Kotlin dialect avoids it. Same reason nothing in that file imports androidx.

No new test tags: CHOOSE_FILE, FILE_CARD_NAME and detailRow already named both ends.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 20:36:31 -05:00
JMR-devandClaude Opus 5 b18f45def7 Give the system file picker something to pick
Nothing in either source set drives SAF as a picker. The only SAF coverage is the
publish side, in OutputPublisherPublishTest, against hand-written ContentProvider
fakes -- so the launcher wiring in ConverterScreen, the MIME filter it passes, and
the grant that comes back have never been executed by a test.

Driving the real picker needs three things this repo did not have.

UiAutomator, because DocumentsUI is another process. Compose's matchers stop at this
process's composition and Espresso's stop at its view hierarchy; neither can see or
tap a window belonging to another package.

It FLOATS, at "2.+", which is the same argument the catalog already makes for work
and lifecycle rather than a new one: androidx.test.uiautomator is inside
floatedGroupPrefixes, so the componentSelection guard makes "+" mean "newest
RELEASED", and that is load-bearing here -- this library publishes 2.4.0-alphas above
its stable, so without the guard the float would be a pin to a prerelease. Resolved
to 2.4.0 (released) on debugAndroidTestRuntimeClasspath, checked rather than assumed.
It is deliberately NOT pinned alongside ktlint/detekt/JaCoCo/Robolectric: those are
pinned because a new rule or a new runtime changes the verdict on files nobody
touched. UiAutomator has no verdict -- it taps what a selector names, and a selector
that stops matching is this repo's test to fix, in a diff that explains itself. The
"2." rather than a bare "+" is the one thing held back: a major is where the selector
API would be free to change under exactly that assumption.

A DocumentsProvider, because DocumentsUI does not browse a filesystem -- it lists what
providers offer it. Writing a file into Downloads would have worked and tested less:
the fixture root declares Root.COLUMN_MIME_TYPES, and DocumentsUI filters the drawer
by it, which is what gives the MIME filter a mutation with a shape rather than "one
file among the hundreds in Downloads was not listed". Its contents are also exactly
one file, where a shared directory accumulates whatever earlier runs left behind.

And the first AndroidManifest.xml this source set has ever had, to declare it -- a
ContentProvider is instantiated by the system and cannot be registered from test
code. In androidTest rather than src/debug so it is installed by the instrumentation
APK only, and never appears in a developer's own file picker.

Two things worth knowing before editing either file. XML comments cannot contain "--",
which the manifest's first draft failed the build on; and "*/" inside a KDoc closes
the comment, which the provider's did. Both are silent in review and loud in the
build.

No test yet, and no new test tag: TestTags.Converter.CHOOSE_FILE and FILE_CARD_NAME
already name both ends of the round trip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 20:15:33 -05:00
JMR-devandClaude Opus 5 225ecdd7e6 Split the API 37 leg so the part that works can gate
CI has never run the API level this app targets. The reason it did not was
never "API 37 is untestable" -- it was that two tests fail on the emulator
image, so one row would be permanently red or permanently allow-listed. This
splits that row instead of choosing between those two.

E2E API 37 gates. It runs 55 of the suite's 57 instrumented tests and must be
green. E2E API 37 Media3 hardware transcode runs the other two, reports, and
never blocks (continue-on-error). Both are driven off ONE marker,
@FailsOnEmulatorApi37: the gating job passes notAnnotation, the advisory job
passes annotation. Two lists would drift, and drift is silent in both
directions -- a test that ends up in neither job reads as green. Excluding by
class was not an option either: Media3EngineTest has four tests and two of
them pass here, so notClass would have thrown away real coverage.

The advisory job is named for what it runs, not for what we think is wrong.
Both its tests drive a full H.264 -> H.265 hardware transcode, which is what
distinguishes them from the two Media3EngineTest cases that pass -- those
never decode video. The goldfish-decoder theory sits in a comment inside the
job, where it can be corrected without renaming a check people have learned to
look for; docs/api-37-emulator-crash.md keeps measurement and inference apart.

The SystemUI disable moves into .github/scripts/e2e-run.sh behind
E2E_DISABLE_SYSTEM_UI, unset everywhere but the two API 37 jobs, so the other
four legs run byte-identical commands -- the same shape as
E2E_EXTRA_GRADLE_ARGS. It runs BEFORE the streamed logcat starts, deliberately:
`adb shell stop` would end that logcat and nothing restarts it, so a disable
placed after it would cost the leg its diagnostics for the part of the run that
matters. The body is probe v2 from api37-debug.yml -- the version measured 4/4
-- not the older one-round form: three rounds, waits for system_server to
actually be gone, verifies against `pm list packages -d`, and requires a 45 s
window with zero new aborts. The weaker probe reported success on a run that
then started SystemUI eight more times.

The caveat is written next to the row rather than left implicit: this leg runs
with SystemUI disabled and the framework restarted under it, a device
configuration no other leg and no Pixel run uses. Anything that touches system
UI must not trust it, and the Pixel check before each release is still the only
API 37 run with SystemUI intact.

docs/api-37-emulator-crash.md's "So should CI take API 37?" said no on three
reasons. Two were claims about CI that had never been measured; the section now
carries the eight runs that measured them, and the third reason is what the
split answers. docs/local-emulator.md and api37-debug.yml's header carried the
same "the matrix stops at 36" claim and are corrected with it.

CLAUDE.md is left alone deliberately -- its "CI's matrix therefore stops at API
36" clause is now false, and that correction is parked in the doc's existing
"Correction owed to CLAUDE.md" section, where two others are already waiting.

Making E2E API 37 an actually-required check is a repository-settings change
and must come after this is on main: adding a required context that does not
exist on the default branch blocks every PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 17:20:49 -05:00
JMR-devandClaude Opus 5 2a68f03134 Give every job a staging path of its own
`<cacheDir>/conversions/` is shared by the convert tab, the join tab and `ConcatEngine`, and
until now none of the three named a file that belonged to one job. A conversion derived its name
from the input's display name, so two `holiday.mp4` from different folders wrote the same file.
A join used the constant `joined.<ext>`, so any two joins of one format did. The list file was
the constant `concat_list.txt`, so any two joins at all did, and one of them would read the
other's input list.

The naming half is not a hypothesis. Two independent conversions on a Pixel each computed
`cache/conversions/input_converted.mp4`, the second silently overwrote the first, and a tag query
in a fresh process then returned **two SUCCEEDED `WorkInfo`s naming that one file** with one file
on disk. That is the collision reaching the point where it makes a *fix* ambiguous rather than
just a file: `Reattachment` can offer the bytes, because they are the user's either way, but it
cannot say which job produced them.

`StagingNames` keys the name on the WorkManager request id. That id is what stays still across a
retry -- `WorkerWrapper` builds `WorkerParameters` from the `WorkSpec` id and only increments
`runAttemptCount` -- which matters more here than uniqueness does, and matters more since the
previous commit made retries routine. A failed attempt deletes its staged file on the way out,
and that only collects the partial the *previous* attempt left when the name has not moved.

Opaque rather than sanitised, deliberately. The staged name is never shown to anyone: `save()`
recomputes a suggested name and the user picks the real one in the SAF dialog. So there was
nothing to lose by dropping the display name, and something to gain -- a provider-supplied
display name can contain a separator, be empty, or be four kilobytes long, and `File(stagingDir,
"../escape_converted.mp4")` resolves to a path outside staging. That was reachable before this
commit and is now unreachable by construction rather than by a sanitiser that has to be right
about every case. There is a test for exactly that name.

The extension stays, and is not decoration: `FFmpegConcatCommand` names no output muxer, so
FFmpeg infers it from the output path. A fully opaque name would quietly produce the wrong
container.

`ConcatEngine`'s list file is derived from the output it belongs to rather than taking another
parameter, so the two cannot drift apart, a directory listing shows which list belongs to which
join, and the sweep ages them together.

Three neighbouring comments claimed things that are no longer true, and are corrected rather than
left to mislead the next reader:

  - `Reattachment.Ambiguous` said it "resolves on its own once each job stages under a name of
    its own". It now does -- for work enqueued from here on. The case is **kept**, because the
    queue outlives the change: WorkManager holds finished work for about a week, and the jobs
    likeliest to be sitting in it when this code first runs are the ones named the old way.
    Behaviour is unchanged and `ReattachmentTest` is untouched.
  - `OutputPublisher.sweepStaging` justified its age rule partly on there being "no per-job
    namespacing". There is now, and the rule still stands on its own: per-job names stop two jobs
    from sharing a file, and say nothing about whether a file's job is still running, which is the
    question a sweep actually asks. Same for `StagingSweep` and the note in
    `LibreMediaConverterApp`.
  - Both ViewModels' `reattach()` explained aliasing as something nothing prevented. Narrowed to
    what is still true of work already in the queue.

`ConcatEngineTest` asks `StagingNames` for the list file's name instead of spelling out
`concat_list.txt`. That is the difference between a test and a tautology: a literal there would
have gone on passing after the rename while asserting that a file nothing creates does not exist.
The same trap was live in the two worker tests from the previous commits, whose staged-file
assertions computed a path of their own -- they now assert on the staging directory being empty,
which cannot go vacuous when a name moves.

`PerJobStagingTest` drives the real worker, because the naming function was never the part that
was wrong: what was wrong was which name the worker asked for. Two jobs converting one file must
leave two files; a second attempt at one job must not leave a second; and a display name that
climbs out of staging must not. The first and third fail before the change with "each job must
have staged its own file, found [input_converted.mp4] expected:<2> but was:<1>" and "the output
belongs in staging expected:<1> but was:<0>" -- the latter because the file had landed in
`cacheDir` instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 20:11:44 -05:00
JMR-devandClaude Opus 5 ec969c41dc Reattach to conversions and joins the ViewModel did not start
The queue surviving process death is the stated reason this app uses WorkManager, and
the ViewModel was where that protection stopped. `activeWorkId` and `observer` are
plain fields, so a process reclaimed after a conversion finished came back to Idle
while the output sat in `cacheDir` with nothing in the UI able to reach it. The
realistic window is not a crash mid-transcode -- it is the job finishing, the user not
saving yet, and the process being reclaimed hours later as an ordinary background one.

Both ViewModels now query their own worker's class name on init and pick up what they
find. Nothing is persisted for it, and nothing needed to be: `WorkRequest.Builder`
seeds every request's tag set with `workerClass.name` (`tags = mutableSetOf(
workerClass.name)`, work-runtime 2.11.2), and R8 keeps those names through
work-runtime's own consumer rule, `-keepnames class * extends
androidx.work.ListenableWorker`. A UUID in a `SavedStateHandle` would have been both
more machinery and less: it cannot find work enqueued by a previous install.

What the query cannot return is the job's input. `WorkInfo` hands back id, state, tags,
progress, output and run-attempt count -- never the `Data` a request was enqueued with
-- so a ViewModel could see that a conversion existed and where its output went, but
not which file it was converting. The display name and size therefore ride on tags too,
which is the whole of the production change to the request builders. The picked `Uri`
deliberately does not: nothing in a reattached state reads it, and a `content://` grant
taken by a picker in a process that no longer exists is not something to hand back as
though it still worked.

The decision is a pure function on the JVM test stack, following `FailureOutcome`:
`Reattachment.choose` takes what WorkManager reported and answers which job, if any.
Cancelled work is excluded -- the user already said no. Failed work is excluded, which
matters more than it looks now that a device has shown an interrupted worker coming
back FAILED rather than retried, its restart's `setForeground` refused as a background
foreground-service start: nothing marks a failure as seen, so it would otherwise
reappear on every launch. A success whose staged file is gone is excluded, because a
Save button that fails on tap is worse than no button. Live work outranks a finished
result, since a running job holds a foreground notification and someone opening the app
while that notification is in the shade is looking for that conversion.

Where several jobs qualify, the newest staged file wins, and that is not a detail.
Losing a tie is not the same as waiting for the next launch: the tag query has no
`ORDER BY`, so its order is unspecified but stable, and an arbitrary winner would keep
winning every launch while the other result stayed unreachable for as long as its file
existed. It is reachable today -- dismiss one result with "Start over", which leaves its
file behind, then convert something else and do not save it. `WorkInfo` carries no
timestamp of any kind, but the edge is already stat'ing the file, so the file's own
mtime is the ordering. A clock that moves backwards makes it a heuristic; an order that
is unspecified and repeats itself is worse.

Aliases are the tie that does not resolve, and that case is not hypothetical. A tag
query on a device returned two SUCCEEDED jobs whose output paths were both
`.../conversions/input_converted.mp4`, with one file on disk -- the staging name is
derived from the input's display name, so a later job overwrites an earlier one's output
and both go on reporting it. Checking the file does not separate them, and neither does
its mtime, since they share it. So the two halves are separated instead. The file is
offered, because it is the user's file either way and losing it is the defect being
fixed; the label is not, because saying which job produced it would be a guess.
`Reattachment.Ambiguous` says so and the card falls back to a neutral name rather than
borrowing the other job's. Aliases whose tags are identical -- the ordinary case, the
same file converted twice -- stay attributed, since nothing turns on which wrote it.

Age is not filtered on, and cannot be: WorkManager keeps finished work about a week and
prunes on its own schedule, so a query in a fresh process routinely returns jobs from
earlier sessions. Whether the staged file is still there is the only signal separating a
result still worth offering from one already dealt with, which is why that check carries
the weight.

Two smaller behaviours fall out of reattaching rather than starting:

  - A reattached job that is cancelled lands on Idle rather than Ready. Ready would put
    a Convert button over an input URI that belongs to a dead process. `observe()` takes
    the cancelled destination as a defaulted parameter, so a job started here is
    unchanged.

  - A FAILED job's message falls back when blank, not only when absent. A worker killed
    before it can report leaves no output data at all, and an exception's message can be
    the empty string; both used to reach the screen as a failure with nothing said.

Deliberately not fixed here, each being its own change: the save dialog's suggested name
and MIME still come from the current picker rather than from the job that ran, so a
reattached job in a non-default format is offered the default extension; a result
dismissed with "Start over" still keeps its staged file, so it can be offered again next
launch -- the file check closes that for free once the file is deleted; and
`setForeground` still sits outside `doWork`'s try, so its throw bypasses the retry
decision entirely.

Tested where it can be. 28 JVM tests cover the choice and the tag round trip, including
the ordering, the aliasing rules and a display name that looks like another tag. The
reattachment itself is instrumented: a ViewModel constructed against the real
WorkManager is the next launch, with no memory of the work. `WorkManagerTestInitHelper`
is deliberately not used -- its `setDelegate` replaces the singleton for the whole
process, which would quietly turn `ConversionWorkerTest` into a synchronous test double
depending on class order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 18:43:10 -05:00
JMR-devandClaude Opus 5 f4962913e1 Correct the JDK claim, and finish the @UnstableApi propagation
Two things the first pass got wrong.

CLAUDE.md said "use a JDK 17-21, AGP 9 does not support 25+". That was carried
over from the sibling repo and is not true here: gradle-daemon-jvm.properties
pins toolchainVersion=25, so Gradle provisions and runs the daemon on Java 25
whatever JAVA_HOME says -- JAVA_HOME only picks the launcher. `gradlew --version`
prints both, and shows them differing on this machine right now. It also means
CI's java-version: '17' is not the JDK that compiles anything, and that the
daemon JVM is the same on a laptop as on a runner, which is a better guarantee
than the one the file claimed.

Marking ConversionDependencies @UnstableApi propagates to its callers, and
FakeFailures in androidTest calls it. That is a warning rather than an error in
Kotlin, and lint does not read the androidTest source set, so the previous
commit compiled clean while leaving one file inconsistent with the very pattern
it described. Marked now.

Left alone deliberately: gradlew.bat. The new `*.bat text eol=crlf` attribute
governs how it is checked out from here on, which is the point of adding it,
and the file already has CRLF in both the tree and the index. Rewriting the
stored bytes of the wrapper script to prove the attribute works is not this
branch's business.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:24:16 -05:00
JMR-devandClaude Opus 5 496f1c7e73 Apply ktlintFormat
Tool output only, no hand edits, so this is safe to read with whitespace
diffing off. It is its own commit for exactly that reason: a whole-repo
reformat folded into the commit that configured the formatter would have
made both unreviewable.

What it did, mostly: trailing commas on wrapped argument lists, signatures
collapsed onto one line where they fit inside 120 columns, import order, and
four genuinely unused imports removed. Unit tests pass unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:10:44 -05:00
JMR-devandClaude Opus 5 b5d5fcfeff Merge Media3's MP4-only correction into the remux branch
CI on the parent branch proved that four of the five containers the router
claimed for Media3 cannot be written by Transformer at all: WebmMuxer, OggMuxer,
WavMuxer and AacMuxer each throw UnsupportedOperationException from
addMetadataEntry, which MuxerWrapper calls for every metadata entry on the track
format.

Consequences here beyond the merge itself:

- MEDIA3_MUXABLE_VIDEO and MEDIA3_MUXABLE_AUDIO drop to a single MP4 entry.
  Every other container is already on its way to FFmpeg before those maps are
  consulted.
- Reason.WEBM_CODEC_UNSUPPORTED is removed. WebM now fails the container check
  first, so nothing could ever produce that reason, and a routing reason no code
  path can reach is worse than no reason at all.
- Media3Muxers gains null branches for the six containers this branch adds. MOV
  is among them despite being MP4's own family: Mp4Muxer exposes no QuickTime
  file format.
- The README no longer claims Media3 writes five containers.

The remux behaviour this branch exists for is unaffected: MKV -> MP4 was always
the hardware direction, because Media3 reads Matroska but has never been able to
write it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 07:28:59 -05:00
JMR-devandClaude Opus 5 92b7395ba9 Media3 can only write MP4, so stop claiming otherwise
CI proved the WAV and Ogg exports this branch added cannot work, and the reason
generalises further than those two.

media3-muxer 1.11.0 ships WebmMuxer, OggMuxer, WavMuxer and AacMuxer, which is
why MEDIA3_CONTAINERS listed the matching containers. But all four throw
UnsupportedOperationException from addMetadataEntry, and
MuxerWrapper.addTrackFormat calls it for every metadata entry on the track
format. Any real recording carries at least a creation timestamp, so the export
dies partway through:

    Caused by: java.lang.UnsupportedOperationException
        at androidx.media3.muxer.OggMuxer.addMetadataEntry(OggMuxer.java:123)
        at androidx.media3.transformer.MuxerWrapper.addTrackFormat(MuxerWrapper.java:488)

They are standalone muxers, not Transformer-compatible ones. WAV fails a second
way before even reaching that: DefaultEncoderFactory has no PCM encoder, so
Transformer reports "No MIME type is supported by both encoder and muxer"
instead of passing raw samples through.

Both observed on an API 35 emulator in CI, not inferred. The tests that found
them were written on the assumption these containers worked.

So MEDIA3_CONTAINERS becomes {MP4}. That the set was wrong went unnoticed
because the engine ignored the container and wrote MP4 regardless — the set
being wrong and the engine being wrong cancelled out. WAV, Opus and raw AAC move
to FFmpeg, which already produces all three with instrumented coverage asserting
the produced files.

WEBM_VP9's routing reason changes from NO_PLATFORM_ENCODER to
CONTAINER_UNSUPPORTED. Both were always true; the container is the more
fundamental, since even given a VP9 encoder the file could not be written.

The audio-only regression guard this branch exists for passed on API 35: M4A
output now carries exactly one AAC track and no video.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 07:17:11 -05:00
JMR-devandClaude Opus 5 2e0cf2f5d6 Let the user pick a container and codecs independently, and remux without re-encoding
OutputFormat was a closed enum of twelve (container, videoCodec, audioCodec)
triples, defended on the grounds that a closed set was what made routing
decidable. Two things it could not express: changing the container while copying
the streams, and choosing codecs per track.

OutputSpec replaces it as the vocabulary; OutputFormat stays as presets over it.
Decidability moves to ContainerCapabilities, which is explicit and unit-tested
rather than implicit in whichever combinations somebody enumerated.

The matrix is indexed by (container, codec, trackType, mode), not one boolean.
"Can MP4 carry AV1" and "can this app encode AV1" have different answers, and
copy is where the difference shows: a single flag would refuse a legitimate
remux or promise an encode neither engine can deliver.

COPY is a codec value rather than a flag, so every exhaustive `when` in the
codebase had to say what it does about copying. CopyPlanner resolves it before
anything else reads the request, and inherits ConcatPlanner's rule that an
unproven match is never a copy — a needless re-encode costs time, a wrong stream
copy costs a file that will not play.

Container now drives -f, the extension and the SAF MIME type, so Matroska
without video is .mka and MP4 without video is .m4a without a preset for each.
FLAC was declared as Container.MKV with a .flac extension, inert only while
nothing read the container; it now has its own. Six containers added: MOV, MKV
audio, MPEG-TS, AVI, FLV and WMV/ASF.

Routing asks the plan, never the request. COPY belongs to none of the capability
sets, so testing the request directly sends every remux to FFmpeg on the first
check — and nothing notices, because -c copy produces a correct file, just on
the CPU. The router also learns what Media3 can *carry* as opposed to encode:
its MP4 muxer takes AAC, Opus, Vorbis and PCM but neither MP3 nor FLAC.

MediaProbe now separates "no video track" from "could not parse" and reports the
source container, which MediaExtractor cannot supply at all. FFprobe runs on
every pick for that reason, not as a fallback.

The Advanced picker shows the whole matrix and lets an impossible combination be
selected on purpose, then explains it and offers alternatives. Convert is what
blocks the job. ConversionWorker validates too, so a stale queued spec fails with
the reason rather than being coerced into something else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 07:07:52 -05:00
JMR-devandClaude Opus 5 00c422f317 Make Media3 write the container and codec it was asked for
Media3Engine never called setMuxerFactory or setAudioMimeType, and built a bare
EditedMediaItem, so it always produced MP4 with an H.265 video track. The router
meanwhile sends it WebM, Ogg, WAV and AAC-ADTS jobs, plus audio-only M4A, Opus
and WAV — and ConversionWorker.media3MimeType() mapped VideoCodec.NONE through
its else branch to VIDEO_H265.

The visible result: "extract audio to M4A" transcoded the video to HEVC and
named the file .m4a. Nothing failed, and nothing caught it, because
Media3EngineTest had no audio-only case at all.

media3-muxer already ships WebmMuxer, OggMuxer, WavMuxer and AacMuxer; only the
MP4 ones come pre-wrapped as a Muxer.Factory. Media3Muxers supplies the rest.
Their reported sample MIME types are read from each muxer's own support check
rather than assumed, because Transformer uses those lists to decide whether a
track needs re-encoding.

HardwareTranscoder.transcode now takes the OutputFormat instead of a video MIME
string, which is what gives the container, the audio codec and "this output has
no video" somewhere to travel.

MEDIA3_CONTAINERS stops being private so a test can assert it agrees with the
factories. Those two drifted once already: the router's set was right the whole
time the engine was ignoring it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 07:07:06 -05:00
JMR-devandClaude Opus 5 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>
2026-08-20 18:31:43 -05:00
JMR-devandClaude Opus 5 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>
2026-08-20 13:01:42 -05:00
JMR-devandClaude Opus 5 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>
2026-08-20 10:02:36 -05:00
JMR-devandClaude Opus 5 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>
2026-08-20 09:54:06 -05:00
JMR-devandClaude Opus 5 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>
2026-08-20 09:45:48 -05:00
JMR-devandClaude Opus 5 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>
2026-08-20 09:39:43 -05:00
JMR-devandClaude Opus 5 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>
2026-08-20 09:12:12 -05:00
JMR-devandClaude Opus 5 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>
2026-08-20 08:21:37 -05:00
JMR-devandClaude Opus 5 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>
2026-08-19 22:47:37 -05:00
JMR-devandClaude Opus 5 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>
2026-08-19 22:39:19 -05:00
JMR-devandClaude Opus 5 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>
2026-08-19 21:27:03 -05:00
JMR-devandClaude Opus 5 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>
2026-08-19 21:18:44 -05:00
JMR-devandClaude Opus 5 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>
2026-08-19 21:18:28 -05:00
JMR-dev 18ae2cff80 initial commit 2026-08-19 17:29:15 -05:00