Files
mantra-kmp/composeApp
Kgothatso Ngako 9be3e01d8b fix(subgroups): hand an adoption's outcome back on the main thread
"Add a parent group", then tapping any group, crashed outright:

    IllegalStateException: Method setCurrentState must be called on the main thread
      at androidx.lifecycle.LifecycleRegistry.enforceMainThreadIfNeeded
      ...
      at NavController.popBackStack
      at SelectAdoptingGroupScreen.kt:153
      at SelectAdoptingGroupViewModel$offerTo$1.invokeSuspend

`offerTo` does its work in `viewModelScope.launch(Dispatchers.IO)` and called
`onDone` from inside it. The screen's callback called `onNavigateBack()` straight
through, which is `navController.popBackStack()` -- and navigation moves a back stack
entry's lifecycle, which is main-thread-only. Nothing in the chain switched.

**The dispatcher is the view model's business, not every caller's**, which is the
shape the app already uses in a dozen places and in both of the files this one was
modelled on: `SelectSubgroupAdminsViewModel.confirm` wraps its navigation in
`withContext(Dispatchers.Main)` and `AddArtifactViewModel` launches its `onSuccess`
there. A `finish` helper does it here, with the crash written above it so the next
person to add a callback to this class sees why. `ChatRoomDetailViewModel.acceptParent`
gets the same treatment: its only caller shows a snackbar, which happens to be
main-safe already, but a callback contract that is "safe if you are careful" is the
same bug waiting for a second caller.

**And the screen no longer navigates away on success**, which fixes a second thing
found on the way to the first. The snackbar was launched in
`rememberCoroutineScope()` -- this screen's own -- and then the screen popped, so the
message was racing its own scope's cancellation; `rememberNotifier` exists for exactly
that hazard and was not being used. Staying put is the better answer anyway, because
the list is the confirmation: the row just asked leaves it, since a certificate now
stands for the pair and `refuseAdoption` says so. The way out is the back button, which
a pushed screen has to have regardless. `offerTo` reloads before handing back so that
is true by the time the message appears.

The outer `scope.launch` around `offerTo` goes too -- it never suspended, and wrapping
a main-thread call in a launch was part of what made the threading hard to see.

No test covers this and none of the 1,416 would have. It is a dispatcher boundary
between a view model and a navigation callback, which is to say it lives in the one
layer this work had never run: compile-and-test verified, never driven through the app.
That was named as the risk when the feature landed, and this is it.

454 common tests, 962 jvm tests, `m3Audit` meets every budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 20:51:26 +02:00
..