diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/cast/SiloCastNsdAdvertiser.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/cast/SiloCastNsdAdvertiser.kt index dd386290c..d3d37c251 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/cast/SiloCastNsdAdvertiser.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/cast/SiloCastNsdAdvertiser.kt @@ -11,7 +11,7 @@ import org.siloserver.silo.common.pairing.PairingDeviceId /** * Advertises the SiloCast receiver. TXT keys mirror silo-apple's * TVControlReceiver Bonjour record (`v`, `name`, `id`, `server`, - * `serverName`, `playing`) so an iPhone browsing `_silocast._tcp` can filter + * `serverName`, `serverIdentity`, `playing`) so an iPhone browsing `_silocast._tcp` can filter * an Android TV by server the same way it filters an Apple TV — and the * Android browser can read Apple receivers. Android's NsdManager can't * mutate a live TXT record, so [updatePlaying] re-registers. @@ -46,8 +46,14 @@ class SiloCastNsdAdvertiser( } @Synchronized - fun start(port: Int, serverId: String?, serverName: String?, playing: Boolean = false) { - register(Registration(port, serverId, serverName, playing)) + fun start( + port: Int, + serverId: String?, + serverName: String?, + playing: Boolean = false, + serverIdentity: String? = null, + ) { + register(Registration(port, serverId, serverName, playing, serverIdentity)) } /** Re-advertise with an updated `playing` flag (no-op when not started). */ @@ -81,6 +87,8 @@ class SiloCastNsdAdvertiser( put("id", deviceIdProvider()) registration.serverId?.let { put("server", it) } registration.serverName?.let { put("serverName", it) } + // Lets a phone signed in at another address of the same deployment list this TV. + registration.serverIdentity?.let { put("serverIdentity", it) } put("playing", if (registration.playing) "1" else "0") } val serviceInfo = NsdServiceInfo().apply { @@ -124,6 +132,7 @@ class SiloCastNsdAdvertiser( val serverId: String?, val serverName: String?, val playing: Boolean, + val serverIdentity: String?, ) private companion object { diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastRemoteScreen.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastRemoteScreen.kt index 934603e94..fcb5907ce 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastRemoteScreen.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastRemoteScreen.kt @@ -896,7 +896,6 @@ private data class MenuEntry( val label: String, val selected: Boolean, val enabled: Boolean = true, - val isSectionHeader: Boolean = false, val onClick: () -> Unit = {}, ) @@ -941,10 +940,9 @@ private fun RemoteSecondaryControls( ) } - val hasSubtitleControls = playback.subtitleTracks.isNotEmpty() || - playback.supportsSubtitleDelay == true || - playback.supportsSubtitlePosition == true - if (hasSubtitleControls) { + // Subtitle delay and position stay on the TV's own player menu; + // from the couch the remote only switches the language. + if (playback.subtitleTracks.isNotEmpty()) { RemoteChipMenu( icon = Icons.Outlined.ClosedCaption, caption = "Subtitles", @@ -972,52 +970,21 @@ private fun subtitleMenuEntries( playback: SiloCastPlaybackState, controller: SiloCastController, ): List = buildList { - if (playback.subtitleTracks.isNotEmpty()) { - add(MenuEntry(label = "Track", selected = false, isSectionHeader = true)) + add( + MenuEntry( + label = "Off", + selected = playback.selectedSubtitleTrackId == null, + onClick = { controller.selectSubtitleTrack(null) }, + ), + ) + playback.subtitleTracks.forEach { track -> add( MenuEntry( - label = "Off", - selected = playback.selectedSubtitleTrackId == null, - onClick = { controller.selectSubtitleTrack(null) }, + label = track.title, + selected = playback.selectedSubtitleTrackId == track.trackId, + onClick = { controller.selectSubtitleTrack(track.trackId) }, ), ) - playback.subtitleTracks.forEach { track -> - add( - MenuEntry( - label = track.title, - selected = playback.selectedSubtitleTrackId == track.trackId, - onClick = { controller.selectSubtitleTrack(track.trackId) }, - ), - ) - } - } - if (playback.supportsSubtitleDelay == true) { - add(MenuEntry(label = "Delay", selected = false, isSectionHeader = true)) - val presets = listOf(-2_000, -1_500, -1_000, -500, -250, 0, 250, 500, 1_000, 1_500, 2_000) - val current = playback.subtitleSyncMs ?: 0 - val options = if (presets.contains(current)) presets else (presets + current).sorted() - options.forEach { ms -> - add( - MenuEntry( - label = subtitleDelayLabel(ms), - selected = current == ms, - onClick = { controller.setSubtitleSyncMs(ms) }, - ), - ) - } - } - if (playback.supportsSubtitlePosition == true) { - add(MenuEntry(label = "Position", selected = false, isSectionHeader = true)) - // tvOS SubtitlePositionPreset raw values; it rejects anything else. - listOf("bottom" to "Bottom", "lower-third" to "Lower Third", "top" to "Top").forEach { (id, label) -> - add( - MenuEntry( - label = label, - selected = playback.subtitlePosition == id, - onClick = { controller.setSubtitlePosition(id) }, - ), - ) - } } } @@ -1054,40 +1021,26 @@ private fun RemoteChipMenu( } SiloDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { entries.forEach { entry -> - if (entry.isSectionHeader) { - Text( - entry.label, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp), - ) - } else { - SiloDropdownMenuItem( - text = { Text(entry.label) }, - enabled = entry.enabled, - leadingIcon = { - if (entry.selected) { - Icon(Icons.Filled.Check, contentDescription = "Selected", modifier = Modifier.size(18.dp)) - } else { - Spacer(modifier = Modifier.size(18.dp)) - } - }, - onClick = { - expanded = false - entry.onClick() - }, - ) - } + SiloDropdownMenuItem( + text = { Text(entry.label) }, + enabled = entry.enabled, + leadingIcon = { + if (entry.selected) { + Icon(Icons.Filled.Check, contentDescription = "Selected", modifier = Modifier.size(18.dp)) + } else { + Spacer(modifier = Modifier.size(18.dp)) + } + }, + onClick = { + expanded = false + entry.onClick() + }, + ) } } } } -private fun subtitleDelayLabel(milliseconds: Int): String { - if (milliseconds == 0) return "0.0s" - return String.format(java.util.Locale.US, "%+.1fs", milliseconds / 1000.0) -} - private fun formatHMS(seconds: Double): String { val total = seconds.toInt().coerceAtLeast(0) val h = total / 3600 diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityManager.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityManager.kt index 90867d4c0..064d384a4 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityManager.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityManager.kt @@ -33,6 +33,8 @@ class RemotePlaybackIdentityManager( val serverId: String, val serverUrl: String, val serverName: String?, + /** The deployment identity the phone verified for [serverId], when it sent one. */ + val serverIdentity: String? = null, val profileId: String, val profileName: String?, val controllerDeviceId: String, @@ -61,13 +63,17 @@ class RemotePlaybackIdentityManager( * the outgoing identity (the TV's own or an earlier phone's) still owns the * credentials, so whatever is playing can close its server session under the * identity it started with. An offer that fails or is denied leaves the - * current identity and playback alone. + * current identity and playback alone. [allowReuse] gates answering a + * matching offer from the active identity without a new approval; the + * receiver decides it ([TvSiloCastReceiverPolicy.mayReuseIdentity]), + * because the device ID in the match is self-reported. */ suspend fun prepare( offer: SiloCastHandoffOffer, controllerDeviceId: String, controllerDeviceName: String?, receiverRun: Long, + allowReuse: Boolean, /** Called once the offer needs the full server exchange rather than a reuse. */ onFullHandoff: () -> Unit = {}, beforeActivation: suspend () -> Unit = {}, @@ -78,7 +84,7 @@ class RemotePlaybackIdentityManager( // An end in flight has already unpublished its identity (see // [endLocked]) and holds this lock, so an identity being ended is // never reused: this waits for it and takes the full path. - activeIdentity?.takeIf { matches(offer, controllerDeviceId) }?.let { active -> + activeIdentity?.takeIf { allowReuse && matches(offer, controllerDeviceId) }?.let { active -> val reclaimed = active.copy(receiverRun = receiverRun) activeIdentity = reclaimed return@withLock reclaimed.toReady(offer.requestId, reused = true) @@ -156,6 +162,7 @@ class RemotePlaybackIdentityManager( serverId = offer.serverId, serverUrl = normalizedUrl(offer.serverURL), serverName = offer.serverName, + serverIdentity = offer.serverIdentity?.trim()?.takeIf { it.isNotEmpty() }, profileId = profileId, profileName = offer.profileName, controllerDeviceId = controllerDeviceId, diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiver.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiver.kt index d68fee994..370a79959 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiver.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiver.kt @@ -31,14 +31,18 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.Json +import org.siloserver.silo.cast.SiloCastCloseReason import org.siloserver.silo.cast.SiloCastControlCommand import org.siloserver.silo.cast.SiloCastError +import org.siloserver.silo.cast.SiloCastFeature import org.siloserver.silo.cast.SiloCastHello import org.siloserver.silo.cast.SiloCastHandoffCancel +import org.siloserver.silo.cast.SiloCastHandoffCancelReason import org.siloserver.silo.cast.SiloCastHandoffOffer import org.siloserver.silo.cast.SiloCastHandoffReady import org.siloserver.silo.cast.SiloCastLaunchRequest import org.siloserver.silo.cast.SiloCastMessage +import org.siloserver.silo.cast.SiloCastNowPlayingSummary import org.siloserver.silo.cast.SiloCastPeerRole import org.siloserver.silo.cast.SiloCastPlaybackState import org.siloserver.silo.cast.SiloCastProtocol @@ -59,7 +63,11 @@ import org.siloserver.silo.network.TokenManager * silo-apple's TVControlReceiver: * * - Transport is TLS-PSK ([SiloCastTls]) over the advertised `_silocast._tcp` - * port; newest controller wins the single session slot. + * port. Up to [TvSiloCastReceiverPolicy.MAX_CONTROLLERS] controllers stay + * connected at once (phones, tablets, integrations), each with its own + * heartbeat, authorization and command budget. The player, the borrowed + * phone identity, the one in-flight handoff and the standby screen are + * shared by all of them. * - The TV sends `hello` immediately after the TLS handshake. The controller * must reply within [AUTH_GRACE_MS]. A same-server controller is authorized * directly; a different-server launch must first complete the temporary @@ -68,16 +76,21 @@ import org.siloserver.silo.network.TokenManager * - Heartbeat: the TV pings every [HEARTBEAT_INTERVAL_MS]; only a `pong` * proves the controller can still receive, so only `pong` resets the * missed counter. More than [MAX_MISSED_HEARTBEATS] misses → drop. - * - State pushes every [STATE_INTERVAL_MS] (Apple's 500ms cadence) while a - * player is registered, an idle "Ready" state otherwise, and one push - * right after every accepted control command. + * - One state tick every [STATE_INTERVAL_MS] (Apple's 500ms cadence) goes to + * every authorized controller, plus one after every accepted command. + * Errors go only to the controller that caused them. * - A deliberate teardown sends a `close` frame ahead of the FIN so the - * controller can tell "disconnected on purpose" from a dropped link. + * controller can tell "disconnected on purpose" from a dropped link; peers + * that list `close_reason` also learn why. * - A same-server phone offering the profile this TV is already signed in * with gets `handoff_ready` at once and plays on the TV's own identity. * A borrowed profile stays installed while its phone is connected, so the * next title reuses it, and is handed back when that phone leaves, another * controller takes over, or after [IDENTITY_IDLE_MS] with nothing playing. + * Reusing it needs the connection that completed the handoff or the resume + * token issued to it, since device IDs are self-reported. + * - A handoff that would replace a title another profile is watching asks + * first (`replace_confirmation_required`) when the controller can ask. */ class TvSiloCastReceiver( private val advertiser: SiloCastNsdAdvertiser, @@ -92,14 +105,27 @@ class TvSiloCastReceiver( private val awaitPlaybackTeardown: suspend () -> Unit, /** True while this TV is in a Watch Party, whose membership an identity swap would end. */ private val inWatchParty: () -> Boolean = { false }, + /** The TV's own active profile name, for the replace prompt on a title it plays on its own identity. */ + private val ownProfileNameProvider: () -> String? = { null }, + private val nowMs: () -> Long = { android.os.SystemClock.elapsedRealtime() }, ) { data class StandbyState( val controllerName: String?, val serverName: String?, /** Set while a full profile handoff prepares a title the phone is about to play. */ val preparing: Preparing? = null, + /** Other non-passive controllers waiting besides [controllerName]. */ + val otherControllerCount: Int = 0, ) { - val controllerLabel: String get() = controllerName?.takeIf { it.isNotBlank() } ?: "phone" + val controllerLabel: String + get() { + val name = controllerName?.takeIf { it.isNotBlank() } ?: "phone" + return when { + otherControllerCount <= 0 -> name + otherControllerCount == 1 -> "$name and 1 other device" + else -> "$name and $otherControllerCount other devices" + } + } data class Preparing(val title: String?) } @@ -111,15 +137,29 @@ class TvSiloCastReceiver( private var scope: CoroutineScope? = null private var serverSocket: ServerSocket? = null - @Volatile - private var activeSession: ControllerSession? = null + + // Every connected controller by connection id, in acceptance order. + // Guarded by the receiver lock. + private val controllers = LinkedHashMap() + private var controllerSequence: Long = 0 + + // The one in-flight handoff. A newer offer from another controller + // cancels it with `superseded`. Guarded by the receiver lock. + private var pendingHandoff: PendingHandoff? = null + + // Who may reuse the active borrowed identity without a new approval: the + // connection whose handoff installed it, or whoever presents the resume + // token issued to that connection. Guarded by the receiver lock. + private var issuedIdentity: IssuedIdentity? = null + private val _standbyState = MutableStateFlow(null) val standbyState: StateFlow = _standbyState.asStateFlow() - /** Bumped whenever the active-controller slot is force-cleared (new accept, - * server switch, stop). Handshakes started before the bump must not - * register — they belong to the previous epoch. */ - private var sessionEpoch: Long = 0 + /** Bumped whenever every controller is force-closed (server switch, stop). + * Handshakes started before the bump must not register — they belong to + * the previous epoch. */ + private var receiverEpoch: Long = 0 + @Volatile private var activePlayer: ActivePlayer? = null private val volumeTracker = SiloCastVolumeTracker() private val launchRequestChannel = Channel(capacity = 1) @@ -186,10 +226,10 @@ class TvSiloCastReceiver( } // Keep the Bonjour TXT record in sync with the active server while the // receiver runs (it stays up across server switches until onStop), and - // drop the live controller session — its hello was authorized against - // the previous server, so keeping it would let an old-server remote - // drive playback on the new one. Only a different server or advertised - // name counts: profile and last-used updates rewrite the entry too, and + // drop the live controllers — their hellos were authorized against the + // previous server, so keeping them would let an old-server remote drive + // playback on the new one. Only a different server or advertised name + // counts: profile and last-used updates rewrite the entry too, and // re-registering on a profile clear raced the stop() that follows it, // losing the Bonjour goodbye so the TV stayed listed after sign-out. newScope.launch { @@ -197,13 +237,39 @@ class TvSiloCastReceiver( .distinctUntilChangedBy { it?.id to it?.displayName } .drop(1) .collect { - closePreviousController() + closeAllControllers(SiloCastCloseReason.Unauthorized) identityManager.end() // Re-advertises under the lock, and is a no-op once stop() // has cleared the socket. refreshAdvertisement() } } + // One state tick for every controller, built once. These jobs are + // roots of a SupervisorJob scope with no handler, so a throw would + // crash the app; a failed tick or expiry is logged and skipped. + newScope.launch { + while (isActive) { + runRecovering("state tick") { broadcastState() } + delay(STATE_INTERVAL_MS) + } + } + // A borrowed profile whose session the server refused to refresh. + newScope.launch { + tokenManager.temporarySessionExpired.collect { generation -> + runRecovering("temporary session expiry") { onTemporarySessionExpired(generation) } + } + } + } + + /** Runs [block], logging instead of throwing: a root job's exception would crash the app. */ + private suspend fun runRecovering(what: String, block: suspend () -> Unit) { + try { + block() + } catch (e: CancellationException) { + throw e + } catch (t: Throwable) { + Log.w(TAG, "SiloCast $what failed", t) + } } @Synchronized @@ -216,14 +282,15 @@ class TvSiloCastReceiver( // torn down; it must not open when the TV app comes back. launchRequestChannel.tryReceive() identityEndJob = null - // Close the session directly (not via closePreviousController, which - // launches the goodbye on `scope` — the scope we cancel a line later, - // which would kill the goodbye before it writes). A direct close() - // sends the FIN; the goodbye frame is best-effort and the socket - // teardown below guarantees the peer disconnects regardless. - sessionEpoch += 1 - activeSession?.close() - activeSession = null + // Close the sessions directly (not via a goodbye launched on `scope` — + // the scope we cancel a line later, which would kill the goodbye before + // it writes). A direct close() sends the FIN; the socket teardown below + // guarantees the peers disconnect regardless. + receiverEpoch += 1 + pendingHandoff = null + issuedIdentity = null + controllers.values.toList().forEach { it.close() } + controllers.clear() _standbyState.value = null runCatching { serverSocket?.close() } serverSocket = null @@ -284,7 +351,7 @@ class TvSiloCastReceiver( identityGeneration = identityGeneration, ) activePlayer = player - activeSession?.let { clearPreparing(it, refresh = false) } + controllers.values.forEach { clearPreparing(it, refresh = false) } _standbyState.value = null advertiser.updatePlaying(true) return Closeable { @@ -312,28 +379,18 @@ class TvSiloCastReceiver( } } - @Synchronized - fun closePreviousController() { - // Bump the epoch unconditionally — a handshake in flight (not yet - // registered, so activeSession is still null) during a server switch - // must also be invalidated, else it registers into the new epoch. - sessionEpoch += 1 - val session = activeSession ?: return - DiagnosticsCastLogger.event("TV cast controller disconnected") - activeSession = null - _standbyState.value = null - val owner = scope - if (owner != null) { - // Goodbye + teardown off the monitor — a blocking write to a - // half-open peer must not stall registerPlayer/stop/accept. - owner.launch { session.goodbyeAndClose() } - } else { - session.close() - } - } - + /** + * The TV's own Disconnect (standby screen). Closes every controller that + * put the standby screen up; passive ones (integrations, silent + * auto-resume) never did and stay connected. + */ fun disconnectRemoteControl() { - closePreviousController() + val closing = synchronized(this) { + controllers.values.filter { !it.passive }.onEach { removeControllerLocked(it) } + } + if (closing.isNotEmpty()) DiagnosticsCastLogger.event("TV cast controllers disconnected on the TV") + closing.forEach { goodbye(it, SiloCastCloseReason.TvDisconnected) } + refreshStandbyState() } internal fun recordPlayerVolume(volume: Double) { @@ -349,6 +406,45 @@ class TvSiloCastReceiver( internal fun resolvePlayerVolume(currentVolume: Double?): SiloCastVolumeState = volumeTracker.resolve(currentVolume) + /** Closes every controller; the epoch bump also stops handshakes in flight. */ + private fun closeAllControllers(reason: String) { + val closing = synchronized(this) { + receiverEpoch += 1 + controllers.values.toList().onEach { removeControllerLocked(it) } + } + if (closing.isNotEmpty()) DiagnosticsCastLogger.event("TV cast controllers disconnected") + closing.forEach { goodbye(it, reason) } + refreshStandbyState() + } + + /** + * Removes [session] from the set and lets go of what it owned. Caller + * holds the receiver lock. Never touches playback, the identity or the + * other controllers. + */ + private fun removeControllerLocked(session: ControllerSession) { + if (controllers[session.id] !== session) return + controllers.remove(session.id) + pendingHandoff?.takeIf { it.controllerId == session.id }?.let { + pendingHandoff = null + it.job?.cancel() + } + // Reuse from here on needs the resume token. + issuedIdentity?.takeIf { it.connectionId == session.id }?.let { + issuedIdentity = it.copy(connectionId = null) + } + } + + /** Goodbye + teardown off the monitor — a blocking write to a half-open peer must not stall the receiver. */ + private fun goodbye(session: ControllerSession, reason: String?) { + val owner = scope + if (owner != null) { + owner.launch { session.goodbyeAndClose(reason) } + } else { + session.close() + } + } + private suspend fun acceptLoop(socket: ServerSocket) { while (true) { val client = try { @@ -357,22 +453,18 @@ class TvSiloCastReceiver( Log.i(TAG, "SiloCast listener stopped") return } - closePreviousController() val ownerScope = scope ?: run { runCatching { client.close() } return } - val sessionJob = ownerScope.launch { - runControllerSession(client) - } - // runControllerSession registers the ControllerSession itself once - // the TLS handshake succeeds; a handshake failure just ends the job. - sessionJob.invokeOnCompletion { } + // A new connection never evicts a working controller here: + // admission decides once its TLS handshake is done. + ownerScope.launch { runControllerSession(client) } } } private suspend fun runControllerSession(client: Socket) { - val epochAtAccept = synchronized(this) { sessionEpoch } + val epochAtAccept = synchronized(this) { receiverEpoch } val tls = try { withContext(Dispatchers.IO) { SiloCastTls.accept(client) } } catch (t: Throwable) { @@ -381,23 +473,42 @@ class TvSiloCastReceiver( runCatching { client.close() } return } - val session = ControllerSession(socket = client, tls = tls, json = json) - val registered = synchronized(this) { - if (sessionEpoch != epochAtAccept) { - // A server switch / newer controller / stop() happened while - // this handshake was in flight — this session lost. - false - } else { - // Newest wins: a session that finished handshaking after us in - // the same epoch would have replaced us here; close any loser. - activeSession?.close() - activeSession = session - true + val session = ControllerSession(id = UUID.randomUUID().toString(), socket = client, tls = tls, json = json) + when (val admitted = admit(session, epochAtAccept)) { + Admitted.Stale -> { + // A server switch or stop() happened while this handshake was + // in flight — this session belongs to the previous epoch. + session.close() + return + } + Admitted.Refused -> { + Log.i(TAG, "SiloCast at controller limit; refusing a new controller") + DiagnosticsCastLogger.warning("TV cast refused a controller at the limit") + runCatching { + withTimeoutOrNull(GOODBYE_TIMEOUT_MS) { + session.send(SiloCastMessage.Hello(makeHello())) + session.send( + SiloCastMessage.Error( + SiloCastError( + code = "too_many_controllers", + message = "Too many devices are controlling this TV. Disconnect one and try again.", + ), + ), + ) + // The peer's features aren't known yet, but every + // shipped decoder ignores the `close` payload. Without + // the reason, a phone that saw `multi_controller` in the + // hello would forget this TV. + session.send(SiloCastMessage.Close.withReason(SiloCastCloseReason.TooManyControllers)) + } + } + session.close() + return + } + is Admitted.Registered -> admitted.evicted?.let { + Log.i(TAG, "SiloCast at controller limit; closing an idle controller") + goodbye(it, SiloCastCloseReason.TooManyControllers) } - } - if (!registered) { - session.close() - return } DiagnosticsCastLogger.event("TV cast controller connected") try { @@ -408,12 +519,18 @@ class TvSiloCastReceiver( // until the controller's hello has been authorized. session.send(SiloCastMessage.Hello(makeHello())) - val stateJob = launch { - while (isActive) { - if (session.isAuthorized) { - session.send(SiloCastMessage.State(currentState())) + // The latest state only: a slow peer never holds up the others, + // and never receives a stale frame after a newer one. + // Checked again once the write is admitted: a state read + // under another profile, or queued before this controller lost + // access, is dropped instead of sent. + val stateWriter = launch { + for (published in session.stateUpdates) { + session.sendState(published.state) { + synchronized(this@TvSiloCastReceiver) { + session.isAuthorized && identityManager.activeIdentity?.generationId == published.generation + } } - delay(STATE_INTERVAL_MS) } } val heartbeatJob = launch { @@ -423,6 +540,7 @@ class TvSiloCastReceiver( if (session.missedHeartbeats > MAX_MISSED_HEARTBEATS) { Log.i(TAG, "SiloCast controller heartbeat timed out") DiagnosticsCastLogger.warning("TV cast controller timed out") + // No goodbye: the peer stopped answering. session.close() return@launch } @@ -434,14 +552,14 @@ class TvSiloCastReceiver( if (!session.didReceiveHello) { Log.i(TAG, "SiloCast controller never authorized; closing") DiagnosticsCastLogger.warning("TV cast authorization timed out") - session.goodbyeAndClose() + session.goodbyeAndClose(SiloCastCloseReason.Timeout) } } readLoop(tls) { message -> handleMessage(session, message) } - stateJob.cancel() + stateWriter.cancel() heartbeatJob.cancel() authWatchdog.cancel() } @@ -450,207 +568,61 @@ class TvSiloCastReceiver( } catch (t: Throwable) { Log.w(TAG, "SiloCast controller session ended", t) } finally { - synchronized(this) { - if (activeSession === session) { - activeSession = null - _standbyState.value = null - } - } + synchronized(this) { removeControllerLocked(session) } + refreshStandbyState() session.close() // The phone that lent the profile left (disconnected, dropped, was - // disconnected from the TV, or lost the slot to another phone). - // Hand the profile back unless a title is playing on it or on its - // way to the player; playback outlives a lost socket. - // A phone that reconnects within the grace keeps it. + // disconnected from the TV, or was replaced). Hand the profile back + // unless a title is playing on it or on its way to the player, or + // another connection from that phone is still here; playback + // outlives a lost socket. A phone that reconnects within the grace + // keeps it. val lent = identityManager.activeIdentity ?.takeIf { it.controllerDeviceId == session.controllerDeviceId } - if (lent != null && isIdentityIdle()) { + if (lent != null && isIdentityIdle() && !isLenderConnected(lent.generationId)) { scheduleIdentityEnd(lent.generationId, keepIfLenderReturns = true) } } } + private sealed interface Admitted { + data object Stale : Admitted + + data object Refused : Admitted + + data class Registered(val evicted: ControllerSession?) : Admitted + } + + /** Adds [session] to the set if it fits (see [TvSiloCastReceiverPolicy.admission]). */ + @Synchronized + private fun admit(session: ControllerSession, epochAtAccept: Long): Admitted { + if (receiverEpoch != epochAtAccept) return Admitted.Stale + val existing = controllers.values.map { it.summary(isHandingOff = pendingHandoff?.controllerId == it.id) } + val evicted = when (val admission = TvSiloCastReceiverPolicy.admission(existing)) { + TvSiloCastReceiverPolicy.Admission.Admit -> null + is TvSiloCastReceiverPolicy.Admission.AdmitEvicting -> + controllers[admission.id]?.also { removeControllerLocked(it) } + TvSiloCastReceiverPolicy.Admission.Refuse -> return Admitted.Refused + } + controllerSequence += 1 + session.sequence = controllerSequence + controllers[session.id] = session + return Admitted.Registered(evicted) + } + /** @return false to end the session's read loop. */ private suspend fun handleMessage(session: ControllerSession, message: SiloCastMessage): Boolean { when (message) { - is SiloCastMessage.Hello -> { - val negotiated = SiloCastProtocol.negotiatedVersion(message.hello.supportedVersions) - if (message.hello.role != SiloCastPeerRole.Phone || negotiated != SiloCastProtocol.version) { - session.send( - SiloCastMessage.Error( - SiloCastError( - code = "version_unsupported", - message = "Update Silo on both devices to continue.", - ), - ), - ) - session.goodbyeAndClose() - return false - } - session.didReceiveHello = true - session.negotiatedVersion = negotiated - session.controllerDeviceId = message.hello.deviceId - session.controllerDeviceName = message.hello.deviceName - session.controllerServerId = message.hello.serverId - val activeServerId = identityManager.activeIdentity?.serverId - ?: serverRegistry.activeServerId.value - val offered = message.hello.serverId - session.isAuthorized = AndroidServerRegistry.serverIdsMatch(offered, activeServerId) - DiagnosticsCastLogger.event( - if (session.isAuthorized) "TV cast controller authorized" else "TV cast handoff required", - ) - refreshStandbyState() - if (session.isAuthorized) { - session.send(SiloCastMessage.State(currentState())) - } - } - is SiloCastMessage.HandoffOffer -> { - val offer = message.handoffOffer - // A handoff swaps in the phone's profile, and any identity - // change leaves the Watch Party this TV is in. - if (inWatchParty()) { - session.send( - SiloCastMessage.HandoffCancel( - SiloCastHandoffCancel( - requestId = offer.requestId, - reason = "watch_party_active", - message = "Leave the Watch Party on the TV first.", - ), - ), - ) - return true - } - val controllerId = session.controllerDeviceId - if (session.negotiatedVersion != SiloCastProtocol.version || controllerId == null) { - session.send( - SiloCastMessage.HandoffCancel( - SiloCastHandoffCancel( - requestId = offer.requestId, - reason = "version_unsupported", - message = "Update Silo on both devices to continue.", - ), - ), - ) - return true - } - // A launch on its way to the player would land on whatever - // identity this handoff installs. The phone tries again once the - // player opens, or the pending launch expires. - if (synchronized(this) { pendingLaunch != null }) { - session.send( - SiloCastMessage.HandoffCancel( - SiloCastHandoffCancel( - requestId = offer.requestId, - reason = "launch_pending", - message = LAUNCH_PENDING_MESSAGE, - ), - ), - ) - return true - } - session.handoffJob?.cancel() - // A reused identity must survive a pending cleanup. One this - // offer would replace stays until approval, so its cleanup still - // runs if the handoff fails; after a replacement it finds a newer - // generation and does nothing. - if (identityManager.matches(offer, controllerId)) { - identityEndJob?.cancel() - identityEndJob = null - } - session.remoteLaunchReady = false - session.ownIdentityProfileId = null - val run = receiverRun - session.handoffJob = scope?.launch { - try { - // The hello may have been judged against a phone's - // identity that is gone now; with none installed, the - // TV's own server decides. - if (identityManager.activeIdentity == null) { - session.isAuthorized = AndroidServerRegistry.serverIdsMatch( - session.controllerServerId, - serverRegistry.activeServerId.value, - ) - refreshStandbyState() - } - ownIdentityReady(session, offer)?.let { ready -> - clearPreparing(session) - session.ownIdentityProfileId = offer.profileId - DiagnosticsCastLogger.event("TV cast handoff skipped on own profile") - session.send(SiloCastMessage.HandoffReady(ready)) - session.send(SiloCastMessage.State(currentState())) - return@launch - } - var fullHandoff = false - val ready = identityManager.prepare( - offer = offer, - controllerDeviceId = controllerId, - controllerDeviceName = session.controllerDeviceName, - receiverRun = run, - onFullHandoff = { - fullHandoff = true - showPreparing(session, offer.title) - }, - beforeActivation = ::stopPlayerBeforeIdentitySwap, - ) { challenge -> - if (activeSession === session) { - session.send(SiloCastMessage.HandoffChallenge(challenge)) - } - } - // A reuse is instant; drop a cancelled offer's title. - if (!fullHandoff) clearPreparing(session) - if (activeSession !== session) { - identityManager.end() - return@launch - } - session.isAuthorized = true - DiagnosticsCastLogger.event("TV cast handoff authorized") - session.remoteLaunchReady = true - refreshStandbyState() - refreshAdvertisement() - session.send(SiloCastMessage.HandoffReady(ready)) - session.send(SiloCastMessage.State(currentState())) - // Replaces the old ready-without-launch timeout: with - // nothing playing, the profile goes back after the - // idle limit. A player cancels this when it opens. - if (activePlayer == null) { - identityManager.activeIdentity?.let { - scheduleIdentityEnd(it.generationId, IDENTITY_IDLE_MS) - } - } - if (fullHandoff) { - session.preparingClearJob?.cancel() - session.preparingClearJob = scope?.launch { - delay(PREPARING_AFTER_READY_MS) - clearPreparing(session) - } - } - } catch (e: CancellationException) { - throw e - } catch (t: Throwable) { - clearPreparing(session) - if (session.remoteLaunchReady && activePlayer == null) { - identityManager.end() - session.remoteLaunchReady = false - refreshAdvertisement() - } - if (activeSession === session) { - session.send( - SiloCastMessage.HandoffCancel( - SiloCastHandoffCancel( - requestId = offer.requestId, - reason = "handoff_failed", - message = t.message ?: "Remote playback handoff failed.", - ), - ), - ) - } - } - } - } + is SiloCastMessage.Hello -> return handleHello(session, message.hello) + is SiloCastMessage.HandoffOffer -> handleOffer(session, message.handoffOffer) is SiloCastMessage.HandoffCancel -> { - session.handoffJob?.cancel() - session.handoffJob = null - session.remoteLaunchReady = false + val cancel = message.handoffCancel + val owned = synchronized(this) { + pendingHandoff?.takeIf { it.controllerId == session.id && it.requestId == cancel.requestId } + ?.also { pendingHandoff = null } + } + owned?.job?.cancel() + session.readyGeneration = null session.ownIdentityProfileId = null clearPreparing(session) // The phone is still here, so a profile it lent stays for its @@ -664,111 +636,13 @@ class TvSiloCastReceiver( } ?.let { scheduleIdentityEnd(it.generationId, IDENTITY_IDLE_MS) } } - is SiloCastMessage.Launch -> { + is SiloCastMessage.Launch -> handleLaunch(session, message.launch) + is SiloCastMessage.Control -> { if (!requireAuthorized(session)) return true - // A Watch Party player is never silently replaced by a cast. - activePlayer?.adapter?.launchRefusal?.invoke()?.let { refusal -> - session.send( - SiloCastMessage.Error( - SiloCastError(code = "watch_party_active", message = refusal), - ), - ) - return true - } - // In a party lobby no party player is registered yet, but a - // solo launch would still take the shared player from the room. - if (inWatchParty()) { - session.send( - SiloCastMessage.Error( - SiloCastError(code = "watch_party_active", message = "Leave the Watch Party on the TV first."), - ), - ) - return true - } - // A sign-in, profile or borrowed-identity change after this - // makes the launch stale (see confirmLaunch). - val identity = settledLaunchIdentity(session)?.takeIf { it.onOwnIdentity || it.borrowed != null } - if (identity == null) { - session.send( - SiloCastMessage.Error( - SiloCastError( - code = "handoff_required", - message = "Prepare the phone profile before playing.", - ), - ), - ) - return true - } - val onOwnIdentity = identity.onOwnIdentity - val borrowed = identity.borrowed - if (!AndroidServerRegistry.serverIdsMatch(message.launch.serverId, identity.serverId)) { - session.send( - SiloCastMessage.Error( - SiloCastError( - code = "server_mismatch", - message = "This TV is connected to a different Silo server.", - ), - ), - ) - return true - } - val generation = if (onOwnIdentity) null else borrowed?.generationId - val launchOwner = UUID.randomUUID().toString() - val admitted = AdmittedLaunch(launchOwner, generation, identity.revision) - val refusal = synchronized(this) { - when { - // A phone that lost the slot while this was in flight. - activeSession !== session -> SiloCastError( - code = "unauthorized", - message = "Another phone is controlling this TV.", - ) - pendingLaunch != null -> SiloCastError( - code = "launch_pending", - message = LAUNCH_PENDING_MESSAGE, - ) - // The identity changed since the checks above, or its - // hand-back has committed. - !isAdmittedIdentityCurrentLocked(admitted) -> SiloCastError( - code = "handoff_required", - message = "Prepare the phone profile before playing.", - ) - else -> { - pendingLaunch = admitted - null - } - } - } - if (refusal != null) { - if (refusal.code == "handoff_required") session.remoteLaunchReady = false - session.send(SiloCastMessage.Error(refusal)) + if (!session.commandBudget.allow(nowMs())) { + noteRateLimited(session) return true } - if (!launchRequestChannel.trySend(Launch(message.launch, launchOwner)).isSuccess) { - clearPendingLaunch(launchOwner) - session.send( - SiloCastMessage.Error( - SiloCastError( - code = "launch_failed", - message = "The TV could not open remote playback.", - ), - ), - ) - return true - } - // A launch whose player never opens must not hold off every - // later handoff. - scope?.launch { - delay(PENDING_LAUNCH_TIMEOUT_MS) - clearPendingLaunch(launchOwner, dropQueued = true) - } - session.remoteLaunchReady = false - session.ownIdentityProfileId = null - // The TV is opening the player now; the preparing view is done. - clearPreparing(session, refresh = false) - _standbyState.value = null - } - is SiloCastMessage.Control -> { - if (!requireAuthorized(session)) return true if (message.control.name !in SiloCastControlCommand.knownNames) { // A command from a newer (or retired, like set_hdr_enabled) // remote. Ignored without an error, as tvOS does: the phone @@ -791,13 +665,18 @@ class TvSiloCastReceiver( withContext(Dispatchers.Main.immediate) { player.adapter.handle(message.control) } - session.send(SiloCastMessage.State(currentState())) + // Everyone sees the change, not only the controller that made it. + broadcastState() + } + is SiloCastMessage.Presence -> { + if (session.didReceiveHello) { + session.passive = message.presence.passive + refreshStandbyState() + } } is SiloCastMessage.Ping -> session.send(SiloCastMessage.Pong()) is SiloCastMessage.Pong -> session.missedHeartbeats = 0 is SiloCastMessage.Close -> return false - // This receiver doesn't list `presence`, so controllers don't send it. - is SiloCastMessage.Presence, is SiloCastMessage.State, is SiloCastMessage.Error, is SiloCastMessage.HandoffChallenge, @@ -807,6 +686,385 @@ class TvSiloCastReceiver( return true } + private suspend fun handleHello(session: ControllerSession, hello: SiloCastHello): Boolean { + if (session.didReceiveHello) return true + // Read first, so even a refusal below can carry a close reason. + session.features = hello.features.orEmpty().toSet() + val negotiated = SiloCastProtocol.negotiatedVersion(hello.supportedVersions) + if (hello.role != SiloCastPeerRole.Phone || negotiated != SiloCastProtocol.version) { + session.send( + SiloCastMessage.Error( + SiloCastError( + code = "version_unsupported", + message = "Update Silo on both devices to continue.", + ), + ), + ) + synchronized(this) { removeControllerLocked(session) } + session.goodbyeAndClose(SiloCastCloseReason.Unauthorized) + return false + } + session.didReceiveHello = true + session.negotiatedVersion = negotiated + session.controllerDeviceId = hello.deviceId + session.controllerDeviceName = hello.deviceName + session.controllerServerId = hello.serverId + session.controllerServerIdentity = hello.serverIdentity?.trim()?.takeIf { it.isNotEmpty() } + session.passive = hello.passive == true + session.clientKind = hello.clientKind + + // The device's older sockets are ones it has abandoned (a Wi-Fi change, + // a return from background). A v2 cross-server controller is kept to + // complete its handoff, so this hello always stays. + val replaced = synchronized(this) { + TvSiloCastReceiverPolicy.connectionsReplaced( + deviceId = hello.deviceId, + connection = session.id, + existing = controllers.values.map { it.id to it.controllerDeviceId }, + ).mapNotNull { id -> controllers[id]?.also { removeControllerLocked(it) } } + } + replaced.forEach { goodbye(it, SiloCastCloseReason.Replaced) } + + session.isAuthorized = matchesEffectiveServer(session.controllerServerId, session.controllerServerIdentity) + DiagnosticsCastLogger.event( + if (session.isAuthorized) "TV cast controller authorized" else "TV cast handoff required", + ) + refreshStandbyState() + if (session.isAuthorized) session.offerState(snapshotState()) + return true + } + + private suspend fun handleOffer(session: ControllerSession, offer: SiloCastHandoffOffer) { + // A handoff swaps in the phone's profile, and any identity change + // leaves the Watch Party this TV is in. + if (inWatchParty()) { + sendHandoffCancel( + session, + offer.requestId, + SiloCastHandoffCancelReason.WatchPartyActive, + "Leave the Watch Party on the TV first.", + ) + return + } + val controllerId = session.controllerDeviceId + if (session.negotiatedVersion != SiloCastProtocol.version || controllerId == null) { + sendHandoffCancel( + session, + offer.requestId, + SiloCastHandoffCancelReason.VersionUnsupported, + "Update Silo on both devices to continue.", + ) + return + } + // A launch on its way to the player would land on whatever identity + // this handoff installs. The phone tries again once the player opens, + // or the pending launch expires. + if (synchronized(this) { pendingLaunch != null }) { + sendHandoffCancel(session, offer.requestId, SiloCastHandoffCancelReason.LaunchPending, LAUNCH_PENDING_MESSAGE) + return + } + // A handoff stops what is on screen before the swap, so consent is + // asked here, not at launch, and checked again before the stop. + titleNeedingConsent(session, offer)?.let { nowPlaying -> + sendReplaceConfirmationRequired(session, offer.requestId, nowPlaying) + return + } + + val pending = PendingHandoff(controllerId = session.id, requestId = offer.requestId) + val superseded = synchronized(this) { + pendingHandoff.also { pendingHandoff = pending } + } + superseded?.let { previous -> + previous.job?.cancel() + synchronized(this) { controllers[previous.controllerId] }?.takeIf { it !== session }?.let { other -> + clearPreparing(other) + // Off this read loop: a stalled peer must not hold up the new offer. + scope?.launch { + sendHandoffCancel( + other, + previous.requestId, + SiloCastHandoffCancelReason.Superseded, + "${session.controllerDeviceName ?: "Another device"} started playing on this TV.", + ) + } + } + } + + val allowReuse = synchronized(this) { + val active = identityManager.activeIdentity + val issued = issuedIdentity?.takeIf { active != null && it.generation == active.generationId } + TvSiloCastReceiverPolicy.mayReuseIdentity( + offerMatchesIdentity = identityManager.matches(offer, controllerId), + identityConnection = issued?.connectionId, + connection = session.id, + issuedResumeToken = issued?.resumeToken, + offeredResumeToken = offer.resumeToken, + ) + } + // A reused identity must survive a pending cleanup. One this offer + // would replace stays until approval, so its cleanup still runs if the + // handoff fails; after a replacement it finds a newer generation and + // does nothing. + if (allowReuse) { + synchronized(this) { + identityEndJob?.cancel() + identityEndJob = null + } + } + session.readyGeneration = null + session.ownIdentityProfileId = null + val run = receiverRun + pending.job = scope?.launch { + // The generation this attempt's completeHandoff installed, if it got that far. + var grantedGeneration: String? = null + try { + // The hello may have been judged against a phone's identity that + // is gone now; with none installed, the TV's own server decides. + if (identityManager.activeIdentity == null) { + session.isAuthorized = matchesEffectiveServer(session.controllerServerId, session.controllerServerIdentity) + refreshStandbyState() + } + ownIdentityReady(session, offer)?.let { ready -> + clearPreparing(session) + if (!finishPendingHandoff(pending)) return@launch + session.ownIdentityProfileId = offer.profileId + DiagnosticsCastLogger.event("TV cast handoff skipped on own profile") + session.send(SiloCastMessage.HandoffReady(ready)) + session.offerState(snapshotState()) + return@launch + } + var fullHandoff = false + val ready = identityManager.prepare( + offer = offer, + controllerDeviceId = controllerId, + controllerDeviceName = session.controllerDeviceName, + receiverRun = run, + allowReuse = allowReuse, + onFullHandoff = { + fullHandoff = true + showPreparing(session, offer.title) + }, + beforeActivation = { + // Another controller may have started a title while the + // server approved this one. + titleNeedingConsent(session, offer)?.let { throw ReplaceConfirmationRequired(it) } + stopPlayerBeforeIdentitySwap() + }, + ) { challenge -> + if (!isCurrentHandoff(pending)) throw CancellationException("The handoff was superseded.") + session.send(SiloCastMessage.HandoffChallenge(challenge)) + } + // A reuse is instant; drop a cancelled offer's title. + if (!fullHandoff) clearPreparing(session) + if (!finishPendingHandoff(pending)) { + // The controller left or was superseded after approval. A + // fresh identity nobody can launch under is handed back; a + // reused one may still be playing. + if (!ready.reused) { + identityManager.activeIdentity?.let { identityManager.end(it.generationId) } + refreshAdvertisement() + reconcileAuthorization() + } + return@launch + } + completeHandoff(session, offer, controllerId, ready, fullHandoff) { grantedGeneration = it } + } catch (e: CancellationException) { + throw e + } catch (refusal: ReplaceConfirmationRequired) { + clearPreparing(session) + finishPendingHandoff(pending) + if (synchronized(this@TvSiloCastReceiver) { controllers[session.id] === session }) { + runCatching { sendReplaceConfirmationRequired(session, offer.requestId, refusal.nowPlaying) } + } + } catch (t: Throwable) { + clearPreparing(session) + // Readiness was granted but `handoff_ready` never reached the + // phone: hand back the generation this attempt granted (a + // newer handoff from the same phone may own the session's + // readiness by now), and re-check the controllers it judged. + val granted = grantedGeneration + if (granted != null && activePlayer == null) { + synchronized(this@TvSiloCastReceiver) { + if (session.readyGeneration == granted) session.readyGeneration = null + } + runRecovering("handoff rollback") { + identityManager.end(granted) + refreshAdvertisement() + reconcileAuthorization() + } + } + finishPendingHandoff(pending) + if (synchronized(this@TvSiloCastReceiver) { controllers[session.id] === session }) { + sendHandoffCancel( + session, + offer.requestId, + SiloCastHandoffCancelReason.HandoffFailed, + t.message ?: "Remote playback handoff failed.", + ) + } + } + } + } + + /** + * Readiness is this controller's alone: an earlier controller's launch + * must never play under this profile. Issues the resume token and tells + * the others the TV now plays as someone else. + */ + private suspend fun completeHandoff( + session: ControllerSession, + offer: SiloCastHandoffOffer, + controllerId: String, + ready: SiloCastHandoffReady, + fullHandoff: Boolean, + onGranted: (generation: String) -> Unit, + ) { + // The identity was handed back in the moment after prepare() returned + // (idle end, expiry, server switch): fail the handoff so the phone + // hears it instead of waiting out its timeout. + val generation = identityManager.activeIdentity?.generationId + ?: error("The TV handed the profile back. Try again.") + val resumeToken = synchronized(this) { + // Readiness is granted only for the profile this offer prepared, + // never one another controller installed in the meantime. + check(identityManager.activeIdentity?.generationId == generation && identityManager.matches(offer, controllerId)) { + "The TV switched to another profile. Try again." + } + controllers.values.filter { it !== session }.forEach { + it.readyGeneration = null + it.ownIdentityProfileId = null + } + session.readyGeneration = generation + session.isAuthorized = true + val token = if (session.supports(SiloCastFeature.ResumeToken)) { + issuedIdentity?.takeIf { it.generation == generation }?.resumeToken + ?: TvSiloCastReceiverPolicy.makeResumeToken() + } else { + null + } + issuedIdentity = IssuedIdentity(generation = generation, connectionId = session.id, resumeToken = token) + token + } + onGranted(generation) + DiagnosticsCastLogger.event("TV cast handoff authorized") + refreshAdvertisement() + // A cross-server identity changes the effective server under the + // other controllers. + reconcileAuthorization() + session.send(SiloCastMessage.HandoffReady(ready.copy(resumeToken = resumeToken))) + session.offerState(snapshotState()) + // Replaces the old ready-without-launch timeout: with nothing playing, + // the profile goes back after the idle limit. A player cancels this + // when it opens. + if (activePlayer == null) scheduleIdentityEnd(generation, IDENTITY_IDLE_MS) + if (fullHandoff) { + session.preparingClearJob?.cancel() + session.preparingClearJob = scope?.launch { + delay(PREPARING_AFTER_READY_MS) + clearPreparing(session) + } + } + } + + private fun isCurrentHandoff(pending: PendingHandoff): Boolean = + synchronized(this) { pendingHandoff === pending && controllers.containsKey(pending.controllerId) } + + /** Clears [pending] if it is still the in-flight handoff; false when it was superseded or its controller left. */ + private fun finishPendingHandoff(pending: PendingHandoff): Boolean = synchronized(this) { + val current = pendingHandoff === pending && controllers.containsKey(pending.controllerId) + if (pendingHandoff === pending) pendingHandoff = null + current + } + + private suspend fun handleLaunch(session: ControllerSession, launch: SiloCastLaunchRequest) { + if (!requireAuthorized(session)) return + // A Watch Party player is never silently replaced by a cast. + activePlayer?.adapter?.launchRefusal?.invoke()?.let { refusal -> + session.send(SiloCastMessage.Error(SiloCastError(code = "watch_party_active", message = refusal))) + return + } + // In a party lobby no party player is registered yet, but a solo + // launch would still take the shared player from the room. + if (inWatchParty()) { + session.send( + SiloCastMessage.Error( + SiloCastError(code = "watch_party_active", message = "Leave the Watch Party on the TV first."), + ), + ) + return + } + // A sign-in, profile or borrowed-identity change after this makes the + // launch stale (see confirmLaunch). + val identity = settledLaunchIdentity(session)?.takeIf { it.onOwnIdentity || it.borrowed != null } + if (identity == null) { + session.send( + SiloCastMessage.Error( + SiloCastError(code = "handoff_required", message = "Prepare the phone profile before playing."), + ), + ) + return + } + val onOwnIdentity = identity.onOwnIdentity + val borrowed = identity.borrowed + // The launch names the phone's registry key; the identity learned from + // its hello lets a same-deployment phone on another address launch. + val launchIdentity = session.controllerServerIdentity + ?.takeIf { AndroidServerRegistry.serverIdsMatch(launch.serverId, session.controllerServerId) } + if (!AndroidServerRegistry.serversMatch(launch.serverId, launchIdentity, identity.serverId, identity.serverIdentity)) { + session.send( + SiloCastMessage.Error( + SiloCastError(code = "server_mismatch", message = "This TV is connected to a different Silo server."), + ), + ) + return + } + val generation = if (onOwnIdentity) null else borrowed?.generationId + val launchOwner = UUID.randomUUID().toString() + val admitted = AdmittedLaunch(launchOwner, generation, identity.revision) + val refusal = synchronized(this) { + when { + // The controller left while this was in flight. + controllers[session.id] !== session -> return + pendingLaunch != null -> SiloCastError(code = "launch_pending", message = LAUNCH_PENDING_MESSAGE) + // The identity changed since the checks above, or its hand-back + // has committed. + !isAdmittedIdentityCurrentLocked(admitted) -> SiloCastError( + code = "handoff_required", + message = "Prepare the phone profile before playing.", + ) + else -> { + pendingLaunch = admitted + null + } + } + } + if (refusal != null) { + if (refusal.code == "handoff_required") session.readyGeneration = null + session.send(SiloCastMessage.Error(refusal)) + return + } + if (!launchRequestChannel.trySend(Launch(launch, launchOwner)).isSuccess) { + clearPendingLaunch(launchOwner) + session.send( + SiloCastMessage.Error( + SiloCastError(code = "launch_failed", message = "The TV could not open remote playback."), + ), + ) + return + } + // A launch whose player never opens must not hold off every later + // handoff. + scope?.launch { + delay(PENDING_LAUNCH_TIMEOUT_MS) + clearPendingLaunch(launchOwner, dropQueued = true) + } + session.readyGeneration = null + session.ownIdentityProfileId = null + // The TV is opening the player now; the preparing view is done. + clearPreparing(session, refresh = false) + _standbyState.value = null + } + /** * Stops the registered player and waits for its server session to close. * An approved handoff runs this just before it installs the phone's @@ -832,6 +1090,83 @@ class TvSiloCastReceiver( check(tornDown && activePlayer == null) { "The TV is still playing. Try again." } } + /** The handoff refused just before it would stop what plays; see [titleNeedingConsent]. */ + private class ReplaceConfirmationRequired(val nowPlaying: SiloCastNowPlayingSummary) : Exception() + + /** + * What [offer] would replace without its user's consent, or null when the + * handoff may go ahead (see [TvSiloCastReceiverPolicy.needsReplaceConfirmation]). + */ + private suspend fun titleNeedingConsent(session: ControllerSession, offer: SiloCastHandoffOffer): SiloCastNowPlayingSummary? { + val titleOnScreen = synchronized(this) { activePlayer != null || pendingLaunch != null } + if (!titleOnScreen || !session.supports(SiloCastFeature.ReplaceConfirmation)) return null + val nowPlaying = nowPlayingSummary() + val needsConsent = TvSiloCastReceiverPolicy.needsReplaceConfirmation( + titleOnScreen = true, + playingUnderOfferedProfile = isPlayingUnderProfile(offer), + replaceConfirmed = TvSiloCastReceiverPolicy.confirmationCovers( + onScreenContentId = nowPlaying.contentId, + replaceConfirmed = offer.replaceConfirmed == true, + replacingContentId = offer.replacingContentId, + ), + controllerCanConfirm = true, + ) + return nowPlaying.takeIf { needsConsent } + } + + /** + * Whether what is on screen plays under the profile [offer] names. The TV + * plays under the borrowed identity when one is active, else under its own + * signed-in profile. + */ + private suspend fun isPlayingUnderProfile(offer: SiloCastHandoffOffer): Boolean { + identityManager.activeIdentity?.let { identity -> + return identity.profileId == offer.profileId && + AndroidServerRegistry.serversMatch(identity.serverId, identity.serverIdentity, offer.serverId, offer.serverIdentity) + } + val server = serverRegistry.activeEntry.value ?: return false + return tokenManager.getProfileId() == offer.profileId && + AndroidServerRegistry.serversMatch(server.id, server.verifiedServerId, offer.serverId, offer.serverIdentity) + } + + private suspend fun nowPlayingSummary(): SiloCastNowPlayingSummary { + val state = withContext(Dispatchers.Main.immediate) { activePlayer?.stateProvider?.invoke() } + val identity = identityManager.activeIdentity + val startedByPhone = identity != null && activePlayer?.identityGeneration == identity.generationId + return SiloCastNowPlayingSummary( + contentId = state?.contentId?.takeIf { it.isNotEmpty() }, + title = state?.title?.takeIf { it.isNotBlank() } ?: "a title", + subtitle = state?.subtitle?.takeIf { it.isNotBlank() }, + // Under a borrowed identity only its own name is right; a phone + // that sent none leaves it null ("Someone"), never the TV's profile. + profileName = if (identity != null) identity.profileName else ownProfileNameProvider(), + controllerName = if (startedByPhone) identity?.controllerDeviceName else null, + ) + } + + private suspend fun sendReplaceConfirmationRequired( + session: ControllerSession, + requestId: String, + nowPlaying: SiloCastNowPlayingSummary, + ) { + session.send( + SiloCastMessage.HandoffCancel( + SiloCastHandoffCancel( + requestId = requestId, + reason = SiloCastHandoffCancelReason.ReplaceConfirmationRequired, + message = "${nowPlaying.profileName ?: "Someone"} is watching ${nowPlaying.title} on this TV.", + nowPlaying = nowPlaying, + ), + ), + ) + } + + private suspend fun sendHandoffCancel(session: ControllerSession, requestId: String, reason: String, message: String) { + runCatching { + session.send(SiloCastMessage.HandoffCancel(SiloCastHandoffCancel(requestId = requestId, reason = reason, message = message))) + } + } + private suspend fun requireAuthorized(session: ControllerSession): Boolean { if (session.isAuthorized) return true session.send( @@ -842,18 +1177,146 @@ class TvSiloCastReceiver( return false } + /** + * Tells a controller over its command budget once a second at most; + * replying to every dropped command would only add to the flood. + */ + private suspend fun noteRateLimited(session: ControllerSession) { + val now = nowMs() + val last = session.rateLimitNoticeAtMs + if (last != null && now - last < RATE_LIMIT_NOTICE_MS) return + session.rateLimitNoticeAtMs = now + Log.i(TAG, "SiloCast rate-limiting a controller") + session.send( + SiloCastMessage.Error(SiloCastError(code = "rate_limited", message = "Too many commands. Slow down and try again.")), + ) + } + + /** + * The server refused to refresh the borrowed profile: everyone watching + * loses the title, not only the phone that lent it. The overlay is already + * gone, so the identity ends without a server logout. + */ + private suspend fun onTemporarySessionExpired(generationId: String) { + if (identityManager.activeIdentity?.generationId != generationId) return + DiagnosticsCastLogger.warning("TV cast borrowed profile session expired") + val error = SiloCastMessage.Error( + SiloCastError(code = "temporary_session_expired", message = "The phone profile session expired."), + ) + // Notices are sent after teardown and off this coroutine: a controller + // that stopped reading must not hold up the stop or the hand-back. + val notified = authorizedControllers() + activePlayer?.takeIf { it.identityGeneration == generationId }?.let { player -> + withContext(Dispatchers.Main.immediate) { + player.adapter.handle(SiloCastControlCommand(name = SiloCastControlCommand.Stop)) + } + } + if (identityManager.end(generationId)) { + refreshAdvertisement() + reconcileAuthorization() + } + notified.forEach { session -> scope?.launch { runCatching { session.send(error) } } } + } + + /** + * Re-checks every controller against the effective server after the + * borrowed identity changed (handoff, end, expiry). Readiness survives + * only for the identity still active. + */ + private suspend fun reconcileAuthorization() { + val activeGeneration = identityManager.activeIdentity?.generationId + val changes = synchronized(this) { + controllers.values.filter { it.didReceiveHello }.mapNotNull { controller -> + val wasAuthorized = controller.isAuthorized + val decision = TvSiloCastReceiverPolicy.reauthorize( + wasAuthorized = wasAuthorized, + matchesEffectiveServer = matchesEffectiveServer(controller.controllerServerId, controller.controllerServerIdentity), + readyGeneration = controller.readyGeneration, + activeGeneration = activeGeneration, + ) + if (decision.clearsReadiness) controller.readyGeneration = null + controller.isAuthorized = decision.isAuthorized + when { + decision.isAuthorized && !wasAuthorized -> controller to true + decision.notifyServerMismatch -> controller to false + else -> null + } + } + } + refreshStandbyState() + if (changes.isEmpty()) return + val state = snapshotState() + changes.forEach { (controller, gained) -> + if (gained) { + controller.offerState(state) + } else { + runCatching { + controller.send( + SiloCastMessage.Error( + SiloCastError( + code = "server_mismatch", + message = "This TV is now playing from a different Silo server.", + ), + ), + ) + } + } + } + } + + /** + * Whether a controller's server matches the TV's effective one: the + * borrowed identity's while one is active, else the TV's own. + */ + private fun matchesEffectiveServer(serverId: String?, serverIdentity: String?): Boolean { + val borrowed = identityManager.activeIdentity + if (borrowed != null) { + return AndroidServerRegistry.serversMatch(serverId, serverIdentity, borrowed.serverId, borrowed.serverIdentity) + } + val own = serverRegistry.activeEntry.value + return AndroidServerRegistry.serversMatch( + serverId, + serverIdentity, + own?.id ?: serverRegistry.activeServerId.value, + own?.verifiedServerId, + ) + } + + private fun authorizedControllers(): List = + synchronized(this) { controllers.values.filter { it.isAuthorized } } + + /** + * Built once and handed to every authorized controller's latest-state + * slot. The recipients are chosen after the state is read, so a handoff + * during the read can't send the new profile's state to a controller it + * de-authorized. + */ + private suspend fun broadcastState() { + if (authorizedControllers().isEmpty()) return + val state = snapshotState() + authorizedControllers().forEach { it.offerState(state) } + } + + /** The playback state with the profile generation it was read under, checked again at the write. */ + private suspend fun snapshotState(): PublishedState { + val generation = identityManager.activeIdentity?.generationId + return PublishedState(currentState(), generation) + } + + private data class PublishedState(val state: SiloCastPlaybackState, val generation: String?) + /** * Ends [generationId] after [delayMs]. With [keepIfLenderReturns] (the - * lending phone left), a phone back in the slot by then keeps it under - * the idle limit instead: a dropped link reconnects within seconds. + * lending phone left), a phone back by then keeps it under the idle limit + * instead: a dropped link reconnects within seconds. */ private fun scheduleIdentityEnd( generationId: String, delayMs: Long = IDENTITY_END_GRACE_MS, keepIfLenderReturns: Boolean = false, - ) { + ): Unit = synchronized(this) { identityEndJob?.cancel() - val owner = scope ?: return + val owner = scope ?: return@synchronized identityEndJob = owner.launch { delay(delayMs) if (keepIfLenderReturns && isLenderConnected(generationId) && isIdentityIdle()) { @@ -885,6 +1348,7 @@ class TvSiloCastReceiver( if (pendingLaunch?.generation == generationId) pendingLaunch = null } refreshAdvertisement() + refreshStandbyState() } } } @@ -895,23 +1359,12 @@ class TvSiloCastReceiver( } if (!ended) return@launch // A newer offer cancelled this mid-logout and now owns the - // session's authorization; judging it here could drop a phone + // controllers' authorization; judging them here could drop a phone // that is still handing off. if (!isActive) return@launch - val session = activeSession - if (session != null) { - session.remoteLaunchReady = false - session.isAuthorized = AndroidServerRegistry.serverIdsMatch( - session.controllerServerId, - serverRegistry.activeServerId.value, - ) - if (session.isAuthorized) { - session.send(SiloCastMessage.State(currentState())) - refreshStandbyState() - } else { - closePreviousController() - } - } + // Cross-server controllers stay connected, unauthorized, and can + // hand off again; same-server ones keep controlling the TV. + runRecovering("authorization after an identity end") { reconcileAuthorization() } } } @@ -934,6 +1387,7 @@ class TvSiloCastReceiver( pendingLaunch?.takeIf { it.id == id }?.let { pending -> if (isAdmittedIdentityCurrentLocked(pending)) return true pendingLaunch = null + refreshStandbyState() return false } registeredLaunch?.takeIf { it.id == id }?.let { registered -> @@ -961,13 +1415,18 @@ class TvSiloCastReceiver( !tokenManager.hasTemporaryScope() && tokenManager.getProfileId() == ownProfileId // activeIdentity is unpublished as soon as an end starts, so a - // launch never lands on an identity being handed back. - val borrowed = identityManager.activeIdentity?.takeIf { session.remoteLaunchReady } + // launch never lands on an identity being handed back. Only the + // controller whose handoff produced it may launch under it. + val borrowed = identityManager.activeIdentity?.takeIf { + TvSiloCastReceiverPolicy.canLaunchBorrowed(session.readyGeneration, it.generationId) + } + val own = serverRegistry.activeEntry.value LaunchIdentity( revision = synchronized(this@TvSiloCastReceiver) { identityRevision }, onOwnIdentity = onOwnIdentity, borrowed = borrowed, serverId = if (onOwnIdentity) serverRegistry.activeServerId.value else borrowed?.serverId, + serverIdentity = if (onOwnIdentity) own?.verifiedServerId else borrowed?.serverIdentity, ) }?.let { return it } } @@ -997,17 +1456,24 @@ class TvSiloCastReceiver( if (pendingLaunch?.id == owner) { pendingLaunch = null if (dropQueued) launchRequestChannel.tryReceive() + // A launch whose player never opened: a waiting controller's + // standby comes back. + refreshStandbyState() } } } - /** Whether the phone that lent [generationId] holds the controller slot. */ + /** + * Whether a connection from the phone that lent [generationId] is here. + * Keeping the profile alive needs only that; using it needs the issuing + * connection or its resume token. + */ private fun isLenderConnected(generationId: String): Boolean { val identity = identityManager.activeIdentity ?: return false - val session = activeSession ?: return false - return identity.generationId == generationId && - session.isAuthorized && - session.controllerDeviceId == identity.controllerDeviceId + if (identity.generationId != generationId) return false + return synchronized(this) { + controllers.values.any { it.isAuthorized && it.controllerDeviceId == identity.controllerDeviceId } + } } /** @@ -1061,20 +1527,35 @@ class TvSiloCastReceiver( if (refresh) refreshStandbyState() } + /** + * The standby screen shows while nothing is on screen and an authorized, + * non-passive controller waits; it names the newest and counts the rest. + * A cross-server phone is not authorized until its handoff finishes, but + * the TV still shows what it is preparing for that phone. + */ + @Synchronized private fun refreshStandbyState() { - val session = activeSession - val preparing = session?.preparing - // A cross-server phone is not authorized until its handoff finishes, - // but the TV still shows what it is preparing for that phone. - _standbyState.value = if (session != null && activePlayer == null && (session.isAuthorized || preparing != null)) { + if (activePlayer != null || pendingLaunch != null) { + _standbyState.value = null + return + } + val serverName = identityManager.activeIdentity?.serverName ?: serverRegistry.activeEntry.value?.displayName + val preparing = controllers.values.lastOrNull { it.preparing != null } + if (preparing != null) { + _standbyState.value = StandbyState( + controllerName = preparing.controllerDeviceName, + serverName = serverName, + preparing = preparing.preparing, + ) + return + } + val pick = TvSiloCastReceiverPolicy.standbyPick(controllers.values.map { it.summary(isHandingOff = false) }) + _standbyState.value = pick?.let { StandbyState( - controllerName = session.controllerDeviceName, - serverName = identityManager.activeIdentity?.serverName - ?: serverRegistry.activeEntry.value?.displayName, - preparing = preparing, + controllerName = controllers[it.controllerId]?.controllerDeviceName, + serverName = serverName, + otherControllerCount = it.otherControllerCount, ) - } else { - null } } @@ -1088,9 +1569,16 @@ class TvSiloCastReceiver( serverId = remote?.serverId ?: saved?.id, serverName = remote?.serverName ?: saved?.displayName, playing = activePlayer != null, + serverIdentity = advertisedServerIdentity(), ) } + /** The deployment identity the TV presents: the borrowed identity's while one is active, else its own server's. */ + private fun advertisedServerIdentity(): String? { + val remote = identityManager.activeIdentity + return if (remote != null) remote.serverIdentity else serverRegistry.activeEntry.value?.verifiedServerId?.trim()?.ifEmpty { null } + } + private suspend fun readLoop( tls: SiloCastTlsSession, onMessage: suspend (SiloCastMessage) -> Boolean, @@ -1119,6 +1607,8 @@ class TvSiloCastReceiver( serverId = remote?.serverId ?: server?.id, serverName = remote?.serverName ?: server?.displayName, supportedVersions = SiloCastProtocol.supportedVersions, + serverIdentity = advertisedServerIdentity(), + features = SiloCastFeature.receiverFeatures, ) } @@ -1157,12 +1647,18 @@ class TvSiloCastReceiver( ) private class ControllerSession( + /** Connection id; a device that reconnects gets a new one. */ + val id: String, val socket: Socket, val tls: SiloCastTlsSession, private val json: Json, ) { val writeMutex = Mutex() + /** Order of acceptance; lower is older. Set at admission. */ + @Volatile + var sequence: Long = 0 + @Volatile var isAuthorized: Boolean = false @@ -1182,7 +1678,22 @@ class TvSiloCastReceiver( var controllerServerId: String? = null @Volatile - var remoteLaunchReady: Boolean = false + var controllerServerIdentity: String? = null + + /** [SiloCastFeature] names from the controller's hello. */ + @Volatile + var features: Set = emptySet() + + /** Watches state only (integration, silent auto-resume); never puts up standby. */ + @Volatile + var passive: Boolean = false + + @Volatile + var clientKind: String? = null + + /** The borrowed identity generation this controller's handoff produced; a launch needs it active. */ + @Volatile + var readyGeneration: String? = null /** Launch-ready on the TV's own identity for this profile (no handoff ran). */ @Volatile @@ -1193,10 +1704,45 @@ class TvSiloCastReceiver( @Volatile var missedHeartbeats: Int = 0 + + @Volatile + var rateLimitNoticeAtMs: Long? = null + val commandBudget = TvSiloCastReceiverPolicy.CommandBudget() + + /** The latest state for this controller's writer; older frames are dropped. */ + val stateUpdates = Channel(Channel.CONFLATED) var job: Job? = null - var handoffJob: Job? = null var preparingClearJob: Job? = null + fun supports(feature: String): Boolean = feature in features + + fun summary(isHandingOff: Boolean) = TvSiloCastReceiverPolicy.ControllerSummary( + id = id, + sequence = sequence, + didReceiveHello = didReceiveHello, + passive = passive, + isAuthorized = isAuthorized, + isHandingOff = isHandingOff, + ) + + fun offerState(state: PublishedState) { + stateUpdates.trySend(state) + } + + /** Writes [state] only if [admit] still holds once this connection's write lock is taken. */ + suspend fun sendState(state: SiloCastPlaybackState, admit: () -> Boolean) { + val frame = SiloCastFrame.encode( + json.encodeToString(SiloCastMessage.serializer(), SiloCastMessage.State(state)).encodeToByteArray(), + ) + writeMutex.withLock { + if (!admit()) return@withLock + withContext(Dispatchers.IO) { + tls.output.write(frame) + tls.output.flush() + } + } + } + suspend fun send(message: SiloCastMessage) { val payload = json.encodeToString(SiloCastMessage.serializer(), message).encodeToByteArray() val frame = SiloCastFrame.encode(payload) @@ -1211,30 +1757,39 @@ class TvSiloCastReceiver( /** * Best-effort `close` goodbye ahead of the FIN, then teardown. The * goodbye lets the peer distinguish a deliberate disconnect from a - * dropped link (Apple auto-reconnects on bare EOF). The write goes - * through the same mutex as every other frame so a mid-flight state - * push can't interleave with it; the whole thing runs suspending so - * callers holding the receiver monitor don't block on a dead peer's - * TCP buffers. + * dropped link (Apple auto-reconnects on bare EOF); a peer that lists + * `close_reason` also learns why. The write goes through the same mutex + * as every other frame so a mid-flight state push can't interleave + * with it; the whole thing runs suspending so callers holding the + * receiver monitor don't block on a dead peer's TCP buffers. */ - suspend fun goodbyeAndClose() { + suspend fun goodbyeAndClose(reason: String? = null) { runCatching { kotlinx.coroutines.withTimeout(GOODBYE_TIMEOUT_MS) { - send(SiloCastMessage.Close()) + send(SiloCastMessage.Close.withReason(reason?.takeIf { supports(SiloCastFeature.CloseReason) })) } } close() } fun close() { - handoffJob?.cancel() preparingClearJob?.cancel() + stateUpdates.close() tls.close() runCatching { socket.close() } job?.cancel() } } + /** The one in-flight handoff and the controller that offered it. */ + private class PendingHandoff(val controllerId: String, val requestId: String) { + @Volatile + var job: Job? = null + } + + /** Who may reuse borrowed identity [generation] without a new approval. */ + private data class IssuedIdentity(val generation: String, val connectionId: String?, val resumeToken: String?) + /** A phone's launch on its way to the player, with the [id] it was admitted under. */ data class Launch(val request: SiloCastLaunchRequest, val id: String) @@ -1250,6 +1805,7 @@ class TvSiloCastReceiver( val onOwnIdentity: Boolean, val borrowed: RemotePlaybackIdentityManager.ActiveIdentity?, val serverId: String?, + val serverIdentity: String?, ) private data class ActivePlayer( @@ -1268,6 +1824,7 @@ class TvSiloCastReceiver( const val MAX_MISSED_HEARTBEATS = 3 const val AUTH_GRACE_MS = 5_000L const val GOODBYE_TIMEOUT_MS = 1_000L + const val RATE_LIMIT_NOTICE_MS = 1_000L const val IDENTITY_END_GRACE_MS = 2_000L const val PLAYBACK_TEARDOWN_TIMEOUT_MS = 15_000L const val PENDING_LAUNCH_TIMEOUT_MS = 30_000L diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiverPolicy.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiverPolicy.kt new file mode 100644 index 000000000..6b9b9d583 --- /dev/null +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiverPolicy.kt @@ -0,0 +1,190 @@ +package org.siloserver.silo.tv.cast + +import java.security.SecureRandom + +/** + * Pure decisions for a receiver that serves several controllers at once, + * ported from silo-apple's `SiloControlReceiverPolicy` so both TVs agree. + * [TvSiloCastReceiver] applies them. + */ +internal object TvSiloCastReceiverPolicy { + /** Most controllers one TV keeps connected. */ + const val MAX_CONTROLLERS = 8 + + /** What the admission rules need to know about a connected controller. */ + data class ControllerSummary( + val id: String, + /** Order of acceptance; lower is older. */ + val sequence: Long, + val didReceiveHello: Boolean, + val passive: Boolean, + val isAuthorized: Boolean = true, + val isHandingOff: Boolean = false, + ) + + sealed interface Admission { + data object Admit : Admission + + /** Admit after closing this controller to make room. */ + data class AdmitEvicting(val id: String) : Admission + + data object Refuse : Admission + } + + /** + * Whether a new connection fits. At the cap, a connection that never said + * hello goes first (likely half-open or a port scan), then a cross-server + * controller that isn't handing off (it can do nothing else, and nothing + * else times it out), then the oldest passive controller. A controller + * mid-handoff and an active one are never evicted by a stranger; the + * newcomer is refused instead. + */ + fun admission(existing: List): Admission { + if (existing.size < MAX_CONTROLLERS) return Admission.Admit + val byAge = existing.sortedBy { it.sequence } + byAge.firstOrNull { !it.didReceiveHello }?.let { return Admission.AdmitEvicting(it.id) } + byAge.firstOrNull { !it.isAuthorized && !it.isHandingOff }?.let { return Admission.AdmitEvicting(it.id) } + byAge.firstOrNull { it.passive && !it.isHandingOff }?.let { return Admission.AdmitEvicting(it.id) } + return Admission.Refuse + } + + /** + * Other connections from the device that just said hello on [connection]. + * They are older sockets the device has abandoned (a Wi-Fi change, a + * return from background) whose heartbeat hasn't noticed yet. + */ + fun connectionsReplaced( + deviceId: String, + connection: String, + existing: List>, + ): List { + if (deviceId.isEmpty()) return emptyList() + return existing.filter { (id, existingDeviceId) -> id != connection && existingDeviceId == deviceId } + .map { it.first } + } + + /** + * A launch plays under the borrowed identity only for the controller whose + * handoff produced the generation now active. Another controller's later + * handoff replaces the identity and with it every earlier readiness. + */ + fun canLaunchBorrowed(readyGeneration: String?, activeGeneration: String?): Boolean = + readyGeneration != null && activeGeneration != null && readyGeneration == activeGeneration + + /** + * Whether an offer that matches the active identity (same server, profile + * and device ID) may reuse it without a new approval. Device IDs are + * self-reported, so the match alone proves nothing: reuse needs the + * connection that completed the handoff, or the resume token issued to it. + */ + fun mayReuseIdentity( + offerMatchesIdentity: Boolean, + identityConnection: String?, + connection: String, + issuedResumeToken: String?, + offeredResumeToken: String?, + ): Boolean { + if (!offerMatchesIdentity) return false + if (identityConnection == connection) return true + if (issuedResumeToken == null || offeredResumeToken == null) return false + return constantTimeEquals(issuedResumeToken, offeredResumeToken) + } + + /** + * Whether a handoff must stop and ask before replacing what the TV shows. + * Only a title playing under another profile needs consent. Controllers + * without `replace_confirmation` can't ask, so they keep newest-launch-wins. + */ + fun needsReplaceConfirmation( + titleOnScreen: Boolean, + playingUnderOfferedProfile: Boolean, + replaceConfirmed: Boolean, + controllerCanConfirm: Boolean, + ): Boolean = titleOnScreen && !playingUnderOfferedProfile && !replaceConfirmed && controllerCanConfirm + + /** A confirmation covers only the title the user agreed to replace; if another started, the TV asks again. */ + fun confirmationCovers(onScreenContentId: String?, replaceConfirmed: Boolean, replacingContentId: String?): Boolean = + replaceConfirmed && replacingContentId == onScreenContentId + + /** What a controller's authorization becomes after the TV's identity changed. */ + data class Reauthorization( + val isAuthorized: Boolean, + /** The controller's readiness is gone (another identity is active, or none). */ + val clearsReadiness: Boolean, + /** Tell it "this TV now plays from a different server". */ + val notifyServerMismatch: Boolean, + ) + + /** + * Re-judges one controller against the identity now active. A controller + * stays authorized while its server matches the TV's effective one, or + * while the borrowed identity it prepared is active. One that loses it is + * told so, except when its own borrowed identity simply ended. + */ + fun reauthorize( + wasAuthorized: Boolean, + matchesEffectiveServer: Boolean, + readyGeneration: String?, + activeGeneration: String?, + ): Reauthorization { + val stillReady = readyGeneration != null && readyGeneration == activeGeneration + val ownIdentityEnded = readyGeneration != null && !stillReady + val authorized = matchesEffectiveServer || stillReady + return Reauthorization( + isAuthorized = authorized, + clearsReadiness = readyGeneration != null && !stillReady, + notifyServerMismatch = wasAuthorized && !authorized && !ownIdentityEnded, + ) + } + + /** Who the standby screen names: the newest waiting controller, and how many others wait. */ + data class StandbyPick(val controllerId: String, val otherControllerCount: Int) + + /** The standby screen shows for authorized, non-passive controllers only. */ + fun standbyPick(waiting: List): StandbyPick? { + val eligible = waiting.filter { it.isAuthorized && !it.passive } + val newest = eligible.maxByOrNull { it.sequence } ?: return null + return StandbyPick(newest.id, eligible.size - 1) + } + + fun makeResumeToken(random: SecureRandom = SecureRandom()): String { + val bytes = ByteArray(32) + random.nextBytes(bytes) + return bytes.joinToString("") { "%02x".format(it) } + } + + private fun constantTimeEquals(lhs: String, rhs: String): Boolean { + val a = lhs.encodeToByteArray() + val b = rhs.encodeToByteArray() + if (a.size != b.size) return false + var diff = 0 + for (i in a.indices) diff = diff or (a[i].toInt() xor b[i].toInt()) + return diff == 0 + } + + /** + * Per-controller command budget: a token bucket, so a stuck integration + * loop can't wedge the player while bursts of hardware volume steps still + * go through. + */ + class CommandBudget { + private var tokens = BURST + private var refilledAtMs: Long? = null + + fun allow(nowMs: Long): Boolean { + refilledAtMs?.let { last -> + val elapsedSeconds = (nowMs - last).coerceAtLeast(0) / 1_000.0 + tokens = (tokens + elapsedSeconds * PER_SECOND).coerceAtMost(BURST) + } + refilledAtMs = nowMs + if (tokens < 1) return false + tokens -= 1 + return true + } + + companion object { + const val PER_SECOND = 20.0 + const val BURST = 40.0 + } + } +} diff --git a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt index 18a888e16..99fcae031 100644 --- a/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt +++ b/androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/di/AndroidTvModule.kt @@ -428,6 +428,7 @@ val androidTvModule = module { } single { val playbackLifecycle = get() + val activeProfiles = get() TvSiloCastReceiver( advertiser = get(), serverRegistry = get(), @@ -442,6 +443,7 @@ val androidTvModule = module { inWatchParty = { get().roomSnapshot.value != null }, + ownProfileNameProvider = { activeProfiles.activeProfile.value?.name }, ) } diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackHandoffOrderTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackHandoffOrderTest.kt index 1eae8fd05..18f607699 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackHandoffOrderTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackHandoffOrderTest.kt @@ -84,6 +84,7 @@ class RemotePlaybackHandoffOrderTest { CONTROLLER, controllerDeviceName = null, receiverRun = 1L, + allowReuse = true, beforeActivation = beforeActivation, ) {} diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityClaimTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityClaimTest.kt index 7cf656f50..ed9bc3580 100644 --- a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityClaimTest.kt +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/RemotePlaybackIdentityClaimTest.kt @@ -90,7 +90,7 @@ class RemotePlaybackIdentityClaimTest { val gated = GatedLogoutApi(api) val manager = RemotePlaybackIdentityManager(gated, tokens, deviceNameProvider = { "TV" }) suspend fun prepareOn(run: Long) = - manager.prepare(offer(), CONTROLLER, controllerDeviceName = null, receiverRun = run) {} + manager.prepare(offer(), CONTROLLER, controllerDeviceName = null, receiverRun = run, allowReuse = true) {} prepareOn(STOPPED_RUN) val ending = assertNotNull(manager.activeIdentity) @@ -113,8 +113,19 @@ class RemotePlaybackIdentityClaimTest { assertNotEquals(ending.generationId, manager.activeIdentity?.generationId) } + /** Several controllers: a matching device ID alone must not reuse a phone's profile. */ + @Test fun aMatchingOfferTheReceiverDidNotClearTakesTheFullHandoff() = runTest { + prepare(offer(), run = NEXT_RUN) + val first = assertNotNull(manager.activeIdentity) + + val ready = manager.prepare(offer(), CONTROLLER, controllerDeviceName = null, receiverRun = NEXT_RUN, allowReuse = false) {} + + assertFalse(ready.reused) + assertNotEquals(first.generationId, manager.activeIdentity?.generationId) + } + private suspend fun prepare(offer: SiloCastHandoffOffer, run: Long) = - manager.prepare(offer, CONTROLLER, controllerDeviceName = null, receiverRun = run) {} + manager.prepare(offer, CONTROLLER, controllerDeviceName = null, receiverRun = run, allowReuse = true) {} private fun offer(profileId: String = "profile-1") = SiloCastHandoffOffer( requestId = "request", diff --git a/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiverPolicyTest.kt b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiverPolicyTest.kt new file mode 100644 index 000000000..924a76aae --- /dev/null +++ b/androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiverPolicyTest.kt @@ -0,0 +1,165 @@ +package org.siloserver.silo.tv.cast + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.siloserver.silo.tv.cast.TvSiloCastReceiverPolicy.Admission +import org.siloserver.silo.tv.cast.TvSiloCastReceiverPolicy.ControllerSummary + +/** Several controllers on one TV; the same cases as silo-apple's SiloControlMultiControllerTests. */ +class TvSiloCastReceiverPolicyTest { + private val policy = TvSiloCastReceiverPolicy + private var nextId = 0 + + private fun summary( + sequence: Long, + hello: Boolean = true, + passive: Boolean = false, + authorized: Boolean = true, + handingOff: Boolean = false, + ) = ControllerSummary("c${nextId++}", sequence, hello, passive, authorized, handingOff) + + @Test + fun admitsBelowTheCap() { + val existing = (0 until policy.MAX_CONTROLLERS - 1).map { summary(it.toLong()) } + assertEquals(Admission.Admit, policy.admission(existing)) + } + + @Test + fun atTheCapEvictsTheOldestConnectionThatNeverSaidHello() { + val existing = (0 until policy.MAX_CONTROLLERS).map { summary(it.toLong(), passive = true) }.toMutableList() + val silentOld = summary(-2, hello = false) + existing[3] = summary(99, hello = false) + existing[5] = silentOld + assertEquals(Admission.AdmitEvicting(silentOld.id), policy.admission(existing)) + } + + @Test + fun atTheCapEvictsAnIdleCrossServerControllerBeforeAPassiveOne() { + val existing = (0 until policy.MAX_CONTROLLERS).map { summary(it.toLong(), passive = true) }.toMutableList() + val idle = summary(40, authorized = false) + existing[2] = idle + existing[4] = summary(10, authorized = false, handingOff = true) + assertEquals(Admission.AdmitEvicting(idle.id), policy.admission(existing)) + } + + @Test + fun atTheCapEvictsTheOldestPassiveControllerNext() { + val existing = (0 until policy.MAX_CONTROLLERS).map { summary(it.toLong()) }.toMutableList() + val passiveOld = summary(20, passive = true) + existing[1] = summary(50, passive = true) + existing[6] = passiveOld + assertEquals(Admission.AdmitEvicting(passiveOld.id), policy.admission(existing)) + } + + @Test + fun atTheCapNeverEvictsAControllerMidHandoffOrAnActiveOne() { + val existing = (0 until policy.MAX_CONTROLLERS).map { summary(it.toLong()) }.toMutableList() + existing[0] = summary(0, authorized = false, handingOff = true) + existing[1] = summary(1, passive = true, handingOff = true) + assertEquals(Admission.Refuse, policy.admission(existing)) + } + + @Test + fun helloReplacesOnlyTheSameDevicesOtherConnections() { + val replaced = policy.connectionsReplaced( + deviceId = "phone-a", + connection = "mine", + existing = listOf("mine" to "phone-a", "old-mine" to "phone-a", "other" to "phone-b", "pre-hello" to null), + ) + assertEquals(listOf("old-mine"), replaced) + assertTrue(policy.connectionsReplaced("", "mine", listOf("x" to "")).isEmpty()) + } + + @Test + fun aLaunchNeedsReadinessForTheActiveGeneration() { + assertTrue(policy.canLaunchBorrowed("g1", "g1")) + assertFalse(policy.canLaunchBorrowed("g1", "g2")) + assertFalse(policy.canLaunchBorrowed(null, "g1")) + assertFalse(policy.canLaunchBorrowed("g1", null)) + } + + @Test + fun reuseNeedsTheSameConnectionOrTheIssuedToken() { + fun reuse(matches: Boolean = true, identityConnection: String? = "conn-1", issued: String? = "tok", offered: String? = null) = + policy.mayReuseIdentity(matches, identityConnection, "conn-2", issued, offered) + assertTrue(policy.mayReuseIdentity(true, "conn-1", "conn-1", null, null)) + assertFalse("a copied device ID alone proves nothing", reuse()) + assertTrue(reuse(offered = "tok")) + assertFalse(reuse(offered = "tok-guess")) + assertFalse(reuse(issued = null, offered = "tok")) + assertFalse(reuse(matches = false, offered = "tok")) + } + + @Test + fun asksBeforeReplacingAnotherProfilesTitleOnly() { + assertTrue(policy.needsReplaceConfirmation(true, false, false, true)) + assertFalse(policy.needsReplaceConfirmation(false, false, false, true)) + assertFalse(policy.needsReplaceConfirmation(true, true, false, true)) + assertFalse(policy.needsReplaceConfirmation(true, false, true, true)) + // Older phones can't show the question, so they keep newest-launch-wins. + assertFalse(policy.needsReplaceConfirmation(true, false, false, false)) + } + + @Test + fun aConfirmationCoversOnlyTheTitleTheUserAgreedToReplace() { + assertTrue(policy.confirmationCovers("oak", true, "oak")) + assertFalse(policy.confirmationCovers("bobs-film", true, "oak")) + assertFalse(policy.confirmationCovers("oak", false, "oak")) + } + + @Test + fun reauthorizationKeepsTheOwnerAndQuietlyDropsAnEndedLender() { + // Same-server controller: still authorized whatever the identity does. + assertEquals( + TvSiloCastReceiverPolicy.Reauthorization(isAuthorized = true, clearsReadiness = false, notifyServerMismatch = false), + policy.reauthorize(wasAuthorized = true, matchesEffectiveServer = true, readyGeneration = null, activeGeneration = "g1"), + ) + // Cross-server controller whose own identity is still active. + assertTrue(policy.reauthorize(true, false, "g1", "g1").isAuthorized) + // Its identity ended (title over, idle end): unauthorized, readiness cleared, no misleading error. + val ended = policy.reauthorize(true, false, "g1", null) + assertFalse(ended.isAuthorized) + assertTrue(ended.clearsReadiness) + assertFalse(ended.notifyServerMismatch) + // Another controller's cross-server identity took over under a same-server one. + val displaced = policy.reauthorize(true, false, null, "g2") + assertFalse(displaced.isAuthorized) + assertTrue(displaced.notifyServerMismatch) + } + + @Test + fun standbyNamesTheNewestWaitingControllerAndCountsTheRest() { + val older = summary(1) + val newest = summary(5) + val passive = summary(9, passive = true) + val crossServer = summary(7, authorized = false) + assertEquals( + TvSiloCastReceiverPolicy.StandbyPick(newest.id, otherControllerCount = 1), + policy.standbyPick(listOf(older, newest, passive, crossServer)), + ) + assertNull("a passive integration never puts up standby", policy.standbyPick(listOf(passive))) + } + + @Test + fun resumeTokensAreLongAndUnique() { + val first = policy.makeResumeToken() + assertEquals(64, first.length) + assertTrue(first.all { it in '0'..'9' || it in 'a'..'f' }) + assertNotEquals(first, policy.makeResumeToken()) + } + + @Test + fun commandBudgetAllowsABurstThenRefills() { + val budget = TvSiloCastReceiverPolicy.CommandBudget() + val start = 1_000_000L + repeat(TvSiloCastReceiverPolicy.CommandBudget.BURST.toInt()) { assertTrue(budget.allow(start)) } + assertFalse(budget.allow(start)) + // 20 per second refills one token every 50 ms. + assertTrue(budget.allow(start + 50)) + assertFalse(budget.allow(start + 50)) + } +} diff --git a/shared/src/androidMain/kotlin/org/siloserver/silo/network/EncryptedTokenManagerImpl.kt b/shared/src/androidMain/kotlin/org/siloserver/silo/network/EncryptedTokenManagerImpl.kt index 62bf6357b..2398fceee 100644 --- a/shared/src/androidMain/kotlin/org/siloserver/silo/network/EncryptedTokenManagerImpl.kt +++ b/shared/src/androidMain/kotlin/org/siloserver/silo/network/EncryptedTokenManagerImpl.kt @@ -76,6 +76,12 @@ class EncryptedTokenManagerImpl( ) override val sessionExpired: SharedFlow = _sessionExpired.asSharedFlow() + private val _temporarySessionExpired = MutableSharedFlow( + extraBufferCapacity = 4, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + override val temporarySessionExpired: SharedFlow = _temporarySessionExpired.asSharedFlow() + init { // Load initial cache for whichever server the registry made active // (post-migration / from disk). @@ -304,9 +310,13 @@ class EncryptedTokenManagerImpl( target = { currentSignOutTarget() }, ) { mutex.withLock { - val wasTemporary = temporaryScope != null + val expiredTemporary = temporaryScope?.generationId clearCurrentScopeLocked() - if (!wasTemporary) _sessionExpired.tryEmit(Unit) + if (expiredTemporary == null) { + _sessionExpired.tryEmit(Unit) + } else { + _temporarySessionExpired.tryEmit(expiredTemporary) + } } } } @@ -925,9 +935,13 @@ class EncryptedTokenManagerImpl( ) { return@withLock false } - val wasTemporary = temporaryScope != null + val expiredTemporary = temporaryScope?.generationId clearCurrentScopeLocked() - if (!wasTemporary) _sessionExpired.tryEmit(Unit) + if (expiredTemporary == null) { + _sessionExpired.tryEmit(Unit) + } else { + _temporarySessionExpired.tryEmit(expiredTemporary) + } true } } diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/network/TokenManager.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/network/TokenManager.kt index 78b7f495d..aec2f7f41 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/network/TokenManager.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/network/TokenManager.kt @@ -1,6 +1,8 @@ package org.siloserver.silo.network +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow data class TemporaryAuthScope( val generationId: String, @@ -133,6 +135,14 @@ interface TokenManager { */ val sessionExpired: SharedFlow + /** + * Emits the generation id of a temporary (remote playback) scope that + * ended involuntarily, its refresh refused, so the owner of that identity + * can stop what plays under it. [sessionExpired] stays reserved for the + * saved account. Implementations without temporary overlays never emit. + */ + val temporarySessionExpired: SharedFlow get() = NoTemporarySessionExpiry + suspend fun getProfileId(): String? suspend fun setProfileId(profileId: String?) suspend fun getProfileToken(): String? @@ -320,3 +330,5 @@ interface TokenManager { suspend fun hasTemporaryScope(): Boolean = false } + +private val NoTemporarySessionExpiry: SharedFlow = MutableSharedFlow().asSharedFlow()