feat(android): native Android client — full app parity build (A1–A36 + S1)
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
Mirror the iOS P0+P1 client as a Gradle multi-module app. Pure-JVM (Kover ≥80% gated): :wire-protocol (frozen contract + HostEndpoint CSWSH origin + byte-exact codec), :session-core (SessionEngine + reconnect/ping/gate/digest reducers), :api-client, :client-tls (pure PKCS12/keymanager), :transport-okhttp (OkHttp WS+REST). Framework: :app (Compose M3 Adaptive, Hilt, FCM, 11 screens + NavGraph), :terminal-view (Termux terminal-emulator/-view via JitPack, Apache-2.0 — the renderer seam proven headless), :host-registry (DataStore), :client-tls-android (AndroidKeyStore + Tink). Highlights: single-key-home mTLS (non-exportable AndroidKeyStore key + re-reading X509KeyManager + connectionPool.evictAll on rotation, ping-pong single-commit so a failed rotation never clobbers the prior identity); FCM Allow/Deny trust split (Deny=BroadcastReceiver, Allow=trampoline Activity hosting BiometricPrompt); per-consumer-Channel EventBus (R10); config-surviving RetainedSessionHolder; byte-exact KeyByteMap; §5.4 pairing warning tiers. Verified: ~484 JVM tests + Kover ≥80% on the pure modules + :app assembles to an APK; all framework modules assemble. Device behaviors (rendering/IME/FCM/biometric/camera, E2E A34/A35, S2 real-handset FCM spike) deferred to android/DEVICE_QA_CHECKLIST.md per plan §7 (no emulator/Firebase here). Built via multi-agent orchestration (TDD builders → adversarial cross-review → fix → re-verify → independent gate); progress in android/PROGRESS_ANDROID.md.
This commit is contained in:
176
android/app/src/main/java/wang/yaojia/webterm/MainActivity.kt
Normal file
176
android/app/src/main/java/wang/yaojia/webterm/MainActivity.kt
Normal file
@@ -0,0 +1,176 @@
|
||||
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 `<intent-filter>`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<String?>(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<String?>,
|
||||
onHostPaired: (Host) -> Unit,
|
||||
) {
|
||||
val startRoute by produceState<String?>(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<String?>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user