Skip to content
Open
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

## 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.


- Add the Apple post-RVD1 RVT1 device path: generated Swift APIs can target a
shared RivetRPCClient protocol; a bounded network client correlates RPCs,
forwards cancellation and delivers events, and its lifecycle-aware run()
Expand Down
4 changes: 4 additions & 0 deletions docs/desktop-closeout-mobile-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
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<SessionAcceptance>) -> Unit)? = null

/** Single-flight: a new user selection requires a new coordinator. */
fun connectSelected(
selection: DeviceDiscovery,
intent: DeviceCompanionIntent,
onFinished: (Result<SessionAcceptance>) -> Unit,
) {
synchronized(lock) {
check(!started && !closed) { "Rivet companion selection is single-use" }
started = true
callback = onFinished
}
if (browser.snapshot.none { it.namesSelectedDevice(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.
// 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
}
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
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<SessionAcceptance>) {
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)))
}
}
Loading
Loading