Files
Android-app/app/src/main/java/com/runicgateway/app/MainActivity.kt
wtclaude 987ddb54f8
All checks were successful
PR Checks / android-build (pull_request) Successful in 20m53s
feat(sso): App Links autoVerify callback + paired-host trust check
Add the app side of Android App Links (M9 follow-up, docs/android/APP_LINKS.md),
layered on the M9 Part 2 native SSO callback:

- Build-time `appLinkHost` Gradle property -> BuildConfig.APP_LINK_HOST +
  manifestPlaceholders["appLinkHost"]. autoVerify needs a literal host, so the
  generic multi-tenant build leaves it empty (placeholder falls back to the
  reserved runic-gateway.invalid sentinel, making the filter inert); a
  white-label build bakes one host with -PappLinkHost=play.myshard.com.
- Manifest: an autoVerify https `/mobile/callback` intent-filter beside the
  unchanged custom-scheme one (the permanent fallback).
- SsoAuthManager: request the https App Link redirect_uri iff the baked host
  matches the paired shard host; matchesAppLinkCallback() enforces a paired-host
  trust check (host must equal the currently-paired base URL host) as
  defense-in-depth. Both matchers feed the same complete()/exchange path.
- MainActivity routes custom-scheme and App Link callbacks identically.

+5 JVM tests (SsoAuthManagerTest -> 14). Built green (JDK 21,
-Pksp.incremental=false); white-label host substitution verified in the merged
manifest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-20 18:37:32 -05:00

129 lines
5.8 KiB
Kotlin

/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app
import android.content.Intent
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.lifecycle.lifecycleScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import com.runicgateway.app.core.auth.sso.SsoAuthManager
import com.runicgateway.app.core.push.PushNotifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.ui.AppViewModel
import com.runicgateway.app.ui.AppViewModel.AppState
import com.runicgateway.app.ui.LocalAssetResolver
import com.runicgateway.app.ui.RunicApp
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.connect.ConnectScreen
import com.runicgateway.app.ui.theme.RunicGatewayTheme
import com.runicgateway.app.ui.theme.parseBrandColor
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Single-activity host (PLAN.md §2). Gates on [AppViewModel]: the first-run
* connect screen until a shard site is configured (§3), then the main app.
* The Material theme is seeded from the per-shard brand accent, and asset-path
* resolution is provided to the whole tree.
*/
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
// Native SSO bridge — handles the runicgateway://auth/callback deep link (M9,
// §4.2). Field-injected because the callback can arrive independent of any
// ViewModel; a successful exchange flips the SessionManager the whole app
// observes, and the login screen consumes SsoAuthManager.outcome.
@Inject
lateinit var ssoAuthManager: SsoAuthManager
// The stream a tapped push notification wants to open (§11, M7 Part 2 item 7).
// Set from the launching intent and from onNewIntent (the activity is singleTop),
// consumed once by RunicApp which navigates to the stream's screen.
private var pendingStream by mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
pendingStream = intent?.getStringExtra(PushNotifier.EXTRA_STREAM)
handleSsoCallback(intent)
// Dark-only app (M5): force light system-bar icons over the transparent bars so
// they stay legible on the deep blue-black surfaces regardless of system theme.
val barStyle = SystemBarStyle.dark(Color.TRANSPARENT)
enableEdgeToEdge(statusBarStyle = barStyle, navigationBarStyle = barStyle)
setContent {
val appViewModel: AppViewModel = hiltViewModel()
val state by appViewModel.state.collectAsStateWithLifecycle()
val accent = (state as? AppState.Ready)?.brand?.let { parseBrandColor(it.accent) }
RunicGatewayTheme(accent = accent) {
CompositionLocalProvider(LocalAssetResolver provides appViewModel::resolveAsset) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
when (val s = state) {
AppState.Loading -> LoadingView()
AppState.NeedsConnection ->
ConnectScreen(onConnected = appViewModel::onConnected)
is AppState.Ready ->
RunicApp(
brand = s.brand,
onChangeServer = appViewModel::changeServer,
deepLinkStream = pendingStream,
onDeepLinkConsumed = { pendingStream = null },
)
}
}
}
}
}
}
/**
* A notification tap or an SSO callback arriving while the activity is already
* running (singleTop) — the common case, since the Custom Tab overlays the live
* app during sign-in.
*/
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
intent.getStringExtra(PushNotifier.EXTRA_STREAM)?.let { pendingStream = it }
handleSsoCallback(intent)
}
/**
* Route an SSO callback VIEW intent into the bridge (M9, §4.2): either the
* custom-scheme `runicgateway://auth/callback` (always) or the verified https
* App Link `https://<paired-host>/mobile/callback` (opt-in hardening —
* docs/android/APP_LINKS.md). Both feed the *same* exchange; the result surfaces
* on `SsoAuthManager.outcome` (success signs the session in; failure shows on the
* login screen). Non-callback intents are ignored.
*/
private fun handleSsoCallback(intent: Intent?) {
val data: Uri = intent?.takeIf { it.action == Intent.ACTION_VIEW }?.data ?: return
val isCallback = ssoAuthManager.matchesCallback(data.scheme, data.host, data.path) ||
ssoAuthManager.matchesAppLinkCallback(data.scheme, data.host, data.path)
if (!isCallback) return
val state = data.getQueryParameter("state")
val code = data.getQueryParameter("code")
val error = data.getQueryParameter("error")
lifecycleScope.launch { ssoAuthManager.complete(state = state, code = code, error = error) }
}
}