5c27801c77c781bdcfd62b1122a6a65bceee2266
21
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
5c27801c77 |
Retry work the system refused to start, instead of failing it terminally
`ConversionWorker`'s KDoc says the queue survives process death, and that is the app's stated
reason for choosing WorkManager at all. A Pixel 10 Pro XL says otherwise. An 18-minute
transcode was killed mid-write with `kill -9`; 119 seconds later, on a natural dispatch with
no `cmd jobscheduler run` anywhere in the session, WorkManager recovered it by itself and the
system refused it:
WM-ForceStopRunnable: Found unfinished work, scheduling it.
ActivityManager: Background started FGS: Disallowed [callingPackage:
org.libremediaconverter; uidState: CEM; BFGS denied: true; code:DENIED]
WM-WorkerWrapper: android.app.ForegroundServiceStartNotAllowedException
WM-WorkerWrapper: Worker result FAILURE
WM-Processor: Processor 3d1c9862 executed; reschedule = false
`Found unfinished work` is the clean process-death recovery path, not a force-stop. The job was
ordinary -- `Priority: 300 [DEFAULT]`, not expedited -- so there was no allowance it could have
carried. `HAS_FOREGROUND_EXEMPTION` was set on it and it was still denied; that flag governs the
runtime guarantee once a service is running, not permission to start one.
The cause is structural rather than subtle. `setForeground(...)` sat above `return try {`, so its
throw escaped `doWork()` without reaching `handleTimeoutIfNeeded`, `Result.retry()`, the
`workDataOf(KEY_ERROR ...)` or `staged.delete()`. Three separate costs, all measured on the
device: `reschedule = false`, so nothing ran again despite `run_attempt_count=2`; output `Data`
of `X'ABEF000100000000'`, a header with zero entries, so the screen said "Conversion failed."
with nothing to add; and 2 MB of a partial `long_input2_converted.mp4` left in staging.
`ConcatWorker` had the same shape.
So `setForeground` moves inside the `try` in both workers, and the staging handle is named just
above it -- `createStagingFile` only builds a path, nothing is written until an engine opens it,
so naming it early costs nothing and makes the catch total. `MediaProbe.probe` was outside the
`try` for the same reason and had the same problem; it is now inside too. On a retry the staged
name is unchanged, so the delete on the way out collects the partial the killed attempt left
behind rather than orphaning it.
What to return was the real decision. `Result.retry()`, because the denial is about *when* the
job ran and not about the job: the file is fine, the settings are fine, and the one thing that
grants an app permission to start a foreground service is being in the foreground, which the
user supplies by opening the app. But WorkManager never gives up on its own, so an unbounded
retry means a job nobody comes back for waking the device forever while the screen says "paused"
and never explains itself. `FailureOutcome` therefore bounds it at ten attempts, chosen against
the default backoff rather than as a round number: exponential from
`WorkRequest.DEFAULT_BACKOFF_DELAY_MILLIS` (30 s), doubling, clamped at `MAX_BACKOFF_MILLIS`
(5 h), which spans about 8 h 30 m before the eleventh attempt fails with a message the user can
act on. The counter is `runAttemptCount`, which counts every attempt and not only denied ones --
WorkManager exposes no other -- so a transcode already retried ten times by the six-hour budget
will fail on its first denial rather than getting ten of its own. That is accepted rather than
overlooked, and the timeout branch ignores the count entirely so the budget's own retries are
untouched.
The rule goes in `FailureOutcome` rather than beside it, which is what its KDoc asks for: isolate
a decision whose triggering condition cannot be provoked in a test. It now reads the stop reason
*and* the cause, with precedence stated rather than left to branch order -- once something has
stopped the worker, the exception it was holding describes that stop and not a reason of its own.
The match is on `ForegroundServiceStartNotAllowedException` exactly, never on its
`IllegalStateException` supertype: a muxer that was never started throws one of those too, and
matching the supertype would retry every ordinary failure for eight hours.
Expedited work is still not used, and this is not an argument for it. It maps to JobScheduler
expedited jobs with a short quota, which is the wrong shape for a multi-minute transcode; the
exemption it buys is for starting, and the quota it costs would be paid by every job.
Tested at both levels, because only one of them bites. `FailureOutcomeTest` gains six cases for
the rule -- retry at the bound, give up past it, an unrelated `IllegalStateException` that must
not be mistaken for a denial, a stop reason winning over the exception, and the budget timeout
ignoring the count. `DeniedForegroundStartTest` covers the wiring, which is where the defect
actually was: a `ForegroundUpdater` whose future completes exceptionally makes `setForeground`
throw exactly what the platform throws, because `WorkForegroundUpdater`'s own comment says it
propagates the exception to the caller and `ListenableFuture.await()` unwraps the
`ExecutionException` on the way. Moving `setForeground` back above the `try` turns all four of
those red with a bare `android.app.ForegroundServiceStartNotAllowedException`, which is the same
line the device logged.
Four comments claimed the old behaviour and are corrected with it. `ConversionState.Waiting`
said the budget had run out; `observe()`'s ENQUEUED branch said the budget was the likely cause,
where a denied restart is now the likelier of the two; and `Reattachment.choose` justified
excluding FAILED partly on interrupted workers coming back FAILED "rather than retried", which is
the sentence this commit falsifies. The exclusion stands on its own and says so now.
The bound's own KDoc says what giving up does *not* buy, too. `FOREGROUND_DENIED` reports through
a FAILED job and `Reattachment` excludes FAILED, so a user who was not watching at the eleventh
attempt meets an empty screen rather than the message. What the bound reliably buys is the end of
the retrying.
Both `Waiting` screens change their wording. The state is `ENQUEUED` with a run attempt behind
it, which cannot distinguish the two causes that now reach it, and the old copy named only the
six-hour budget -- which is now the less likely of the two. "Keeping the app open helps it along"
covers both, and for a denied start it is not filler but the actual remedy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
eb37f9ebea |
Merge branch 'fix/reattach-unfinished-work' into scratch/integrate-d2-d3
# Conflicts: # app/src/main/java/org/libremediaconverter/join/JoinViewModel.kt |
||
|
|
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>
|
||
|
|
cfd705af05 |
Delete staged output the user never saved, instead of waiting for the OS
Every conversion writes a full-size file into <cacheDir>/conversions/. save()
published it and deleted it, but reset() -- what the "Start over" button on the
Converted and Joined states calls -- dropped the File reference and left the file
behind. Converting something and deciding not to save it is an ordinary path
through the UI, so it leaked a full-size copy every time. cacheDir is evictable,
so this was never unbounded growth; it was the app relying on the OS to clean up
after it, and on a device under no storage pressure "eventually" means never.
OutputPublisher.clearStaging() was written for exactly this and called from
nowhere. It is NOT wired up here -- it is deleted. It emptied the directory
unconditionally, and the convert tab, the join tab and ConcatEngine's
concat_list.txt all share that directory with no per-job namespacing (D8), so a
blanket delete could take a file out from under a running job. Two narrower
methods replace it:
discardStaged(file) one file, guarded. The handle reaches the ViewModel as a
path string in WorkInfo.outputData and becomes a File with
nothing checking where it points, so this compares the
CANONICAL parent against the staging dir -- the naive
string comparison accepts conversions/../elsewhere.
sweepStaging(now) age-based, for orphans no ViewModel is left to clean up.
The cleanup handle is a ViewModel field, not something read back out of the state
machine, because the state machine cannot answer it on the path that needs it
most: a failed save lands on Failed(message), which carries no file reference at
all. On that path the file is deliberately kept -- it may be the only copy of an
hour of transcoding and the destination did not receive it, so deleting to tidy a
cache directory would destroy the work. It stays collectable by a later reset()
or by the sweep.
The sweep runs once per process from a new Application subclass, off the main
thread. The reason it cannot race a live job is the grace period, not ordering:
WorkManager initialises through androidx.startup's InitializationProvider, a
ContentProvider, so it is already up before onCreate() and can be resuming a
worker in this same process while the sweep runs. StagingSweep only collects a
file nothing has written to for 24 hours. Outputs are written continuously and
keep their own mtime fresh; concat_list.txt is the one file written once and then
only read, and a WorkManager attempt is capped by the six-hour foreground-service
budget with retries restarting doWork() from the top, so no attempt can hold a
file still for a day. sweepStaging() also re-reads each timestamp immediately
before deleting, closing the window between listing the directory and acting on
the list -- unlinking an inode a running job still holds open would end with the
job reporting success for a path that no longer exists.
The rule itself is a pure function over (name, lastModifiedMs) pairs and a clock.
Timestamps are values rather than Files so the tests measure the arithmetic --
the grace boundary, and a clock that moved backwards -- rather than the
filesystem's mtime granularity.
Tests cover the tool AND the wiring, because the wiring is where the defect was.
A pure rule test and a Robolectric test of OutputPublisher both stay green when
the discardStaged call is deleted from reset(), which would have made the number
read as coverage of a bug that was still there. So both ViewModels are driven --
through a real WorkManager, to Converted/Joined -- and then asserted on the
filesystem: Start over deletes the staged file; a successful save leaves nothing
to delete twice; a failed save keeps the file and a later reset collects it, which
pins the argued decision above rather than leaving it as a comment. Verified by
deleting the discardStaged line from both reset() methods: 4 of the 6 fail with
"reset() should have discarded exactly the staged file expected:<[...]> but
was:<[]>", and the two save-path tests correctly stay green.
Three things made that reachable, all reusing what was already here:
- Both ViewModels now resolve their publisher through ConversionDependencies,
like the workers already did. They were the only place bypassing the seam.
- MediaProbe joins that seam too. It spawns FFprobe, and FFmpegKit's loader
throws a bare java.lang.Error when the native library is absent -- which its
own `catch (e: Exception)` cannot catch, so every JVM test died on the file
pick. Instrumented tests are unaffected and still get the real probe.
That error path is a LATENT PRODUCTION HAZARD, recorded in the KDoc and
deliberately not fixed here: onInputPicked does not catch it either, so a
missing .so would surface as an uncaught error rather than the "could not read
this file" the code was written to give. It cannot fire on a device that ships
the libraries, so widening MediaProbe's catch to Throwable would change the
pick path on the strength of a condition no user meets. Its own commit.
- reset()'s cleanup dispatcher is a constructor parameter defaulting to
Dispatchers.IO, which makes the delete assertable and states the ordering --
Idle is published synchronously, the delete is dispatched -- as a decision
rather than an accident. @JvmOverloads keeps the single-argument constructor
that viewModel()'s AndroidViewModelFactory looks up reflectively.
androidx-work-testing was already in the catalog and already inside the prerelease
guard via its androidx. group, so it needed no new pinning argument.
Robolectric is added for the one assertion no pure function can make: that the
file is really gone from a real cacheDir. It is PINNED at 4.16.1 and belongs with
ktlint/detekt/jacoco rather than the floating libraries. The prerelease guard in
app/build.gradle.kts only covers androidx., junit and com.arthenica, so
org.robolectric is unguarded and a "4.+" would resolve to 4.17-beta-3; beyond
that, a bump changes which android-all jar the tests execute against, which is the
same "a tool moved under a diff that cannot explain it" failure the linters are
pinned for.
Two things Robolectric needed. testOptions did not exist in this module at all;
it now sets isIncludeAndroidResources so the merged manifest and resource table
reach the JVM tests, and grants --enable-native-access, which Java 25 otherwise
warns about four times per run when Robolectric's native runtime calls
System.load(). And robolectric.properties pins sdk=36: Robolectric defaults to the
manifest's targetSdk of 37, there is no android-all jar for 37, and the class
fails to initialise before any test body runs. 36 is where CI's emulator matrix
already stops, so this does not widen the gap -- API 37 was already a manual check
on the Pixel 10 Pro XL before each release.
Known and left alone: a conversion that fails inside the worker never reaches
Converted, so pendingStaged is never set and any partial output relies on the
sweep alone. reset()'s delete is also fire-and-forget on viewModelScope, so it is
cancelled if the Activity finishes first. The sweep is the backstop for both.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
65a94b4ec1 |
Clear the 35 findings the new tools reported
detekt found 29 and Android lint 6, on a codebase neither had ever seen. Each
one was either fixed or relaxed with the reason written next to it; nothing was
suppressed to make the build quiet.
Fixed, because the tool was right:
- ConversionDependencies constructs Media3Engine, which is @UnstableApi, and
was not marked. Every other type here that touches Media3 propagates the
marker rather than swallowing it with @OptIn, so this one does too. Lint
was the only thing that had ever noticed.
- MediaProbe converted microseconds to milliseconds with a bare 1000, twice,
in a file that also handles a seconds-based duration from a different API.
US_PER_MS and MS_PER_SECOND now say which is which -- that confusion is a
real bug source in media code, not a style question.
- Foreground service types compared SDK_INT against 34 and 35 as raw ints
while the doc comment above spelled the version names out. VERSION_CODES
says it in the code.
- take(3) is a product decision about how many alternatives an error offers.
It means nothing until it is named; MAX_SUGGESTIONS does.
- setProgress(100, ...) is a percentage max, now PERCENT_MAX.
Relaxed, because the rule did not fit:
- The model package is excluded from ReturnCount and CyclomaticComplexMethod
ONLY. It is the decision layer: ConversionRouter.route scores 17 because
the app can give 17 distinct answers to "which engine, and why", each with
its own user-visible reason, and route's own comment records that their
ORDER decides which message is shown. Counting those as complexity measures
how many answers exist, not how hard the code is to follow. Everything else
-- LongMethod, NestedBlockDepth, ComplexCondition -- still applies there.
- A flat `when` used as a lookup table scores a point per entry, so
MediaProbe's demuxer-name to Container map read as complexity 21 with no
nesting and no state. ignoreSingleWhenExpression is the rule's own answer.
- TooGenericExceptionCaught off. MediaProbe, ConversionWorker and ConcatWorker
sit in front of native code that reports a malformed file as anything from
IllegalArgumentException to a bare RuntimeException, undocumented.
Enumerating that list means guessing, and a wrong guess crashes the app on a
file it could have reported as unreadable. SwallowedException stays on, so
these still have to log and handle.
- SI thresholds in the byte formatter, via ignoreNumbers. Each literal sits on
the line with the unit string it belongs to; BYTES_PER_MB would need a Long
and a Double and say nothing the line does not.
- allowedFunctionsPerObject, which the first pass simply missed.
Lint's three version-freshness nags are off. They do not describe this code --
they go red the day someone else publishes a release, which turns a PR red for
something its author cannot see in their diff, and they want the network at
lint time. Upgrades here are deliberate; Kotlin in particular is pinned to AGP's
bundled KGP and is not free to follow the newest release.
UsableSpace is informational rather than disabled, because it is a real finding
that this commit is choosing not to act on. hasSpaceFor reads File.usableSpace,
which ignores reclaimable cache, so the app can refuse a conversion it had room
for. StorageManager.getAllocatableBytes is the better answer, but it changes
when a job is rejected and can throw -- a behaviour change to a safety check,
which deserves its own commit and its own test rather than a drive-by here.
informational keeps it in every lint report instead of hiding it.
Also adds the CI gate and a CLAUDE.md. Coverage is reported and not gated: the
measured baseline is 31% of lines, which is exactly why LibreMail's 0.84 floor
was evidence about LibreMail and not a number to copy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|