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

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:
Yaojia Wang
2026-07-10 16:41:21 +02:00
parent 542fde9580
commit e254918b1c
159 changed files with 20114 additions and 73 deletions

View 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)
}
}