From 3d93192ca91098f95adade743182e296b831983d Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:02:19 -0400 Subject: [PATCH 1/9] test(cast): add adb hooks to inspect and inject Remote Control targets Emulators sit behind NAT that mDNS can't cross, so a phone emulator can't discover a TV emulator or an Apple TV simulator, and there was no way to see what a TV advertises. Debug builds get two more PlaybackDebugReceiver actions: - CAST_STATUS (TV): the live Remote Control port, name and TXT record. - CAST_TARGET (phone): adds or replaces a TV target from a host, port, name and TXT string resolved on the host; `--ez clear true` removes them. Injected targets merge with real discovery, survive a restart of it, and win over an mDNS record with the same device id. The browser's TXT parsing moves into SiloCastNsdBrowser.targetFromRecord so the hook and discovery read records the same way, and the advertiser keeps the record it registered so the hook reports exactly that. Behavior of release builds is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../silo/common/cast/SiloCastNsdAdvertiser.kt | 26 +++++-- .../silo/common/cast/SiloCastNsdBrowser.kt | 76 ++++++++++++------- .../player/debug/PlaybackDebugReceiver.kt | 57 ++++++++++++++ .../src/androidMain/AndroidManifest.xml | 2 + .../src/androidMain/AndroidManifest.xml | 2 + 5 files changed, 131 insertions(+), 32 deletions(-) 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 acff156e65..8c4c0c8a0e 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 @@ -31,6 +31,15 @@ class SiloCastNsdAdvertiser( private var registrationListener: NsdManager.RegistrationListener? = null private var lastRegistration: Registration? = null + private var lastRecord: Map? = null + + /** The live advertisement's name, port and TXT record, for the debug adb hook. */ + @Synchronized + fun currentAdvertisement(): Triple>? { + val registration = lastRegistration ?: return null + val record = lastRecord ?: return null + return Triple(record["name"].orEmpty(), registration.port, record) + } @Synchronized fun start(port: Int, serverId: String?, serverName: String?, playing: Boolean = false) { @@ -52,6 +61,7 @@ class SiloCastNsdAdvertiser( } registrationListener = null lastRegistration = null + lastRecord = null } private fun register(registration: Registration) { @@ -60,16 +70,19 @@ class SiloCastNsdAdvertiser( } registrationListener = null val name = nameProvider() + val record = buildMap { + put("v", SiloCastProtocol.version.toString()) + put("name", name) + put("id", deviceIdProvider()) + registration.serverId?.let { put("server", it) } + registration.serverName?.let { put("serverName", it) } + put("playing", if (registration.playing) "1" else "0") + } val serviceInfo = NsdServiceInfo().apply { serviceName = name serviceType = SiloCastProtocol.serviceType port = registration.port - setAttribute("v", SiloCastProtocol.version.toString()) - setAttribute("name", name) - setAttribute("id", deviceIdProvider()) - registration.serverId?.let { setAttribute("server", it) } - registration.serverName?.let { setAttribute("serverName", it) } - setAttribute("playing", if (registration.playing) "1" else "0") + record.forEach { (key, value) -> setAttribute(key, value) } } val listener = object : NsdManager.RegistrationListener { override fun onServiceRegistered(info: NsdServiceInfo) { @@ -90,6 +103,7 @@ class SiloCastNsdAdvertiser( } registrationListener = listener lastRegistration = registration + lastRecord = record nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, listener) } diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/cast/SiloCastNsdBrowser.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/cast/SiloCastNsdBrowser.kt index 17c2d279d4..1de07f7790 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/cast/SiloCastNsdBrowser.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/cast/SiloCastNsdBrowser.kt @@ -43,6 +43,25 @@ class SiloCastNsdBrowser(context: Context) { private val pendingResolutions = ArrayDeque() private var activeResolution: PendingResolution? = null + /** Targets added by the debug adb hook; see [setDebugTargets]. */ + private var debugTargets: List = emptyList() + + /** + * Debug builds only (PlaybackDebugReceiver): targets supplied over adb. + * An emulator sits behind NAT that mDNS can't cross, so a test harness + * resolves TVs on the host and hands them in; they merge with (and + * survive a restart of) real discovery. An empty list removes them. + */ + @Synchronized + fun setDebugTargets(targets: List) { + val replacedIds = debugTargets.map { it.deviceId }.toSet() + debugTargets = targets + _targets.update { current -> + (current.filterNot { it.deviceId in replacedIds || targets.any { t -> t.deviceId == it.deviceId } } + targets) + .sortedBy { it.name.lowercase() } + } + } + @Synchronized fun start() { if (discoveryListener != null) return @@ -67,7 +86,10 @@ class SiloCastNsdBrowser(context: Context) { ?.cancelled = true pendingResolutions.removeAll { it.serviceInfo.serviceName == lostName } } - _targets.update { targets -> targets.filterNot { it.serviceName == lostName } } + _targets.update { targets -> + val injectedIds = synchronized(this@SiloCastNsdBrowser) { debugTargets.map { it.deviceId }.toSet() } + targets.filterNot { it.serviceName == lostName && it.deviceId !in injectedIds } + } } override fun onDiscoveryStopped(serviceType: String) { @@ -95,7 +117,7 @@ class SiloCastNsdBrowser(context: Context) { discoveryListener = null pendingResolutions.clear() activeResolution = null - _targets.value = emptyList() + _targets.value = debugTargets } /** @@ -133,7 +155,8 @@ class SiloCastNsdBrowser(context: Context) { discoveryListener != null && activeResolution === pending && !pending.cancelled } if (mayPublish) { - info.toSiloCastTarget()?.let { target -> + // A debug-injected target wins over an mDNS record with its id. + info.toSiloCastTarget()?.takeIf { found -> debugTargets.none { it.deviceId == found.deviceId } }?.let { target -> _targets.update { current -> (current.filterNot { it.deviceId == target.deviceId } + target) .sortedBy { it.name.lowercase() } @@ -166,34 +189,35 @@ class SiloCastNsdBrowser(context: Context) { private fun NsdServiceInfo.toSiloCastTarget(): SiloCastTarget? { val host = this.host?.hostAddress ?: return null - val port = port.takeIf { it > 0 } ?: return null - val name = attributes.string("name") ?: serviceName - val mdnsName = serviceName - val deviceId = attributes.string("deviceId") ?: attributes.string("id") ?: "$host:$port" - // Every v2 receiver advertises `v`; one that doesn't predates it (iOS reads it the same way). - val version = attributes.string("v")?.toIntOrNull() ?: 1 - return SiloCastTarget( - serviceName = mdnsName, - deviceId = deviceId, - name = name, - host = host, - port = port, - version = version, - serverId = attributes.string("server"), - serverName = attributes.string("serverName"), - isPlaying = attributes.string("playing") == "1", - ) + val record = attributes.mapValues { (_, value) -> value?.toString(Charset.forName("UTF-8")).orEmpty() } + return targetFromRecord(host = host, port = port, serviceName = serviceName, txt = record) } - private fun Map.string(key: String): String? = - this[key]?.toString(Charset.forName("UTF-8"))?.takeIf { it.isNotBlank() } + companion object { + /** A resolved `_silocast._tcp` record, read the way iOS reads it. */ + fun targetFromRecord(host: String, port: Int, serviceName: String, txt: Map): SiloCastTarget? { + if (port <= 0) return null + fun string(key: String): String? = txt[key]?.takeIf { it.isNotBlank() } + val deviceId = string("deviceId") ?: string("id") ?: "$host:$port" + return SiloCastTarget( + serviceName = serviceName, + deviceId = deviceId, + name = string("name") ?: serviceName, + host = host, + port = port, + // Every v2 receiver advertises `v`; one that doesn't predates it (iOS reads it the same way). + version = string("v")?.toIntOrNull() ?: 1, + serverId = string("server"), + serverName = string("serverName"), + isPlaying = string("playing") == "1", + ) + } + + private const val TAG = "SiloCastNsdBrowser" + } private class PendingResolution( val serviceInfo: NsdServiceInfo, var cancelled: Boolean = false, ) - - private companion object { - const val TAG = "SiloCastNsdBrowser" - } } diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/debug/PlaybackDebugReceiver.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/debug/PlaybackDebugReceiver.kt index 771f776cdb..9b9e360fd0 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/debug/PlaybackDebugReceiver.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/debug/PlaybackDebugReceiver.kt @@ -11,6 +11,9 @@ import androidx.media3.exoplayer.ExoPlayer import org.json.JSONObject import org.koin.java.KoinJavaComponent import org.siloserver.silo.common.BuildConfig +import org.siloserver.silo.common.cast.SiloCastNsdAdvertiser +import org.siloserver.silo.common.cast.SiloCastNsdBrowser +import org.siloserver.silo.common.cast.SiloCastTarget import org.siloserver.silo.common.player.ActivePlayerHolder import org.siloserver.silo.common.player.watchparty.WatchPartyDebugRegistry import org.siloserver.silo.repository.WatchTogetherRepository @@ -27,6 +30,11 @@ import org.siloserver.silo.repository.WatchTogetherRepository * -a org.siloserver.silo.debug.PLAYBACK_STATUS * Command: * ... -a org.siloserver.silo.debug.PLAYBACK_COMMAND --es cmd seek --el positionMs 300000 + * Remote Control (emulators sit behind NAT that mDNS can't cross): + * ... -a org.siloserver.silo.debug.CAST_STATUS (TV: the live port + TXT record) + * ... -a org.siloserver.silo.debug.CAST_TARGET --es host 10.0.2.2 --ei port 4711 \ + * --es name "Den TV" --es txt "v=2;id=tv-1;server=srv;serverName=Home;playing=0" + * (phone: adds or replaces a target; `--ez clear true` removes them all) * * The JSON comes back in `am broadcast`'s printed `data=` result. The explicit * component (`-n`) is required — implicit broadcasts don't reach @@ -48,9 +56,48 @@ class PlaybackDebugReceiver : BroadcastReceiver() { when (intent.action) { ACTION_STATUS -> setResultData(statusJson(player).toString()) ACTION_COMMAND -> setResultData(runCommand(player, intent).toString()) + ACTION_CAST_STATUS -> setResultData(castStatusJson().toString()) + ACTION_CAST_TARGET -> setResultData(setCastTarget(intent).toString()) } } + private fun castStatusJson(): JSONObject { + val advertiser = runCatching { + KoinJavaComponent.get(SiloCastNsdAdvertiser::class.java) + }.getOrNull() ?: return JSONObject().put("advertising", false).put("reason", "no receiver in this app") + val (name, port, record) = advertiser.currentAdvertisement() + ?: return JSONObject().put("advertising", false) + return JSONObject() + .put("advertising", true) + .put("name", name) + .put("port", port) + .put("txt", JSONObject(record)) + } + + private fun setCastTarget(intent: Intent): JSONObject { + val browser = runCatching { + KoinJavaComponent.get(SiloCastNsdBrowser::class.java) + }.getOrNull() ?: return JSONObject().put("ok", false).put("reason", "no browser in this app") + if (intent.getBooleanExtra(EXTRA_CLEAR, false)) { + injectedCastTargets.clear() + browser.setDebugTargets(emptyList()) + return JSONObject().put("ok", true).put("targets", 0) + } + val txt = intent.getStringExtra(EXTRA_TXT).orEmpty() + .split(';') + .filter { '=' in it } + .associate { it.substringBefore('=').trim() to it.substringAfter('=').trim() } + val target = SiloCastNsdBrowser.targetFromRecord( + host = intent.getStringExtra(EXTRA_HOST) ?: return JSONObject().put("ok", false).put("reason", "host missing"), + port = intent.getIntExtra(EXTRA_PORT, 0), + serviceName = intent.getStringExtra(EXTRA_NAME) ?: txt["name"].orEmpty(), + txt = txt, + ) ?: return JSONObject().put("ok", false).put("reason", "port missing") + injectedCastTargets[target.deviceId] = target + browser.setDebugTargets(injectedCastTargets.values.toList()) + return JSONObject().put("ok", true).put("targets", injectedCastTargets.size) + } + private fun statusJson(player: Player?): JSONObject { val json = JSONObject() // Correlate samples across devices: monotonic and wall time at the @@ -175,7 +222,17 @@ class PlaybackDebugReceiver : BroadcastReceiver() { companion object { const val ACTION_STATUS = "org.siloserver.silo.debug.PLAYBACK_STATUS" const val ACTION_COMMAND = "org.siloserver.silo.debug.PLAYBACK_COMMAND" + const val ACTION_CAST_STATUS = "org.siloserver.silo.debug.CAST_STATUS" + const val ACTION_CAST_TARGET = "org.siloserver.silo.debug.CAST_TARGET" const val EXTRA_CMD = "cmd" const val EXTRA_POSITION_MS = "positionMs" + const val EXTRA_HOST = "host" + const val EXTRA_PORT = "port" + const val EXTRA_NAME = "name" + const val EXTRA_TXT = "txt" + const val EXTRA_CLEAR = "clear" + + /** Targets injected so far, keyed by device id; main-thread only. */ + private val injectedCastTargets = linkedMapOf() } } diff --git a/androidApp/src/androidMain/AndroidManifest.xml b/androidApp/src/androidMain/AndroidManifest.xml index 2f4473507f..f6678bea2d 100644 --- a/androidApp/src/androidMain/AndroidManifest.xml +++ b/androidApp/src/androidMain/AndroidManifest.xml @@ -117,6 +117,8 @@ + + diff --git a/androidTvApp/src/androidMain/AndroidManifest.xml b/androidTvApp/src/androidMain/AndroidManifest.xml index c683382045..b8b4f7cb4d 100644 --- a/androidTvApp/src/androidMain/AndroidManifest.xml +++ b/androidTvApp/src/androidMain/AndroidManifest.xml @@ -68,6 +68,8 @@ + + From bf39789b0a33d8f09a445813a04c2ea8e445a81f Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:40:35 -0400 Subject: [PATCH 2/9] feat(cast): let the phone share a TV with other controllers Apple TVs now keep several Remote Control controllers connected and say why they close a session; Android phones treated every close as "someone took over", forgot the TV, and replaced other profiles' titles without asking. This brings the phone and the shared wire format level with silo-apple's SiloControl contract. Every addition is optional and feature-gated, so older peers see today's behavior. Wire (shared): - hello: serverIdentity, features, passive, clientKind; `presence` message; optional `close` reason, decoded leniently as Apple does. - handoff_offer: serverIdentity, resumeToken, replaceConfirmed, replacingContentId. handoff_ready: resumeToken. handoff_cancel: nowPlaying. - Feature, close-reason, cancel-reason and client-kind names, matching Apple's strings. - TVs advertising serverIdentity match an active server on the same deployment at another address. Phone: - Lists close_reason, resume_token and replace_confirmation; a silent auto-resume connects passive and sends presence once the user engages (remote opened, a command, or a title sent). - Close policy: an older TV's close still forgets it. A multi-controller TV's timeout reconnects; tv_disconnected forgets the TV; other reasons end the session but keep the target, and the remote keeps the TV's error. - Sends its profile name, its verified server identity, and the resume token the TV issued it; the token lives in memory per TV, server and profile. - "Replace what's playing?" on the remote, for a TV's replace_confirmation_required and before sending a different title to a TV that is playing one. The consent names the title the user saw. - Play and pause buttons send explicit play/pause for what they show, never play_pause. A rate_limited reply no longer fails a launch. Co-Authored-By: Claude Opus 5.5 --- .../silo/common/cast/SiloCastNsdBrowser.kt | 4 + .../silo/android/cast/SiloCastController.kt | 246 ++++++++++++++---- .../silo/android/cast/SiloCastPhonePolicy.kt | 127 +++++++++ .../silo/android/di/AndroidModule.kt | 5 + .../ui/screens/cast/SiloCastMiniBar.kt | 4 +- .../ui/screens/cast/SiloCastRemoteScreen.kt | 12 + .../screens/cast/SiloCastTargetPickerSheet.kt | 11 +- .../src/androidMain/res/values/strings.xml | 2 + .../android/cast/SiloCastPhonePolicyTest.kt | 100 +++++++ .../silo/tv/cast/TvSiloCastReceiver.kt | 2 + .../silo/network/AndroidServerRegistry.kt | 12 + .../siloserver/silo/cast/SiloCastMessage.kt | 74 +++++- .../siloserver/silo/cast/SiloCastProtocol.kt | 71 +++++ .../silo/cast/SiloCastMessageTest.kt | 141 +++++++++- 14 files changed, 754 insertions(+), 57 deletions(-) create mode 100644 androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastPhonePolicy.kt create mode 100644 androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/cast/SiloCastPhonePolicyTest.kt diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/cast/SiloCastNsdBrowser.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/cast/SiloCastNsdBrowser.kt index 1de07f7790..988841f42e 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/cast/SiloCastNsdBrowser.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/cast/SiloCastNsdBrowser.kt @@ -26,6 +26,9 @@ data class SiloCastTarget( * controllable without a handoff; others need the v2 profile handoff. */ val serverId: String? = null, val serverName: String? = null, + /** The deployment identity behind [serverId] (TXT `serverIdentity`), so a + * phone signed in at another address of the same server still matches. */ + val serverIdentity: String? = null, /** The receiver's advertised `playing` TXT flag. Drives the picker's * "Playing now" badge and gates silent auto-resume (idle TVs must never * be reattached — a bare connection flips them into standby takeover). */ @@ -209,6 +212,7 @@ class SiloCastNsdBrowser(context: Context) { version = string("v")?.toIntOrNull() ?: 1, serverId = string("server"), serverName = string("serverName"), + serverIdentity = string("serverIdentity"), isPlaying = string("playing") == "1", ) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastController.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastController.kt index 20a7206f97..f324d21d9a 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastController.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastController.kt @@ -36,6 +36,8 @@ import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.Json import org.siloserver.silo.cast.SiloCastControlCommand +import org.siloserver.silo.cast.SiloCastFeature +import org.siloserver.silo.cast.SiloCastHandoffCancelReason import org.siloserver.silo.cast.SiloCastHello import org.siloserver.silo.cast.SiloCastHandoffCancel import org.siloserver.silo.cast.SiloCastHandoffChallenge @@ -43,10 +45,12 @@ 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.SiloCastPlaybackClock import org.siloserver.silo.cast.SiloCastPlaybackRequest import org.siloserver.silo.cast.SiloCastPlaybackState +import org.siloserver.silo.cast.SiloCastPresence import org.siloserver.silo.cast.SiloCastProtocol import org.siloserver.silo.common.cast.SiloCastFrame import org.siloserver.silo.common.cast.SiloCastFrameBuffer @@ -59,6 +63,7 @@ import org.siloserver.silo.network.ServerRegistry import org.siloserver.silo.network.TokenManager import org.siloserver.silo.network.ApiResult import org.siloserver.silo.network.AuthScopeSnapshot +import org.siloserver.silo.model.server.ServerEntry import org.siloserver.silo.network.api.DeviceLoginApi import org.siloserver.silo.repository.CatalogRepository @@ -83,6 +88,8 @@ data class SiloCastControllerState( * its player registers, so without this the remote would show "Pick * something from your library…" right after the user picked something. */ val launch: SiloCastPendingLaunch? = null, + /** A launch waiting for the user to agree to replace what the TV plays. */ + val pendingReplace: SiloCastPendingReplace? = null, val error: String? = null, ) { val isConnected: Boolean get() = connectedTarget != null && !isReconnecting @@ -113,8 +120,9 @@ enum class SiloCastLaunchFeedback { Sent, Started, Failed } * beats tear the transport down (a half-open socket can't stay pinned); * - a dropped transport auto-reconnects to the same target with 1–4 s * backoff (5 attempts) while the UI shows "Reconnecting…"; - * - a deliberate close — ours or the TV's — never reconnects and forgets - * the persisted target; + * - a deliberate close never reconnects. A TV that keeps several controllers + * says why it closed, and only a Disconnect on the TV forgets the persisted + * target; an older TV's close always forgets it; * - on app foreground with no session, the last-controlled TV is silently * re-attached *only* while its Bonjour `playing` flag is set; idle TVs * are never touched (a bare connection flips them into standby takeover). @@ -128,6 +136,8 @@ class SiloCastController( private val lastTargetStore: SiloCastLastTargetStore, private val deviceNameProvider: () -> String, private val deviceIdProvider: () -> String, + /** Display name of [profileId] when it is the active profile; null otherwise. */ + private val profileNameProvider: (profileId: String) -> String? = { null }, ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val json = Json { @@ -198,6 +208,17 @@ class SiloCastController( @Volatile private var sessionIsAutoResumed = false + // This connection's hello told the TV it is passive, and no `presence` + // has changed that since. + @Volatile + private var sessionPassive = false + + // The TV's hello features for the current connection; empty until it arrives. + @Volatile + private var tvFeatures: Set = emptySet() + + private val resumeTokens = SiloCastResumeTokens(AndroidServerRegistry::serverIdsMatch) + @Volatile private var remoteScreenVisible = false @@ -235,7 +256,11 @@ class SiloCastController( browser.stop() } - fun launchOnTarget(target: SiloCastTarget, request: SiloCastLaunchRequest) { + fun launchOnTarget( + target: SiloCastTarget, + request: SiloCastLaunchRequest, + replaceConsent: SiloCastReplaceConsent? = null, + ) { val pending = SiloCastPendingLaunch(contentId = request.playback.contentId) val job = scope.launch(start = CoroutineStart.LAZY) { val self = currentCoroutineContext()[Job] ?: return@launch @@ -245,10 +270,13 @@ class SiloCastController( try { launchMutex.withLock { ensureConnected(target, allowCrossServer = true) + // Sending a title is engaging with the TV, even on a + // session that attached silently. + markEngaged() // Again AFTER ensureConnected: its teardown of a previous // session clears the launch set at the tap. _state.update { if (isCurrentLaunch(self)) it.copy(launch = pending) else it } - prepareRemoteIdentity(request, title) + prepareRemoteIdentity(target, request, title, replaceConsent) // Read before sending: the TV's state for the new title can // arrive before the update below, and must not be taken // for the title being replaced. @@ -289,6 +317,27 @@ class SiloCastController( _state.update { it.copy(isConnecting = false, connectingDeviceId = null, launch = null) } } throw error + } catch (error: SiloCastHandoffRefusal) { + if (!isCurrentLaunch(self)) return@launch + if (error.reason == SiloCastHandoffCancelReason.ReplaceConfirmationRequired) { + // Nothing changed on the TV; the session stays up while the + // user decides. + _state.update { + it.copy( + isConnecting = false, + connectingDeviceId = null, + launch = null, + pendingReplace = SiloCastReplacePrompt.fromTv(target, request, error.nowPlaying, error.message), + ) + } + } else { + // The TV turned this handoff down (another device's play + // superseded it, a watch party is on); the session stays. + _state.update { + it.copy(isConnecting = false, connectingDeviceId = null, launch = null, error = error.message) + } + _launchFeedback.tryEmit(SiloCastLaunchFeedback.Failed) + } } catch (error: Throwable) { if (isCurrentLaunch(self)) { _state.update { @@ -315,7 +364,7 @@ class SiloCastController( } // From the tap, after the swap: a superseded job can no longer // overwrite this launch. - _state.update { it.copy(launch = pending, error = null) } + _state.update { it.copy(launch = pending, pendingReplace = null, error = null) } _launchFeedback.tryEmit(SiloCastLaunchFeedback.Sent) previous?.cancel(CancellationException("Superseded by a newer remote launch.")) job.start() @@ -359,13 +408,41 @@ class SiloCastController( _state.update { it.copy(error = "Choose a server before controlling a TV.") } return true } - launchOnTarget( - target = target, - request = SiloCastLaunchRequest(serverId = server.id, playback = playback), - ) + val request = SiloCastLaunchRequest(serverId = server.id, playback = playback) + val playing = _state.value.playbackState + val playingContentId = playing?.contentId + if (playing != null && playingContentId != null && + SiloCastReplacePrompt.replacesPlayingTitle(playingContentId, playback.contentId) + ) { + // The TV is mid-title and this is a different one: ask first. The + // same title (a Resume of what is on) goes straight through. + _state.update { + it.copy( + pendingReplace = SiloCastReplacePrompt.beforeSending(target, request, playingContentId, playing.title), + error = null, + ) + } + return true + } + launchOnTarget(target = target, request = request) return true } + /** The user agreed to replace what the TV is playing. */ + fun confirmReplace() { + var pending: SiloCastPendingReplace? = null + _state.update { + pending = it.pendingReplace + it.copy(pendingReplace = null) + } + val choice = pending ?: return + launchOnTarget(choice.target, choice.request, choice.consent) + } + + fun cancelReplace() { + _state.update { it.copy(pendingReplace = null) } + } + fun connect(target: SiloCastTarget) { scope.launch { runCatching { ensureConnected(target) } @@ -394,12 +471,15 @@ class SiloCastController( } } + /** + * Sends the explicit command for what the remote shows, never + * `play_pause`: two people toggling at once would cancel each other out. + */ fun playPause() { - sendControl(SiloCastControlCommand.playPause()) - clock.setOptimisticPlaying(!isPlaying(), nowMs()) + setPlaying(!isPlaying()) } - /** Idempotent transport command used by Android system media controls. */ + /** Idempotent transport command, used by the remote, the mini-bar and Android system media controls. */ fun setPlaying(playing: Boolean) { sendControl( if (playing) SiloCastControlCommand.play() else SiloCastControlCommand.pause(), @@ -542,7 +622,7 @@ class SiloCastController( * auto-resumed session won't let go quietly while it's visible. */ fun setRemoteScreenVisible(visible: Boolean) { remoteScreenVisible = visible - if (visible) sessionIsAutoResumed = false + if (visible) markEngaged() } fun onAppForeground() { @@ -597,14 +677,13 @@ class SiloCastController( } val found = match ?: return@launch Log.i(TAG, "SiloCast auto-resume probe found playing TV ${found.name}") - runCatching { ensureConnected(found) }.onFailure { + runCatching { ensureConnected(found, passive = true) }.onFailure { _state.update { state -> state.copy(isAutoResuming = false, isConnecting = false) } return@launch } - // Flag AFTER connecting: ensureConnected's teardown of the - // previous session resets both auto-resume markers, so setting - // them earlier would be silently undone mid-connect. - sessionIsAutoResumed = true + // AFTER connecting: ensureConnected's teardown of the previous + // session resets the auto-resume markers; the passive connect + // set sessionIsAutoResumed again before its hello. _state.update { it.copy(isAutoResuming = true) } // Safety net: the TV sends state right after hello; if nothing // confirms playback shortly, let go quietly. @@ -629,7 +708,7 @@ class SiloCastController( private fun sendControl(command: SiloCastControlCommand) { // Any outbound command counts as user engagement — the session is no // longer a passive auto-resume attachment after this. - sessionIsAutoResumed = false + markEngaged() val transport = controlTransport if (transport == null || controlCommands.trySend(QueuedControlCommand(command, transport)).isFailure @@ -638,13 +717,33 @@ class SiloCastController( } } + /** + * The user engaged with this session: it is no longer a silent auto-resume + * attachment, and a TV that tracks presence hears so. + */ + private fun markEngaged() { + sessionIsAutoResumed = false + sendPresenceIfEngaged() + } + + /** Tells a TV that lists `presence` this passive connection is now active. */ + private fun sendPresenceIfEngaged() { + if (!sessionPassive || sessionIsAutoResumed || SiloCastFeature.Presence !in tvFeatures) return + sessionPassive = false + scope.launch { runCatching { send(SiloCastMessage.Presence(SiloCastPresence(passive = false))) } } + } + private suspend fun ensureConnected( target: SiloCastTarget, allowCrossServer: Boolean = false, + passive: Boolean = false, ) = connectionMutex.withLock { - val activeServerId = serverRegistry.activeEntry.value?.id + val activeServer = serverRegistry.activeEntry.value ?: error("Choose a server before controlling a TV.") - val targetsActiveServer = AndroidServerRegistry.serverIdsMatch(target.serverId, activeServerId) + val activeServerId = activeServer.id + val targetsActiveServer = AndroidServerRegistry.serversMatch( + target.serverId, target.serverIdentity, activeServerId, activeServer.verifiedServerId, + ) require( targetsActiveServer || (allowCrossServer && target.version >= SiloCastProtocol.version), @@ -664,7 +763,7 @@ class SiloCastController( error = null, ) } - openSessionLocked(target) + openSessionLocked(target, passive) if (targetsActiveServer) { lastTargetStore.save( SiloCastPersistedTarget(deviceId = target.deviceId, name = target.name, serverId = target.serverId), @@ -677,7 +776,7 @@ class SiloCastController( * hold [connectionMutex] and have torn the previous transport down. * State only flips to connected after the hello is on the wire, so a * half-open failure never leaves a zombie session behind. */ - private suspend fun openSessionLocked(target: SiloCastTarget) { + private suspend fun openSessionLocked(target: SiloCastTarget, passive: Boolean) { // Captured from inside the withContext block: if this coroutine is // cancelled while the blocking connect is in flight, withContext // discards the block's result and throws — the catch below is then @@ -692,7 +791,12 @@ class SiloCastController( output = newSession.output negotiatedVersion = CompletableDeferred() missedHeartbeats = 0 - send(SiloCastMessage.Hello(makeHello())) + tvFeatures = emptySet() + sessionPassive = passive + // Before the read loop starts: the TV's hello can arrive at once, + // and must not read an unset flag as engagement. + if (passive) sessionIsAutoResumed = true + send(SiloCastMessage.Hello(makeHello(passive))) // Publish the queue token only after Hello is fully written, so a // control can never overtake the session handshake. controlTransport = ControlTransport(newSession.output) @@ -717,7 +821,7 @@ class SiloCastController( } } - private fun makeHello(): SiloCastHello { + private fun makeHello(passive: Boolean): SiloCastHello { val server = serverRegistry.activeEntry.value return SiloCastHello( role = SiloCastPeerRole.Phone, @@ -726,10 +830,18 @@ class SiloCastController( serverId = server?.id, serverName = server?.displayName, supportedVersions = SiloCastProtocol.supportedVersions, + serverIdentity = server?.deploymentIdentity, + features = SiloCastFeature.phoneFeatures, + passive = if (passive) true else null, ) } - private suspend fun prepareRemoteIdentity(request: SiloCastLaunchRequest, title: Deferred) { + private suspend fun prepareRemoteIdentity( + target: SiloCastTarget, + request: SiloCastLaunchRequest, + title: Deferred, + replaceConsent: SiloCastReplaceConsent?, + ) { val version = awaitHandoffStep(VERSION_TIMEOUT_MS, "The TV did not respond.") { negotiatedVersion.await() } require(version == SiloCastProtocol.version) { "Update Silo on both devices to use Remote Control." } @@ -754,11 +866,15 @@ class SiloCastController( serverURL = server.url, serverName = server.displayName, profileId = profileId, - profileName = null, + profileName = profileNameProvider(profileId), // Brief wait only: a TV that already has this profile // answers at once, and the title shows only during a // full handoff. title = withTimeoutOrNull(OFFER_TITLE_WAIT_MS) { title.await() }, + serverIdentity = server.deploymentIdentity, + resumeToken = resumeTokens.token(target.deviceId, server.id, profileId), + replaceConfirmed = if (replaceConsent != null) true else null, + replacingContentId = replaceConsent?.contentId, ), ), ) @@ -800,6 +916,7 @@ class SiloCastController( ) { "The TV activated a different remote playback profile." } + resumeTokens.store(ready.resumeToken, target.deviceId, server.id, profileId) } catch (t: Throwable) { // Superseding a launch cancels this coroutine. Finish the old // handoff's server/TV cleanup before releasing launchMutex so the @@ -810,16 +927,19 @@ class SiloCastController( deviceLoginApi.denyDeviceLoginForScope(scope.withProfile(profileId), it.userCode) } } - runCatching { - send( - SiloCastMessage.HandoffCancel( - SiloCastHandoffCancel( - requestId = handoff.requestId, - reason = "controller_cancelled", - message = t.message, + // A refusal came from the TV, which has already let the offer go. + if (t !is SiloCastHandoffRefusal) { + runCatching { + send( + SiloCastMessage.HandoffCancel( + SiloCastHandoffCancel( + requestId = handoff.requestId, + reason = SiloCastHandoffCancelReason.ControllerCancelled, + message = t.message, + ), ), - ), - ) + ) + } } } throw t @@ -913,7 +1033,8 @@ class SiloCastController( val reconnected = connectionMutex.withLock { if (session != null) return@launch try { - openSessionLocked(target) + // Still passive if the user never engaged. + openSessionLocked(target, passive = sessionIsAutoResumed) true } catch (e: CancellationException) { throw e @@ -965,7 +1086,11 @@ class SiloCastController( is SiloCastMessage.Hello -> { val version = SiloCastProtocol.negotiatedVersion(message.hello.supportedVersions) ?: error("Remote Control protocol is not compatible.") + // Before the version: a launch waiting on it reads the features next. + tvFeatures = message.hello.features.orEmpty().toSet() negotiatedVersion.complete(version) + // The user may have engaged before the TV's hello arrived. + sendPresenceIfEngaged() } is SiloCastMessage.HandoffChallenge -> { pendingHandoff?.takeIf { message.handoffChallenge.requestId == it.requestId }?.let { @@ -978,9 +1103,12 @@ class SiloCastController( } } is SiloCastMessage.HandoffCancel -> { - pendingHandoff?.takeIf { message.handoffCancel.requestId == it.requestId }?.let { - val error = IllegalStateException( - message.handoffCancel.message ?: "The TV cancelled profile setup.", + val cancel = message.handoffCancel + pendingHandoff?.takeIf { cancel.requestId == it.requestId }?.let { + val error = SiloCastHandoffRefusal( + reason = cancel.reason, + nowPlaying = cancel.nowPlaying, + message = cancel.message ?: "The TV cancelled profile setup.", ) it.handoffChallenge.completeExceptionally(error) it.handoffReady.completeExceptionally(error) @@ -1040,13 +1168,22 @@ class SiloCastController( is SiloCastMessage.Ping -> send(SiloCastMessage.Pong()) is SiloCastMessage.Pong -> missedHeartbeats = 0 is SiloCastMessage.Close -> { - // The TV ended the session deliberately (Disconnect Remote, or - // another controller took over). Respect that intent: no - // reconnect, and forget the persisted target so no later - // foreground probe silently reattaches. - suppressReconnect = true - lastTargetStore.clear() - closeConnection() + // The TV ended the session deliberately. An older TV only does + // that for a takeover or Disconnect, so the phone forgets it and + // never silently reattaches; a TV that keeps several controllers + // says why (see SiloCastClosePolicy). The remote keeps showing + // any error the TV sent just before. + Log.i(TAG, "SiloCast close from TV reason=${message.reason ?: "none"}") + when (val response = SiloCastClosePolicy.response(message.reason, SiloCastFeature.MultiController in tvFeatures)) { + // Closing the socket ends the read loop, whose finally + // block starts the reconnect. + SiloCastCloseResponse.Reconnect -> runCatching { session?.close() } + is SiloCastCloseResponse.LetGo -> { + suppressReconnect = true + if (response.forgetTarget) lastTargetStore.clear() + closeConnection() + } + } } else -> Unit } @@ -1104,6 +1241,7 @@ class SiloCastController( pendingHandoff?.cancel() pendingHandoff = null negotiatedVersion.cancel() + tvFeatures = emptySet() } private fun closeConnectionLocked() { @@ -1120,6 +1258,7 @@ class SiloCastController( isReconnecting = false, isAutoResuming = false, launch = null, + pendingReplace = null, ) } } @@ -1161,6 +1300,17 @@ class SiloCastController( private fun AuthScopeSnapshot.withProfile(profileId: String) = copy(profileId = profileId) + /** The deployment identity verified for this server, sent so peers on another of its addresses match. */ + private val ServerEntry.deploymentIdentity: String? + get() = verifiedServerId?.trim()?.takeIf { it.isNotEmpty() } + + /** The TV turned a handoff down with `handoff_cancel`; [reason] is a [SiloCastHandoffCancelReason]. */ + private class SiloCastHandoffRefusal( + val reason: String, + val nowPlaying: SiloCastNowPlayingSummary?, + message: String, + ) : IllegalStateException(message) + private fun ApiResult.successOrThrow(): T = when (this) { is ApiResult.Success -> data is ApiResult.Error -> error(message.ifBlank { "Server returned HTTP $code." }) @@ -1176,7 +1326,7 @@ class SiloCastController( const val OFFER_TITLE_WAIT_MS = 300L const val LAUNCH_START_TIMEOUT_MS = 30_000L /** TV errors that answer a control command rather than a launch. */ - val CONTROL_REPLY_ERRORS = setOf("player_not_ready", "command_failed") + val CONTROL_REPLY_ERRORS = setOf("player_not_ready", "command_failed", "rate_limited") const val HEARTBEAT_INTERVAL_MS = 3_000L const val MAX_MISSED_HEARTBEATS = 3 const val MAX_RECONNECT_ATTEMPTS = 5 diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastPhonePolicy.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastPhonePolicy.kt new file mode 100644 index 0000000000..091d11f6d7 --- /dev/null +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastPhonePolicy.kt @@ -0,0 +1,127 @@ +package org.siloserver.silo.android.cast + +import org.siloserver.silo.cast.SiloCastCloseReason +import org.siloserver.silo.cast.SiloCastLaunchRequest +import org.siloserver.silo.cast.SiloCastNowPlayingSummary +import org.siloserver.silo.common.cast.SiloCastTarget + +/** What the phone does when the TV closes its session (silo-apple `SiloControlCloseResponse`). */ +sealed interface SiloCastCloseResponse { + /** Connect again quietly, as after a dropped link. */ + data object Reconnect : SiloCastCloseResponse + + /** End the session. With [forgetTarget], no later foreground probe silently reattaches to that TV. */ + data class LetGo(val forgetTarget: Boolean) : SiloCastCloseResponse +} + +object SiloCastClosePolicy { + /** + * A TV without `multi_controller` closes only to hand the slot to another + * phone or because someone pressed Disconnect, so the phone lets go and + * forgets it, as it always has. A TV that keeps several controllers says + * why: a deliberate disconnect (or no reason) still forgets the TV, a + * timeout reconnects, and the rest end the session but let a later + * foreground find the TV again. + */ + fun response(reason: String?, tvKeepsOtherControllers: Boolean): SiloCastCloseResponse { + if (!tvKeepsOtherControllers) return SiloCastCloseResponse.LetGo(forgetTarget = true) + return when (reason) { + SiloCastCloseReason.Timeout -> SiloCastCloseResponse.Reconnect + null, SiloCastCloseReason.TvDisconnected -> SiloCastCloseResponse.LetGo(forgetTarget = true) + else -> SiloCastCloseResponse.LetGo(forgetTarget = false) + } + } +} + +/** + * The user's agreement to replace one particular title on the TV, carried from + * the prompt to the offer's `replacingContentId`. + */ +data class SiloCastReplaceConsent( + /** The title the user saw when agreeing; null when the TV didn't say. */ + val contentId: String?, +) + +/** + * A launch waiting for the user to agree to replace what the TV plays. Asked + * either by the phone before sending a different title to an engaged TV, or by + * the TV when another profile's title is on screen. + */ +data class SiloCastPendingReplace( + val target: SiloCastTarget, + val request: SiloCastLaunchRequest, + val message: String, + val confirmLabel: String, + /** Consent sent with the launch once the user agrees. */ + val consent: SiloCastReplaceConsent, +) + +object SiloCastReplacePrompt { + /** The TV refused the handoff because another profile's title is on screen. */ + fun fromTv( + target: SiloCastTarget, + request: SiloCastLaunchRequest, + nowPlaying: SiloCastNowPlayingSummary?, + fallbackMessage: String?, + ): SiloCastPendingReplace = SiloCastPendingReplace( + target = target, + request = request, + message = tvMessage(nowPlaying, target.name, fallbackMessage), + confirmLabel = "Replace", + consent = SiloCastReplaceConsent(contentId = nowPlaying?.contentId), + ) + + /** The phone is about to send a different title to a TV that is playing one. */ + fun beforeSending( + target: SiloCastTarget, + request: SiloCastLaunchRequest, + currentContentId: String, + currentTitle: String, + ): SiloCastPendingReplace = SiloCastPendingReplace( + target = target, + request = request, + message = "${target.name} is playing $currentTitle. Playing this will stop it.", + confirmLabel = "Play on ${target.name}", + consent = SiloCastReplaceConsent(contentId = currentContentId), + ) + + /** Whether sending [contentId] would replace a different title the TV is playing. */ + fun replacesPlayingTitle(playingContentId: String?, contentId: String): Boolean = + !playingContentId.isNullOrEmpty() && playingContentId != contentId + + /** The same sentence silo-apple builds from the TV's summary. */ + fun tvMessage(nowPlaying: SiloCastNowPlayingSummary?, tvName: String, fallbackMessage: String?): String { + if (nowPlaying == null) return fallbackMessage ?: "Someone is watching something on $tvName. Play yours instead?" + val who = nowPlaying.profileName ?: "Someone" + val what = listOfNotNull(nowPlaying.subtitle, nowPlaying.title) + .filter { it.isNotEmpty() } + .joinToString(" · ") + return "$who is watching $what on $tvName. Play yours instead?" + } +} + +/** + * Resume tokens the TVs issued this phone, held in memory only. A token lets a + * later connection to the same TV reuse the profile it already holds for this + * phone without a new approval, as after a Wi-Fi change. + */ +class SiloCastResumeTokens( + private val serverIdsMatch: (String?, String?) -> Boolean, +) { + private data class Entry(val serverId: String, val profileId: String, val token: String) + + private val entries = mutableMapOf() + + @Synchronized + fun token(tvId: String, serverId: String, profileId: String): String? { + val entry = entries[tvId] ?: return null + if (!serverIdsMatch(entry.serverId, serverId) || entry.profileId != profileId) return null + return entry.token + } + + @Synchronized + fun store(token: String?, tvId: String, serverId: String, profileId: String) { + if (token.isNullOrEmpty()) return + entries[tvId] = Entry(serverId, profileId, token) + } +} diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt index 6a5dbf654d..9637c1475c 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import org.siloserver.silo.model.profile.ActiveProfileStore import org.siloserver.silo.common.downloads.DownloadEnqueuer import org.siloserver.silo.common.downloads.DownloadSubscriptionEvaluatorFactory import org.siloserver.silo.common.downloads.DownloadSubscriptionWorker @@ -293,6 +294,7 @@ val androidModule = module { } single { SharedPrefsSiloCastLastTargetStore(androidContext()) } single { + val activeProfiles = get() SiloCastController( browser = get(), serverRegistry = get(), @@ -304,6 +306,9 @@ val androidModule = module { android.os.Build.MODEL?.trim()?.ifBlank { null } ?: "Android Phone" }, deviceIdProvider = { PairingDeviceId.stable(androidContext()) }, + profileNameProvider = { profileId -> + activeProfiles.activeProfile.value?.takeIf { it.id == profileId }?.name + }, ) } single { FirebaseAndroidPushTokenProvider(androidContext()) } diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastMiniBar.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastMiniBar.kt index fa60941e66..21b6f04a6b 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastMiniBar.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastMiniBar.kt @@ -120,7 +120,9 @@ fun SiloCastMiniBar( strokeWidth = 2.dp, ) } else { - IconButton(onClick = { controller.playPause() }) { + // Explicit, for what the bar shows: another controller may + // have changed it since. + IconButton(onClick = { controller.setPlaying(playback?.isPlaying != true) }) { Icon( if (playback?.isPlaying == true) Icons.Filled.Pause else Icons.Filled.PlayArrow, contentDescription = if (playback?.isPlaying == true) "Pause" else "Play", 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 e8f5c079d7..934603e946 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 @@ -222,6 +222,18 @@ fun SiloCastRemoteScreen( ) } + state.pendingReplace?.let { pending -> + SiloConfirmDialog( + title = stringResource(R.string.remote_replace_title), + body = pending.message, + confirmLabel = pending.confirmLabel, + dismissLabel = stringResource(R.string.remote_replace_cancel), + destructive = false, + onConfirm = controller::confirmReplace, + onDismiss = controller::cancelReplace, + ) + } + if (showBatteryPrompt) { val dismissBatteryPrompt = { RemoteControlBatteryOptimization.markPromptShown(context) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastTargetPickerSheet.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastTargetPickerSheet.kt index 549095624b..abc031ca97 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastTargetPickerSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/cast/SiloCastTargetPickerSheet.kt @@ -63,7 +63,9 @@ fun SiloCastTargetPickerSheet( serverRegistry: ServerRegistry = koinInject(), ) { val state by controller.state.collectAsState() - val activeServerId = serverRegistry.activeEntry.collectAsState().value?.id + val activeServer = serverRegistry.activeEntry.collectAsState().value + val activeServerId = activeServer?.id + val activeServerIdentity = activeServer?.verifiedServerId var searchTimedOut by remember { mutableStateOf(false) } // Remote-only mode keeps the sheet open through the connect (the row shows // a spinner, like Apple's picker) and dismisses once the session is up. @@ -89,7 +91,7 @@ fun SiloCastTargetPickerSheet( state.targets } else { state.targets.filter { - AndroidServerRegistry.serverIdsMatch(it.serverId, activeServerId) + AndroidServerRegistry.serversMatch(it.serverId, it.serverIdentity, activeServerId, activeServerIdentity) } } @@ -122,6 +124,7 @@ fun SiloCastTargetPickerSheet( target = target, launchRequest = launchRequest, activeServerId = activeServerId, + activeServerIdentity = activeServerIdentity, isConnectingToThis = state.connectingDeviceId == target.deviceId, onSelect = { if (launchRequest == null) { @@ -202,12 +205,14 @@ private fun TargetRow( target: SiloCastTarget, launchRequest: SiloCastLaunchRequest?, activeServerId: String?, + activeServerIdentity: String?, isConnectingToThis: Boolean, onSelect: () -> Unit, ) { val needsUpdate = target.version < SiloCastProtocol.version val enabled = !needsUpdate - val targetsActiveServer = AndroidServerRegistry.serverIdsMatch(target.serverId, activeServerId) + val targetsActiveServer = + AndroidServerRegistry.serversMatch(target.serverId, target.serverIdentity, activeServerId, activeServerIdentity) Row( verticalAlignment = Alignment.CenterVertically, diff --git a/androidApp/src/androidMain/res/values/strings.xml b/androidApp/src/androidMain/res/values/strings.xml index c089c86b80..d1333cc443 100644 --- a/androidApp/src/androidMain/res/values/strings.xml +++ b/androidApp/src/androidMain/res/values/strings.xml @@ -18,6 +18,8 @@ Sign Out + Replace what\'s playing? + Cancel Keep Remote Control connected Allow Silo to run without battery restrictions so Remote Control can stay connected after you leave the app. In Battery optimization, choose Silo and set it to Not optimized. Battery settings diff --git a/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/cast/SiloCastPhonePolicyTest.kt b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/cast/SiloCastPhonePolicyTest.kt new file mode 100644 index 0000000000..a9873a1974 --- /dev/null +++ b/androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/cast/SiloCastPhonePolicyTest.kt @@ -0,0 +1,100 @@ +package org.siloserver.silo.android.cast + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.siloserver.silo.cast.SiloCastCloseReason +import org.siloserver.silo.cast.SiloCastLaunchRequest +import org.siloserver.silo.cast.SiloCastNowPlayingSummary +import org.siloserver.silo.cast.SiloCastPlaybackRequest +import org.siloserver.silo.common.cast.SiloCastTarget + +/** The phone's rules for a TV that keeps several controllers (silo-apple SiloControlPhonePolicy). */ +class SiloCastPhonePolicyTest { + private val target = SiloCastTarget(deviceId = "tv-1", name = "Living Room", host = "10.0.0.2", port = 4711, version = 2) + private val request = SiloCastLaunchRequest( + serverId = "srv-1", + playback = SiloCastPlaybackRequest(contentId = "miasma", startFromBeginning = true), + ) + + @Test + fun anOlderTvsCloseAlwaysForgetsIt() { + for (reason in listOf(null, SiloCastCloseReason.Timeout, SiloCastCloseReason.Replaced)) { + assertEquals( + SiloCastCloseResponse.LetGo(forgetTarget = true), + SiloCastClosePolicy.response(reason, tvKeepsOtherControllers = false), + ) + } + } + + @Test + fun aMultiControllerTvSaysWhetherToReconnectOrForget() { + fun response(reason: String?) = SiloCastClosePolicy.response(reason, tvKeepsOtherControllers = true) + assertEquals(SiloCastCloseResponse.Reconnect, response(SiloCastCloseReason.Timeout)) + assertEquals(SiloCastCloseResponse.LetGo(forgetTarget = true), response(SiloCastCloseReason.TvDisconnected)) + assertEquals(SiloCastCloseResponse.LetGo(forgetTarget = true), response(null)) + for (reason in listOf( + SiloCastCloseReason.TooManyControllers, + SiloCastCloseReason.Unauthorized, + SiloCastCloseReason.LaunchTimeout, + SiloCastCloseReason.Replaced, + )) { + assertEquals(reason, SiloCastCloseResponse.LetGo(forgetTarget = false), response(reason)) + } + } + + @Test + fun theTvsPromptNamesWhoIsWatchingAndTiesConsentToThatTitle() { + val pending = SiloCastReplacePrompt.fromTv( + target, + request, + SiloCastNowPlayingSummary(contentId = "oak", title = "Oak Street", subtitle = "Season 1", profileName = "Alex"), + fallbackMessage = "Alex is watching Oak Street on this TV.", + ) + assertEquals("Alex is watching Season 1 · Oak Street on Living Room. Play yours instead?", pending.message) + assertEquals("Replace", pending.confirmLabel) + assertEquals(SiloCastReplaceConsent(contentId = "oak"), pending.consent) + assertEquals(request, pending.request) + } + + @Test + fun aPromptWithoutAProfileNameOrSummaryStillReads() { + assertEquals( + "Someone is watching Oak Street on Living Room. Play yours instead?", + SiloCastReplacePrompt.tvMessage(SiloCastNowPlayingSummary(title = "Oak Street"), "Living Room", null), + ) + val fallback = SiloCastReplacePrompt.fromTv(target, request, nowPlaying = null, fallbackMessage = "Taken.") + assertEquals("Taken.", fallback.message) + assertNull(fallback.consent.contentId) + } + + @Test + fun thePhoneAsksBeforeReplacingADifferentTitleOnly() { + assertTrue(SiloCastReplacePrompt.replacesPlayingTitle("oak", "miasma")) + assertFalse(SiloCastReplacePrompt.replacesPlayingTitle("oak", "oak")) + assertFalse(SiloCastReplacePrompt.replacesPlayingTitle(null, "miasma")) + assertFalse(SiloCastReplacePrompt.replacesPlayingTitle("", "miasma")) + + val pending = SiloCastReplacePrompt.beforeSending(target, request, currentContentId = "oak", currentTitle = "Oak Street") + assertEquals("Living Room is playing Oak Street. Playing this will stop it.", pending.message) + assertEquals("Play on Living Room", pending.confirmLabel) + assertEquals(SiloCastReplaceConsent(contentId = "oak"), pending.consent) + } + + @Test + fun resumeTokensBelongToOneTvServerAndProfile() { + val tokens = SiloCastResumeTokens { lhs, rhs -> lhs == rhs } + tokens.store("tok-1", tvId = "tv-1", serverId = "srv-1", profileId = "alex") + assertEquals("tok-1", tokens.token("tv-1", "srv-1", "alex")) + assertNull(tokens.token("tv-2", "srv-1", "alex")) + assertNull(tokens.token("tv-1", "srv-2", "alex")) + assertNull(tokens.token("tv-1", "srv-1", "sam")) + + // A ready without a token (older TV, or a reused identity) keeps the last one. + tokens.store(null, tvId = "tv-1", serverId = "srv-1", profileId = "alex") + tokens.store("", tvId = "tv-1", serverId = "srv-1", profileId = "alex") + assertEquals("tok-1", tokens.token("tv-1", "srv-1", "alex")) + } +} 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 28383fd0c8..d68fee994e 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 @@ -796,6 +796,8 @@ class TvSiloCastReceiver( 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, diff --git a/shared/src/androidMain/kotlin/org/siloserver/silo/network/AndroidServerRegistry.kt b/shared/src/androidMain/kotlin/org/siloserver/silo/network/AndroidServerRegistry.kt index 876ce6f07b..682e8f0217 100644 --- a/shared/src/androidMain/kotlin/org/siloserver/silo/network/AndroidServerRegistry.kt +++ b/shared/src/androidMain/kotlin/org/siloserver/silo/network/AndroidServerRegistry.kt @@ -438,6 +438,18 @@ class AndroidServerRegistry( * are canonicalized; credentials and the remaining URL components are * retained exactly. */ + /** + * Whether two server references name the same deployment: the same + * registry key, or the same verified deployment identity (silo-apple + * `ServerRegistry.serversMatch`). + */ + fun serversMatch(lhsId: String?, lhsIdentity: String?, rhsId: String?, rhsIdentity: String?): Boolean { + if (serverIdsMatch(lhsId, rhsId)) return true + val lhs = lhsIdentity?.trim()?.takeIf { it.isNotEmpty() } ?: return false + val rhs = rhsIdentity?.trim()?.takeIf { it.isNotEmpty() } ?: return false + return lhs == rhs + } + fun serverIdsMatch(lhs: String?, rhs: String?): Boolean { if (lhs.isNullOrEmpty() || rhs.isNullOrEmpty()) return false if (lhs == rhs) return true diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/cast/SiloCastMessage.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/cast/SiloCastMessage.kt index 473c1898e7..f7af999fd9 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/cast/SiloCastMessage.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/cast/SiloCastMessage.kt @@ -8,9 +8,12 @@ import kotlinx.serialization.SerializationException import kotlinx.serialization.descriptors.elementNames import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonClassDiscriminator +import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.put /** * Wire model for the `_silocast._tcp` phone→TV control channel. @@ -20,8 +23,10 @@ import kotlinx.serialization.json.contentOrNull * schema and cannot be revved in lockstep, so every field name, casing, enum * string, and envelope key here mirrors the Swift Codable output: camelCase * payload fields, `{type, v, }` envelope with the payload nested under - * the kind key, payloadless ping/pong/close, and Int64 track ids. Change this - * file only together with silo-apple. + * the kind key, payloadless ping/pong, an optional `close` payload, and Int64 + * track ids. Every field added after the first release is optional and sent + * only to peers that list the matching [SiloCastFeature]. Change this file + * only together with silo-apple. */ @OptIn(ExperimentalSerializationApi::class) @Serializable @@ -92,6 +97,14 @@ sealed class SiloCastMessage { @EncodeDefault(EncodeDefault.Mode.ALWAYS) override val v: Int = SiloCastProtocol.version, ) : SiloCastMessage() + /** A controller's `passive` flag after hello. Sent only to receivers that list [SiloCastFeature.Presence]. */ + @Serializable + @SerialName("presence") + data class Presence( + val presence: SiloCastPresence, + @EncodeDefault(EncodeDefault.Mode.ALWAYS) override val v: Int = SiloCastProtocol.version, + ) : SiloCastMessage() + @Serializable @SerialName("ping") data class Ping( @@ -104,11 +117,27 @@ sealed class SiloCastMessage { @EncodeDefault(EncodeDefault.Mode.ALWAYS) override val v: Int = SiloCastProtocol.version, ) : SiloCastMessage() + /** + * Ends the session. [close] carries `{"reason": ...}` ([SiloCastCloseReason]) + * only for peers that list [SiloCastFeature.CloseReason]; older peers get a + * bare close. Kept as raw JSON so a malformed payload reads as no reason, as + * on Apple, instead of failing the frame. + */ @Serializable @SerialName("close") data class Close( + val close: JsonElement? = null, @EncodeDefault(EncodeDefault.Mode.ALWAYS) override val v: Int = SiloCastProtocol.version, - ) : SiloCastMessage() + ) : SiloCastMessage() { + val reason: String? + get() = ((close as? JsonObject)?.get("reason") as? JsonPrimitive) + ?.takeIf { it.isString }?.contentOrNull + + companion object { + fun withReason(reason: String?): Close = + Close(close = reason?.let { buildJsonObject { put("reason", it) } }) + } + } companion object { /** Every `type` this build understands, read from the serializer so it can't drift. */ @@ -156,7 +185,17 @@ data class SiloCastHello( val serverId: String? = null, val serverName: String? = null, val supportedVersions: List, -) + /** The deployment identity behind [serverId], when known, so peers on different addresses of one server match. */ + val serverIdentity: String? = null, + /** [SiloCastFeature] names this peer supports. Absent from older peers, which support none. */ + val features: List? = null, + /** A controller that only watches state (silent auto-resume, an integration); it never puts up the TV's standby screen. */ + val passive: Boolean? = null, + /** [SiloCastClientKind]. `role` stays `phone` for integrations because shipped receivers reject other roles. */ + val clientKind: String? = null, +) { + fun supports(feature: String): Boolean = features?.contains(feature) == true +} @Serializable data class SiloCastPlaybackRequest( @@ -185,6 +224,14 @@ data class SiloCastHandoffOffer( val profileName: String? = null, /** Display-only: what the phone is about to launch. Older peers omit and ignore it. */ val title: String? = null, + /** The deployment identity the phone verified at [serverURL]. */ + val serverIdentity: String? = null, + /** The token from an earlier `handoff_ready`, to reuse that identity from a new connection without another approval. */ + val resumeToken: String? = null, + /** The user agreed to replace a title another profile started, after the TV answered `replace_confirmation_required`. */ + val replaceConfirmed: Boolean? = null, + /** With [replaceConfirmed]: the title the user agreed to replace. The TV honors it only while that title is on screen. */ + val replacingContentId: String? = null, ) @Serializable @@ -202,6 +249,8 @@ data class SiloCastHandoffReady( val profileId: String, val sessionExpiresAt: String, val reused: Boolean, + /** Proof for reusing this identity from a later connection, sent only to controllers that list [SiloCastFeature.ResumeToken]. */ + val resumeToken: String? = null, ) @Serializable @@ -209,6 +258,23 @@ data class SiloCastHandoffCancel( val requestId: String, val reason: String, val message: String? = null, + /** With reason `replace_confirmation_required`: the title the handoff would replace, so the controller can ask. */ + val nowPlaying: SiloCastNowPlayingSummary? = null, +) + +/** What a controller needs to ask "Replace what Alex is watching?". */ +@Serializable +data class SiloCastNowPlayingSummary( + val contentId: String? = null, + val title: String, + val subtitle: String? = null, + val profileName: String? = null, + val controllerName: String? = null, +) + +@Serializable +data class SiloCastPresence( + val passive: Boolean, ) @Serializable diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/cast/SiloCastProtocol.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/cast/SiloCastProtocol.kt index 1d1fed7aed..982ddffc76 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/cast/SiloCastProtocol.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/cast/SiloCastProtocol.kt @@ -8,3 +8,74 @@ object SiloCastProtocol { fun negotiatedVersion(peerVersions: List): Int? = supportedVersions.filter(peerVersions::contains).maxOrNull() } + +/** + * `hello.features` names, matching silo-apple's `SiloControlFeature`. A peer + * sends a new field, payload, reason or message type only when the other side + * listed the matching feature, so nothing reaches a build that would misread + * it. Peers ignore names they don't know. + */ +object SiloCastFeature { + /** The receiver keeps other controllers connected; a `close` from it concerns this controller alone. */ + const val MultiController = "multi_controller" + + /** `close` may carry a reason ([SiloCastCloseReason]). */ + const val CloseReason = "close_reason" + + /** The receiver issues `resumeToken` in `handoff_ready`; the controller sends it back to reuse that identity. */ + const val ResumeToken = "resume_token" + + /** + * The receiver refuses a handoff that would replace another profile's title + * with reason `replace_confirmation_required`, and the controller resends + * with `replaceConfirmed` after asking. + */ + const val ReplaceConfirmation = "replace_confirmation" + + /** The receiver accepts `presence`, so a controller can change its `passive` flag without reconnecting. */ + const val Presence = "presence" + + val receiverFeatures: List = listOf(MultiController, CloseReason, ResumeToken, ReplaceConfirmation, Presence) + val phoneFeatures: List = listOf(CloseReason, ResumeToken, ReplaceConfirmation) +} + +/** Why a peer closed the session. Sent only to peers that list [SiloCastFeature.CloseReason]. */ +object SiloCastCloseReason { + /** A newer connection from the same device took this one's place. */ + const val Replaced = "replaced" + + /** Someone disconnected remote control on the TV. */ + const val TvDisconnected = "tv_disconnected" + + /** The receiver is at its controller limit. */ + const val TooManyControllers = "too_many_controllers" + + /** The peer never sent hello, or stopped answering pings. */ + const val Timeout = "timeout" + + /** The peer can't be authorized (version or server mismatch). */ + const val Unauthorized = "unauthorized" + + /** A borrowed profile was never used and has been handed back. */ + const val LaunchTimeout = "launch_timeout" +} + +/** `handoff_cancel.reason` values both platforms send. */ +object SiloCastHandoffCancelReason { + /** A newer handoff from another controller replaced this one. */ + const val Superseded = "superseded" + + /** The handoff would replace a title another profile started; resend with `replaceConfirmed` once the user agrees. */ + const val ReplaceConfirmationRequired = "replace_confirmation_required" + const val WatchPartyActive = "watch_party_active" + const val VersionUnsupported = "version_unsupported" + const val LaunchPending = "launch_pending" + const val HandoffFailed = "handoff_failed" + const val ControllerCancelled = "controller_cancelled" +} + +/** Values for `SiloCastHello.clientKind`. Absent means a Silo app. */ +object SiloCastClientKind { + /** A home-automation or other third-party integration. */ + const val Integration = "integration" +} diff --git a/shared/src/commonTest/kotlin/org/siloserver/silo/cast/SiloCastMessageTest.kt b/shared/src/commonTest/kotlin/org/siloserver/silo/cast/SiloCastMessageTest.kt index 38e71b9507..893c17d9ac 100644 --- a/shared/src/commonTest/kotlin/org/siloserver/silo/cast/SiloCastMessageTest.kt +++ b/shared/src/commonTest/kotlin/org/siloserver/silo/cast/SiloCastMessageTest.kt @@ -5,8 +5,10 @@ import kotlinx.serialization.json.Json import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNull +import kotlin.test.assertTrue /** * Wire-parity tests against silo-apple's SiloControlMessage encoder @@ -264,9 +266,146 @@ class SiloCastMessageTest { assertEquals( setOf( "hello", "handoff_offer", "handoff_challenge", "handoff_ready", "handoff_cancel", - "launch", "control", "state", "error", "ping", "pong", "close", + "launch", "control", "state", "error", "presence", "ping", "pong", "close", ), SiloCastMessage.knownTypes, ) } + + // Several controllers on one TV (silo-apple SiloControlMultiControllerTests). + + @Test + fun phoneHelloCarriesFeaturesPassiveAndIdentity() { + assertWireEquals( + """ + {"type":"hello","v":2,"hello":{ + "role":"phone","deviceName":"Pixel 9","deviceId":"android-abc", + "serverId":"srv-1","serverName":"Home","supportedVersions":[2], + "serverIdentity":"deploy-1", + "features":["close_reason","resume_token","replace_confirmation"], + "passive":true + }} + """, + SiloCastMessage.Hello( + SiloCastHello( + role = SiloCastPeerRole.Phone, + deviceName = "Pixel 9", + deviceId = "android-abc", + serverId = "srv-1", + serverName = "Home", + supportedVersions = SiloCastProtocol.supportedVersions, + serverIdentity = "deploy-1", + features = SiloCastFeature.phoneFeatures, + passive = true, + ), + ), + ) + } + + @Test + fun decodesAppleMultiControllerTvHello() { + val hello = assertIs( + json.decodeFromString( + SiloCastMessage.serializer(), + """{"type":"hello","v":2,"hello":{"role":"tv","deviceName":"Den","deviceId":"atv-1",""" + + """"serverId":"srv-1","serverName":"Home","supportedVersions":[1,2],"serverIdentity":"deploy-1",""" + + """"features":["multi_controller","close_reason","resume_token","replace_confirmation","presence"]}}""", + ), + ).hello + assertTrue(hello.supports(SiloCastFeature.MultiController)) + assertTrue(hello.supports(SiloCastFeature.Presence)) + assertEquals("deploy-1", hello.serverIdentity) + // An older TV lists nothing. + assertFalse(SiloCastHello(SiloCastPeerRole.Tv, "Old", "atv-0", supportedVersions = listOf(2)).supports(SiloCastFeature.MultiController)) + } + + @Test + fun closeCarriesAReasonOnlyWhenGiven() { + assertWireEquals( + """{"type":"close","v":2,"close":{"reason":"replaced"}}""", + SiloCastMessage.Close.withReason(SiloCastCloseReason.Replaced), + ) + assertWireEquals("""{"type":"close","v":2}""", SiloCastMessage.Close.withReason(null)) + fun reason(frame: String) = assertIs(SiloCastMessage.decodeOrNull(json, frame)).reason + assertEquals("tv_disconnected", reason("""{"type":"close","v":2,"close":{"reason":"tv_disconnected"}}""")) + assertNull(reason("""{"type":"close","v":2}""")) + // A malformed payload is no reason, never a broken frame (Apple decodes it with try?). + assertNull(reason("""{"type":"close","v":2,"close":{"reason":5}}""")) + assertNull(reason("""{"type":"close","v":2,"close":"replaced"}""")) + } + + @Test + fun presenceMatchesApple() { + assertWireEquals( + """{"type":"presence","v":2,"presence":{"passive":false}}""", + SiloCastMessage.Presence(SiloCastPresence(passive = false)), + ) + } + + @Test + fun offerCarriesIdentityResumeTokenAndConsent() { + assertWireEquals( + """{"type":"handoff_offer","v":2,"handoffOffer":{"requestId":"req-2","serverId":"srv-1",""" + + """"serverURL":"https://silo.example","profileId":"p","profileName":"Alex","serverIdentity":"deploy-1",""" + + """"resumeToken":"tok","replaceConfirmed":true,"replacingContentId":"oak"}}""", + SiloCastMessage.HandoffOffer( + SiloCastHandoffOffer( + requestId = "req-2", + serverId = "srv-1", + serverURL = "https://silo.example", + profileId = "p", + profileName = "Alex", + serverIdentity = "deploy-1", + resumeToken = "tok", + replaceConfirmed = true, + replacingContentId = "oak", + ), + ), + ) + } + + @Test + fun decodesAppleReadyWithResumeTokenAndCancelWithNowPlaying() { + val ready = assertIs( + json.decodeFromString( + SiloCastMessage.serializer(), + """{"type":"handoff_ready","v":2,"handoffReady":{"requestId":"r","serverId":"s","profileId":"p",""" + + """"sessionExpiresAt":"2099-01-01T00:00:00Z","reused":true,"resumeToken":"tok"}}""", + ), + ).handoffReady + assertEquals("tok", ready.resumeToken) + + val cancel = assertIs( + json.decodeFromString( + SiloCastMessage.serializer(), + """{"type":"handoff_cancel","v":2,"handoffCancel":{"requestId":"r","reason":"replace_confirmation_required",""" + + """"message":"Alex is watching Oak Street on this TV.","nowPlaying":{"contentId":"oak","title":"Oak Street",""" + + """"profileName":"Alex","controllerName":"Sam's iPhone"}}}""", + ), + ).handoffCancel + assertEquals(SiloCastHandoffCancelReason.ReplaceConfirmationRequired, cancel.reason) + assertEquals( + SiloCastNowPlayingSummary(contentId = "oak", title = "Oak Street", profileName = "Alex", controllerName = "Sam's iPhone"), + cancel.nowPlaying, + ) + } + + @Test + fun featureAndReasonNamesMatchApple() { + assertEquals( + listOf("multi_controller", "close_reason", "resume_token", "replace_confirmation", "presence"), + SiloCastFeature.receiverFeatures, + ) + assertEquals(listOf("close_reason", "resume_token", "replace_confirmation"), SiloCastFeature.phoneFeatures) + assertEquals( + listOf("replaced", "tv_disconnected", "too_many_controllers", "timeout", "unauthorized", "launch_timeout"), + listOf( + SiloCastCloseReason.Replaced, SiloCastCloseReason.TvDisconnected, SiloCastCloseReason.TooManyControllers, + SiloCastCloseReason.Timeout, SiloCastCloseReason.Unauthorized, SiloCastCloseReason.LaunchTimeout, + ), + ) + assertEquals("superseded", SiloCastHandoffCancelReason.Superseded) + assertEquals("replace_confirmation_required", SiloCastHandoffCancelReason.ReplaceConfirmationRequired) + assertEquals("integration", SiloCastClientKind.Integration) + } } From 0d7ce75600bd0a51d26044670d376988edd0fe0a Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:53:55 -0400 Subject: [PATCH 3/9] feat(tv): keep several Remote Control controllers connected to one TV An Android TV kept one controller: every new TCP connection, even a port probe, closed the phone that was using it, so two people on a couch, or a phone and a Home Assistant integration, kept knocking each other off. Apple TVs now keep several controllers; this brings the Android TV receiver to the same contract (silo-apple SiloControlReceiverPolicy), with the pure rules in TvSiloCastReceiverPolicy. - Up to 8 controllers, each with its own heartbeat, authorization and command budget (20/s, burst 40, `rate_limited`). At the cap the TV closes, in order, a connection that never said hello, an idle cross-server controller, then the oldest passive one, never one that is handing off or in use; otherwise it refuses the newcomer with `too_many_controllers`. - A new connection from the same device replaces its older sockets. - One state tick goes to every authorized controller, through a latest-only slot per controller so a slow peer holds up no one; errors go only to the controller that caused them. - One handoff at a time: a newer offer from another controller cancels the pending one with `superseded`. A launch plays under a borrowed profile only for the controller whose handoff produced it, and reusing that profile needs the same connection or the resume token issued to it (device IDs are self-reported). Keeping it alive while its phone reconnects still follows that phone. - A handoff that would replace a title another profile is watching asks first (`replace_confirmation_required` with what is playing), when the controller can ask, and checks again just before it stops playback. - Passive controllers never put up standby; standby names the newest waiting controller and counts the rest. Disconnect on the TV closes the non-passive ones with `tv_disconnected`. - After the borrowed profile changes, every controller is re-checked; one that loses authorization is told `server_mismatch`, unless its own profile simply ended, and cross-server ones stay connected to hand off again. - A borrowed profile whose session the server stops refreshing now ends: every authorized controller gets `temporary_session_expired`, playback under it stops, and the TV falls back to its own sign-in. The token manager reports this through a new `temporarySessionExpired` flow. - Close reasons go to controllers that list `close_reason`; the TV advertises `serverIdentity` in hello and Bonjour and matches controllers on it as well as the server ID. Co-Authored-By: Claude Opus 5.5 --- .../silo/common/cast/SiloCastNsdAdvertiser.kt | 15 +- .../tv/cast/RemotePlaybackIdentityManager.kt | 11 +- .../silo/tv/cast/TvSiloCastReceiver.kt | 1341 +++++++++++------ .../silo/tv/cast/TvSiloCastReceiverPolicy.kt | 190 +++ .../siloserver/silo/tv/di/AndroidTvModule.kt | 2 + .../tv/cast/RemotePlaybackHandoffOrderTest.kt | 1 + .../cast/RemotePlaybackIdentityClaimTest.kt | 15 +- .../tv/cast/TvSiloCastReceiverPolicyTest.kt | 165 ++ .../silo/network/EncryptedTokenManagerImpl.kt | 22 +- .../siloserver/silo/network/TokenManager.kt | 12 + 10 files changed, 1343 insertions(+), 431 deletions(-) create mode 100644 androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiverPolicy.kt create mode 100644 androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/cast/TvSiloCastReceiverPolicyTest.kt 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 8c4c0c8a0e..2e57239d91 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. @@ -42,8 +42,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). */ @@ -76,6 +82,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 { @@ -112,6 +120,7 @@ class SiloCastNsdAdvertiser( val serverId: String?, val serverName: String?, val playing: Boolean, + val serverIdentity: String?, ) private companion object { 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 90867d4c02..064d384a41 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 d68fee994e..a61fa34e4c 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,13 +519,10 @@ 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())) - } - delay(STATE_INTERVAL_MS) - } + // The latest state only: a slow peer never holds up the others, + // and never receives a stale frame after a newer one. + val stateWriter = launch { + for (state in session.stateUpdates) session.send(SiloCastMessage.State(state)) } val heartbeatJob = launch { while (isActive) { @@ -423,6 +531,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 +543,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 +559,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 +627,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."), - ), - ) + if (!session.commandBudget.allow(nowMs())) { + noteRateLimited(session) 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)) - 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 +656,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 +677,367 @@ 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(currentState()) + 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 { + 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(currentState()) + 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, ready, fullHandoff) + } 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 that generation only, and re-check the + // controllers its activation judged. + val granted = session.readyGeneration + if (granted != null && activePlayer == null) { + 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, ready: SiloCastHandoffReady, fullHandoff: Boolean) { + // 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) { + 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 + } + 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(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) 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 +1063,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 +1150,131 @@ 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."), + ) + authorizedControllers().forEach { runCatching { it.send(error) } } + activePlayer?.takeIf { it.identityGeneration == generationId }?.let { player -> + withContext(Dispatchers.Main.immediate) { + player.adapter.handle(SiloCastControlCommand(name = SiloCastControlCommand.Stop)) + } + } + if (identityManager.end(generationId)) { + refreshAdvertisement() + reconcileAuthorization() + } + } + + /** + * 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 = currentState() + 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. */ + private suspend fun broadcastState() { + val targets = authorizedControllers() + if (targets.isEmpty()) return + val state = currentState() + targets.forEach { it.offerState(state) } + } + /** * 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 +1306,7 @@ class TvSiloCastReceiver( if (pendingLaunch?.generation == generationId) pendingLaunch = null } refreshAdvertisement() + refreshStandbyState() } } } @@ -895,23 +1317,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 +1345,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 +1373,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 +1414,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 +1485,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 +1527,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 +1565,8 @@ class TvSiloCastReceiver( serverId = remote?.serverId ?: server?.id, serverName = remote?.serverName ?: server?.displayName, supportedVersions = SiloCastProtocol.supportedVersions, + serverIdentity = advertisedServerIdentity(), + features = SiloCastFeature.receiverFeatures, ) } @@ -1157,12 +1605,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 +1636,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 +1662,31 @@ 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: SiloCastPlaybackState) { + stateUpdates.trySend(state) + } + suspend fun send(message: SiloCastMessage) { val payload = json.encodeToString(SiloCastMessage.serializer(), message).encodeToByteArray() val frame = SiloCastFrame.encode(payload) @@ -1211,30 +1701,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 +1749,7 @@ class TvSiloCastReceiver( val onOwnIdentity: Boolean, val borrowed: RemotePlaybackIdentityManager.ActiveIdentity?, val serverId: String?, + val serverIdentity: String?, ) private data class ActivePlayer( @@ -1268,6 +1768,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 0000000000..6b9b9d5833 --- /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 18a888e16a..99fcae031c 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 1eae8fd054..18f6076993 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 7cf656f50f..ed9bc3580c 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 0000000000..924a76aae9 --- /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 62bf6357b3..2398fceee8 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 78b7f495d5..aec2f7f412 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() From 3b121d9cb84a51f2dd72d5960c26ac172ba076be Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:41:55 -0400 Subject: [PATCH 4/9] fix(cast): keep injected targets authoritative and report only a finished registration Review follow-ups on the debug hooks: - A resolved mDNS record could pass the "no injected target with this id" check outside the browser's lock and then overwrite an injected target set in between. Every write to the target list now holds the lock. - Clearing an injected target dropped the discovered record it had replaced until discovery found it again. The browser keeps the hidden record and restores it. - CAST_STATUS reported the requested advertisement before NsdManager confirmed it, and under the requested name. It now reports only a registered advertisement, under the name NsdManager registered. Co-Authored-By: Claude Opus 5.5 --- .../silo/common/cast/SiloCastNsdAdvertiser.kt | 16 ++++++- .../silo/common/cast/SiloCastNsdBrowser.kt | 44 ++++++++++++------- 2 files changed, 42 insertions(+), 18 deletions(-) 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 8c4c0c8a0e..dd386290ce 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 @@ -33,12 +33,16 @@ class SiloCastNsdAdvertiser( private var lastRegistration: Registration? = null private var lastRecord: Map? = null - /** The live advertisement's name, port and TXT record, for the debug adb hook. */ + /** The service name NsdManager registered, which it may change to resolve a conflict; null until it confirms. */ + private var registeredName: String? = null + + /** The registered advertisement's name, port and TXT record, for the debug adb hook. */ @Synchronized fun currentAdvertisement(): Triple>? { val registration = lastRegistration ?: return null val record = lastRecord ?: return null - return Triple(record["name"].orEmpty(), registration.port, record) + val name = registeredName ?: return null + return Triple(name, registration.port, record) } @Synchronized @@ -62,6 +66,7 @@ class SiloCastNsdAdvertiser( registrationListener = null lastRegistration = null lastRecord = null + registeredName = null } private fun register(registration: Registration) { @@ -87,10 +92,16 @@ class SiloCastNsdAdvertiser( val listener = object : NsdManager.RegistrationListener { override fun onServiceRegistered(info: NsdServiceInfo) { Log.i(TAG, "SiloCast registered on ${info.port}") + synchronized(this@SiloCastNsdAdvertiser) { + if (registrationListener === this) registeredName = info.serviceName + } } override fun onRegistrationFailed(info: NsdServiceInfo, errorCode: Int) { Log.w(TAG, "SiloCast registration failed: $errorCode") + synchronized(this@SiloCastNsdAdvertiser) { + if (registrationListener === this) registeredName = null + } } override fun onServiceUnregistered(info: NsdServiceInfo) { @@ -104,6 +115,7 @@ class SiloCastNsdAdvertiser( registrationListener = listener lastRegistration = registration lastRecord = record + registeredName = null nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, listener) } diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/cast/SiloCastNsdBrowser.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/cast/SiloCastNsdBrowser.kt index 1de07f7790..f634bb336e 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/cast/SiloCastNsdBrowser.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/cast/SiloCastNsdBrowser.kt @@ -46,18 +46,35 @@ class SiloCastNsdBrowser(context: Context) { /** Targets added by the debug adb hook; see [setDebugTargets]. */ private var debugTargets: List = emptyList() + /** Discovered targets an injected one with the same id hides, restored when it goes. */ + private var shadowedTargets: Map = emptyMap() + /** * Debug builds only (PlaybackDebugReceiver): targets supplied over adb. * An emulator sits behind NAT that mDNS can't cross, so a test harness * resolves TVs on the host and hands them in; they merge with (and * survive a restart of) real discovery. An empty list removes them. + * Every write to [_targets] holds this monitor, so discovery can't + * overwrite an injected target. */ @Synchronized fun setDebugTargets(targets: List) { - val replacedIds = debugTargets.map { it.deviceId }.toSet() + val previousIds = debugTargets.map { it.deviceId }.toSet() + val injectedIds = targets.map { it.deviceId }.toSet() + val discovered = _targets.value.filterNot { it.deviceId in previousIds } + shadowedTargets.values + shadowedTargets = discovered.filter { it.deviceId in injectedIds }.associateBy { it.deviceId } debugTargets = targets + _targets.value = (discovered.filterNot { it.deviceId in injectedIds } + targets).sortedBy { it.name.lowercase() } + } + + /** Publishes a resolved mDNS record; a debug-injected target wins over one with its id. */ + private fun publishDiscoveredLocked(target: SiloCastTarget) { + if (debugTargets.any { it.deviceId == target.deviceId }) { + shadowedTargets = shadowedTargets + (target.deviceId to target) + return + } _targets.update { current -> - (current.filterNot { it.deviceId in replacedIds || targets.any { t -> t.deviceId == it.deviceId } } + targets) + (current.filterNot { it.deviceId == target.deviceId } + target) .sortedBy { it.name.lowercase() } } } @@ -85,10 +102,11 @@ class SiloCastNsdBrowser(context: Context) { ?.takeIf { it.serviceInfo.serviceName == lostName } ?.cancelled = true pendingResolutions.removeAll { it.serviceInfo.serviceName == lostName } - } - _targets.update { targets -> - val injectedIds = synchronized(this@SiloCastNsdBrowser) { debugTargets.map { it.deviceId }.toSet() } - targets.filterNot { it.serviceName == lostName && it.deviceId !in injectedIds } + shadowedTargets = shadowedTargets.filterValues { it.serviceName != lostName } + val injectedIds = debugTargets.map { it.deviceId }.toSet() + _targets.update { targets -> + targets.filterNot { it.serviceName == lostName && it.deviceId !in injectedIds } + } } } @@ -117,6 +135,7 @@ class SiloCastNsdBrowser(context: Context) { discoveryListener = null pendingResolutions.clear() activeResolution = null + shadowedTargets = emptyMap() _targets.value = debugTargets } @@ -151,16 +170,9 @@ class SiloCastNsdBrowser(context: Context) { } override fun onServiceResolved(info: NsdServiceInfo) { - val mayPublish = synchronized(this@SiloCastNsdBrowser) { - discoveryListener != null && activeResolution === pending && !pending.cancelled - } - if (mayPublish) { - // A debug-injected target wins over an mDNS record with its id. - info.toSiloCastTarget()?.takeIf { found -> debugTargets.none { it.deviceId == found.deviceId } }?.let { target -> - _targets.update { current -> - (current.filterNot { it.deviceId == target.deviceId } + target) - .sortedBy { it.name.lowercase() } - } + synchronized(this@SiloCastNsdBrowser) { + if (discoveryListener != null && activeResolution === pending && !pending.cancelled) { + info.toSiloCastTarget()?.let(::publishDiscoveredLocked) } } finishResolution(pending) From f435ae72663e5361876c7bfbda255e4b7383b9b2 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:09:16 -0400 Subject: [PATCH 5/9] fix(cast): learn the server's identity when Remote Control starts A server signed in before the identity contract has no verified identity on the phone. The picker then can't list a TV that advertises another address of the same deployment, and hello and the handoff offer can't name the deployment. silo-apple learns it on the server's next activation; the phone now learns it once when it starts looking for TVs (the picker or a foreground auto-resume), through ServerIdentityRepository. Co-Authored-By: Claude Opus 5.5 --- .../silo/android/cast/SiloCastController.kt | 22 +++++++++++++++++++ .../silo/android/di/AndroidModule.kt | 1 + 2 files changed, 23 insertions(+) diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastController.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastController.kt index f324d21d9a..4a259f7ad6 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastController.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/cast/SiloCastController.kt @@ -66,6 +66,7 @@ import org.siloserver.silo.network.AuthScopeSnapshot import org.siloserver.silo.model.server.ServerEntry import org.siloserver.silo.network.api.DeviceLoginApi import org.siloserver.silo.repository.CatalogRepository +import org.siloserver.silo.repository.ServerIdentityRepository data class SiloCastControllerState( val targets: List = emptyList(), @@ -138,6 +139,7 @@ class SiloCastController( private val deviceIdProvider: () -> String, /** Display name of [profileId] when it is the active profile; null otherwise. */ private val profileNameProvider: (profileId: String) -> String? = { null }, + private val serverIdentities: ServerIdentityRepository? = null, ) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val json = Json { @@ -249,9 +251,28 @@ class SiloCastController( } fun startBrowsing() { + learnActiveServerIdentity() browser.start() } + /** The server whose identity this process already tried to learn. */ + @Volatile + private var identityLookupServerId: String? = null + + /** + * A server signed in before the identity contract has no verified + * identity, so the picker can't recognise a TV that advertises another of + * its addresses, and hello and the offer can't name it. Learn it once, as + * silo-apple does on the server's next activation. + */ + private fun learnActiveServerIdentity() { + val identities = serverIdentities ?: return + val entry = serverRegistry.activeEntry.value ?: return + if (entry.deploymentIdentity != null || identityLookupServerId == entry.id) return + identityLookupServerId = entry.id + scope.launch { runCatching { identities.identityOf(entry) } } + } + fun stopBrowsing() { browser.stop() } @@ -666,6 +687,7 @@ class SiloCastController( autoResumeJob = scope.launch { try { + learnActiveServerIdentity() browser.start() var match: SiloCastTarget? = null var rounds = 0 diff --git a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt index 9637c1475c..3e2f317b1d 100644 --- a/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt +++ b/androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt @@ -309,6 +309,7 @@ val androidModule = module { profileNameProvider = { profileId -> activeProfiles.activeProfile.value?.takeIf { it.id == profileId }?.name }, + serverIdentities = get(), ) } single { FirebaseAndroidPushTokenProvider(androidContext()) } From d6f2e2702e2186de32592eeac6f0f8f1ba455333 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:12:02 -0400 Subject: [PATCH 6/9] fix(tv): fence state and handoff rollback to the right controller Review follow-ups in the multi-controller receiver: - broadcastState chose its recipients before reading the player state, so a handoff that changed the TV's profile during the read could send the new profile's state to a controller it had just de-authorized. It now chooses recipients after the read, and each controller's writer checks authorization again before sending a queued state. - A failed handoff_ready rolled back the session's current readiness. A newer offer from the same phone could own it by then, so the older attempt's failure ended the newer profile. Each attempt now rolls back only the generation it granted. Co-Authored-By: Claude Opus 5.5 --- .../silo/tv/cast/TvSiloCastReceiver.kt | 44 ++++++++++++++----- 1 file changed, 33 insertions(+), 11 deletions(-) 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 a61fa34e4c..f421cac30f 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 @@ -521,8 +521,15 @@ class TvSiloCastReceiver( // The latest state only: a slow peer never holds up the others, // and never receives a stale frame after a newer one. + // Authorization is checked again at the write: a state queued + // just before a handoff changed the TV's profile must not reach + // a controller that lost access. val stateWriter = launch { - for (state in session.stateUpdates) session.send(SiloCastMessage.State(state)) + for (state in session.stateUpdates) { + if (synchronized(this@TvSiloCastReceiver) { session.isAuthorized }) { + session.send(SiloCastMessage.State(state)) + } + } } val heartbeatJob = launch { while (isActive) { @@ -806,6 +813,8 @@ class TvSiloCastReceiver( 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. @@ -856,7 +865,7 @@ class TvSiloCastReceiver( } return@launch } - completeHandoff(session, ready, fullHandoff) + completeHandoff(session, ready, fullHandoff) { grantedGeneration = it } } catch (e: CancellationException) { throw e } catch (refusal: ReplaceConfirmationRequired) { @@ -868,11 +877,14 @@ class TvSiloCastReceiver( } catch (t: Throwable) { clearPreparing(session) // Readiness was granted but `handoff_ready` never reached the - // phone: hand back that generation only, and re-check the - // controllers its activation judged. - val granted = session.readyGeneration + // 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) { - session.readyGeneration = null + synchronized(this@TvSiloCastReceiver) { + if (session.readyGeneration == granted) session.readyGeneration = null + } runRecovering("handoff rollback") { identityManager.end(granted) refreshAdvertisement() @@ -897,7 +909,12 @@ class TvSiloCastReceiver( * 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, ready: SiloCastHandoffReady, fullHandoff: Boolean) { + private suspend fun completeHandoff( + session: ControllerSession, + 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. @@ -919,6 +936,7 @@ class TvSiloCastReceiver( 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 @@ -1255,12 +1273,16 @@ class TvSiloCastReceiver( private fun authorizedControllers(): List = synchronized(this) { controllers.values.filter { it.isAuthorized } } - /** Built once and handed to every authorized controller's latest-state slot. */ + /** + * 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() { - val targets = authorizedControllers() - if (targets.isEmpty()) return + if (authorizedControllers().isEmpty()) return val state = currentState() - targets.forEach { it.offerState(state) } + authorizedControllers().forEach { it.offerState(state) } } /** From 6e21a9d22db0b9076e86cb176bc4286f669b1ac8 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:22:08 -0400 Subject: [PATCH 7/9] fix(tv): grant readiness only for the prepared profile, and don't let expiry wait on writes Review follow-ups in the multi-controller receiver: - completeHandoff granted readiness for whichever profile was active. It now checks, under the receiver lock, that the active profile is the one this offer prepared, and fails the handoff otherwise. - When the server refused a borrowed profile's refresh, the TV notified every controller before stopping playback and ending the profile, so a controller that stopped reading could stall both. It now tears down first and sends the notices in the background. Co-Authored-By: Claude Opus 5.5 --- .../siloserver/silo/tv/cast/TvSiloCastReceiver.kt | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) 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 f421cac30f..7f6cf15a31 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 @@ -865,7 +865,7 @@ class TvSiloCastReceiver( } return@launch } - completeHandoff(session, ready, fullHandoff) { grantedGeneration = it } + completeHandoff(session, offer, controllerId, ready, fullHandoff) { grantedGeneration = it } } catch (e: CancellationException) { throw e } catch (refusal: ReplaceConfirmationRequired) { @@ -911,6 +911,8 @@ class TvSiloCastReceiver( */ private suspend fun completeHandoff( session: ControllerSession, + offer: SiloCastHandoffOffer, + controllerId: String, ready: SiloCastHandoffReady, fullHandoff: Boolean, onGranted: (generation: String) -> Unit, @@ -921,6 +923,11 @@ class TvSiloCastReceiver( 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 @@ -1194,7 +1201,9 @@ class TvSiloCastReceiver( val error = SiloCastMessage.Error( SiloCastError(code = "temporary_session_expired", message = "The phone profile session expired."), ) - authorizedControllers().forEach { runCatching { it.send(error) } } + // 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)) @@ -1204,6 +1213,7 @@ class TvSiloCastReceiver( refreshAdvertisement() reconcileAuthorization() } + notified.forEach { session -> scope?.launch { runCatching { session.send(error) } } } } /** From 8a5b80b64a24716f56c2f8d02c485f834dc96c7e Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:34:21 -0400 Subject: [PATCH 8/9] fix(tv): check state authorization when the write is admitted A queued state was checked against the controller's authorization before the writer waited for the connection's write lock, and wasn't tied to the profile it was read under. Each state now carries the borrowed-profile generation it was read under, and the writer checks authorization and that generation after taking the write lock, just before writing. A state read under another profile, or queued before the controller lost access, is dropped; the next tick follows within half a second. Co-Authored-By: Claude Opus 5.5 --- .../silo/tv/cast/TvSiloCastReceiver.kt | 50 ++++++++++++++----- 1 file changed, 37 insertions(+), 13 deletions(-) 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 7f6cf15a31..370a799593 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 @@ -521,13 +521,15 @@ class TvSiloCastReceiver( // The latest state only: a slow peer never holds up the others, // and never receives a stale frame after a newer one. - // Authorization is checked again at the write: a state queued - // just before a handoff changed the TV's profile must not reach - // a controller that lost access. + // 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 (state in session.stateUpdates) { - if (synchronized(this@TvSiloCastReceiver) { session.isAuthorized }) { - session.send(SiloCastMessage.State(state)) + for (published in session.stateUpdates) { + session.sendState(published.state) { + synchronized(this@TvSiloCastReceiver) { + session.isAuthorized && identityManager.activeIdentity?.generationId == published.generation + } } } } @@ -728,7 +730,7 @@ class TvSiloCastReceiver( if (session.isAuthorized) "TV cast controller authorized" else "TV cast handoff required", ) refreshStandbyState() - if (session.isAuthorized) session.offerState(currentState()) + if (session.isAuthorized) session.offerState(snapshotState()) return true } @@ -828,7 +830,7 @@ class TvSiloCastReceiver( session.ownIdentityProfileId = offer.profileId DiagnosticsCastLogger.event("TV cast handoff skipped on own profile") session.send(SiloCastMessage.HandoffReady(ready)) - session.offerState(currentState()) + session.offerState(snapshotState()) return@launch } var fullHandoff = false @@ -950,7 +952,7 @@ class TvSiloCastReceiver( // other controllers. reconcileAuthorization() session.send(SiloCastMessage.HandoffReady(ready.copy(resumeToken = resumeToken))) - session.offerState(currentState()) + 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. @@ -1243,7 +1245,7 @@ class TvSiloCastReceiver( } refreshStandbyState() if (changes.isEmpty()) return - val state = currentState() + val state = snapshotState() changes.forEach { (controller, gained) -> if (gained) { controller.offerState(state) @@ -1291,10 +1293,18 @@ class TvSiloCastReceiver( */ private suspend fun broadcastState() { if (authorizedControllers().isEmpty()) return - val state = currentState() + 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 by then keeps it under the idle limit @@ -1700,7 +1710,7 @@ class TvSiloCastReceiver( val commandBudget = TvSiloCastReceiverPolicy.CommandBudget() /** The latest state for this controller's writer; older frames are dropped. */ - val stateUpdates = Channel(Channel.CONFLATED) + val stateUpdates = Channel(Channel.CONFLATED) var job: Job? = null var preparingClearJob: Job? = null @@ -1715,10 +1725,24 @@ class TvSiloCastReceiver( isHandingOff = isHandingOff, ) - fun offerState(state: SiloCastPlaybackState) { + 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) From ac5195af8d9b0c560781b2c794c2ec00027d2b7e Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:36:10 -0400 Subject: [PATCH 9/9] feat(cast): show only subtitle languages in the phone remote (#654) The remote's Subtitles menu listed tracks, eleven delay offsets and three positions. It now lists Off and the subtitle tracks only, the same as silo-apple. Delay and position stay in the TV's own player menu, which is where they are adjusted while watching. The dropdown no longer needs section headers. Co-authored-by: Claude Opus 5.5 --- .../ui/screens/cast/SiloCastRemoteScreen.kt | 105 +++++------------- 1 file changed, 29 insertions(+), 76 deletions(-) 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 934603e946..fcb5907ce6 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