package wang.yaojia.webterm import android.Manifest import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.core.content.ContextCompat import androidx.fragment.app.FragmentActivity import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.navigation.NavHostController import androidx.navigation.compose.rememberNavController import dagger.hilt.android.AndroidEntryPoint import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import wang.yaojia.webterm.designsystem.WebTermTheme import wang.yaojia.webterm.hostregistry.Host import wang.yaojia.webterm.nav.DeepLinkRouter import wang.yaojia.webterm.nav.NavRoutes import wang.yaojia.webterm.nav.WebTermNavHost import wang.yaojia.webterm.push.PushCoordinator import wang.yaojia.webterm.wiring.AppEnvironment import javax.inject.Inject /** * The single launcher Activity and composition root. A `@AndroidEntryPoint` [FragmentActivity] * (FragmentActivity so the biometric/allow trampoline hierarchy is consistent) that: * * - **warms the mTLS/OkHttp stack off `Main`** ([AppEnvironment.warmUp]) before any terminal bind; * - **registers this device's FCM token with every paired host on app start** ([PushCoordinator]); * - **hands the launch/new intents to [DeepLinkRouter]** (the ONE whitelist parser) and navigates only a * validated [NavRoutes.forDeepLink] route — the manifest ``s deliver, never validate; * - renders [WebTermNavHost] with the cold-start start destination ([AppEnvironment.coldStartPolicy]). */ @AndroidEntryPoint public class MainActivity : FragmentActivity() { @Inject public lateinit var appEnvironment: AppEnvironment @Inject public lateinit var pushCoordinator: PushCoordinator /** The latest deep-link URI to route (null = nothing pending). Fed by onCreate + onNewIntent. */ private val pendingDeepLink = MutableStateFlow(null) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // Build the shared OkHttp/mTLS stack OFF-Main before the first bind (warmUp hops to IO itself); // a real failure is swallowed (the terminal screen surfaces its own retry, FIX 5) but cancellation // is rethrown so a destroyed Activity tears the warm-up down cleanly. lifecycleScope.launch { try { appEnvironment.warmUp() } catch (cancel: CancellationException) { throw cancel } catch (_: Throwable) { // best-effort pre-warm; TerminalScreen re-runs warmUp with an actionable retry. } } // App start: register the current FCM token with every paired host (best-effort self-heal). pushCoordinator.registerAllHosts() handleDeepLinkIntent(intent) setContent { WebTermTheme { Surface(modifier = Modifier.fillMaxSize()) { val navController = rememberNavController() NotificationPermissionGate() ColdStartHost( env = appEnvironment, navController = navController, pendingDeepLink = pendingDeepLink, onHostPaired = { host -> pushCoordinator.registerHost(host) }, ) } } } } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) handleDeepLinkIntent(intent) } /** Capture a VIEW deep link's data URI; a launcher/push-body intent has none → nothing to route. */ private fun handleDeepLinkIntent(intent: Intent?) { pendingDeepLink.value = intent?.data?.toString() } } /** * Resolve the cold-start destination ([AppEnvironment.coldStartPolicy]) then render the graph. Shows a * spinner until the (suspend) host-presence read completes. */ @Composable private fun ColdStartHost( env: AppEnvironment, navController: NavHostController, pendingDeepLink: MutableStateFlow, onHostPaired: (Host) -> Unit, ) { val startRoute by produceState(initialValue = null, key1 = env) { value = NavRoutes.startRouteFor(env.coldStartPolicy.initialRoute()) } val route = startRoute if (route == null) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } } else { WebTermNavHost( env = env, startRoute = route, navController = navController, onHostPaired = onHostPaired, ) // Route pending deep links ONLY here — inside the `route != null` branch, so `WebTermNavHost` // (which calls navController.setGraph) has composed and the graph is set before the effect body // runs. On a cold launch the VIEW intent is captured before setContent, but its navigation is // deferred to this point instead of racing an unset graph (which silently dropped the link). DeepLinkEffect(pending = pendingDeepLink, navController = navController) } } /** * Route a pending deep-link URI through [DeepLinkRouter] (the ONE whitelist parser) and navigate only a * validated route; invalid/ambiguous links are ignored (never partially applied). Clears the pending * value once handled. */ @Composable private fun DeepLinkEffect( pending: MutableStateFlow, navController: NavHostController, ) { val uri by pending.collectAsStateWithLifecycle() LaunchedEffect(uri) { val target = uri ?: return@LaunchedEffect NavRoutes.forDeepLink(DeepLinkRouter.route(target))?.let { route -> runCatching { navController.navigate(route) } } pending.value = null } } /** Request POST_NOTIFICATIONS once on first launch (API 33+); a denial only stops push from being SHOWN. */ @Composable private fun NotificationPermissionGate() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return val context = LocalContext.current val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { } LaunchedEffect(Unit) { val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED if (!granted) launcher.launch(Manifest.permission.POST_NOTIFICATIONS) } }