/* * 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(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:///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) } } }