Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,9 @@ class MediaAuthSession(
return MediaAuthSnapshot(null, null, null, tokenManager.getCurrentServerId(), "")
}
val accessToken = tokenManager.getAccessToken()
val profileId = tokenManager.getProfileId()
val profileToken = tokenManager.getProfileToken()
val profileIdentity = tokenManager.getProfileIdentity()
val profileId = profileIdentity.profileId
val profileToken = profileIdentity.profileToken
val serverIdAfter = tokenManager.getCurrentServerId()
val serverUrlAfter = tokenManager.getServerUrl()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import org.siloserver.silo.network.CleartextOriginNotApprovedException
import org.siloserver.silo.network.DefaultWatchTogetherRealtimeClient
import org.siloserver.silo.network.RoomRealtimeEvent
import org.siloserver.silo.network.SiloJson
import org.siloserver.silo.network.ProfileIdentity
import org.siloserver.silo.network.TokenManager
import org.siloserver.silo.network.TokenManagerImpl
import org.siloserver.silo.network.canonicalHttpOrigin
Expand Down Expand Up @@ -314,6 +315,10 @@ class WatchTogetherRealtimeWebSocketTest {

override suspend fun getProfileId(): String = if (activeB) "profile-b" else "profile-a"

// See SiloAuthPluginPinTest: the delegated default would bypass these.
override suspend fun getProfileIdentity(): ProfileIdentity =
ProfileIdentity(getProfileId(), getProfileToken())

override suspend fun getProfileToken(): String {
val token = if (activeB) "PROFILE_B" else "PROFILE_A"
activeB = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ import org.siloserver.silo.android.ui.navigation.ExternalRouteRequestFactory
import org.siloserver.silo.android.ui.navigation.Route
import org.siloserver.silo.android.ui.navigation.clearConsumedExternalRouteRequest
import org.siloserver.silo.android.ui.navigation.contentDeepLinkRouteOrNull
import org.siloserver.silo.android.ui.navigation.ExternalRouteScope
import org.siloserver.silo.android.ui.navigation.notificationExternalRouteOrNull
import org.siloserver.silo.android.ui.navigation.deviceLoginPairRouteOrNull
import org.siloserver.silo.android.ui.navigation.hasLocalDownloadsForScope
import org.siloserver.silo.android.ui.navigation.inviteClaimRouteOrNull
Expand Down Expand Up @@ -77,6 +79,27 @@ class MainActivity : ComponentActivity() {
// start. Mirrors the TV-side flag in MainTvActivity.
@Volatile
private var hasShownColdSplash = false

/**
* Set on the launch Intent once its external route has been delivered.
* `putExtra` mutates the process-local Intent, which covers ordinary
* in-process Activity recreation but NOT process death — the system may
* rebuild the task from the original launch Intent, without this. The
* saved-state route below is what covers that case; this is the fast
* path.
*/
private const val EXTRA_EXTERNAL_ROUTE_CONSUMED =
"org.siloserver.silo.EXTERNAL_ROUTE_CONSUMED"

/**
* Stands in for an active server whose identity could not be read, so a
* scope built from it matches nothing instead of everything.
*/
private const val UNRESOLVED_IDENTITY = "silo:unresolved-identity"

/** Saved-state key for [consumedExternalRoute]. */
private const val STATE_CONSUMED_EXTERNAL_ROUTE =
"org.siloserver.silo.CONSUMED_EXTERNAL_ROUTE"
}

private val externalRouteRequestFactory = ExternalRouteRequestFactory()
Expand All @@ -85,6 +108,14 @@ class MainActivity : ComponentActivity() {
// A replay-free SharedFlow can silently drop exactly that warm delivery.
private val pendingExternalRouteRequests = MutableStateFlow<ExternalRouteRequest?>(null)

/**
* The external route already delivered for the Intent this Activity was
* launched with, carried across process death in saved state so a restored
* task cannot replay a link the user already followed and navigated away
* from.
*/
private var consumedExternalRoute: String? = null

// POST_NOTIFICATIONS is required on Android 13+ for any notification —
// download progress / completion notifications silently never appear
// without it.
Expand All @@ -95,6 +126,7 @@ class MainActivity : ComponentActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
consumedExternalRoute = savedInstanceState?.getString(STATE_CONSUMED_EXTERNAL_ROUTE)
enableEdgeToEdge()
maybeRequestNotificationPermission()
maybeRequestLegacyPublicDownloadPermission()
Expand All @@ -112,10 +144,11 @@ class MainActivity : ComponentActivity() {
// its target after auth instead of being silently dropped.
// The pending route is only consumed once the main graph is
// showing, so pre-auth starts just hold it.
(notificationRouteOrNull(intent) ?: contentDeepLinkRouteOrNull(intent?.dataString))
?.let { route ->
pendingExternalRouteRequests.value = externalRouteRequestFactory.create(route)
}
// Skip an Intent whose route was already delivered: it is only
// still here because the Activity retains it.
if (intent?.getBooleanExtra(EXTRA_EXTERNAL_ROUTE_CONSUMED, false) != true) {
queueExternalRouteFrom(intent)
}
launchAuthenticatedStartupWarmup(route)
}

Expand Down Expand Up @@ -151,7 +184,25 @@ class MainActivity : ComponentActivity() {
AppNavigation(
startDestination = resolvedRoute,
pendingExternalRoute = pendingExternalRoute,
onRequeueExternalRoute = { route ->
// A fresh request: clear the consumed marker so
// this re-delivery is not mistaken for the
// already-followed original.
consumedExternalRoute = null
intent?.removeExtra(EXTRA_EXTERNAL_ROUTE_CONSUMED)
pendingExternalRouteRequests.value =
externalRouteRequestFactory.create(route)
},
onExternalRouteConsumed = { consumedRequest ->
// Record the delivery in two places. The Intent
// extra covers in-process Activity recreation,
// which re-parses the retained Intent in
// onCreate and would otherwise yank the user
// back to a link they already followed. It is
// process-local, so the saved-state route below
// is what covers process death.
intent?.putExtra(EXTRA_EXTERNAL_ROUTE_CONSUMED, true)
consumedExternalRoute = consumedRequest.route
pendingExternalRouteRequests.update { pendingRequest ->
clearConsumedExternalRouteRequest(
pendingRequest = pendingRequest,
Expand All @@ -175,14 +226,19 @@ class MainActivity : ComponentActivity() {
lifecycleScope.launch(Dispatchers.IO) { refresher.refreshIfStale() }
}

override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
consumedExternalRoute?.let { outState.putString(STATE_CONSUMED_EXTERNAL_ROUTE, it) }
}

override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// A genuinely new Intent has not been consumed, whatever the old one
// carried.
intent.removeExtra(EXTRA_EXTERNAL_ROUTE_CONSUMED)
consumedExternalRoute = null
setIntent(intent)
val route = deviceLoginPairRouteOrNull(intent.dataString)
?: inviteClaimRouteOrNull(intent.dataString)
?: notificationRouteOrNull(intent)
?: contentDeepLinkRouteOrNull(intent.dataString)
route?.let { pendingExternalRouteRequests.value = externalRouteRequestFactory.create(it) }
lifecycleScope.launch { queueExternalRouteFrom(intent) }
Comment on lines 234 to +241

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

A second Intent during the suspension can restore an older route.

queueExternalRouteFrom suspends at currentIdentityScope(). If a second Intent arrives while the first coroutine is suspended, the second coroutine can finish first. The first one then resumes and overwrites pendingExternalRouteRequests with the older route. The user lands on the previous link instead of the one just opened.

Bind the write to the Intent that is still current.

🐛 Proposed fix: drop a result whose Intent is no longer current
-    private suspend fun queueExternalRouteFrom(intent: Intent?) {
+    private suspend fun queueExternalRouteFrom(intent: Intent?) {
         if (intent?.getBooleanExtra(EXTRA_EXTERNAL_ROUTE_CONSUMED, false) == true) return
+        // A newer Intent may have arrived while currentIdentityScope() was
+        // suspended; its coroutine may already have published. Do not clobber
+        // it with this older route.
+        if (intent !== this.intent) return
         pendingExternalRouteRequests.value =
             externalRouteRequestFactory.create(route = route, scope = scope)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@androidApp/src/androidMain/kotlin/org/siloserver/silo/android/MainActivity.kt`
around lines 234 - 241, Update queueExternalRouteFrom so its
pendingExternalRouteRequests write occurs only when the processed Intent is
still the current activity Intent after currentIdentityScope() resumes. Ignore
stale coroutine results when a newer Intent has replaced it, preserving the
latest route in onNewIntent.

}

/**
Expand Down Expand Up @@ -228,6 +284,95 @@ class MainActivity : ComponentActivity() {
requestLegacyPublicDownloadPermission.launch(LEGACY_PUBLIC_DOWNLOAD_PERMISSION)
}


/**
* Parses an Intent into a pending external route, tagged with the identity
* it is only meaningful under.
*
* Everything that can wait through authentication has to declare its scope,
* because "wait" can mean days for a notification PendingIntent and several
* profile switches:
* - a pairing link names its issuing SERVER ORIGIN;
* - a notification was generated for one profile's inbox on one server, so
* it carries the identity stamped on it at post time;
* - a content link (`silo://item`, `silo://play`) carries no identity of
* its own, but its ids are server-local — so it is pinned to whoever is
* signed in when the link arrives. Arriving signed-out pins nothing,
* which is what lets a link opened before login still work after it.
*/
private suspend fun queueExternalRouteFrom(intent: Intent?) {
if (intent?.getBooleanExtra(EXTRA_EXTERNAL_ROUTE_CONSUMED, false) == true) return

// NOT gated on the active server. The route carries its issuing origin
// and the pairing destination refuses — and explains — a mismatch, with
// a switch action. Dropping it here was silent: the user scanned a code
// and nothing happened. A link whose origin cannot be read does not
// parse into a route at all.
val deviceRoute = deviceLoginPairRouteOrNull(intent?.dataString)
// Rejected outright unless it says whose it is — see
// [notificationExternalRouteOrNull].
val notification = notificationExternalRouteOrNull(
route = notificationRouteOrNull(intent),
serverId = intent?.getStringExtra(PushNotificationPresenter.EXTRA_SERVER_ID),
profileId = intent?.getStringExtra(PushNotificationPresenter.EXTRA_PROFILE_ID),
)
val notificationRoute = notification?.first
val contentRoute = contentDeepLinkRouteOrNull(intent?.dataString)
val inviteRoute = inviteClaimRouteOrNull(intent?.dataString)

val route = notificationRoute ?: contentRoute ?: deviceRoute ?: inviteRoute ?: return
if (route == consumedExternalRoute) return

val scope = when {
// Unscoped for DELIVERY: the pairing screen owns the server check,
// so the request must actually arrive for it to be explained.
route === deviceRoute -> ExternalRouteScope.Unscoped
// Non-null by construction: `route` is only this when `notification`
// produced it, and that requires a complete identity.
route === notificationRoute -> checkNotNull(notification).second
route === contentRoute -> currentIdentityScope()
// An invite claim carries its own target server and is designed to
// work before authentication, so it must NOT be pinned to the
// current identity.
else -> ExternalRouteScope.Unscoped
}

pendingExternalRouteRequests.value =
externalRouteRequestFactory.create(route = route, scope = scope)
}

/**
* One cohesive read of the live identity.
*
* Reading the server and profile through separate getters could tear across
* a switch — the cached server id from before it, the profile id from after
* — producing a hybrid identity that belongs to nobody, which then either
* consumes a valid one-shot route or weakens it with a null wildcard.
*/
private suspend fun currentIdentityScope(): ExternalRouteScope {
val scope = get<TokenManager>(TokenManager::class.java).snapshotCurrentScope()
if (scope != null) {
return ExternalRouteScope.Identity(
serverId = scope.serverId,
profileId = scope.profileId,
identityGeneration = scope.identityGeneration,
)
}
// A null snapshot means "no active server" — nothing to pin to, and the
// link must survive setup and login. But it ALSO means "snapshotting
// failed" or "this manager does not model scopes", and turning those
// into a wildcard would quietly unpin a link that should have been
// pinned. Only an actually-absent server is allowed to be unpinned.
val registry = get<ServerRegistry>(ServerRegistry::class.java)
return if (registry.activeServerId.value == null) {
ExternalRouteScope.Identity(serverId = null, profileId = null)
} else {
// An active server we cannot describe: pin to something nothing
// matches rather than to everything.
ExternalRouteScope.Identity(serverId = UNRESOLVED_IDENTITY, profileId = null)
}
}

private fun notificationRouteOrNull(intent: Intent?): String? =
notificationNavigationRouteOrNull(
intent?.getStringExtra(PushNotificationPresenter.EXTRA_NAV_ROUTE),
Expand All @@ -250,11 +395,18 @@ class MainActivity : ComponentActivity() {
* - All set → `Home`
*/
private suspend fun resolveStartDestination(): String {
deviceLoginPairRouteOrNull(intent?.dataString)?.let { return it }

val registry = get<ServerRegistry>(ServerRegistry::class.java)
val tokenManager = get<TokenManager>(TokenManager::class.java)

// NOTE: a device link is deliberately NOT returned as the start
// destination. It used to be, which put Pair Device at the root of a
// signed-out app: its "Sign In" pushed Login, and the successful login
// then cleared the whole stack with popUpTo(0), losing the pairing
// request entirely. It is queued as a pending external route instead,
// so the normal server/token/profile gates run first and the pairing
// screen arrives on top of an authenticated stack — which also means
// its Back/Done has somewhere real to return to.

val activeEntry = registry.activeEntry.value
?: return Route.ServerSetup.route

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ val androidModule = module {
PushNotificationPresenter(
context = androidContext(),
notificationsRepository = get(),
tokenManager = get(),
)
}
single { PushMessageHandler(presenter = get()) }
Expand Down
Loading