4f8a9094ec9b

stop reporting coroutine cancellation as a pairing rejection, and cancel the previous transport collector on restart, so switching to the local-network code cannot be overwritten by the relay session it replaced (v2.4.11)

time
2026-07-26 13:16 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
pairing UX pass
parent
1f887b74dd3a
4 files VERSION+1 −1app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/RelayAdoptionHost.kt+7 −0app/src/main/java/com/peachpasswords/ui/pairing/CodePairViewModel.kt+14 −2
VERSION+1 −1
@@ -1 +1 @@1-2.4.101+2.4.11

view file

app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 24001020-        versionName "2.4.10"19+        versionCode 24001120+        versionName "2.4.11"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/sync/RelayAdoptionHost.kt+7 −0
@@ -88,6 +88,13 @@8888                 }8989             } catch (error: kotlinx.coroutines.TimeoutCancellationException) {9090                 trySend(RelayAdoptionEvent.Expired)91+            } catch (error: kotlinx.coroutines.CancellationException) {92+                // stop() cancels this job when the user switches to LAN-only or93+                // leaves the screen. That is not a pairing failure — reporting it94+                // as one raced the LAN listener and replaced the freshly shown95+                // pairing code with "Couldn't pair: StandaloneCoroutine was96+                // cancelled". Cancellation must propagate, never be swallowed.97+                throw error9198             } catch (error: Throwable) {9299                 trySend(RelayAdoptionEvent.Rejected(error.message ?: "relay_adoption_failed"))93100             } finally {

view file

app/src/main/java/com/peachpasswords/ui/pairing/CodePairViewModel.kt+14 −2
@@ -26,6 +26,7 @@2626 import com.peachpasswords.license.LicenseRepository2727 import dagger.hilt.android.lifecycle.HiltViewModel2828 import dagger.hilt.android.qualifiers.ApplicationContext29+import kotlinx.coroutines.Job2930 import kotlinx.coroutines.flow.MutableStateFlow3031 import kotlinx.coroutines.flow.StateFlow3132 import kotlinx.coroutines.flow.asStateFlow@@ -64,6 +65,13 @@6465     val state: StateFlow<CodePairUiState> = _state.asStateFlow()6566 6667     /**68+     * The single live transport collector. Switching between relay and LAN69+     * restarts the screen, and without cancelling the previous collector both70+     * would keep writing [_state] — the older one landing last and winning.71+     */72+    private var collectJob: Job? = null73+74+    /**6775      * The battery-exemption dialog to show, or null if it's already granted or6876      * already been declined once. Consumed by the screen, which owns an6977      * Activity to launch it from.@@ -94,7 +102,7 @@94102             _state.value = CodePairUiState.ConfirmSas(pending.sas, pending.deviceName, pending.session)95103         }96104 97-        viewModelScope.launch {105+        collectJob = viewModelScope.launch {98106             try {99107                 server.start(100108                     viewModelScope,@@ -131,7 +139,7 @@131139         identity: LocalChirpIdentity,132140         sourceDeps: AdoptionSourceDeps,133141     ) {134-        viewModelScope.launch {142+        collectJob = viewModelScope.launch {135143             try {136144                 relayHost.start(137145                     viewModelScope,@@ -207,6 +215,8 @@207215     }208216 209217     fun restart() {218+        collectJob?.cancel()219+        collectJob = null210220         relayHost.stop()211221         server.stop()212222         _state.value = CodePairUiState.Starting@@ -219,6 +229,8 @@219229     }220230 221231     fun stop() {232+        collectJob?.cancel()233+        collectJob = null222234         relayHost.stop()223235         server.confirmationSink = null224236         server.stop()

view file