Stop a permission answer starting a second conversion (#202)

currentInput() answered for Converting, Waiting and Converted as well as Ready. Those three
arms were unreachable by tapping Convert -- the button renders only in the Ready branch --
but they were reachable through the POST_NOTIFICATIONS *result*, which ConverterScreen.kt:91
wires to convert() rather than to the button.

Reaching one enqueued a SECOND job over a live one: activeWorkId was overwritten, and the
first job kept running with its foreground notification orphaned and nothing left holding
its id to cancel it.

#202 decided to narrow rather than to test it as it stood, because a test written against
the old shape would have frozen the double-enqueue as intended behaviour -- the F1/F5 failure
mode docs/coverage-read-findings.md names. currentInput() is now
(_state.value as? ConversionState.Ready)?.input, which is what JoinViewModel.join() has been
all along; the two screens are the same shape and only one of them was over-general.

Four cold refusal arms come with it, all reached the same way -- a system callback arriving
after the screen has moved on, which is what a result redelivered after process death does:

  ConversionViewModel.kt:513   currentInput() ?: return
  ConversionViewModel.kt:600   pendingSave() ?: return
  JoinViewModel.kt:316         (as? Ready)?.inputs ?: return
  JoinViewModel.kt:390         pendingSave() ?: return

One fixture note worth keeping: the second case needs a real staged file in the worker's
output Data. A SUCCEEDED job with no output path maps to Failed rather than Converted, so
Data.EMPTY never reaches the state the case is about -- which cost a timed-out awaitState
before it was spotted.

Mutations, all run and restored:

  restore the over-general four-arm when   1 red  <- the defect this change fixes
  currentInput()!! at :513                 2 red
  pendingSave()!! in save()                1 red
  drop both join guards                    1 red

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-05 19:36:38 -05:00
co-authored by Claude Opus 5
parent 2db0dc65d3
commit 4e88de3045
2 changed files with 164 additions and 7 deletions
@@ -673,13 +673,23 @@ class ConversionViewModel @JvmOverloads constructor(
else -> null
}
private fun currentInput(): InputFile? = when (val s = _state.value) {
is ConversionState.Ready -> s.input
is ConversionState.Converting -> s.input
is ConversionState.Waiting -> s.input
is ConversionState.Converted -> s.input
else -> null
}
/**
* The input `convert()` may act on, which is only ever the one on a `Ready` screen.
*
* This used to answer for `Converting`, `Waiting` and `Converted` as well. Those arms were not
* reachable by tapping Convert -- the button renders only in the `Ready` branch -- but they
* were reachable through the POST_NOTIFICATIONS **result**, which `ConverterScreen.kt:91` wires
* to `convert()` rather than to the button. Reaching one of them enqueued a *second* job over a
* live one: `activeWorkId` was overwritten, and the first job kept running with its foreground
* notification orphaned and nothing left holding its id to cancel it.
*
* Narrowed under #202 rather than tested as it stood, because a test written against the old
* shape would have frozen the double-enqueue as intended behaviour -- the F1/F5 failure mode.
*
* `JoinViewModel.join()` has been `(_state.value as? JoinState.Ready)?.inputs ?: return` all
* along. The two screens are the same shape and only one of them was over-general.
*/
private fun currentInput(): InputFile? = (_state.value as? ConversionState.Ready)?.input
private companion object {
/**