From b7faead0aae3599e8fd0a0dca5be83eda315b545 Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Thu, 22 Oct 2026 20:10:03 +0800 Subject: [PATCH 1/3] feat(android): coordinate explicit NSD selection through pinned device session --- CHANGELOG.md | 9 + docs/desktop-closeout-mobile-plan.md | 4 + .../rivet/session/DeviceCompanionSession.kt | 2 +- .../SelectedDeviceCompanionCoordinator.kt | 145 +++++++++++++ .../SelectedDeviceCompanionCoordinatorTest.kt | 201 ++++++++++++++++++ 5 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 platform/android/src/main/kotlin/dev/rivet/session/SelectedDeviceCompanionCoordinator.kt create mode 100644 platform/android/src/test/kotlin/dev/rivet/session/SelectedDeviceCompanionCoordinatorTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index e2a6e54..896d43e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +- Add an Android selected-device connection coordinator joining live + canonical NSD resolution to one explicit pinned-TLS/RVD1 attempt on a + product-supplied executor. Reject closed pairing windows, stale candidates + and duplicate starts; cancel outstanding resolution and active transports + without a delayed dial or automatic pairing fallback. Deterministic + JVM tests cover selection, successful authenticated handoff, cancellation + before queued work, and stale native callbacks. Product secure storage, + native UI and physical-device evidence remain separate. + - Bridge Android companion TLS streams to the RVD1 client through a one-shot selected-endpoint session driver. Enforce the stored SPKI pin on reconnect without implicit pairing fallback, preserve explicit pairing diff --git a/docs/desktop-closeout-mobile-plan.md b/docs/desktop-closeout-mobile-plan.md index 0527b37..0500a8e 100644 --- a/docs/desktop-closeout-mobile-plan.md +++ b/docs/desktop-closeout-mobile-plan.md @@ -303,6 +303,10 @@ Kotlin CI job; canonical TXT mapping has JVM tests. The product still owns local-network permission prompts, foreground/multicast lifecycle, secure credential storage and mobile UI; physical-device network pairing remains unverified. +The portable `SelectedDeviceCompanionCoordinator` now bridges that live NSD +selection to exactly one authenticated RVD1 attempt on an injected worker, +with fail-closed cancellation and explicit pairing-window policy. It still +requires product-owned credential storage, UI approval and real-device proof. ### M3 — second desktop host and Android client diff --git a/platform/android/src/main/kotlin/dev/rivet/session/DeviceCompanionSession.kt b/platform/android/src/main/kotlin/dev/rivet/session/DeviceCompanionSession.kt index 21cf2c2..0a187b2 100644 --- a/platform/android/src/main/kotlin/dev/rivet/session/DeviceCompanionSession.kt +++ b/platform/android/src/main/kotlin/dev/rivet/session/DeviceCompanionSession.kt @@ -50,7 +50,7 @@ fun interface DeviceCompanionDialer { ): DeviceCompanionTransport } -private val systemDeviceCompanionDialer = DeviceCompanionDialer { endpoint, authentication -> +internal val systemDeviceCompanionDialer = DeviceCompanionDialer { endpoint, authentication -> val connection = DeviceTLSClient.connect( endpoint.host, endpoint.port, authentication, ) diff --git a/platform/android/src/main/kotlin/dev/rivet/session/SelectedDeviceCompanionCoordinator.kt b/platform/android/src/main/kotlin/dev/rivet/session/SelectedDeviceCompanionCoordinator.kt new file mode 100644 index 0000000..fe01244 --- /dev/null +++ b/platform/android/src/main/kotlin/dev/rivet/session/SelectedDeviceCompanionCoordinator.kt @@ -0,0 +1,145 @@ +package dev.rivet.session + +import java.io.Closeable +import java.io.InputStream +import java.io.OutputStream +import java.util.concurrent.Executor + +/** A selection or lifecycle rejection, never an alternative trust path. */ +enum class DeviceSelectionFailure { + STALE_SELECTION, + PAIRING_WINDOW_CLOSED, + CANCELLED, +} + +class DeviceSelectionException(val failure: DeviceSelectionFailure) : + IllegalStateException("Rivet selected device connection rejected: $failure") + +/** + * Turns one user-selected live NSD candidate into one TLS/RVD1 connection. + * + * Resolution may finish on an Android NSD callback thread. Actual TLS and + * blocking RVD1 I/O run only on the product-supplied [worker] executor. + * An authenticated stream reaches [onAccepted] only via the underlying + * DeviceCompanionSession's persistence and confirmation gates. + * + * Own this coordinator through the connection lifecycle and close it on + * navigation away, discovery shutdown, or disconnect. The caller must not + * treat the advertisement ID as a pin or auto-retry failed reconnects. + */ +class SelectedDeviceCompanionCoordinator( + private val browser: DeviceDiscoveryBrowser, + resolutionDriver: DeviceServiceResolutionDriver, + private val worker: Executor, + private val credentialStore: SessionCredentialStore? = null, + private val onPairingCode: ((DevicePairingCode) -> Unit)? = null, + private val onAccepted: (SessionAcceptance, InputStream, OutputStream) -> Unit, + private val dialer: DeviceCompanionDialer = systemDeviceCompanionDialer, +) : Closeable { + private val resolver = SelectedDeviceResolver(browser, resolutionDriver) + private val lock = Any() + private var started = false + private var closed = false + private var finished = false + private var current: DeviceCompanionSession? = null + private var callback: ((Result) -> Unit)? = null + + /** Single-flight: a new user selection requires a new coordinator. */ + fun connectSelected( + selection: DeviceDiscovery, + intent: DeviceCompanionIntent, + onFinished: (Result) -> Unit, + ) { + synchronized(lock) { + check(!started && !closed) { "Rivet companion selection is single-use" } + started = true + callback = onFinished + } + if (browser.snapshot.none { it == selection }) { + complete(Result.failure(DeviceSelectionException(DeviceSelectionFailure.STALE_SELECTION))) + return + } + if (intent is DeviceCompanionIntent.Pair && !selection.pairingWindowOpen) { + complete(Result.failure(DeviceSelectionException(DeviceSelectionFailure.PAIRING_WINDOW_CLOSED))) + return + } + resolver.resolveSelected(selection) { endpoint -> + if (!isPending()) return@resolveSelected + if (endpoint == null) { + complete(Result.failure(DeviceSelectionException(DeviceSelectionFailure.STALE_SELECTION))) + return@resolveSelected + } + try { + worker.execute { + if (!isPending()) return@execute + // Discovery may change between the native resolution + // callback and this worker actually starting the dial. + if (browser.snapshot.none { it == selection }) { + complete(Result.failure(DeviceSelectionException(DeviceSelectionFailure.STALE_SELECTION))) + return@execute + } + val session = DeviceCompanionSession( + endpoint = DeviceCompanionEndpoint(endpoint.host, endpoint.port), + intent = intent, + credentialStore = credentialStore, + onPairingCode = onPairingCode, + onAccepted = onAccepted, + dialer = dialer, + ) + val abandoned = synchronized(lock) { + if (closed || finished) true else { + current = session + false + } + } + if (abandoned) { + session.close() + return@execute + } + try { + val accepted = session.run() + complete(Result.success(accepted)) + } catch (failure: Throwable) { + session.close() + complete(Result.failure(failure)) + } + } + } catch (failure: RuntimeException) { + complete(Result.failure(failure)) + } + } + } + + /** A separate user decision, never inferred from the discovery result. */ + fun confirmPairingOnDevice(confirmed: Boolean) { + val session = synchronized(lock) { current } + ?: throw IllegalStateException("Rivet selected pairing has not started") + session.confirmPairingOnDevice(confirmed) + } + + private fun isPending(): Boolean = synchronized(lock) { started && !closed && !finished } + + private fun complete(result: Result) { + val notify = synchronized(lock) { + if (finished) null else { + finished = true + callback.also { callback = null } + } + } + notify?.invoke(result) + } + + /** Idempotent and effective while resolving, queued, dialing, or connected. */ + override fun close() { + val session: DeviceCompanionSession? + synchronized(lock) { + if (closed) return + closed = true + session = current + current = null + } + resolver.cancel() + session?.close() + complete(Result.failure(DeviceSelectionException(DeviceSelectionFailure.CANCELLED))) + } +} diff --git a/platform/android/src/test/kotlin/dev/rivet/session/SelectedDeviceCompanionCoordinatorTest.kt b/platform/android/src/test/kotlin/dev/rivet/session/SelectedDeviceCompanionCoordinatorTest.kt new file mode 100644 index 0000000..295b6a9 --- /dev/null +++ b/platform/android/src/test/kotlin/dev/rivet/session/SelectedDeviceCompanionCoordinatorTest.kt @@ -0,0 +1,201 @@ +package dev.rivet.session + +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.io.OutputStream +import java.util.concurrent.Executor +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class SelectedDeviceCompanionCoordinatorTest { + private val advertisementID = "0f1e2d3c4b5a69788796a5b4c3d2e1f0" + private val id = SessionUuid(ByteArray(16) { it.toByte() }) + private val nonce = ByteArray(SESSION_NONCE_SIZE) { 0x22 } + private val pin = ByteArray(SESSION_FINGERPRINT_SIZE) { 0x55 } + + private class Harness { + lateinit var discovery: (String, ByteArray?, Boolean) -> Unit + var resolved: ((DeviceResolvedService?) -> Unit)? = null + var resolutionCount = 0 + val browser = DeviceDiscoveryBrowser(object : DeviceDiscoveryDriver { + override fun start(onResult: (String, ByteArray?, Boolean) -> Unit) { discovery = onResult } + override fun stop() {} + }) { _, _ -> } + + val driver = DeviceServiceResolutionDriver { _, result -> + resolutionCount++ + resolved = result + } + + fun select(advertisementID: String, pairing: Boolean): DeviceDiscovery { + browser.start() + discovery("Device A", encodeDiscoveryTXT(DeviceAdvertisement(advertisementID, pairing)), false) + return browser.snapshot.single() + } + + fun reply(selected: DeviceDiscovery) { + resolved!!( + DeviceResolvedService( + selected.serviceName, "192.0.2.1", 12345, + encodeDiscoveryTXT(DeviceAdvertisement(selected.advertisementID, selected.pairingWindowOpen)), + ), + ) + } + } + + private fun intent(): DeviceCompanionIntent = DeviceCompanionIntent.Reconnect( + "com.example.app", id, ByteArray(SESSION_CREDENTIAL_SIZE) { 0x33 }, pin, + ) + + @Test + fun selectedReconnectHandsOffOnlyAfterAuthenticationAndNoDuplicateDial() { + val h = Harness() + val selected = h.select(advertisementID, false) + val sessionID = SessionUuid(ByteArray(16) { 0x44 }) + val incoming = ByteArrayInputStream( + encodeSessionRecord(SessionMessage.AuthChallenge(nonce)) + + encodeSessionRecord(SessionMessage.Accept(sessionID, listOf("reader"), 512L * 1024, 64)), + ) + val outgoing = ByteArrayOutputStream() + var dials = 0 + var handed = 0 + val decisions = mutableListOf>() + val coordinator = SelectedDeviceCompanionCoordinator( + h.browser, h.driver, Executor { it.run() }, + onAccepted = { acceptance, input, output -> + handed++ + assertEquals(sessionID, acceptance.sessionID) + assertSame(incoming, input) + assertSame(outgoing, output) + }, + dialer = DeviceCompanionDialer { _, authentication -> + dials++ + assertTrue(authentication is DeviceServerAuthentication.Pinned) + object : DeviceCompanionTransport { + override val serverFingerprint get() = pin.copyOf() + override val input: InputStream get() = incoming + override val output: OutputStream get() = outgoing + override fun close() { incoming.close(); outgoing.close() } + } + }, + ) + coordinator.connectSelected(selected, intent()) { decisions += it } + h.reply(selected) + h.reply(selected) + assertEquals(1, dials) + assertEquals(1, handed) + assertEquals(1, decisions.size) + assertEquals(sessionID, decisions.single().getOrThrow().sessionID) + assertFailsWith { + coordinator.connectSelected(selected, intent()) { } + } + coordinator.close() + assertEquals(1, decisions.size) + h.browser.stop() + } + + @Test + fun pairingRequiresAdvertisedOpenWindowAndCannotDialSilently() { + val h = Harness() + val selected = h.select(advertisementID, false) + var dialed = false + val results = mutableListOf>() + val coordinator = SelectedDeviceCompanionCoordinator( + h.browser, h.driver, Executor { it.run() }, + onAccepted = { _, _, _ -> }, + dialer = DeviceCompanionDialer { _, _ -> + dialed = true + error("must not connect") + }, + ) + coordinator.connectSelected( + selected, + DeviceCompanionIntent.Pair("com.example.app", id, "Android"), + ) { results += it } + assertEquals(0, h.resolutionCount) + val failure = results.single().exceptionOrNull() as DeviceSelectionException + assertEquals(DeviceSelectionFailure.PAIRING_WINDOW_CLOSED, failure.failure) + assertFalse(dialed) + coordinator.close() + } + + @Test + fun staleResolutionCannotDialAndCallbacksRemainSingleShot() { + val h = Harness() + val selected = h.select(advertisementID, true) + var dialed = false + val results = mutableListOf>() + val coordinator = SelectedDeviceCompanionCoordinator( + h.browser, h.driver, Executor { it.run() }, + onAccepted = { _, _, _ -> }, + dialer = DeviceCompanionDialer { _, _ -> + dialed = true + error("must not connect") + }, + ) + coordinator.connectSelected(selected, intent()) { results += it } + h.discovery(selected.serviceName, null, true) + h.reply(selected) + h.reply(selected) + assertEquals(1, results.size) + assertEquals( + DeviceSelectionFailure.STALE_SELECTION, + (results.single().exceptionOrNull() as DeviceSelectionException).failure, + ) + assertFalse(dialed) + coordinator.close() + } + + @Test + fun queuedDialCannotRunAfterCloseAndCancellationOnlyReportsOnce() { + val h = Harness() + val selected = h.select(advertisementID, true) + val pending = mutableListOf() + var dialed = false + val results = mutableListOf>() + val coordinator = SelectedDeviceCompanionCoordinator( + h.browser, h.driver, Executor { pending += it }, + onAccepted = { _, _, _ -> }, + dialer = DeviceCompanionDialer { _, _ -> + dialed = true + error("must not connect") + }, + ) + coordinator.connectSelected(selected, intent()) { results += it } + h.reply(selected) + assertEquals(1, pending.size) + coordinator.close() + coordinator.close() + pending.single().run() + assertEquals(1, results.size) + assertEquals( + DeviceSelectionFailure.CANCELLED, + (results.single().exceptionOrNull() as DeviceSelectionException).failure, + ) + assertFalse(dialed) + h.browser.stop() + } + + @Test + fun missingSelectionDoesNotResolveOrDial() { + val h = Harness() + val selection = DeviceDiscovery("Not listed", advertisementID, true) + val results = mutableListOf>() + val coordinator = SelectedDeviceCompanionCoordinator( + h.browser, h.driver, Executor { it.run() }, + onAccepted = { _, _, _ -> }, + ) + coordinator.connectSelected(selection, intent()) { results += it } + assertEquals(0, h.resolutionCount) + assertEquals( + DeviceSelectionFailure.STALE_SELECTION, + (results.single().exceptionOrNull() as DeviceSelectionException).failure, + ) + coordinator.close() + } +} From dbff11957599b0fda9d81c29724fe438f81e968f Mon Sep 17 00:00:00 2001 From: jrtxio Date: Thu, 22 Oct 2026 23:48:38 +0800 Subject: [PATCH 2/3] fix(android): resolve selection failures on the worker instead of dying A resolved endpoint that DeviceCompanionEndpoint rejects threw from the queued worker task: the guard around worker.execute only covers task submission, so the shared worker thread died with the callback never invoked and a product UI waiting forever. Construction now fails through the same completion path as a failed dial. Staleness also compared the whole DeviceDiscovery, so a pairing-window flip between selection and worker start reported STALE_SELECTION for a device that is still present; presence now matches stable identity (service name plus listener ID) and the window is judged from the fresh advertisement, giving Pair intents the PAIRING_WINDOW_CLOSED code. JVM tests pin the rejected-endpoint callback, reconnect proceeding across a window flip, and pair failing closed with the right code. --- .../kotlin/dev/rivet/session/Discovery.kt | 8 ++ .../SelectedDeviceCompanionCoordinator.kt | 36 ++++++-- .../SelectedDeviceCompanionCoordinatorTest.kt | 91 +++++++++++++++++++ 3 files changed, 125 insertions(+), 10 deletions(-) diff --git a/platform/android/src/main/kotlin/dev/rivet/session/Discovery.kt b/platform/android/src/main/kotlin/dev/rivet/session/Discovery.kt index 601ad19..05d33ed 100644 --- a/platform/android/src/main/kotlin/dev/rivet/session/Discovery.kt +++ b/platform/android/src/main/kotlin/dev/rivet/session/Discovery.kt @@ -117,6 +117,14 @@ data class DeviceDiscovery( val pairingWindowOpen: Boolean, ) +/** + * Stable selection identity across snapshots: the pairing-window bit flips + * independently of device presence, so presence checks compare service name + * and listener identity only and judge the window from the fresh result. + */ +fun DeviceDiscovery.namesSelectedDevice(selected: DeviceDiscovery): Boolean = + serviceName == selected.serviceName && advertisementID == selected.advertisementID + enum class DeviceDiscoveryEventKind { ADDED, REMOVED } /** diff --git a/platform/android/src/main/kotlin/dev/rivet/session/SelectedDeviceCompanionCoordinator.kt b/platform/android/src/main/kotlin/dev/rivet/session/SelectedDeviceCompanionCoordinator.kt index fe01244..7c1c102 100644 --- a/platform/android/src/main/kotlin/dev/rivet/session/SelectedDeviceCompanionCoordinator.kt +++ b/platform/android/src/main/kotlin/dev/rivet/session/SelectedDeviceCompanionCoordinator.kt @@ -55,7 +55,7 @@ class SelectedDeviceCompanionCoordinator( started = true callback = onFinished } - if (browser.snapshot.none { it == selection }) { + if (browser.snapshot.none { it.namesSelectedDevice(selection) }) { complete(Result.failure(DeviceSelectionException(DeviceSelectionFailure.STALE_SELECTION))) return } @@ -74,18 +74,34 @@ class SelectedDeviceCompanionCoordinator( if (!isPending()) return@execute // Discovery may change between the native resolution // callback and this worker actually starting the dial. - if (browser.snapshot.none { it == selection }) { + // The pairing-window bit flips independently of device + // presence, so staleness matches stable identity and the + // window is judged from the fresh advertisement. + val fresh = browser.snapshot.firstOrNull { it.namesSelectedDevice(selection) } + if (fresh == null) { complete(Result.failure(DeviceSelectionException(DeviceSelectionFailure.STALE_SELECTION))) return@execute } - val session = DeviceCompanionSession( - endpoint = DeviceCompanionEndpoint(endpoint.host, endpoint.port), - intent = intent, - credentialStore = credentialStore, - onPairingCode = onPairingCode, - onAccepted = onAccepted, - dialer = dialer, - ) + if (intent is DeviceCompanionIntent.Pair && !fresh.pairingWindowOpen) { + complete(Result.failure(DeviceSelectionException(DeviceSelectionFailure.PAIRING_WINDOW_CLOSED))) + return@execute + } + val session = try { + DeviceCompanionSession( + endpoint = DeviceCompanionEndpoint(endpoint.host, endpoint.port), + intent = intent, + credentialStore = credentialStore, + onPairingCode = onPairingCode, + onAccepted = onAccepted, + dialer = dialer, + ) + } catch (failure: Throwable) { + // A rejected endpoint or session configuration must + // still resolve the caller's callback instead of + // killing the shared worker thread. + complete(Result.failure(failure)) + return@execute + } val abandoned = synchronized(lock) { if (closed || finished) true else { current = session diff --git a/platform/android/src/test/kotlin/dev/rivet/session/SelectedDeviceCompanionCoordinatorTest.kt b/platform/android/src/test/kotlin/dev/rivet/session/SelectedDeviceCompanionCoordinatorTest.kt index 295b6a9..8f8eb7b 100644 --- a/platform/android/src/test/kotlin/dev/rivet/session/SelectedDeviceCompanionCoordinatorTest.kt +++ b/platform/android/src/test/kotlin/dev/rivet/session/SelectedDeviceCompanionCoordinatorTest.kt @@ -198,4 +198,95 @@ class SelectedDeviceCompanionCoordinatorTest { ) coordinator.close() } + + @Test + fun rejectedResolvedEndpointFailsCallbackWithoutKillingTheWorker() { + val h = Harness() + val selected = h.select(advertisementID, true) + val pending = mutableListOf() + var dialed = false + val results = mutableListOf>() + val coordinator = SelectedDeviceCompanionCoordinator( + h.browser, h.driver, Executor { pending += it }, + onAccepted = { _, _, _ -> }, + dialer = DeviceCompanionDialer { _, _ -> + dialed = true + error("must not connect") + }, + ) + coordinator.connectSelected(selected, intent()) { results += it } + h.resolved!!( + DeviceResolvedService( + selected.serviceName, "192.0.2.1", 0, + encodeDiscoveryTXT(DeviceAdvertisement(selected.advertisementID, selected.pairingWindowOpen)), + ), + ) + assertEquals(1, pending.size) + pending.single().run() + assertEquals(1, results.size) + assertTrue(results.single().exceptionOrNull() is IllegalArgumentException) + assertFalse(dialed) + coordinator.close() + } + + @Test + fun windowFlipBeforeWorkerStartJudgedFromFreshAdvertisement() { + val h = Harness() + val selected = h.select(advertisementID, true) + val pending = mutableListOf() + var dialed = false + val results = mutableListOf>() + val coordinator = SelectedDeviceCompanionCoordinator( + h.browser, h.driver, Executor { pending += it }, + onAccepted = { _, _, _ -> }, + dialer = DeviceCompanionDialer { _, _ -> + dialed = true + error("stop at dial") + }, + ) + coordinator.connectSelected(selected, intent()) { results += it } + h.reply(selected) + // Only the pairing-window bit flips; the device is still present, so a + // reconnect dial proceeds while a pair dial fails with the window code. + h.discovery( + selected.serviceName, + encodeDiscoveryTXT(DeviceAdvertisement(selected.advertisementID, false)), + false, + ) + pending.single().run() + assertTrue(dialed) + coordinator.close() + + val h2 = Harness() + val selected2 = h2.select(advertisementID, true) + val pending2 = mutableListOf() + var dialed2 = false + val results2 = mutableListOf>() + val coordinator2 = SelectedDeviceCompanionCoordinator( + h2.browser, h2.driver, Executor { pending2 += it }, + onAccepted = { _, _, _ -> }, + dialer = DeviceCompanionDialer { _, _ -> + dialed2 = true + error("must not connect") + }, + ) + coordinator2.connectSelected( + selected2, + DeviceCompanionIntent.Pair("com.example.app", id, "Android"), + ) { results2 += it } + h2.reply(selected2) + h2.discovery( + selected2.serviceName, + encodeDiscoveryTXT(DeviceAdvertisement(selected2.advertisementID, false)), + false, + ) + pending2.single().run() + assertEquals(1, results2.size) + assertEquals( + DeviceSelectionFailure.PAIRING_WINDOW_CLOSED, + (results2.single().exceptionOrNull() as DeviceSelectionException).failure, + ) + assertFalse(dialed2) + coordinator2.close() + } } From 8e045e739c41951195f3edfe04193b3be665a290 Mon Sep 17 00:00:00 2001 From: jrtxio Date: Fri, 23 Oct 2026 03:10:08 +0800 Subject: [PATCH 3/3] test(android): pin the rejected-resolution contract the resolver owns The resolver's validate() rejects out-of-range ports before the coordinator's worker ever sees an endpoint, so the intended bad-endpoint scenario completes with STALE_SELECTION synchronously, not through the worker's construction guard; the guard stays as defense-in-depth but the test now pins the actual single-shot callback contract. Local gradle verification needs ANDROID_HOME now that the android-nsd module compiles in the same build. --- .../SelectedDeviceCompanionCoordinatorTest.kt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/platform/android/src/test/kotlin/dev/rivet/session/SelectedDeviceCompanionCoordinatorTest.kt b/platform/android/src/test/kotlin/dev/rivet/session/SelectedDeviceCompanionCoordinatorTest.kt index 8f8eb7b..55a0ab7 100644 --- a/platform/android/src/test/kotlin/dev/rivet/session/SelectedDeviceCompanionCoordinatorTest.kt +++ b/platform/android/src/test/kotlin/dev/rivet/session/SelectedDeviceCompanionCoordinatorTest.kt @@ -200,7 +200,7 @@ class SelectedDeviceCompanionCoordinatorTest { } @Test - fun rejectedResolvedEndpointFailsCallbackWithoutKillingTheWorker() { + fun rejectedResolutionFailsCallbackOnceWithoutDialingOrQueueingWork() { val h = Harness() val selected = h.select(advertisementID, true) val pending = mutableListOf() @@ -215,16 +215,21 @@ class SelectedDeviceCompanionCoordinatorTest { }, ) coordinator.connectSelected(selected, intent()) { results += it } + // An out-of-range port fails the resolver's validation, so the + // selection resolves to null and must complete the caller's callback + // exactly once without queueing worker work or opening a dial. h.resolved!!( DeviceResolvedService( selected.serviceName, "192.0.2.1", 0, encodeDiscoveryTXT(DeviceAdvertisement(selected.advertisementID, selected.pairingWindowOpen)), ), ) - assertEquals(1, pending.size) - pending.single().run() + assertEquals(0, pending.size) assertEquals(1, results.size) - assertTrue(results.single().exceptionOrNull() is IllegalArgumentException) + assertEquals( + DeviceSelectionFailure.STALE_SELECTION, + (results.single().exceptionOrNull() as DeviceSelectionException).failure, + ) assertFalse(dialed) coordinator.close() }