All checks were successful
PR Checks / android-build (pull_request) Successful in 20m53s
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
129 lines
5.8 KiB
Kotlin
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) }
|
|
}
|
|
}
|