The stateful outer composables hand `ConverterScreenContent` and `JoinScreenContent` a list of `viewModel::` references. No test in the suite had ever seen that list: the content tests build their own `ConverterActions`, so they drive the stateless inner and never touch the wiring. THE TICKET'S PREMISE WAS HALF WRONG, AND CHECKING BEAT ASSUMING. #156 was filed claiming a transposition of any two of seventeen bindings would survive the suite. Measured instead of trusted: onVideoCodec <-> onAudioCodec -> REJECTED: "Inapplicable candidate(s): fun setAudioCodec(codec: AudioCodec)" onCancel <-> onReset -> COMPILES Every typed binding -- container, both codecs, preset, suggestion, quality, engine preference -- takes a distinct parameter type, so the compiler is already the test. Writing assertions against those transpositions would have been theatre, and this file says so rather than quietly including them. WHAT IS ACTUALLY AT RISK is the `() -> Unit` bindings, which are interchangeable to the compiler: two on the converter screen (onCancel, onReset) and *three* on the join screen (onJoin, onCancel, onReset). A Cancel button that discards the finished file, a Start-over that leaves it on screen, or a Join button that cancels -- each is one wrong word and each ships. I got that wrong in the first check too: an early run reported the onCancel/ onReset swap as rejected, from a grep-and-exit-code test that misread a stale build. Re-running it properly printed BUILD SUCCESSFUL with the swap in place. THE SEAM. `converterActions(viewModel, onPickInput, onConvert, onSave)` and `joinActions(viewModel, onPickInputs, onSave)`. The launcher-backed actions stay parameters -- they need an ActivityResultLauncher, which is the part that genuinely needs a composition, and keeping them out means the rest needs none. Told apart by effect rather than by a recording double: `reset()` sets the state to Idle, `cancel()` with no active job leaves it alone (`activeWorkId?.let`, which SettingsEditsTest pins). Mutations -- every transposition caught, each by two tests: converter onCancel <-> onReset | 2 tests join onJoin <-> onCancel | 2 tests join onReset <-> onCancel | 2 tests a typed binding dropped to {} | 1 test a launcher action rerouted | 1 test The two-test symmetry is deliberate: one direction alone passes against a wiring with BOTH actions bound to the same method, which is what a copy-pasted line produces. Three guard assertions earned their place during writing -- the picks land through an injected dispatcher, and without `ParkedPickDispatcher.runAll()` all three state-based tests sat on Idle and would have asserted nothing. They failed loudly instead of passing quietly. `@UnstableApi` on both builders, per CLAUDE.md; lint caught their absence, as it did in W1. 525 -> 537 tests, 88.0% -> 88.9% line, 70.5% -> 75.4% branch. Gate green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
296 lines
13 KiB
Kotlin
296 lines
13 KiB
Kotlin
package org.libremediaconverter.join
|
|
|
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
import androidx.activity.result.contract.ActivityResultContracts
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.rememberScrollState
|
|
import androidx.compose.foundation.verticalScroll
|
|
import androidx.compose.material3.Button
|
|
import androidx.compose.material3.Card
|
|
import androidx.compose.material3.LinearProgressIndicator
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.OutlinedButton
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.platform.testTag
|
|
import androidx.compose.ui.text.style.TextAlign
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
import androidx.media3.common.util.UnstableApi
|
|
import org.libremediaconverter.convert.InputFile
|
|
import org.libremediaconverter.model.ConcatStrategy
|
|
import org.libremediaconverter.ui.PrimaryButtonHeight
|
|
import org.libremediaconverter.ui.ScreenPaddingHorizontal
|
|
import org.libremediaconverter.ui.ScreenPaddingVertical
|
|
import org.libremediaconverter.ui.TestTags
|
|
import org.libremediaconverter.work.ConcatWorker
|
|
|
|
@UnstableApi
|
|
@Composable
|
|
fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewModel()) {
|
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
|
|
|
val pickInputs = rememberLauncherForActivityResult(
|
|
ActivityResultContracts.OpenMultipleDocuments(),
|
|
) { uris -> if (uris.isNotEmpty()) viewModel.onInputsPicked(uris) }
|
|
|
|
// The contract's MIME type comes from the finished job rather than from a literal: some
|
|
// providers rewrite a document's extension to match it, so naming MP4 for a join that is not
|
|
// one can hand the user a file the extension lies about. Through pendingSave() rather than a
|
|
// cast to Joined, so a retry after a failed save opens with the type its first attempt used.
|
|
// Remembered against that type so the launcher re-registers only when it actually changes.
|
|
val destinationMime = state.pendingSave()?.mimeType ?: ConcatWorker.DEFAULT_FORMAT.mimeType
|
|
val chooseDestination = rememberLauncherForActivityResult(
|
|
remember(destinationMime) { ActivityResultContracts.CreateDocument(destinationMime) },
|
|
) { uri -> uri?.let(viewModel::save) }
|
|
|
|
JoinScreenContent(
|
|
state = state,
|
|
actions = joinActions(
|
|
viewModel = viewModel,
|
|
onPickInputs = { pickInputs.launch(arrayOf("video/*")) },
|
|
onSave = { suggestedName -> chooseDestination.launch(suggestedName) },
|
|
),
|
|
modifier = modifier,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Which of the ViewModel's methods each affordance on the join screen calls.
|
|
*
|
|
* The join-side twin of `converterActions`, and the transposition risk here is worse: **three**
|
|
* `() -> Unit` bindings rather than two. `onJoin`, `onCancel` and `onReset` are mutually
|
|
* interchangeable as far as the compiler is concerned, so a Join button that cancels, or a Cancel
|
|
* button that starts the job, is a swap nothing but a test would catch.
|
|
*
|
|
* See `converterActions` for why the launcher-backed actions stay parameters.
|
|
*/
|
|
@UnstableApi
|
|
internal fun joinActions(
|
|
viewModel: JoinViewModel,
|
|
onPickInputs: () -> Unit,
|
|
onSave: (suggestedName: String) -> Unit,
|
|
): JoinActions = JoinActions(
|
|
onPickInputs = onPickInputs,
|
|
onJoin = viewModel::join,
|
|
onCancel = viewModel::cancel,
|
|
onSave = onSave,
|
|
onReset = viewModel::reset,
|
|
)
|
|
|
|
/**
|
|
* Everything [JoinScreenContent] can ask for, in one value.
|
|
*
|
|
* Five callbacks would fit under detekt's `LongParameterList` threshold, unlike the converter's
|
|
* twelve. It is a holder anyway, so both screens present the same shape to the state tests and
|
|
* neither one has to be reworked the first time a branch grows a button.
|
|
*/
|
|
internal data class JoinActions(
|
|
/** Open the multi-document picker. The `Idle` and `Ready` branches both offer it. */
|
|
val onPickInputs: () -> Unit,
|
|
val onJoin: () -> Unit,
|
|
val onCancel: () -> Unit,
|
|
/** Open the save dialog. Takes the name the job chose -- see `ConcatWorker.KEY_SUGGESTED_NAME`. */
|
|
val onSave: (suggestedName: String) -> Unit,
|
|
val onReset: () -> Unit,
|
|
)
|
|
|
|
/**
|
|
* The join screen, with its state handed in.
|
|
*
|
|
* The same split as [org.libremediaconverter.convert.ConverterScreenContent], for the same reason:
|
|
* `JoinState.Waiting` follows a denied foreground start and `JoinState.Joined` follows a completed
|
|
* concatenation, so neither is reachable by driving a real `JoinViewModel`.
|
|
*/
|
|
@UnstableApi
|
|
@Composable
|
|
internal fun JoinScreenContent(state: JoinState, actions: JoinActions, modifier: Modifier = Modifier) {
|
|
Column(
|
|
modifier = modifier
|
|
.fillMaxSize()
|
|
.padding(horizontal = ScreenPaddingHorizontal, vertical = ScreenPaddingVertical),
|
|
) {
|
|
Text(
|
|
"Join files",
|
|
style = MaterialTheme.typography.headlineMedium,
|
|
modifier = Modifier.padding(bottom = 16.dp),
|
|
)
|
|
|
|
// Same split as the converter screen: the empty state is centred, the working
|
|
// states scroll because their content can exceed the screen.
|
|
val body = Modifier.fillMaxWidth().weight(1f)
|
|
|
|
when (val s = state) {
|
|
is JoinState.Idle -> Column(
|
|
modifier = body,
|
|
verticalArrangement = Arrangement.Center,
|
|
horizontalAlignment = Alignment.CenterHorizontally,
|
|
) {
|
|
Text(
|
|
"Pick two or more files to join, in the order you want them.",
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
textAlign = TextAlign.Center,
|
|
modifier = Modifier.padding(bottom = 16.dp),
|
|
)
|
|
Button(
|
|
onClick = actions.onPickInputs,
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.height(PrimaryButtonHeight)
|
|
.testTag(TestTags.Join.CHOOSE_FILES),
|
|
) { Text("Choose files") }
|
|
}
|
|
|
|
else -> Column(
|
|
modifier = body.verticalScroll(rememberScrollState()),
|
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
) {
|
|
when (s) {
|
|
is JoinState.Idle -> Unit
|
|
|
|
is JoinState.Ready -> {
|
|
s.inputs.forEach { FileRow(it) }
|
|
Button(
|
|
onClick = actions.onJoin,
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.height(PrimaryButtonHeight)
|
|
.testTag(TestTags.Join.JOIN),
|
|
) { Text("Join ${s.inputs.size} files") }
|
|
OutlinedButton(
|
|
onClick = actions.onPickInputs,
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.testTag(TestTags.Join.CHOOSE_DIFFERENT_FILES),
|
|
) { Text("Choose different files") }
|
|
}
|
|
|
|
is JoinState.Joining -> {
|
|
Text("Joining ${s.inputs.size} files…")
|
|
// Indeterminate on purpose: FFmpeg reports progress against a
|
|
// single input's duration, which means nothing across a
|
|
// concatenation. A fabricated percentage would be worse than none.
|
|
LinearProgressIndicator(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.testTag(TestTags.Join.PROGRESS),
|
|
)
|
|
OutlinedButton(
|
|
onClick = actions.onCancel,
|
|
modifier = Modifier.fillMaxWidth().testTag(TestTags.CANCEL),
|
|
) { Text("Cancel") }
|
|
}
|
|
|
|
is JoinState.Waiting -> {
|
|
// Same two causes as the converter screen's Waiting state, and the same
|
|
// wording for them -- see the comment there.
|
|
Text(
|
|
"Paused. Android limits background media processing, so this will " +
|
|
"resume automatically — keeping the app open helps it along.",
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
)
|
|
OutlinedButton(
|
|
onClick = actions.onCancel,
|
|
modifier = Modifier.fillMaxWidth().testTag(TestTags.CANCEL),
|
|
) { Text("Cancel") }
|
|
}
|
|
|
|
is JoinState.Joined -> {
|
|
Text("Joined — ${s.staged.length() / 1_000_000} MB.")
|
|
Text(
|
|
when (s.strategy) {
|
|
ConcatStrategy.STREAM_COPY ->
|
|
"Files matched, so they were joined without " +
|
|
"re-encoding — no quality loss."
|
|
ConcatStrategy.REENCODE ->
|
|
"Files differed in format, so they were re-encoded " +
|
|
"to match."
|
|
},
|
|
style = MaterialTheme.typography.bodySmall,
|
|
)
|
|
Button(
|
|
onClick = { actions.onSave(s.suggestedName) },
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.height(PrimaryButtonHeight)
|
|
.testTag(TestTags.SAVE_FILE),
|
|
) { Text("Save file") }
|
|
OutlinedButton(
|
|
onClick = actions.onReset,
|
|
modifier = Modifier.fillMaxWidth().testTag(TestTags.START_OVER),
|
|
) { Text("Start over") }
|
|
}
|
|
|
|
is JoinState.Saved -> {
|
|
Text("Saved ${s.displayName}.", style = MaterialTheme.typography.bodyLarge)
|
|
Button(
|
|
onClick = actions.onReset,
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.height(PrimaryButtonHeight)
|
|
.testTag(TestTags.Join.JOIN_MORE),
|
|
) { Text("Join more") }
|
|
}
|
|
|
|
is JoinState.Failed -> {
|
|
Text(
|
|
s.message,
|
|
color = MaterialTheme.colorScheme.error,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
)
|
|
val retry = s.retry
|
|
if (retry == null) {
|
|
// Nothing staged, so "Start over" is all there is and stays primary.
|
|
Button(
|
|
onClick = actions.onReset,
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.height(PrimaryButtonHeight)
|
|
.testTag(TestTags.START_OVER),
|
|
) { Text("Start over") }
|
|
} else {
|
|
Button(
|
|
onClick = { actions.onSave(retry.suggestedName) },
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.height(PrimaryButtonHeight)
|
|
.testTag(TestTags.RETRY_SAVE),
|
|
) { Text("Try saving again") }
|
|
// Start over still deletes the carried file, for the reason the
|
|
// converter screen writes out next to the same pair of buttons:
|
|
// the delete is a choice only once the alternative is on screen.
|
|
OutlinedButton(
|
|
onClick = actions.onReset,
|
|
modifier = Modifier.fillMaxWidth().testTag(TestTags.START_OVER),
|
|
) { Text("Start over") }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
internal fun FileRow(input: InputFile) {
|
|
Card(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.testTag(TestTags.Join.fileRow(input.displayName)),
|
|
) {
|
|
Column(modifier = Modifier.padding(12.dp)) {
|
|
Text(input.displayName, style = MaterialTheme.typography.bodyMedium)
|
|
}
|
|
}
|
|
}
|