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

@@ -1,27 +1,28 @@
// ─────────────────────────────────────────────────────────────────────────────
// :app — the Android application (Compose UI, ViewModels, Hilt DI, FCM push,
// DeepLinkRouter, DesignSystem, EventBus). Mirrors iOS App/WebTerm.
// :app — the Android application (Compose Material 3 + Adaptive design system,
// Hilt DI skeleton, launcher MainActivity). Mirrors iOS App/WebTerm.
//
// SCAFFOLD STUB ONLY. This module is COMMENTED OUT in settings.gradle.kts because
// this build environment has NO Android SDK. The block below is the intended shape;
// it applies the Android Gradle Plugin, which cannot resolve without an SDK.
// A13 establishes the Android UI-stack version matrix for every later Android task
// (the app-stack baseline, analogous to how A1 established the JVM catalog). All
// UI versions are resolved in gradle/libs.versions.toml.
//
// TODO(android-sdk): uncomment the include in settings.gradle.kts AND this block,
// add AGP + google() to pluginManagement, and provide local.properties -> sdk.dir.
// Plugins (AGP 9 has BUILT-IN Kotlin → never apply org.jetbrains.kotlin.android):
// com.android.application · kotlin.plugin.compose · ksp · dagger.hilt.android
// ─────────────────────────────────────────────────────────────────────────────
/*
plugins {
id("com.android.application")
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
id("com.google.dagger.hilt.android")
id("com.google.gms.google-services")
alias(libs.plugins.android.application)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
}
android {
namespace = "wang.yaojia.webterm"
compileSdk = 35
// compileSdk 36 (Android 16): the contemporaneous androidx/Compose line for
// Kotlin 2.3.21 requires compiling against SDK 36+ (BOM 2025.11 → ui 1.9.5).
// targetSdk stays 35 per plan §2 (compileSdk ≥ targetSdk is the normal rule).
compileSdk = 36
defaultConfig {
applicationId = "wang.yaojia.webterm"
@@ -31,18 +32,91 @@ android {
versionName = "0.1.0"
}
buildFeatures { compose = true }
buildFeatures {
compose = true
}
buildTypes {
debug {
// No applicationId suffix — keep it stable for deep-link testing (A32).
}
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
kotlin { jvmToolchain(17) }
kotlin {
jvmToolchain(17)
}
dependencies {
// Modules A15 wires into the DI graph.
implementation(project(":wire-protocol"))
implementation(project(":session-core"))
implementation(project(":api-client"))
implementation(project(":transport-okhttp"))
implementation(project(":client-tls"))
// A15: bridge the mTLS device identity (:client-tls-android) onto the shared client, and
// provide the DataStore-backed stores (:host-registry). :client-tls-android exposes
// :client-tls via `api`, but the explicit dep above is kept for clarity.
implementation(project(":client-tls-android"))
implementation(project(":host-registry"))
// Terminal render (A16): RemoteTerminalSession/RemoteTerminalView for the live terminal (A21) and
// the raw Termux TerminalEmulator for A18's off-screen thumbnail rasterisation (§6.7). :terminal-view
// scopes Termux as `implementation`, so :app names the emulator directly for the off-screen path.
implementation(project(":terminal-view"))
implementation(libs.termux.terminal.emulator)
// QR pairing (A19): CameraX + on-device ML Kit barcode scanning.
implementation(libs.bundles.camerax)
implementation(libs.mlkit.barcode.scanning)
// Push (A30) + nav/deep-links (A32).
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.messaging)
implementation(libs.androidx.biometric)
implementation(libs.androidx.navigation.compose)
// OkHttp is `implementation` (not `api`) in :transport-okhttp, so :app names OkHttpClient /
// ConnectionPool directly in NetworkModule/TlsModule.
implementation(libs.okhttp)
// Coroutines are used directly by the wiring (EventBus fan-out, engine confinement scope).
implementation(libs.kotlinx.coroutines.core)
// Preferences DataStore is named in StorageModule's @Provides return types.
implementation(libs.androidx.datastore.preferences)
// AndroidX foundation
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
// Compose (the BOM governs every version below)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.bundles.compose)
debugImplementation(libs.androidx.compose.ui.tooling)
// Hilt (Dagger) — KSP annotation processing
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.androidx.hilt.navigation.compose)
// JVM unit tests (no device) — the frozen design-token spec, EventBus fan-out, and the
// RetainedSessionHolder lifecycle invariant driven by a fake transport under virtual time.
testImplementation(libs.bundles.unit.test)
testImplementation(project(":test-support")) // FakeTermTransport for the holder lifecycle test (FIX 6)
testRuntimeOnly(libs.junit.platform.launcher)
}
// Local (JVM) unit tests run on the JUnit 5 platform, matching the pure modules.
tasks.withType<Test>().configureEach {
useJUnitPlatform()
}
*/

4
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,4 @@
# WebTerm :app R8/ProGuard rules.
# Release is not minified yet (isMinifyEnabled = false); this file exists so the
# release buildType's proguardFiles(...) reference resolves. Real keep-rules for
# kotlinx.serialization / Hilt / OkHttp land when minification is enabled later.

View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Talk to the WebTerm server over WS/HTTP. No NEARBY_WIFI_DEVICES: Android
has no local-network permission prompt; plain WS to a LAN IP needs none
(plan §8 / R12). -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Push (A30/A31) — requested with rationale at runtime on API 33+. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- QR pairing (A19) — CameraX preview; requested with rationale at runtime, denial
degrades to manual URL entry. android:required=false so non-camera devices install. -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
<application
android:name=".WebTermApp"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.WebTerm"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="false">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.WebTerm">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- A32 deep links → DeepLinkRouter (v4-UUID whitelist; the manifest never validates). -->
<!-- Custom scheme: webterminal://open?host=<uuid>&join=<uuid> -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="webterminal" android:host="open" />
</intent-filter>
<!-- Verified App Link: https://terminal.yaojia.wang/open?host=&join= .
autoVerify needs assetlinks.json (release SHA-256) served at
/.well-known/assetlinks.json — a deploy artifact (§8), not built here. -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="terminal.yaojia.wang" android:pathPrefix="/open" />
</intent-filter>
</activity>
<!-- Push (A30) — FCM data-only entry point (server sends no notification block). -->
<service
android:name=".push.FcmService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<!-- Deny action: auth-free, no-UI, expedited POST (goAsync). -->
<receiver
android:name=".push.DenyBroadcastReceiver"
android:exported="false" />
<!-- Allow action: translucent, excluded-from-recents trampoline hosting BiometricPrompt
(a receiver/service cannot present it — R1). -->
<activity
android:name=".push.AllowTrampolineActivity"
android:theme="@style/Theme.WebTerm.Translucent"
android:excludeFromRecents="true"
android:taskAffinity=""
android:exported="false" />
</application>
</manifest>

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

View File

@@ -0,0 +1,12 @@
package wang.yaojia.webterm
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
/**
* Application entry point. `@HiltAndroidApp` triggers Hilt's code generation and
* creates the app-level `SingletonComponent` (see `di/AppModule`). The real
* composition root / wiring is A15 — this stays intentionally empty.
*/
@HiltAndroidApp
public class WebTermApp : Application()

View File

@@ -0,0 +1,92 @@
package wang.yaojia.webterm.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.WebTermCard
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.session.AwayDigest
import wang.yaojia.webterm.wire.TimelineEvent
/**
* # AwayDigestView (A22) — the "what happened while I was away" reattach summary.
*
* The Android analogue of the iOS away-digest banner (plan §1): shown ABOVE the terminal on the
* first reconnect after a gap, summarising the [AwayDigest] reduced over the events since the user
* left — tool-run and waiting counts plus done/stuck flags. Its **展开** ("expand") affordance opens
* the A28 activity-timeline sheet via [onExpand] (this component only exposes the seam; A28 wires it).
*
* An [empty][AwayDigest.isEmpty] digest renders **nothing** (all-zero suppressed) — the caller
* ([GateViewModel] holds `null` for an empty digest) normally never passes one, but the guard makes
* the component safe in isolation. Recent-event labels are server-derived text rendered as **inert
* [Text]** (no linkify/markdown, §8).
*/
@Composable
public fun AwayDigestView(
digest: AwayDigest,
onExpand: () -> Unit,
modifier: Modifier = Modifier,
) {
if (digest.isEmpty) return // all-zero suppressed; render nothing.
WebTermCard(modifier = modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
Text(
text = summaryLine(digest),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
digest.recent.lastOrNull()?.let { latest ->
// INERT: server-derived phrase rendered verbatim, single line (§8).
Text(
text = latest.label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(onClick = onExpand) { Text(text = "展开") }
}
}
}
}
/** The one-line count summary, e.g. "离开期间3 次工具调用 · 1 次等待 · 已完成". Pure presentation. */
internal fun summaryLine(digest: AwayDigest): String {
val parts = mutableListOf<String>()
if (digest.toolRuns > 0) parts += "${digest.toolRuns} 次工具调用"
if (digest.waitingCount > 0) parts += "${digest.waitingCount} 次等待"
if (digest.sawDone) parts += "已完成"
if (digest.sawStuck) parts += "疑似卡住"
val body = if (parts.isEmpty()) "有活动" else parts.joinToString(" · ")
return "离开期间:$body"
}
// ── Preview ─────────────────────────────────────────────────────────────────────
@Preview(name = "AwayDigestView")
@Composable
private fun AwayDigestViewPreview() {
WebTermTheme {
AwayDigestView(
digest = AwayDigest(
toolRuns = 3,
waitingCount = 1,
sawDone = true,
sawStuck = false,
recent = listOf(TimelineEvent(at = 0L, eventClass = "tool", toolName = "Bash", label = "ran Bash")),
),
onExpand = {},
)
}
}

View File

@@ -0,0 +1,145 @@
package wang.yaojia.webterm.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import wang.yaojia.webterm.designsystem.Radius
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.designsystem.WebTermType
/**
* # ContinueLastBanner (A29) — the "继续上次会话" re-entry affordance.
*
* The visible half of the continue-last-session cold-start UX (plan §1, §5 A29). When a host still has a
* live last session ([LastSessionStore][wang.yaojia.webterm.hostregistry.LastSessionStore], kept current
* by [SessionActivityBridge][wang.yaojia.webterm.wiring.SessionActivityBridge]), this banner is layered
* on top of the sessions landing; tapping it re-opens THAT session (full scrollback replay) instead of
* spawning a new one. It renders in two layouts:
* - [ContinueLastVariant.Stack] — a full-width strip pinned above a stacked (phone) session list;
* - [ContinueLastVariant.Sidebar] — a compact rounded card for the tablet nav sidebar.
*
* ### Shown IFF a last session exists
* The pure [continueLastModel] maps the persisted last-session id to a [ContinueLastModel] (or `null`),
* and the composable renders NOTHING for a `null` model — so "shown iff a last session exists" is the
* single JVM-tested rule, no device needed. Layout/gesture polish is device-QA (plan §7).
*
* ### Inert rendering (plan §8)
* The session id is a SERVER-issued (untrusted) string; it is shown as a plain, inert [Text] — no
* Markdown / autolink / `AnnotatedString` linkify.
*/
/** Which layout the banner renders in — a top strip (phone) or a sidebar card (tablet). */
public enum class ContinueLastVariant { Stack, Sidebar }
/** The banner's derived state: the last session that can be re-opened. */
public data class ContinueLastModel(val sessionId: String)
/**
* PURE derivation: a non-blank persisted [lastSessionId] yields a [ContinueLastModel] (banner shown);
* `null`/blank yields `null` (banner hidden). Blank is treated as absent so a corrupt persisted value
* never renders an un-openable banner.
*/
public fun continueLastModel(lastSessionId: String?): ContinueLastModel? =
lastSessionId?.takeIf { it.isNotBlank() }?.let { ContinueLastModel(it) }
/**
* The banner. Renders NOTHING when [model] is `null` (no last session). Tapping the whole surface fires
* [onContinue] with the session id to re-open. [variant] picks the stack vs sidebar layout.
*/
@Composable
public fun ContinueLastBanner(
model: ContinueLastModel?,
onContinue: (String) -> Unit,
modifier: Modifier = Modifier,
variant: ContinueLastVariant = ContinueLastVariant.Stack,
) {
if (model == null) return
val shape: Shape = when (variant) {
ContinueLastVariant.Stack -> RectangleShape
ContinueLastVariant.Sidebar -> RoundedCornerShape(Radius.md12)
}
val widthModifier =
if (variant == ContinueLastVariant.Stack) Modifier.fillMaxWidth() else Modifier
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
shape = shape,
modifier = modifier
.then(widthModifier)
.clickable { onContinue(model.sessionId) },
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
) {
Text(text = "", style = MaterialTheme.typography.titleMedium)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(Spacing.xs2),
) {
Text(text = "继续上次会话", style = MaterialTheme.typography.bodyMedium)
// Untrusted server-issued id — inert Text, no autolink/markdown (§8).
Text(
text = shortSessionId(model.sessionId),
style = WebTermType.metaMono,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(text = "", style = MaterialTheme.typography.titleMedium)
}
}
}
/** A short, inert label for the (untrusted) session id — the first id segment, capped so it never wraps. */
private fun shortSessionId(sessionId: String): String =
sessionId.substringBefore('-').take(SHORT_ID_MAX)
private const val SHORT_ID_MAX: Int = 12
// ── Preview ───────────────────────────────────────────────────────────────────
@Preview(name = "ContinueLastBanner — stack")
@Composable
private fun ContinueLastBannerStackPreview() {
WebTermTheme {
ContinueLastBanner(
model = ContinueLastModel("a1b2c3d4-5678-4abc-9def-000000000000"),
onContinue = {},
variant = ContinueLastVariant.Stack,
)
}
}
@Preview(name = "ContinueLastBanner — sidebar", widthDp = 220)
@Composable
private fun ContinueLastBannerSidebarPreview() {
WebTermTheme {
ContinueLastBanner(
model = ContinueLastModel("a1b2c3d4-5678-4abc-9def-000000000000"),
onContinue = {},
variant = ContinueLastVariant.Sidebar,
modifier = Modifier.padding(Spacing.md12),
)
}
}

View File

@@ -0,0 +1,89 @@
package wang.yaojia.webterm.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import wang.yaojia.webterm.designsystem.DisplayStatus
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.StatusBadge
import wang.yaojia.webterm.designsystem.WebTermCard
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.session.GateState
import wang.yaojia.webterm.viewmodels.GateDecision
import wang.yaojia.webterm.wire.GateKind
/**
* # GateBanner (A22) — the tool-gate two-button approve/reject card.
*
* The Android analogue of the iOS tool-gate card (public/tabs.ts:334-350): a `WebTermCard` holding
* a "needs me" status badge, the server-supplied [detail][GateState.detail] rendered as **inert
* [Text]** (untrusted OSC/tool text — no Markdown / autolink, plan §8), and two actions: **批准**
* (approve → `Approve(mode=null)`) and **拒绝** (reject).
*
* ### Epoch capture (the stale-guard seam)
* Each button hands back the [epoch][GateState.epoch] of the gate IT rendered, so a tap is bound to
* the gate the user actually saw. The screen (A21) wires `onDecide = { d, e -> vm.decide(d, e) }`;
* [GateViewModel.decide] then drops the tap if that epoch is no longer the live gate.
*
* This card is for [GateKind.TOOL] gates only; plan gates use [PlanGateSheet] (three-way).
* Layout / colors are device-QA; the wired decision + epoch capture is the load-bearing contract.
*
* @param onDecide invoked with the tapped [GateDecision] and this gate's epoch.
*/
@Composable
public fun GateBanner(
gate: GateState,
onDecide: (GateDecision, Int) -> Unit,
modifier: Modifier = Modifier,
) {
WebTermCard(modifier = modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
StatusBadge(status = DisplayStatus.PendingApproval, showsLabel = true)
gate.detail?.let { detail ->
// INERT: untrusted server string rendered verbatim, single-line, no linkify (§8).
Text(
text = detail,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
OutlinedButton(onClick = { onDecide(GateDecision.REJECT, gate.epoch) }) {
Text(text = "拒绝")
}
Button(onClick = { onDecide(GateDecision.APPROVE, gate.epoch) }) {
Text(text = "批准")
}
}
}
}
}
// ── Preview ─────────────────────────────────────────────────────────────────────
@Preview(name = "GateBanner")
@Composable
private fun GateBannerPreview() {
WebTermTheme {
GateBanner(
gate = GateState(kind = GateKind.TOOL, detail = "Bash(rm -rf build/)", epoch = 3),
onDecide = { _, _ -> },
)
}
}

View File

@@ -0,0 +1,278 @@
package wang.yaojia.webterm.components
import android.content.res.Configuration
import android.view.KeyEvent
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import wang.yaojia.webterm.designsystem.LayoutTokens
import wang.yaojia.webterm.designsystem.Opacity
import wang.yaojia.webterm.designsystem.Radius
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.session.KeyByteMap
/**
* # KeyBar (A17) — the mobile IME-bypass touch key-bar + hardware-chord router.
*
* The Android port of `public/keybar.ts`: a horizontal strip of the Claude Code
* high-frequency keys a phone soft keyboard can't easily produce (Esc / Esc² /
* ⇧Tab / arrows / Enter / Ctrl-chords / Tab / `/`), most-used first.
*
* ### Byte source of truth (plan §6.3, A6)
* Every label→bytes lookup — buttons AND hardware chords — resolves through
* [KeyByteMap] (byte-for-byte matched to the web `KEY_MAP`). NO escape sequence is
* ever hand-written here; hand-writing `ESC[A` etc. is a review finding.
*
* ### IME bypass (plan §6.3 source #2, R8)
* A key-bar tap calls [emitKey] → `onSend(bytes)` DIRECTLY, mirroring the web
* `ws.send` bypass. It never routes through a `BasicTextField` / the emulator's
* `InputConnection`, so tapping a key never pops the soft keyboard. `onSend` is
* wired by A21 to `TerminalSessionController.sendInput`.
*
* ### DECCKM split (plan §6.3 source #3)
* Hardware keys are routed by [HardwareKeyRouter]: Esc / Ctrl-<letter> / ⇧Tab are
* mapped app-side via [KeyByteMap]; **arrows / Enter / (plain) Tab are LEFT to
* Termux's `KeyHandler`** (via `RemoteTerminalView.onKeyCommand` returning false)
* so `cursorKeysApplication` (DECCKM) still emits `ESC O A` — never `ESC [ A` —
* for vim/htop.
*
* Compose layout / IME-inset behaviour / real key events are DEVICE-QA (plan §7);
* the byte contract + the routing decision are the JVM-tested core.
*/
/** One key-bar button: fixed glyph + Chinese caption + the [KeyByteMap.Key] it emits. */
public data class KeyBarButton(
val label: String,
val caption: String,
val key: KeyByteMap.Key,
val title: String,
val isPrimary: Boolean = false,
)
/**
* Button layout — most-used Claude Code keys first, mirroring the web
* `KEYBAR_BUTTONS` (public/keybar.ts) order, glyphs and captions 1:1. The 🎤 voice
* button (web A2) is out of scope for A17.
*/
public val KEYBAR_LAYOUT: List<KeyBarButton> = listOf(
KeyBarButton("Esc", "中断", KeyByteMap.Key.ESC, "Esc — interrupt Claude / dismiss", isPrimary = true),
KeyBarButton("Esc²", "回溯", KeyByteMap.Key.ESC_ESC, "Esc Esc — rewind / clear draft"),
KeyBarButton("⇧Tab", "模式", KeyByteMap.Key.SHIFT_TAB, "Shift+Tab — cycle plan / auto-accept mode"),
KeyBarButton("", "上一个", KeyByteMap.Key.ARROW_UP, "Up — previous option / history"),
KeyBarButton("", "下一个", KeyByteMap.Key.ARROW_DOWN, "Down — next option / history"),
KeyBarButton("", "确认", KeyByteMap.Key.ENTER, "Enter — confirm"),
KeyBarButton("^C", "取消", KeyByteMap.Key.CTRL_C, "Ctrl+C — cancel / quit"),
KeyBarButton("^R", "搜历史", KeyByteMap.Key.CTRL_R, "Ctrl+R — reverse-search command history"),
KeyBarButton("^O", "详情", KeyByteMap.Key.CTRL_O, "Ctrl+O — expand transcript / tool detail"),
KeyBarButton("^L", "重绘", KeyByteMap.Key.CTRL_L, "Ctrl+L — redraw screen"),
KeyBarButton("^T", "任务", KeyByteMap.Key.CTRL_T, "Ctrl+T — toggle task list"),
KeyBarButton("^B", "后台", KeyByteMap.Key.CTRL_B, "Ctrl+B — background running task"),
KeyBarButton("^D", "退出", KeyByteMap.Key.CTRL_D, "Ctrl+D — exit session (EOF)"),
KeyBarButton("Tab", "补全", KeyByteMap.Key.TAB, "Tab — complete / toggle"),
KeyBarButton("", "左移", KeyByteMap.Key.ARROW_LEFT, "Left — move cursor left"),
KeyBarButton("", "右移", KeyByteMap.Key.ARROW_RIGHT, "Right — move cursor right"),
KeyBarButton("/", "命令", KeyByteMap.Key.SLASH, "Slash — command launcher"),
)
/**
* The single tap→wire funnel: resolve [key]'s bytes through [KeyByteMap] and hand
* them VERBATIM to [sink] (invariant #9 — no content filtering). This is exactly
* what a button's `onClick` invokes, and what the JVM byte-contract test drives.
*/
internal fun emitKey(key: KeyByteMap.Key, sink: (String) -> Unit) {
sink(KeyByteMap.bytes(key))
}
/** Web parity: the key-bar is a mobile affordance, hidden on wide screens (web hides it >768px). */
internal const val WIDE_SCREEN_MAX_DP: Int = 768
/**
* Whether the key-bar should render: shown on narrow (phone-width) screens only,
* AND hidden when a hardware keyboard is present (the physical keys make the
* touch bar redundant — the iPad-equivalent auto-hide, plan §"iPad-equivalent").
*/
internal fun shouldShowKeyBar(screenWidthDp: Int, hardwareKeyboardPresent: Boolean): Boolean =
screenWidthDp <= WIDE_SCREEN_MAX_DP && !hardwareKeyboardPresent
/**
* Best-effort hardware-keyboard heuristic from the current [Configuration]
* (accepted-less-reliable, plan §"iPad-equivalent" — no `GCKeyboard` equivalent):
* an alphabetic keyboard that is currently exposed (lid open / dock attached).
*/
internal fun isHardwareKeyboardPresent(keyboard: Int, hardKeyboardHidden: Int): Boolean =
keyboard == Configuration.KEYBOARD_QWERTY && hardKeyboardHidden == Configuration.HARDKEYBOARDHIDDEN_NO
/**
* The mobile key-bar overlay. Pinned ABOVE the IME via [WindowInsets.ime] (falling
* back to the navigation-bar inset when the keyboard is hidden), horizontally
* scrollable so every key stays reachable on a narrow phone. Renders nothing on
* wide screens or when a hardware keyboard is present ([shouldShowKeyBar]).
*
* @param onSend the IME-bypass sink — A21 wires it to `TerminalSessionController.sendInput`.
*/
@Composable
public fun KeyBar(
onSend: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val config = LocalConfiguration.current
val hardwareKeyboard = isHardwareKeyboardPresent(config.keyboard, config.hardKeyboardHidden)
if (!shouldShowKeyBar(config.screenWidthDp, hardwareKeyboard)) return
Row(
modifier = modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.ime.union(WindowInsets.navigationBars))
.horizontalScroll(rememberScrollState())
.padding(horizontal = Spacing.sm8, vertical = Spacing.xs4),
horizontalArrangement = Arrangement.spacedBy(Spacing.xs4),
) {
for (button in KEYBAR_LAYOUT) {
KeyBarKey(button = button, onSend = onSend)
}
}
}
/** One inert key button. A tap emits bytes directly via [emitKey] — never through a text field. */
@Composable
private fun KeyBarKey(button: KeyBarButton, onSend: (String) -> Unit) {
val container =
if (button.isPrimary) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant
val content =
if (button.isPrimary) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
Surface(
onClick = { emitKey(button.key, onSend) },
shape = RoundedCornerShape(Radius.sm8),
color = container,
contentColor = content,
modifier = Modifier
.defaultMinSize(minWidth = LayoutTokens.minHitTarget, minHeight = LayoutTokens.minHitTarget)
.semantics { contentDescription = button.title },
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.padding(horizontal = Spacing.sm8, vertical = Spacing.xs4),
) {
Text(text = button.label, style = MaterialTheme.typography.labelLarge)
Text(
text = button.caption,
style = MaterialTheme.typography.labelSmall,
color = content.copy(alpha = Opacity.stale),
)
}
}
}
/**
* The DECCKM split router (plan §6.3 source #3): decides whether a hardware key is
* handled app-side (mapped through [KeyByteMap]) or LEFT to Termux's `KeyHandler`.
*
* Pure decision logic (primitive keyCode + modifier booleans, mirroring the Android
* `KeyEvent` constants) so it is JVM-unit-testable with no device / Robolectric.
* The Android glue lives in [handle].
*/
public object HardwareKeyRouter {
/** The ONLY Ctrl-<letter> chords the app claims — exactly the web key-bar's control keys. */
private val CTRL_LETTER_KEYS: Map<Int, KeyByteMap.Key> = mapOf(
KeyEvent.KEYCODE_C to KeyByteMap.Key.CTRL_C,
KeyEvent.KEYCODE_R to KeyByteMap.Key.CTRL_R,
KeyEvent.KEYCODE_O to KeyByteMap.Key.CTRL_O,
KeyEvent.KEYCODE_L to KeyByteMap.Key.CTRL_L,
KeyEvent.KEYCODE_T to KeyByteMap.Key.CTRL_T,
KeyEvent.KEYCODE_B to KeyByteMap.Key.CTRL_B,
KeyEvent.KEYCODE_D to KeyByteMap.Key.CTRL_D,
)
/**
* Resolve a hardware key to a routing decision.
*
* - **⇧Tab** → app-handled `ESC[Z`; **plain Tab** defers (completion / DECCKM nav).
* - **Esc** (unmodified) → app-handled `ESC`.
* - **Ctrl+{C,R,O,L,T,B,D}** → app-handled control byte via [KeyByteMap].
* - **Everything else** (arrows, Enter, plain Tab, plain letters, unmapped Ctrl
* chords) → [HardwareKeyResult.DeferToTerminal] so Termux's `KeyHandler`
* emits the DECCKM-correct sequence (`ESC O A` under application-cursor mode).
*/
public fun resolve(keyCode: Int, ctrl: Boolean, shift: Boolean, alt: Boolean): HardwareKeyResult {
// ⇧Tab is app-handled; plain Tab is left to Termux (do this BEFORE the generic defer).
if (keyCode == KeyEvent.KEYCODE_TAB) {
return if (shift && !ctrl && !alt) app(KeyByteMap.Key.SHIFT_TAB) else HardwareKeyResult.DeferToTerminal
}
// Esc → interrupt Claude; a modified Escape is not our chord.
if (keyCode == KeyEvent.KEYCODE_ESCAPE) {
return if (!ctrl && !shift && !alt) app(KeyByteMap.Key.ESC) else HardwareKeyResult.DeferToTerminal
}
// The mapped Ctrl-<letter> chords only; unmapped Ctrl letters defer to Termux.
if (ctrl && !alt) {
CTRL_LETTER_KEYS[keyCode]?.let { return app(it) }
}
// Arrows / Enter / plain Tab / plain keys → Termux KeyHandler (DECCKM-correct, §6.3).
return HardwareKeyResult.DeferToTerminal
}
/**
* Android glue for `RemoteTerminalView.onKeyCommand`: extract modifiers from
* [event], route via [resolve], and on an app-handled DOWN emit the bytes to
* [onSend], returning true to CONSUME the event. Returns false otherwise so the
* stock Termux path (`KeyHandler`) handles arrows/Enter/Tab. Device-QA'd
* (touches `KeyEvent` accessor methods).
*/
public fun handle(keyCode: Int, event: KeyEvent, onSend: (String) -> Unit): Boolean {
if (event.action != KeyEvent.ACTION_DOWN) return false
return when (val result = resolve(keyCode, event.isCtrlPressed, event.isShiftPressed, event.isAltPressed)) {
is HardwareKeyResult.AppHandled -> {
onSend(result.bytes)
true
}
HardwareKeyResult.DeferToTerminal -> false
}
}
private fun app(key: KeyByteMap.Key): HardwareKeyResult =
HardwareKeyResult.AppHandled(KeyByteMap.bytes(key))
}
/** The routing decision for one hardware key (the DECCKM split, plan §6.3). */
public sealed interface HardwareKeyResult {
/** The app consumes the key and sends these exact [bytes] (resolved via [KeyByteMap]). */
public data class AppHandled(val bytes: String) : HardwareKeyResult
/** The key is left to Termux's `KeyHandler` so DECCKM stays correct (arrows/Enter/Tab). */
public data object DeferToTerminal : HardwareKeyResult
}
// ── Preview ───────────────────────────────────────────────────────────────────
@Preview(name = "KeyBar")
@Composable
private fun KeyBarPreview() {
WebTermTheme {
KeyBar(onSend = {})
}
}

View File

@@ -0,0 +1,101 @@
package wang.yaojia.webterm.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.session.GateState
import wang.yaojia.webterm.viewmodels.GateDecision
import wang.yaojia.webterm.wire.GateKind
/**
* # PlanGateSheet (A22) — the plan-gate three-way approval bottom sheet.
*
* The Android analogue of the iOS ExitPlanMode gate (public/tabs.ts:334-350): a Material 3
* [ModalBottomSheet] with three actions —
* - **批准** → approve + review (`Approve(mode="default")`, the default action),
* - **批准并自动接受** → approve + auto-accept edits (`Approve(mode="acceptEdits")`),
* - **继续计划** → keep planning (wires to `reject`).
*
* `mode` is the **TOP-LEVEL** `approve.mode` wire key (plan §1/§4.1) — carried by the resolved
* [GateState.Affordance.clientMessage] via [GateViewModel.decide], never hand-built here. The
* server-supplied [detail][GateState.detail] renders as **inert [Text]** (untrusted, §8).
*
* ### Epoch capture (stale-guard seam)
* Each action hands back this gate's [epoch][GateState.epoch] so the tap is bound to the gate on
* screen; [GateViewModel.decide] drops it if the live gate has moved on. Dismissing the sheet
* (scrim/back) resolves NOTHING — the gate stays held until an explicit decision.
*
* Sheet presentation / detents are device-QA; the wired decisions + epoch capture are the contract.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
public fun PlanGateSheet(
gate: GateState,
onDecide: (GateDecision, Int) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Spacing.lg16, vertical = Spacing.md12),
verticalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
Text(text = "批准执行计划", style = MaterialTheme.typography.titleMedium)
gate.detail?.let { detail ->
// INERT: untrusted plan text rendered verbatim, no linkify/markdown (§8).
Text(
text = detail,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
)
}
Button(
onClick = { onDecide(GateDecision.APPROVE, gate.epoch) },
modifier = Modifier.fillMaxWidth(),
) { Text(text = "批准") }
OutlinedButton(
onClick = { onDecide(GateDecision.ACCEPT_EDITS, gate.epoch) },
modifier = Modifier.fillMaxWidth(),
) { Text(text = "批准并自动接受") }
TextButton(
onClick = { onDecide(GateDecision.REJECT, gate.epoch) },
modifier = Modifier.fillMaxWidth(),
) { Text(text = "继续计划") }
}
}
}
// ── Preview ─────────────────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class)
@Preview(name = "PlanGateSheet")
@Composable
private fun PlanGateSheetPreview() {
WebTermTheme {
PlanGateSheet(
gate = GateState(kind = GateKind.PLAN, detail = "Refactor the auth module into 3 files.", epoch = 5),
onDecide = { _, _ -> },
onDismiss = {},
)
}
}

View File

@@ -0,0 +1,97 @@
package wang.yaojia.webterm.components
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.isSecondaryPressed
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.DpOffset
import wang.yaojia.webterm.nav.LayoutMode
import wang.yaojia.webterm.nav.PointerMenuPolicy
/**
* # PointerContextMenu (A26) — the large-screen right-click / trackpad-secondary menu.
*
* The Android analogue of iOS's pointer context menu (open-in-cwd / kill / copy). A reusable wrapper
* that installs a `Modifier.pointerInput` detecting a **secondary** pointer button (mouse right-click,
* trackpad two-finger / secondary click) and anchors a Material `DropdownMenu` at the click position.
*
* ### Gating (plan §5 A26, §"iPad-equivalent", R13, §8)
* The whole affordance is gated by the pure [PointerMenuPolicy.enabled] predicate — enabled **only** in
* [LayoutMode.LIST_DETAIL] on a genuine tablet (`smallestScreenWidthDp >= 600`). When disabled the
* wrapper is a transparent pass-through (no pointer interception, no menu) so phones behave exactly as
* before. This is `Modifier.pointerInput` + an anchored `DropdownMenu` — deliberately NOT Compose
* Desktop's `ContextMenuArea`, which is not stable on Android (platform review).
*
* The pointer detection / menu placement / gesture behaviour is device-QA (plan §7); the gate is the
* JVM-tested [PointerMenuPolicy] core.
*
* @param mode the current [LayoutMode] from [wang.yaojia.webterm.nav.LayoutPolicy].
* @param actions the menu entries (e.g. 在当前目录开新会话 / 终止 / 复制), each a label + callback.
* @param smallestScreenWidthDp the device's smallest-width; defaults to the current configuration.
* @param content the wrapped content the secondary-click targets.
*/
@Composable
public fun PointerContextMenu(
mode: LayoutMode,
actions: List<ContextMenuAction>,
modifier: Modifier = Modifier,
smallestScreenWidthDp: Int = LocalConfiguration.current.smallestScreenWidthDp,
content: @Composable () -> Unit,
) {
// Gate: on a phone / compact layout the menu never exists and the pointer is never intercepted.
if (!PointerMenuPolicy.enabled(mode, smallestScreenWidthDp)) {
Box(modifier = modifier) { content() }
return
}
var expanded by remember { mutableStateOf(false) }
var anchor by remember { mutableStateOf(DpOffset.Zero) }
val density = LocalDensity.current
Box(
modifier = modifier.pointerInput(actions) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
if (event.buttons.isSecondaryPressed) {
val position = event.changes.first().position
anchor = with(density) { DpOffset(position.x.toDp(), position.y.toDp()) }
expanded = true
// Consume so the underlying content doesn't also react to the secondary press.
event.changes.forEach { it.consume() }
}
}
}
},
) {
content()
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
offset = anchor,
) {
for (action in actions) {
DropdownMenuItem(
text = { Text(action.label) },
onClick = {
expanded = false
action.onClick()
},
)
}
}
}
}
/** One entry in the [PointerContextMenu]: an inert label + the callback fired when it is chosen. */
public data class ContextMenuAction(val label: String, val onClick: () -> Unit)

View File

@@ -0,0 +1,111 @@
package wang.yaojia.webterm.components
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.SuggestionChipDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import wang.yaojia.webterm.designsystem.Radius
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.WebTermTheme
/**
* # QuickReply (A25) — the floating quick-reply chip row.
*
* A horizontal strip of the editable palette ([QuickReplyStore]). It **floats only
* while a gate is held** (plan §1 quick-reply row) — the moment Claude is waiting on
* the user is exactly when a one-tap canned reply is useful — and each chip, on tap,
* sends its [text][QuickReplyChip.text] VERBATIM via the injected [onSend].
*
* ### The load-bearing seam (JVM-tested; layout/float animation are device-QA §7)
* - **Visibility:** [shouldShowQuickReply] gates the row on `gateHeld && hasChips`.
* - **Send:** a tap calls [sendQuickReply] → `onSend(chip.text)` with NO added byte.
* A21 wires `onSend` to `TerminalSessionController.sendInput` (invariant #1 —
* input passed through verbatim), and `gateHeld` to the [GateViewModel]
* [wang.yaojia.webterm.viewmodels.GateViewModel] held-gate state.
*
* The chip label is the user's OWN palette text (not untrusted server data), rendered
* as a single-line, ellipsised [Text] — no Markdown / autolink.
*
* @param chips the palette to show, in display order.
* @param gateHeld whether a gate is currently held (drives the float).
* @param onSend receives the tapped chip's exact text.
*/
@Composable
public fun QuickReply(
chips: List<QuickReplyChip>,
gateHeld: Boolean,
onSend: (String) -> Unit,
modifier: Modifier = Modifier,
) {
if (!shouldShowQuickReply(gateHeld = gateHeld, hasChips = chips.isNotEmpty())) return
Surface(
modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(Radius.pill),
tonalElevation = Spacing.xs2,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = Spacing.sm8, vertical = Spacing.xs4),
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
chips.forEach { chip ->
SuggestionChip(
onClick = { sendQuickReply(chip, onSend) },
label = {
Text(
text = chip.text,
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
shape = RoundedCornerShape(Radius.pill),
colors = SuggestionChipDefaults.suggestionChipColors(),
)
}
}
}
}
/**
* The tap→emit seam (mirrors `KeyBar.emitKey`): send the chip's [text][QuickReplyChip.text]
* VERBATIM. Kept as a top-level function so the exact-byte contract is JVM-testable
* without driving Compose.
*/
internal fun sendQuickReply(chip: QuickReplyChip, onSend: (String) -> Unit) {
onSend(chip.text)
}
/** The row floats only while a gate is held AND the palette is non-empty. */
internal fun shouldShowQuickReply(gateHeld: Boolean, hasChips: Boolean): Boolean =
gateHeld && hasChips
// ── Preview ─────────────────────────────────────────────────────────────────────
@Preview(name = "QuickReply (gate held)")
@Composable
private fun QuickReplyPreview() {
WebTermTheme {
QuickReply(
chips = QuickReplyDefaults.chips,
gateHeld = true,
onSend = {},
)
}
}

View File

@@ -0,0 +1,237 @@
package wang.yaojia.webterm.components
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.add
import kotlinx.serialization.json.addJsonObject
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.put
import java.util.UUID
/**
* # QuickReplyChip (A25) — one entry of the editable quick-reply palette.
*
* A canned snippet the user can tap to send while a gate is held. [id] is a stable
* identity used for edit/remove/reorder (survives a [text] change); [text] is the
* exact string sent VERBATIM as input via `TerminalSessionController.sendInput`
* (plan §1 quick-reply row / §5 A25). No trailing byte is added — the palette owner
* types whatever they want submitted.
*/
public data class QuickReplyChip(
val id: String,
val text: String,
)
/**
* Sensible starter palette, used the first time the store is read (no stored blob
* yet). Ids are stable so a default chip can still be edited/removed/reordered by id.
* Once the user mutates the palette (including deleting every chip → an empty list),
* their choice is persisted and these defaults are never re-seeded.
*/
public object QuickReplyDefaults {
public val chips: List<QuickReplyChip> = listOf(
QuickReplyChip("qr-continue", "continue"),
QuickReplyChip("qr-yes", "yes"),
QuickReplyChip("qr-no", "no"),
QuickReplyChip("qr-1", "1"),
QuickReplyChip("qr-2", "2"),
)
}
/**
* Frozen CRUD contract for the editable quick-reply palette (plan §5 A25). Every
* mutation returns the NEW list (immutable style, mirrors [HostStore]
* [wang.yaojia.webterm.hostregistry.HostStore]); nothing is mutated in place.
* Cross-device sync is explicitly OUT of scope — each device's store is independent
* (plan §1 "Cross-device palette sync" DEFERRED).
*
* Implementations: [DataStoreQuickReplyStore] (real, Preferences-DataStore-backed)
* and [InMemoryQuickReplyStore] (in-`main` double for JVM tests / previews).
*/
public interface QuickReplyStore {
/** The palette in display order (defaults on first read; the user's list thereafter). */
public suspend fun loadAll(): List<QuickReplyChip>
/**
* Append a new chip carrying [text] (a fresh id is minted). Blank [text] is an
* explicit no-op (returns the list unchanged) — a boundary guard so the palette
* never accumulates empty chips. Non-blank [text] is stored VERBATIM (leading /
* trailing whitespace preserved). Returns the new list.
*/
public suspend fun add(text: String): List<QuickReplyChip>
/**
* Replace the [text] of the chip with [id] (position preserved). Unknown [id] or
* blank [text] → no-op (unchanged list). Returns the new list.
*/
public suspend fun edit(id: String, text: String): List<QuickReplyChip>
/** Remove the chip with [id]. Unknown [id] is a no-op. Returns the new list. */
public suspend fun remove(id: String): List<QuickReplyChip>
/**
* Move the chip at [fromIndex] to [toIndex] (drag-reorder). Out-of-range indices
* are a no-op (returns the unchanged list). Returns the new list.
*/
public suspend fun move(fromIndex: Int, toIndex: Int): List<QuickReplyChip>
}
// ── Pure collection transforms shared by all QuickReplyStore implementations (DRY) ──
// Never mutate the receiver — always return a fresh list. `internal` so both stores
// and the same-module unit tests use them without widening the public surface.
/** Append a chip with [id]/[text], unless [text] is blank (boundary guard → no-op). */
internal fun List<QuickReplyChip>.adding(text: String, id: String): List<QuickReplyChip> =
if (text.isBlank()) this else this + QuickReplyChip(id, text)
/** Replace the [text] of the chip with [id] (position kept). Blank text / unknown id → no-op. */
internal fun List<QuickReplyChip>.editing(id: String, text: String): List<QuickReplyChip> =
if (text.isBlank()) this else map { if (it.id == id) it.copy(text = text) else it }
/** A copy without the chip whose id equals [id] (unknown id → an unchanged copy). */
internal fun List<QuickReplyChip>.removingId(id: String): List<QuickReplyChip> =
filter { it.id != id }
/** Immutable move [from] → [to]; out-of-range (either end) is a no-op. */
internal fun List<QuickReplyChip>.moving(from: Int, to: Int): List<QuickReplyChip> {
if (from !in indices || to !in indices || from == to) return this
val next = toMutableList()
next.add(to, next.removeAt(from))
return next
}
/**
* Pure JSON codec for the persisted palette (JVM-testable, no Context). A chip list
* ⇆ a JSON array of `{"id","text"}` objects. Encode is total; decode is defensive —
* the blob is untrusted at rest, so a corrupt array or a chip missing a string
* id/text is dropped rather than crashing. Uses the kotlinx JSON element API (no
* `@Serializable` codegen → no serialization compiler plugin needed on :app; the
* runtime lib is on the classpath via `:wire-protocol`'s `api` dependency).
*/
internal object QuickReplyCodec {
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
fun encode(chips: List<QuickReplyChip>): String =
buildJsonArray {
chips.forEach { chip ->
addJsonObject {
put("id", chip.id)
put("text", chip.text)
}
}
}.toString()
fun decode(raw: String?): List<QuickReplyChip> {
if (raw.isNullOrBlank()) return emptyList()
val array = try {
json.parseToJsonElement(raw) as? JsonArray ?: return emptyList()
} catch (_: Exception) {
return emptyList() // corrupt blob at rest → start clean, never crash
}
return array.mapNotNull { element ->
val obj = element as? JsonObject ?: return@mapNotNull null
val id = obj["id"].asStringOrNull()
val text = obj["text"].asStringOrNull()
if (id.isNullOrEmpty() || text.isNullOrEmpty()) null else QuickReplyChip(id, text)
}
}
private fun kotlinx.serialization.json.JsonElement?.asStringOrNull(): String? =
(this as? JsonPrimitive)?.takeIf { it.isString }?.content
}
/**
* Preferences-DataStore-backed [QuickReplyStore]. The whole palette is ONE JSON
* string under [PALETTE_KEY], serialized by [QuickReplyCodec]; writes are an atomic
* read-modify-write via [DataStore.edit], reusing the shared immutable transforms.
*
* Key-absent (first run) reads/seeds [QuickReplyDefaults]; once any write lands, the
* stored list wins — including an empty list, so "delete all" is respected. The
* [DataStore] is injected (constructed from a Context in :app DI), so this class has
* no Android-framework surface of its own and is exercised in JVM tests via an
* in-memory `DataStore<Preferences>` double.
*/
public class DataStoreQuickReplyStore(
private val dataStore: DataStore<Preferences>,
private val newId: () -> String = { UUID.randomUUID().toString() },
) : QuickReplyStore {
override suspend fun loadAll(): List<QuickReplyChip> {
val raw = dataStore.data.first()[PALETTE_KEY]
return if (raw == null) QuickReplyDefaults.chips else QuickReplyCodec.decode(raw)
}
override suspend fun add(text: String): List<QuickReplyChip> =
writeTransform { it.adding(text, newId()) }
override suspend fun edit(id: String, text: String): List<QuickReplyChip> =
writeTransform { it.editing(id, text) }
override suspend fun remove(id: String): List<QuickReplyChip> =
writeTransform { it.removingId(id) }
override suspend fun move(fromIndex: Int, toIndex: Int): List<QuickReplyChip> =
writeTransform { it.moving(fromIndex, toIndex) }
private suspend fun writeTransform(
transform: (List<QuickReplyChip>) -> List<QuickReplyChip>,
): List<QuickReplyChip> {
lateinit var updated: List<QuickReplyChip>
dataStore.edit { prefs ->
val raw = prefs[PALETTE_KEY]
val current = if (raw == null) QuickReplyDefaults.chips else QuickReplyCodec.decode(raw)
updated = transform(current)
prefs[PALETTE_KEY] = QuickReplyCodec.encode(updated)
}
return updated
}
private companion object {
val PALETTE_KEY = stringPreferencesKey("quickReplyPalette")
}
}
/**
* In-memory [QuickReplyStore] — lives in `main` (not `test`) so it doubles for the
* DataStore store in JVM CRUD tests AND backs Compose previews. Seeded with
* [QuickReplyDefaults] by default. A [Mutex] serializes the read-modify-write so
* concurrent callers can't interleave; state is replaced wholesale on every change.
*/
public class InMemoryQuickReplyStore(
initial: List<QuickReplyChip> = QuickReplyDefaults.chips,
private val newId: () -> String = { UUID.randomUUID().toString() },
) : QuickReplyStore {
private val mutex = Mutex()
private var chips: List<QuickReplyChip> = initial.toList()
override suspend fun loadAll(): List<QuickReplyChip> = mutex.withLock { chips }
override suspend fun add(text: String): List<QuickReplyChip> =
write { it.adding(text, newId()) }
override suspend fun edit(id: String, text: String): List<QuickReplyChip> =
write { it.editing(id, text) }
override suspend fun remove(id: String): List<QuickReplyChip> =
write { it.removingId(id) }
override suspend fun move(fromIndex: Int, toIndex: Int): List<QuickReplyChip> =
write { it.moving(fromIndex, toIndex) }
private suspend fun write(
transform: (List<QuickReplyChip>) -> List<QuickReplyChip>,
): List<QuickReplyChip> = mutex.withLock {
val updated = transform(chips)
chips = updated
updated
}
}

View File

@@ -0,0 +1,216 @@
package wang.yaojia.webterm.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.session.Connection
import wang.yaojia.webterm.session.ConnectionState
import wang.yaojia.webterm.session.Exited
import wang.yaojia.webterm.session.FailureReason
import wang.yaojia.webterm.session.SessionEvent
import wang.yaojia.webterm.wire.WireConstants
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* # ReconnectBanner (A21) — the connection-status / EXIT banner row.
*
* The Android port of iOS `ReconnectBanner` + `TerminalViewModel.bannerModel` (plan §1, §5 A21). A
* single strip pinned above the terminal that reflects the engine's connection lifecycle:
* - `connecting` — first dial (spinner);
* - `reconnecting(attempt, countdown)` — the back-off ladder (spinner);
* - `failed(replayTooLarge)` — a NON-retryable wall: actionable copy, **NO spinner**, "新会话" action;
* - `exited(code, reason)` — the shell is gone: `code == -1` is a spawn-failure (reason required),
* otherwise a normal exit; both offer "开新会话".
*
* ### Precedence (the load-bearing rule — plan §1)
* **Terminal phases (`failed` / `exited`) OUTRANK the transient ones (`connecting` / `reconnecting`).**
* Once the session is terminally failed or exited, a stray transient connection event must never
* overwrite the banner — the reducer [bannerModel] enforces this. This mirrors the iOS `bannerModel`
* computed property where the exit/failure state is checked before the reconnect state.
*
* ### Inert rendering (plan §8)
* The exit `reason` is an untrusted server string — it is rendered as a plain, inert Compose [Text]
* with no Markdown / autolink / `AnnotatedString` linkify.
*/
/**
* What the banner shows, derived purely from the [SessionEvent] stream by [bannerModel]. A sealed model
* so the precedence rule and the "no-spinner on failure" rule are data, unit-testable without a device.
*/
public sealed interface BannerModel {
/** Whether a progress spinner is shown — true ONLY for the transient (retrying) phases. */
public val showsSpinner: Boolean
/** Whether the banner offers a "new session in cwd" action (`attach(null, cwd)`). */
public val offersNewSession: Boolean
/** Connected / idle — nothing to show. */
public data object Hidden : BannerModel {
override val showsSpinner: Boolean get() = false
override val offersNewSession: Boolean get() = false
}
/** The first connection is being established. */
public data object Connecting : BannerModel {
override val showsSpinner: Boolean get() = true
override val offersNewSession: Boolean get() = false
}
/** The transport dropped; retry [attempt] fires after [countdown] (the [ConnectionState.Reconnecting] ladder). */
public data class Reconnecting(val attempt: Int, val countdown: Duration) : BannerModel {
override val showsSpinner: Boolean get() = true
override val offersNewSession: Boolean get() = false
}
/**
* A NON-retryable terminal failure (e.g. [FailureReason.REPLAY_TOO_LARGE]). Actionable copy, **no
* spinner** (reconnecting would hit the same wall forever), and a "新会话" affordance.
*/
public data class Failed(val reason: FailureReason) : BannerModel {
override val showsSpinner: Boolean get() = false
override val offersNewSession: Boolean get() = true
}
/**
* The shell exited. [isSpawnFailure] (`code == -1`) means the PTY never spawned — [reason] is then
* required and drives the copy. Either way the session is over; offers "开新会话".
*/
public data class Exited(val code: Int, val reason: String?) : BannerModel {
override val showsSpinner: Boolean get() = false
override val offersNewSession: Boolean get() = true
/** `code == WireConstants.SPAWN_FAILED_EXIT_CODE` (-1) → the spawn itself failed. */
public val isSpawnFailure: Boolean get() = code == WireConstants.SPAWN_FAILED_EXIT_CODE
}
}
/**
* PURE reducer: fold one [SessionEvent] into the [current] [BannerModel]. Only connection-lifecycle and
* exit events affect the banner; every other event (`Output`/`Gate`/`Digest`/`Telemetry`/`Adopted`)
* leaves it unchanged.
*
* The precedence rule (plan §1): the terminal phases ([BannerModel.Failed] / [BannerModel.Exited]) are
* STICKY over the transient phases — a `connecting`/`reconnecting`/`connected`/`closed` event arriving
* after the session already failed or exited does NOT override the terminal banner. A fresh terminal
* event always wins (a later exit/failure replaces an earlier one).
*/
public fun bannerModel(current: BannerModel, event: SessionEvent): BannerModel = when (event) {
is Exited -> BannerModel.Exited(event.code, event.reason) // terminal — always wins
is Connection -> reduceConnection(current, event.state)
else -> current
}
private fun reduceConnection(current: BannerModel, state: ConnectionState): BannerModel = when (state) {
is ConnectionState.Failed -> BannerModel.Failed(state.reason) // terminal — always wins
ConnectionState.Connecting -> current.orKeepTerminal(BannerModel.Connecting)
is ConnectionState.Reconnecting -> current.orKeepTerminal(BannerModel.Reconnecting(state.attempt, state.next))
ConnectionState.Connected -> current.orKeepTerminal(BannerModel.Hidden)
ConnectionState.Closed -> current.orKeepTerminal(BannerModel.Hidden)
}
/** A terminal banner outranks any transient update; otherwise take the [transient] one. */
private fun BannerModel.orKeepTerminal(transient: BannerModel): BannerModel =
if (this is BannerModel.Failed || this is BannerModel.Exited) this else transient
// ── UI ───────────────────────────────────────────────────────────────────────
/**
* The banner row. Renders NOTHING for [BannerModel.Hidden]. [onNewSession] fires the "new session in
* cwd" action (the exit-banner and replay-too-large affordances both route to it).
*
* Compose layout / spinner animation is device-QA (plan §7); the reduced [model] + its copy/affordance
* mapping is the JVM-tested core.
*/
@Composable
public fun ReconnectBanner(
model: BannerModel,
onNewSession: () -> Unit,
modifier: Modifier = Modifier,
) {
if (model is BannerModel.Hidden) return
val container = when (model) {
is BannerModel.Failed, is BannerModel.Exited -> MaterialTheme.colorScheme.errorContainer
else -> MaterialTheme.colorScheme.surfaceVariant
}
val content = when (model) {
is BannerModel.Failed, is BannerModel.Exited -> MaterialTheme.colorScheme.onErrorContainer
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
Surface(color = container, contentColor = content, modifier = modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
) {
if (model.showsSpinner) {
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp))
}
// Untrusted server strings (exit reason) render as inert Text — no autolink/markdown (§8).
Text(
text = bannerCopy(model),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
if (model.offersNewSession) {
TextButton(onClick = onNewSession) { Text(newSessionLabel(model)) }
}
}
}
}
/** The Chinese status copy for [model]. Kept separate from the reducer so copy never leaks into logic. */
private fun bannerCopy(model: BannerModel): String = when (model) {
BannerModel.Hidden -> ""
BannerModel.Connecting -> "连接中…"
is BannerModel.Reconnecting ->
"连接断开,重连中(第 ${model.attempt} 次,${model.countdown.inWholeSeconds} 秒后重试)"
is BannerModel.Failed -> when (model.reason) {
FailureReason.REPLAY_TOO_LARGE ->
"回放数据过大,无法自动重连。请降低服务器 SCROLLBACK_BYTES 或提高客户端上限后开新会话。"
}
is BannerModel.Exited ->
if (model.isSpawnFailure) {
"会话启动失败:${model.reason ?: "未知原因"}"
} else {
"会话已结束(退出码 ${model.code}"
}
}
/** "开新会话" for a clean exit, "新会话" for the replay-too-large wall — both call [ReconnectBanner]'s onNewSession. */
private fun newSessionLabel(model: BannerModel): String =
if (model is BannerModel.Failed) "新会话" else "开新会话"
// ── Preview ───────────────────────────────────────────────────────────────────
@Preview(name = "ReconnectBanner — reconnecting")
@Composable
private fun ReconnectBannerReconnectingPreview() {
WebTermTheme {
ReconnectBanner(model = BannerModel.Reconnecting(attempt = 2, countdown = 4.seconds), onNewSession = {})
}
}
@Preview(name = "ReconnectBanner — exited")
@Composable
private fun ReconnectBannerExitedPreview() {
WebTermTheme {
ReconnectBanner(model = BannerModel.Exited(code = 0, reason = null), onNewSession = {})
}
}

View File

@@ -0,0 +1,243 @@
package wang.yaojia.webterm.components
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import wang.yaojia.webterm.api.models.LiveSessionInfo
import wang.yaojia.webterm.designsystem.DisplayStatus
import wang.yaojia.webterm.designsystem.Opacity
import wang.yaojia.webterm.designsystem.Radius
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.StatusBadge
import wang.yaojia.webterm.designsystem.WebTermColors
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.designsystem.WebTermType
import wang.yaojia.webterm.viewmodels.SessionRow
import wang.yaojia.webterm.wire.ClaudeStatus
import wang.yaojia.webterm.wire.StatusTelemetry
import java.util.UUID
/**
* # SessionListRow (A20) — one dashboard row for a running/just-exited session.
*
* Renders the parts the plan §1 session-list row enumerates: **preview thumbnail** (left) · **status
* badge** + sanitized **title** + **unread dot** (top line) · **cols×rows** geometry + cwd (meta line) ·
* **telemetry chips** (bottom line). Every server-influenced string ([SessionRow.title] — already run
* through `TitleSanitizer` in the ViewModel — plus cwd and the telemetry chip labels) renders as INERT
* [Text]: no linkify / Markdown / `AnnotatedString` autolink (plan §8). Exited rows dim to
* [Opacity.exited].
*
* The whole row is tap-to-open ([onOpen]); swipe-to-kill is owned by the enclosing `SwipeToDismissBox` in
* `SessionListScreen`. Layout/gesture behaviour is device-QA (plan §7); the row's DERIVED display values
* are computed by the JVM-tested `sessionRowsOf` in the ViewModel.
*
* @param thumbnails the off-screen preview seam — production wires it to the active host's
* [ThumbnailPipeline][wang.yaojia.webterm.wiring.ThumbnailPipeline] (`(sessionId, lastOutputAt)`-keyed,
* §6.7); `null` renders the placeholder tile (the JVM/preview path draws no bitmap).
*/
@Composable
public fun SessionListRow(
row: SessionRow,
onOpen: () -> Unit,
modifier: Modifier = Modifier,
thumbnails: SessionThumbnails? = null,
nowMs: Long = System.currentTimeMillis(),
) {
Row(
modifier = modifier
.fillMaxWidth()
.alpha(if (row.displayStatus == DisplayStatus.Exited) Opacity.exited else 1f)
.clip(RoundedCornerShape(Radius.md12))
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onOpen)
.padding(Spacing.md12),
horizontalArrangement = Arrangement.spacedBy(Spacing.md12),
verticalAlignment = Alignment.CenterVertically,
) {
ThumbnailTile(session = row.info, thumbnails = thumbnails)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(Spacing.xs4),
) {
TitleLine(row = row)
Text(
text = metaLine(row.info),
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
row.info.telemetry?.let { telemetry ->
TelemetryChips(telemetry = telemetry, nowMs = nowMs)
}
}
}
}
/** Top line: status badge · unread dot · sanitized title (or an id/cwd fallback when the title is empty). */
@Composable
private fun TitleLine(row: SessionRow) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
verticalAlignment = Alignment.CenterVertically,
) {
StatusBadge(status = row.displayStatus)
if (row.isUnread) UnreadDot()
Text(
text = row.title.ifBlank { fallbackLabel(row.info) },
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
/** The unread dot (accent-filled), shown when server output is newer than the local seen-watermark (§1). */
@Composable
private fun UnreadDot() {
Box(
modifier = Modifier
.size(Spacing.sm8)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
)
}
/**
* The preview thumbnail tile. Loads the bitmap once per `(id, lastOutputAt)` from the [thumbnails] seam
* (so an unchanged screen is served from the pipeline's cache) and draws it; while loading (or when no
* seam is wired) it shows a terminal-colored placeholder so the row height never jumps.
*/
@Composable
private fun ThumbnailTile(session: LiveSessionInfo, thumbnails: SessionThumbnails?) {
var bitmap by remember(session.id, session.lastOutputAt) { mutableStateOf<Bitmap?>(null) }
LaunchedEffect(session.id, session.lastOutputAt, thumbnails) {
bitmap = thumbnails?.bitmapFor(session)
}
Box(
modifier = Modifier
.width(THUMBNAIL_WIDTH)
.height(THUMBNAIL_HEIGHT)
.clip(RoundedCornerShape(Radius.sm8))
.background(WebTermColors.terminalBackground),
contentAlignment = Alignment.Center,
) {
bitmap?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().height(THUMBNAIL_HEIGHT),
)
}
}
}
/** "161×50 · ~/src/web-terminal" — tabular geometry + cwd (× is U+00D7). Both inert (§8). */
private fun metaLine(info: LiveSessionInfo): String {
val dims = "${info.cols}×${info.rows}"
val cwd = info.cwd?.takeIf { it.isNotBlank() }
return if (cwd != null) "$dims · $cwd" else dims
}
/** When a session has no OSC title, label it by cwd (last segment) or the short id — never blank. */
private fun fallbackLabel(info: LiveSessionInfo): String {
val cwd = info.cwd?.takeIf { it.isNotBlank() }
if (cwd != null) return cwd.trimEnd('/').substringAfterLast('/').ifBlank { cwd }
return info.id.toString().substringBefore('-')
}
/**
* The off-screen thumbnail seam. Production is a thin adapter over the active host's
* [ThumbnailPipeline][wang.yaojia.webterm.wiring.ThumbnailPipeline]
* (`{ session -> pipeline.thumbnail(session) }`); the default `null` seam renders the placeholder tile so
* the row composes with no `android.graphics` dependency under preview/JVM.
*/
public fun interface SessionThumbnails {
/** The cached/rendered preview bitmap for [session], or `null` when unavailable (placeholder shown). */
public suspend fun bitmapFor(session: LiveSessionInfo): Bitmap?
}
private val THUMBNAIL_WIDTH = Spacing.xxl24 * 4 // 96dp
private val THUMBNAIL_HEIGHT = Spacing.xxl24 * 2.5f // 60dp
// ── Preview ─────────────────────────────────────────────────────────────────────
@Preview(name = "SessionListRow")
@Composable
private fun SessionListRowPreview() {
WebTermTheme {
Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm8), modifier = Modifier.padding(Spacing.md12)) {
SessionListRow(
row = SessionRow(
info = previewInfo(status = ClaudeStatus.WORKING, title = "web-terminal", lastOutputAt = 2L),
displayStatus = DisplayStatus.Working,
title = "web-terminal",
isUnread = true,
),
onOpen = {},
)
SessionListRow(
row = SessionRow(
info = previewInfo(status = ClaudeStatus.IDLE, title = "", cols = 80, rows = 24),
displayStatus = DisplayStatus.Exited,
title = "",
isUnread = false,
),
onOpen = {},
)
}
}
}
private fun previewInfo(
status: ClaudeStatus,
title: String,
cols: Int = 161,
rows: Int = 50,
lastOutputAt: Long? = null,
): LiveSessionInfo = LiveSessionInfo(
id = UUID.fromString("11111111-2222-4333-8444-555555555555"),
createdAt = 1L,
clientCount = 1,
status = status,
exited = false,
cwd = "/Users/dev/src/web-terminal",
title = title.ifBlank { null },
cols = cols,
rows = rows,
telemetry = StatusTelemetry(contextUsedPct = 42.0, costUsd = 0.12, model = "opus", at = 0L),
lastOutputAt = lastOutputAt,
)

View File

@@ -0,0 +1,114 @@
package wang.yaojia.webterm.components
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.rememberScrollState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.TelemetryChip
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.wire.PrInfo
import wang.yaojia.webterm.wire.RateInfo
import wang.yaojia.webterm.wire.StatusTelemetry
import wang.yaojia.webterm.wire.Tunables
import java.util.Locale
import kotlin.math.roundToInt
/**
* # TelemetryChips (A22) — the statusLine telemetry chip row.
*
* A horizontally-scrollable row of A13 [TelemetryChip] primitives — context %, $cost, model, PR,
* rate — derived from the latest [StatusTelemetry] frame (plan §1). Every chip **greys out together**
* when the frame is stale (its [at][StatusTelemetry.at] older than
* [TELEMETRY_STALE_TTL_MS][Tunables.TELEMETRY_STALE_TTL_MS]); a chip switches to the amber warning
* color when its metric crosses a threshold (near-full context, near-limit rate, PR changes-requested).
*
* Chip text is inert monospaced [TelemetryChip] output (no linkify, §8). The model string is
* server-controlled but renders as a plain chip label. Layout is device-QA; the staleness rule and
* chip derivation are the JVM-tested core ([isTelemetryStale] / [telemetryChipModels]).
*
* @param nowMs current wall clock (ms since epoch); the screen re-supplies it on a tick so chips grey
* as the frame ages.
*/
@Composable
public fun TelemetryChips(
telemetry: StatusTelemetry,
nowMs: Long,
modifier: Modifier = Modifier,
) {
val stale = isTelemetryStale(telemetry.at, nowMs)
val models = telemetryChipModels(telemetry)
Row(
modifier = modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(Spacing.xs4),
) {
for (model in models) {
TelemetryChip(text = model.text, isStale = stale, isWarning = model.isWarning)
}
}
}
/** One derived chip: pre-formatted [text] plus whether it crossed a warning threshold. */
public data class TelemetryChipModel(val text: String, val isWarning: Boolean = false)
/** Context %, 5-hour and 7-day rate at/above which a chip turns amber (mirrors the web statusline). */
private const val CONTEXT_WARN_PCT: Double = 90.0
private const val RATE_WARN_PCT: Double = 90.0
private const val REVIEW_CHANGES_REQUESTED: String = "CHANGES_REQUESTED"
/**
* True iff [telemetry.at][StatusTelemetry.at] (server receive time) is older than the stale TTL as of
* [nowMs]. Boundary: exactly-TTL-old is NOT yet stale ("older than", [Tunables.TELEMETRY_STALE_TTL_MS]).
* Pure — the JVM-tested staleness threshold.
*/
public fun isTelemetryStale(atMs: Long, nowMs: Long): Boolean =
nowMs - atMs > Tunables.TELEMETRY_STALE_TTL_MS
/**
* Derive the ordered chip list from a telemetry frame, skipping absent metrics. Pure + deterministic
* so the formatting + warning thresholds are unit-tested without Compose. Order mirrors the web
* statusLine: context · cost · model · PR · rate.
*/
public fun telemetryChipModels(telemetry: StatusTelemetry): List<TelemetryChipModel> {
val chips = mutableListOf<TelemetryChipModel>()
telemetry.contextUsedPct?.let {
chips += TelemetryChipModel("ctx ${it.roundToInt()}%", isWarning = it >= CONTEXT_WARN_PCT)
}
telemetry.costUsd?.let {
chips += TelemetryChipModel(String.format(Locale.US, "$%.2f", it))
}
telemetry.model?.takeIf { it.isNotBlank() }?.let {
chips += TelemetryChipModel(it)
}
telemetry.pr?.let { chips += prChip(it) }
telemetry.rate?.fiveHourPct?.let {
chips += TelemetryChipModel("5h ${it.roundToInt()}%", isWarning = it >= RATE_WARN_PCT)
}
return chips
}
private fun prChip(pr: PrInfo): TelemetryChipModel =
TelemetryChipModel("PR #${pr.number}", isWarning = pr.reviewState == REVIEW_CHANGES_REQUESTED)
// ── Preview ─────────────────────────────────────────────────────────────────────
@Preview(name = "TelemetryChips")
@Composable
private fun TelemetryChipsPreview() {
WebTermTheme {
TelemetryChips(
telemetry = StatusTelemetry(
contextUsedPct = 92.0,
costUsd = 0.1234,
model = "opus-4.8",
pr = PrInfo(number = 7, url = "", reviewState = REVIEW_CHANGES_REQUESTED),
rate = RateInfo(fiveHourPct = 40.0),
at = 0L,
),
nowMs = 0L,
)
}
}

View File

@@ -0,0 +1,94 @@
package wang.yaojia.webterm.designsystem
/**
* # WebTerm design system — the FROZEN numeric spec (pure Kotlin, JVM-testable).
*
* Mirrors the iOS `DS` token vocabulary
* (`ios/App/WebTerm/DesignSystem/Tokens.swift`). Every visual constant lives here
* as a raw ARGB [Long] / [Float] / [Int] with **no Compose import**, so it can be
* unit-tested on the plain JVM with no device (see `DesignSpecTest`). `Tokens.kt`
* builds Compose `Color`/`Dp` values from these; screens must reference the tokens,
* never inline a hex/gap.
*
* Direction: "精致原生" (refined native), dark-appearance-first, amber-gold accent
* matched to the desktop/web theme (`public/style.css` `--accent`). Color is NEVER
* the only status signal — pair it with the shape/label in `StatusStyle`.
*/
public object DesignSpec {
// ── Accent (amber gold — matches desktop --accent / --accent-2) ─────────────
// Adaptive: dark = #E3A64A (gold), light = #C9892F (deeper gold) for contrast
// on a light background. Gold needs DARK ink on top → use [ON_ACCENT].
public const val ACCENT_DARK: Long = 0xFFE3A64AL // #E3A64A (web --accent)
public const val ACCENT_LIGHT: Long = 0xFFC9892FL // #C9892F (web --accent-2)
public const val ON_ACCENT: Long = 0xFF1A1305L // #1A1305 (web --on-accent)
// ── Semantic status colors (match the desktop/web status palette) ───────────
// These are the ONLY status colors. `StatusStyle` pairs each with a distinct
// shape so status is never conveyed by color alone (color-blind safe).
public const val STATUS_WORKING: Long = 0xFF46D07FL // #46D07F (web --green)
public const val STATUS_WAITING: Long = 0xFFF5B14CL // #F5B14C (web --amber)
public const val STATUS_STUCK: Long = 0xFFFF6B6BL // #FF6B6B (web --red)
public const val STATUS_IDLE: Long = 0xFF8B8578L // warm secondary gray
public const val STATUS_UNKNOWN: Long = 0xFF6E6A61L // neutral gray (no signal yet)
// ── Timeline event classes (A28) ────────────────────────────────────────────
public const val TIMELINE_TOOL: Long = 0xFF5E9EFFL // indigo — a tool run
public const val TIMELINE_USER: Long = 0xFFAF7BFFL // violet — a user message
// ── Surfaces & text — DARK scheme (the default appearance) ──────────────────
public const val DARK_BACKGROUND: Long = 0xFF14130FL // warm near-black chrome
public const val DARK_SURFACE: Long = 0xFF1C1A15L
public const val DARK_CARD: Long = 0xFF24211AL // grouped-content step-up
public const val DARK_HAIRLINE: Long = 0xFF35322AL // separator/border
public const val DARK_TEXT_PRIMARY: Long = 0xFFECE9E3L // warm off-white (web --text)
public const val DARK_TEXT_SECONDARY: Long = 0xFFA8A296L
public const val DARK_TEXT_TERTIARY: Long = 0xFF6E6A61L
// ── Surfaces & text — LIGHT scheme ──────────────────────────────────────────
public const val LIGHT_BACKGROUND: Long = 0xFFFAF8F3L // warm off-white
public const val LIGHT_SURFACE: Long = 0xFFFFFFFFL
public const val LIGHT_CARD: Long = 0xFFF0EDE6L
public const val LIGHT_HAIRLINE: Long = 0xFFD8D3C8L
public const val LIGHT_TEXT_PRIMARY: Long = 0xFF1A1712L
public const val LIGHT_TEXT_SECONDARY: Long = 0xFF6B6559L
public const val LIGHT_TEXT_TERTIARY: Long = 0xFF9A9488L
// ── Terminal canvas (FIXED — independent of app theme) ──────────────────────
// A terminal reads as dark regardless of app appearance (like the desktop).
// Values mirror the web chrome: --bg #100F0D / --text #ECE9E3, gold caret.
public const val TERMINAL_BACKGROUND: Long = 0xFF100F0DL // web --bg
public const val TERMINAL_FOREGROUND: Long = 0xFFECE9E3L // web --text
public const val TERMINAL_CARET: Long = 0xFFE3A64AL // gold caret
// ── Spacing scale — 2·4·8·12·16·20·24 (dp). No off-scale gaps. ──────────────
public const val SPACE_XS2: Float = 2f
public const val SPACE_XS4: Float = 4f
public const val SPACE_SM8: Float = 8f
public const val SPACE_MD12: Float = 12f
public const val SPACE_LG16: Float = 16f
public const val SPACE_XL20: Float = 20f
public const val SPACE_XXL24: Float = 24f
// ── Corner radii (dp) ───────────────────────────────────────────────────────
public const val RADIUS_SM8: Float = 8f
public const val RADIUS_MD12: Float = 12f
public const val RADIUS_LG16: Float = 16f
public const val RADIUS_PILL: Float = 999f
// ── Stroke (dp) ──────────────────────────────────────────────────────────────
public const val STROKE_HAIRLINE: Float = 1f
// ── Opacity (dimming multipliers) ────────────────────────────────────────────
public const val OPACITY_STALE: Float = 0.45f // telemetry past its TTL
public const val OPACITY_EXITED: Float = 0.55f // a session that has exited
public const val OPACITY_PRESSED: Float = 0.72f // pressed-state feedback
public const val OPACITY_ACCENT_SOFT: Float = 0.15f // faint accent wash
// ── Layout (dp) ───────────────────────────────────────────────────────────────
public const val MIN_HIT_TARGET: Float = 44f // Material/HIG minimum touch target
// ── Motion (ms) — subtle, eased; ALWAYS gate through reduce-motion ──────────
public const val MOTION_FAST_MS: Int = 180
public const val MOTION_BASE_MS: Int = 250
}

View File

@@ -0,0 +1,179 @@
package wang.yaojia.webterm.designsystem
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import wang.yaojia.webterm.wire.ClaudeStatus
/**
* # Primitives — reusable Compose building blocks (mirrors iOS `Primitives.swift`).
*
* `StatusBadge`, `TelemetryChip`, `WebTermCard`. Each pulls ALL constants from the
* design tokens — no inline magic. Untrusted server strings render as INERT
* [Text] (no Markdown / linkify / AnnotatedString autolink), per plan §8.
*/
/**
* The visual states a status indicator can show — a superset of the wire
* [ClaudeStatus] plus two App-layer emphasis states (mirrors iOS `DisplayStatus`):
* `PendingApproval` ("needs me", outranks status) and `Exited` (read-only).
*/
public enum class DisplayStatus {
Working, Waiting, Idle, Stuck, Unknown, PendingApproval, Exited;
public companion object {
/** Bridge from the wire enum (no pending/exited emphasis — callers add). */
public fun from(status: ClaudeStatus): DisplayStatus = when (status) {
ClaudeStatus.WORKING -> Working
ClaudeStatus.WAITING -> Waiting
ClaudeStatus.IDLE -> Idle
ClaudeStatus.STUCK -> Stuck
ClaudeStatus.UNKNOWN -> Unknown
}
}
}
/**
* Resolved visuals for one status: a semantic [color], a DISTINCT [symbol] glyph
* (so shape alone disambiguates — color-blind safe), and a Chinese [label] (also
* the accessibility description). Pure + deterministic (mirrors iOS `StatusStyle`).
*/
public data class StatusStyle(
val color: Color,
val symbol: String,
val label: String,
) {
public companion object {
public fun of(status: DisplayStatus): StatusStyle = when (status) {
DisplayStatus.Working -> StatusStyle(WebTermColors.statusWorking, "", "运行中")
DisplayStatus.Waiting -> StatusStyle(WebTermColors.statusWaiting, "", "等待中")
DisplayStatus.Idle -> StatusStyle(WebTermColors.statusIdle, "", "空闲")
DisplayStatus.Stuck -> StatusStyle(WebTermColors.statusStuck, "", "卡住")
DisplayStatus.Unknown -> StatusStyle(WebTermColors.statusUnknown, "?", "未知")
DisplayStatus.PendingApproval -> StatusStyle(WebTermColors.statusWaiting, "!", "等待审批")
DisplayStatus.Exited -> StatusStyle(WebTermColors.statusIdle, "", "已退出")
}
public fun of(status: ClaudeStatus): StatusStyle = of(DisplayStatus.from(status))
}
}
/**
* Color + distinct glyph (+ optional Chinese label) for one status. Status is
* conveyed by shape, color AND the semantics label — never color alone.
*/
@Composable
public fun StatusBadge(
status: DisplayStatus,
modifier: Modifier = Modifier,
showsLabel: Boolean = false,
) {
val style = StatusStyle.of(status)
Row(
modifier = modifier.semantics { contentDescription = style.label },
horizontalArrangement = Arrangement.spacedBy(Spacing.xs4),
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = style.symbol, color = style.color, style = MaterialTheme.typography.labelMedium)
if (showsLabel) {
Text(
text = style.label,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelSmall,
)
}
}
}
/**
* One pill of monospaced-tabular telemetry (context %, $cost, model, PR…). Greys
* out when [isStale]; switches to the waiting/amber color when [isWarning].
*/
@Composable
public fun TelemetryChip(
text: String,
modifier: Modifier = Modifier,
isStale: Boolean = false,
isWarning: Boolean = false,
) {
val fg = if (isWarning) WebTermColors.statusWaiting else MaterialTheme.colorScheme.onSurfaceVariant
Text(
text = text,
style = WebTermType.metaMono,
color = fg,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
.alpha(if (isStale) Opacity.stale else 1f)
.background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(Radius.pill))
.padding(horizontal = Spacing.sm8, vertical = Spacing.xs2),
)
}
/**
* Standard card container: card surface + hairline border + `md12` radius +
* standard padding. The uniform card spec for rows, grid cells and panels.
*/
@Composable
public fun WebTermCard(
modifier: Modifier = Modifier,
padding: androidx.compose.ui.unit.Dp = Spacing.md12,
content: @Composable () -> Unit,
) {
androidx.compose.foundation.layout.Box(
modifier = modifier
.background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(Radius.md12))
.border(Stroke.hairline, MaterialTheme.colorScheme.outline, RoundedCornerShape(Radius.md12))
.padding(padding),
) {
content()
}
}
// ── Previews ────────────────────────────────────────────────────────────────
@Preview(name = "StatusBadge")
@Composable
private fun StatusBadgePreview() {
WebTermTheme {
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.md12)) {
DisplayStatus.entries.forEach { StatusBadge(status = it, showsLabel = true) }
}
}
}
@Preview(name = "TelemetryChip")
@Composable
private fun TelemetryChipPreview() {
WebTermTheme {
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
TelemetryChip(text = "ctx 92%", isWarning = true)
TelemetryChip(text = "$0.1234")
TelemetryChip(text = "PR #7", isStale = true)
}
}
}
@Preview(name = "Card")
@Composable
private fun CardPreview() {
WebTermTheme {
WebTermCard {
Text(text = "web-terminal · 161×50", style = WebTermType.metaMono)
}
}
}

View File

@@ -0,0 +1,94 @@
package wang.yaojia.webterm.designsystem
import android.provider.Settings
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.platform.LocalContext
/**
* Whether the OS "remove animations" accessibility setting is on. Motion tokens
* are ALWAYS routed through this (the analogue of iOS `DS.Motion.gated`): when
* true, callers collapse animations to instant. Provided by [WebTermTheme].
*/
public val LocalReduceMotion: androidx.compose.runtime.ProvidableCompositionLocal<Boolean> =
staticCompositionLocalOf { false }
/**
* # WebTermTheme — the app's Material 3 theme (mirrors the iOS `DS` appearance).
*
* Dark-appearance-first: the [darkColorScheme] carries the real design intent;
* the [lightColorScheme] is a faithful light counterpart. The amber-gold accent
* maps to `primary` (with dark `onPrimary` ink — gold needs dark text). Surface
* and text colors come from [WebTermColors]. The terminal canvas colors are NOT
* part of the scheme — they are fixed and read directly from [WebTermColors] by
* the terminal view, independent of app appearance.
*/
@Composable
public fun WebTermTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
val reduceMotion = rememberReduceMotion()
CompositionLocalProvider(LocalReduceMotion provides reduceMotion) {
MaterialTheme(
colorScheme = colorScheme,
typography = WebTermType.ramp,
content = content,
)
}
}
/** Reads the OS animator-duration-scale; 0 means "remove animations" is on. */
@Composable
private fun rememberReduceMotion(): Boolean {
val context = LocalContext.current
return remember(context) {
val scale = Settings.Global.getFloat(
context.contentResolver,
Settings.Global.ANIMATOR_DURATION_SCALE,
1f,
)
scale == 0f
}
}
// The scheme-independent status/accent colors stay in WebTermColors; the scheme
// here only maps surfaces/text/accent into Material 3's slots.
private val DarkColorScheme: ColorScheme = darkColorScheme(
primary = WebTermColors.dark.accent,
onPrimary = WebTermColors.onAccent,
secondary = WebTermColors.dark.accent,
onSecondary = WebTermColors.onAccent,
background = WebTermColors.dark.background,
onBackground = WebTermColors.dark.textPrimary,
surface = WebTermColors.dark.surface,
onSurface = WebTermColors.dark.textPrimary,
surfaceVariant = WebTermColors.dark.card,
onSurfaceVariant = WebTermColors.dark.textSecondary,
outline = WebTermColors.dark.hairline,
error = WebTermColors.statusStuck,
)
private val LightColorScheme: ColorScheme = lightColorScheme(
primary = WebTermColors.light.accent,
onPrimary = WebTermColors.onAccent,
secondary = WebTermColors.light.accent,
onSecondary = WebTermColors.onAccent,
background = WebTermColors.light.background,
onBackground = WebTermColors.light.textPrimary,
surface = WebTermColors.light.surface,
onSurface = WebTermColors.light.textPrimary,
surfaceVariant = WebTermColors.light.card,
onSurfaceVariant = WebTermColors.light.textSecondary,
outline = WebTermColors.light.hairline,
error = WebTermColors.statusStuck,
)

View File

@@ -0,0 +1,116 @@
package wang.yaojia.webterm.designsystem
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* # Tokens — the Compose-facing design vocabulary (mirrors iOS `DS`).
*
* Thin wrappers that lift [DesignSpec]'s raw numbers into Compose types
* (`Color`, `Dp`). Screens/components reference these — never an inline hex, gap
* or radius. Split into small objects that mirror the iOS `DS.*` groups.
*/
/**
* The two adaptive palettes (dark = default) plus the theme-independent tokens
* (accent, semantic status, terminal canvas). The scheme-specific surface/text
* colors are consumed by [WebTermTheme] to build the Material 3 `ColorScheme`.
*/
public object WebTermColors {
// Accent — theme-adaptive; injected once via the color scheme's `primary`.
public val accentDark: Color = Color(DesignSpec.ACCENT_DARK)
public val accentLight: Color = Color(DesignSpec.ACCENT_LIGHT)
public val onAccent: Color = Color(DesignSpec.ON_ACCENT)
/** Faint accent wash (selection/soft highlight) — web --accent-soft. */
public val accentSoft: Color = Color(DesignSpec.ACCENT_DARK).copy(alpha = DesignSpec.OPACITY_ACCENT_SOFT)
// Semantic status (theme-independent — same hex reads on desktop/iOS/Android).
public val statusWorking: Color = Color(DesignSpec.STATUS_WORKING)
public val statusWaiting: Color = Color(DesignSpec.STATUS_WAITING)
public val statusStuck: Color = Color(DesignSpec.STATUS_STUCK)
public val statusIdle: Color = Color(DesignSpec.STATUS_IDLE)
public val statusUnknown: Color = Color(DesignSpec.STATUS_UNKNOWN)
// Timeline event classes (A28).
public val timelineTool: Color = Color(DesignSpec.TIMELINE_TOOL)
public val timelineUser: Color = Color(DesignSpec.TIMELINE_USER)
// Terminal canvas — FIXED, independent of app theme.
public val terminalBackground: Color = Color(DesignSpec.TERMINAL_BACKGROUND)
public val terminalForeground: Color = Color(DesignSpec.TERMINAL_FOREGROUND)
public val terminalCaret: Color = Color(DesignSpec.TERMINAL_CARET)
/** One appearance's surface + text colors. Consumed by [WebTermTheme]. */
@Immutable
public data class Scheme(
val background: Color,
val surface: Color,
val card: Color,
val hairline: Color,
val textPrimary: Color,
val textSecondary: Color,
val textTertiary: Color,
val accent: Color,
)
public val dark: Scheme = Scheme(
background = Color(DesignSpec.DARK_BACKGROUND),
surface = Color(DesignSpec.DARK_SURFACE),
card = Color(DesignSpec.DARK_CARD),
hairline = Color(DesignSpec.DARK_HAIRLINE),
textPrimary = Color(DesignSpec.DARK_TEXT_PRIMARY),
textSecondary = Color(DesignSpec.DARK_TEXT_SECONDARY),
textTertiary = Color(DesignSpec.DARK_TEXT_TERTIARY),
accent = accentDark,
)
public val light: Scheme = Scheme(
background = Color(DesignSpec.LIGHT_BACKGROUND),
surface = Color(DesignSpec.LIGHT_SURFACE),
card = Color(DesignSpec.LIGHT_CARD),
hairline = Color(DesignSpec.LIGHT_HAIRLINE),
textPrimary = Color(DesignSpec.LIGHT_TEXT_PRIMARY),
textSecondary = Color(DesignSpec.LIGHT_TEXT_SECONDARY),
textTertiary = Color(DesignSpec.LIGHT_TEXT_TERTIARY),
accent = accentLight,
)
}
/** Spacing scale — 2·4·8·12·16·20·24 (mirrors `DS.Space`). */
public object Spacing {
public val xs2: Dp = DesignSpec.SPACE_XS2.dp
public val xs4: Dp = DesignSpec.SPACE_XS4.dp
public val sm8: Dp = DesignSpec.SPACE_SM8.dp
public val md12: Dp = DesignSpec.SPACE_MD12.dp
public val lg16: Dp = DesignSpec.SPACE_LG16.dp
public val xl20: Dp = DesignSpec.SPACE_XL20.dp
public val xxl24: Dp = DesignSpec.SPACE_XXL24.dp
}
/** Corner radii (mirrors `DS.Radius`). */
public object Radius {
public val sm8: Dp = DesignSpec.RADIUS_SM8.dp
public val md12: Dp = DesignSpec.RADIUS_MD12.dp
public val lg16: Dp = DesignSpec.RADIUS_LG16.dp
public val pill: Dp = DesignSpec.RADIUS_PILL.dp
}
/** Border widths (mirrors `DS.Stroke`). */
public object Stroke {
public val hairline: Dp = DesignSpec.STROKE_HAIRLINE.dp
}
/** Dimming multipliers (mirrors `DS.Opacity`). */
public object Opacity {
public const val stale: Float = DesignSpec.OPACITY_STALE
public const val exited: Float = DesignSpec.OPACITY_EXITED
public const val pressed: Float = DesignSpec.OPACITY_PRESSED
}
/** Non-spacing layout constants (mirrors `DS.Layout`). */
public object LayoutTokens {
public val minHitTarget: Dp = DesignSpec.MIN_HIT_TARGET.dp
}

View File

@@ -0,0 +1,47 @@
package wang.yaojia.webterm.designsystem
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
/**
* # Typography — the type ramp (mirrors iOS `DS.Typography`).
*
* The proportional ramp is Material 3's default [Typography] (scales with the
* system font-size setting, the a11y "keep it scalable" point). Numbers /
* dimensions / cost / `cols×rows` / timestamps use [monoTabular]: a monospace
* family with **tabular figures** (`tnum`) so columns line up and digits don't
* jitter as values change — the analogue of iOS `DS.Typography.mono(_:)`.
*/
public object WebTermType {
/** Material 3 default ramp — proportional, system-scalable. */
public val ramp: Typography = Typography()
/**
* Monospace + tabular-figures style at the given [size]. Use for anything
* numeric that must align or not jump: `cols×rows`, device/client counts,
* `$cost`, context %, relative timestamps.
*/
public fun monoTabular(size: Int = 13): TextStyle = TextStyle(
fontFamily = FontFamily.Monospace,
fontFeatureSettings = TABULAR_FIGURES,
fontSize = size.sp,
)
/** The canonical meta-number style: caption-sized mono + tabular. */
public val metaMono: TextStyle = monoTabular(size = 12)
/** The terminal-line style: mono at body size (the emulator overrides glyphs). */
public val terminal: TextStyle = TextStyle(
fontFamily = FontFamily.Monospace,
fontFeatureSettings = TABULAR_FIGURES,
fontSize = 14.sp,
fontWeight = FontWeight.Normal,
)
/** OpenType feature string enabling tabular (fixed-advance) numerals. */
private const val TABULAR_FIGURES: String = "tnum"
}

View File

@@ -0,0 +1,77 @@
package wang.yaojia.webterm.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.ConnectionPool
import okhttp3.OkHttpClient
import wang.yaojia.webterm.transport.ClientIdentity
import wang.yaojia.webterm.transport.ClientIdentityProvider
import wang.yaojia.webterm.transport.OkHttpClientFactory
import wang.yaojia.webterm.transport.OkHttpHttpTransport
import wang.yaojia.webterm.transport.OkHttpTermTransport
import wang.yaojia.webterm.tlsandroid.IdentityRepository
import wang.yaojia.webterm.wire.HttpTransport
import wang.yaojia.webterm.wire.TermTransport
import javax.inject.Singleton
/**
* Network boundary (A15): the ONE shared [OkHttpClient] both transports use, the mTLS bridge, and the
* WS + REST transports. One client → the `SSLSocketFactory` + `cache(null)` posture apply uniformly to
* WS and REST, and the connection pool is shared (plan §2/§8).
*
* ### mTLS bridge (A11 [IdentityRepository] → A7 [ClientIdentityProvider])
* `:transport-okhttp` never depends on `:client-tls-android`; this module is the bridge. The provider
* reads the repository's stable `(SSLSocketFactory, X509TrustManager)` pair; the factory is backed by a
* re-reading `X509KeyManager` so a mid-run cert rotation is presented on the NEXT handshake with no
* client rebuild (R4). The provider is read ONCE, when the client is built.
*
* ### Breaking the construction cycle (A11's frozen constructor)
* `AndroidIdentityRepository` needs the client for `connectionPool.evictAll()` on rotation, while the
* client needs the repository's SSL material — a cycle. It is broken with an explicit shared
* [ConnectionPool]: the repository is given a lightweight evict-only client that SHARES that pool
* ([TlsModule]), and the shared client + its WS variant both use the same pool, so `evictAll()` drops
* the real pooled/resumed connections. The graph is then acyclic (client → provider → repo → pool).
*/
@Module
@InstallIn(SingletonComponent::class)
public object NetworkModule {
/** The one connection pool shared by the transports AND the repository's evict-only client. */
@Provides
@Singleton
public fun provideConnectionPool(): ConnectionPool = ConnectionPool()
/** Bridge the mTLS device identity onto the shared client (read once, at client build). */
@Provides
@Singleton
public fun provideClientIdentityProvider(
identityRepository: IdentityRepository,
): ClientIdentityProvider = ClientIdentityProvider {
val material = identityRepository.sslMaterial()
ClientIdentity(material.sslSocketFactory, material.trustManager)
}
/** The single shared client (plan §2 "one OkHttpClient"), pinned to the shared [ConnectionPool]. */
@Provides
@Singleton
public fun provideOkHttpClient(
identityProvider: ClientIdentityProvider,
connectionPool: ConnectionPool,
): OkHttpClient =
OkHttpClientFactory.create(identityProvider)
.newBuilder()
.connectionPool(connectionPool)
.build()
/** WS transport over the shared client (it derives a streaming-tuned variant sharing the pool). */
@Provides
@Singleton
public fun provideTermTransport(client: OkHttpClient): TermTransport = OkHttpTermTransport(client)
/** REST transport over the SAME shared client (Origin stamped by `:api-client`, never here). */
@Provides
@Singleton
public fun provideHttpTransport(client: OkHttpClient): HttpTransport = OkHttpHttpTransport(client)
}

View File

@@ -0,0 +1,22 @@
package wang.yaojia.webterm.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import wang.yaojia.webterm.push.PushRegistrarTokenSink
import wang.yaojia.webterm.push.PushTokenSink
/**
* Push boundary (app-assembly): binds the A31 [PushTokenSink] seam that A30 declared with
* `@BindsOptionalOf` (in `push/PushTokenSink.kt`). With a concrete `@Binds` present, the
* `Optional<PushTokenSink>` [wang.yaojia.webterm.push.FcmService] injects resolves to `Optional.of(...)`
* — the FCM `onNewToken` rotation now reaches [PushRegistrar][wang.yaojia.webterm.push.PushRegistrar]
* (self-heal). This is the intended optional-collaborator handoff (no mutable global).
*/
@Module
@InstallIn(SingletonComponent::class)
public interface PushModule {
@Binds
public fun bindPushTokenSink(impl: PushRegistrarTokenSink): PushTokenSink
}

View File

@@ -0,0 +1,33 @@
package wang.yaojia.webterm.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import wang.yaojia.webterm.hostregistry.HostStore
import wang.yaojia.webterm.wiring.ColdStartPolicy
import wang.yaojia.webterm.wiring.DefaultColdStartPolicy
import javax.inject.Singleton
/**
* Session-wiring boundary (A15): the cold-start route seam.
*
* The per-host / per-session builders (`ApiClientFactory`, `SessionEngineFactory`) and the composition
* root (`AppEnvironment`) are constructor-injected (`@Inject`), so Hilt provides them without an entry
* here — this module only wires the [ColdStartPolicy] interface binding.
*
* NOTE (FIX 1): there is deliberately NO `EventBus` provider. The engine→UI fan-out is PER-SESSION —
* a fresh `EventBus` is minted inside `RetainedSessionHolder.bind()` alongside each engine's confined
* scope, so two sessions never share one bus (an app-wide singleton would cross-talk `Output`/`Gate`
* between sessions since `SessionEvent` carries no session id).
*/
@Module
@InstallIn(SingletonComponent::class)
public object SessionModule {
/** Bind the cold-start seam A29 consumes to the host-presence policy. */
@Provides
@Singleton
public fun provideColdStartPolicy(hostStore: HostStore): ColdStartPolicy =
DefaultColdStartPolicy(hostStore)
}

View File

@@ -0,0 +1,47 @@
package wang.yaojia.webterm.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import wang.yaojia.webterm.hostregistry.DataStoreHostStore
import wang.yaojia.webterm.hostregistry.DataStoreLastSessionStore
import wang.yaojia.webterm.hostregistry.HostStore
import wang.yaojia.webterm.hostregistry.LastSessionStore
import javax.inject.Singleton
/**
* Persistence boundary (A15 → A12): the non-secret UI state stores over ONE Preferences DataStore.
* The host list ([HostStore], key `"hosts"`) and per-host last-session id ([LastSessionStore], key
* `"lastSessionId.<hostId>"`) use disjoint keys, so they safely share one DataStore file. Secret
* material (the device cert) never lives here — that is the Tink store in [TlsModule].
*/
@Module
@InstallIn(SingletonComponent::class)
public object StorageModule {
@Provides
@Singleton
public fun provideDataStore(
@ApplicationContext context: Context,
): DataStore<Preferences> =
PreferenceDataStoreFactory.create { context.preferencesDataStoreFile(DATASTORE_NAME) }
@Provides
@Singleton
public fun provideHostStore(dataStore: DataStore<Preferences>): HostStore =
DataStoreHostStore(dataStore)
@Provides
@Singleton
public fun provideLastSessionStore(dataStore: DataStore<Preferences>): LastSessionStore =
DataStoreLastSessionStore(dataStore)
private const val DATASTORE_NAME: String = "webterm"
}

View File

@@ -0,0 +1,56 @@
package wang.yaojia.webterm.di
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import okhttp3.ConnectionPool
import okhttp3.OkHttpClient
import wang.yaojia.webterm.tlsandroid.AndroidIdentityRepository
import wang.yaojia.webterm.tlsandroid.AndroidKeyStoreImporter
import wang.yaojia.webterm.tlsandroid.CertStore
import wang.yaojia.webterm.tlsandroid.IdentityRepository
import wang.yaojia.webterm.tlsandroid.TinkCertStore
import javax.inject.Singleton
/**
* mTLS device-identity boundary (A15 → A11): the AndroidKeyStore importer (the ONE non-exportable key
* home), the Tink-AEAD-encrypted cert-chain store, and the [IdentityRepository] that ties them to the
* shared client for no-relaunch rotation (`connectionPool.evictAll()`).
*
* The repository's constructor is I/O-free (SSL material is built from standard JCA; keystore/Tink I/O
* is lazy). Per A11's KDoc, the FIRST identity touch (summary / mutation / first handshake) does
* synchronous AndroidKeyStore + Tink work and MUST be triggered off `Dispatchers.Main` — that warm-up
* is A21/A27's concern, not this wiring's.
*
* The repository is given an evict-only client that SHARES [NetworkModule]'s [ConnectionPool] (see the
* cycle-break note there), so `evictAll()` drops the real transports' pooled/resumed connections
* without this module depending on the shared client (which would reintroduce the cycle).
*/
@Module
@InstallIn(SingletonComponent::class)
public object TlsModule {
@Provides
@Singleton
public fun provideKeyStoreImporter(): AndroidKeyStoreImporter = AndroidKeyStoreImporter()
@Provides
@Singleton
public fun provideCertStore(@ApplicationContext context: Context): CertStore = TinkCertStore(context)
@Provides
@Singleton
public fun provideIdentityRepository(
importer: AndroidKeyStoreImporter,
certStore: CertStore,
connectionPool: ConnectionPool,
): IdentityRepository {
// Evict-only client: no mTLS of its own, just the SHARED pool so remove()/rotate()'s
// `connectionPool.evictAll()` clears the connections the real transports pooled (R4/§8).
val evictClient = OkHttpClient.Builder().connectionPool(connectionPool).build()
return AndroidIdentityRepository(importer, certStore, evictClient)
}
}

View File

@@ -0,0 +1,18 @@
package wang.yaojia.webterm.nav
import kotlin.coroutines.cancellation.CancellationException
/**
* Like [runCatching] but NEVER swallows structured-concurrency cancellation: a [CancellationException]
* is rethrown so a cancelled `produceState` / `LaunchedEffect` producer tears down cleanly, while any
* real failure (a DataStore/transport error) degrades to a [Result.failure] the caller maps to a null /
* fallback UI state. `inline` so the suspend calls in [block] inline into the caller's suspend context.
*/
internal inline fun <T> runCatchingCancellable(block: () -> T): Result<T> =
try {
Result.success(block())
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
Result.failure(error)
}

View File

@@ -0,0 +1,170 @@
package wang.yaojia.webterm.nav
import wang.yaojia.webterm.wire.Validation
import java.net.URI
/**
* # DeepLinkRouter (A32) — the ONE deep-link validation surface.
*
* Android analogue of iOS `DeepLinkRouter` (`ios/App/WebTerm/DeepLinkRouter.swift`). Three external
* entry points share ONE whitelist parser so there is never a second, drift-prone regex:
*
* - `webterminal://open?host=<uuid>&join=<uuid>` — the **custom scheme** (registered by the
* `<intent-filter>` on the launcher Activity), and
* - `https://<APP_LINK_HOST>/open?host=<uuid>&join=<uuid>` — the **verified App Link** (an
* `autoVerify=true` `<intent-filter>`, gated by a `assetlinks.json` deploy artifact), and
* - the FCM push-tap payload `{sessionId,…}` (A30 reuses [route] — same UUID rules, no second parser).
*
* ## Security contract (plan §8 "App Links", §1 deep-links row)
* A deep link is EXTERNAL, untrusted input. Every field is whitelist-validated: scheme / action /
* verified host / query keys, and BOTH ids as v4 UUIDs through the frozen [Validation.isValidSessionId]
* (never re-regexed here — M7). ANY invalid, ambiguous (duplicate key), or missing part collapses the
* whole link to [DeepLinkRoute.Ignore] — it is **never partially applied** (a valid host with a bad
* session id does NOT open the host). Ignores are tallied by [DeepLinkTally], never echoed (the URL is
* untrusted — log the counter only, never the link).
*
* ## Purity
* PURE Kotlin/JVM — parses with `java.net.URI` (stdlib, not an Android import), exactly like
* [wang.yaojia.webterm.wire.HostEndpoint]. The caller (the launcher Activity / the A30 push tap)
* converts the platform `android.net.Uri`/`RemoteMessage.data` to a `String`/`Map` and hands it here,
* so this whole surface is JVM-unit-testable with no device. Host EXISTENCE (is this a paired host?)
* is resolved LATER against the `HostStore`, never here — the router only proves syntactic validity.
*/
public object DeepLinkRouter {
/** Custom-scheme name — case-insensitive per RFC 3986, compared lowercased. */
public const val SCHEME: String = "webterminal"
/** Custom-scheme action (the URI "host" of `webterminal://open`). */
public const val ACTION: String = "open"
/**
* The verified App-Links domain. `assetlinks.json` must be served over HTTPS (no redirect) at
* `https://<APP_LINK_HOST>/.well-known/assetlinks.json` carrying the RELEASE signing-cert SHA-256
* (a deploy artifact, plan §8). The tunnel presents a real LE cert for this host.
*/
public const val APP_LINK_HOST: String = "terminal.yaojia.wang"
/** The App-Links path (the https analogue of the custom-scheme [ACTION]). */
public const val APP_LINK_PATH: String = "/open"
/** Query key carrying the paired-host id (a v4 UUID resolved against the store later). */
public const val QUERY_HOST: String = "host"
/** Query key carrying the session id to join (a v4 UUID). */
public const val QUERY_JOIN: String = "join"
/** Push-payload key carrying the gate's session id (payload minimized to `sessionId/cls/token`, §8). */
public const val PUSH_SESSION_ID: String = "sessionId"
/** URI paths accepted for the custom scheme (`webterminal://open` with an empty or root path). */
private val EMPTY_PATHS: Set<String> = setOf("", "/")
/** URI paths accepted for the App Link (`/open`, with or without a trailing slash). */
private val APP_LINK_PATHS: Set<String> = setOf(APP_LINK_PATH, "$APP_LINK_PATH/")
/**
* Parse an incoming deep-link URI string. Handles BOTH the custom scheme and the verified App Link;
* returns [DeepLinkRoute.OpenSession] only when the shape is whitelisted AND both ids are v4 UUIDs,
* else [DeepLinkRoute.Ignore]. Never throws — a malformed URI is caught and ignored.
*/
public fun route(uri: String): DeepLinkRoute {
val parsed = try {
URI(uri)
} catch (_: Exception) {
return DeepLinkRoute.Ignore
}
if (!isWhitelistedShape(parsed)) return DeepLinkRoute.Ignore
val query = parseQuery(parsed.rawQuery)
val hostId = uniqueValidatedId(query, QUERY_HOST) ?: return DeepLinkRoute.Ignore
val sessionId = uniqueValidatedId(query, QUERY_JOIN) ?: return DeepLinkRoute.Ignore
return DeepLinkRoute.OpenSession(hostId = hostId, sessionId = sessionId)
}
/**
* Parse an FCM push-tap payload (A30). The payload is minimized to `sessionId/cls/token`; only a
* v4-valid `sessionId` yields [DeepLinkRoute.GateSession] — there is no host id in the payload
* (A30's notification handler owns host resolution). Anything else → [DeepLinkRoute.Ignore].
*/
public fun route(pushData: Map<String, String>): DeepLinkRoute {
val sessionId = pushData[PUSH_SESSION_ID]?.let(::validatedId) ?: return DeepLinkRoute.Ignore
return DeepLinkRoute.GateSession(sessionId = sessionId)
}
/** True when [uri] matches the custom-scheme OR the verified-App-Link shape (scheme/host/path). */
private fun isWhitelistedShape(uri: URI): Boolean {
val scheme = uri.scheme?.lowercase() ?: return false
val host = uri.host?.lowercase() ?: return false
val path = uri.path ?: ""
return when (scheme) {
SCHEME -> host == ACTION && path in EMPTY_PATHS
"https" -> host == APP_LINK_HOST && path in APP_LINK_PATHS
else -> false
}
}
/**
* Exactly ONE occurrence of [key], and its value is a v4 UUID. Zero occurrences (missing key) or
* duplicates (ambiguous input) → null, so the link is ignored rather than partially applied
* (mirrors iOS `uniqueValidatedId`).
*/
private fun uniqueValidatedId(query: Map<String, List<String>>, key: String): String? {
val values = query[key] ?: return null
if (values.size != 1) return null
return validatedId(values.first())
}
/**
* The frozen-contract v4 check FIRST ([Validation.isValidSessionId], M7). Returns the canonical
* (unchanged) id string when valid, else null. UUIDs never need percent-decoding, so a value that
* is not a bare v4 UUID (encoded, empty, garbage) simply fails the whitelist → ignored.
*/
private fun validatedId(raw: String): String? = if (Validation.isValidSessionId(raw)) raw else null
/**
* Split a raw query string (`a=1&b=2&a=3`) into name → ordered values. Preserves duplicates so
* [uniqueValidatedId] can reject ambiguous links. A segment without `=` maps to an empty value; an
* empty/blank name is dropped. Values are NOT decoded (see [validatedId]).
*/
private fun parseQuery(rawQuery: String?): Map<String, List<String>> {
if (rawQuery.isNullOrEmpty()) return emptyMap()
val out = LinkedHashMap<String, MutableList<String>>()
for (segment in rawQuery.split("&")) {
if (segment.isEmpty()) continue
val eq = segment.indexOf('=')
val name = if (eq >= 0) segment.substring(0, eq) else segment
val value = if (eq >= 0) segment.substring(eq + 1) else ""
if (name.isEmpty()) continue
out.getOrPut(name) { mutableListOf() }.add(value)
}
return out
}
}
/**
* A deep-link parse outcome carrying RAW, syntactically-validated ids (host existence is resolved later
* against the `HostStore`, never here). Mirrors iOS `DeepLinkRouter.Route`.
*/
public sealed interface DeepLinkRoute {
/** `webterminal://open` / verified App Link with both ids valid v4 → open [sessionId] on [hostId]. */
public data class OpenSession(val hostId: String, val sessionId: String) : DeepLinkRoute
/** Push-tap with a valid [sessionId] (no host id in the payload; A30 resolves the host). */
public data class GateSession(val sessionId: String) : DeepLinkRoute
/** Non-whitelisted / ambiguous / invalid / missing input. Never partially applied; tallied. */
public data object Ignore : DeepLinkRoute
}
/**
* Immutable tally of invalid deep links dropped (the Android analogue of iOS
* `DeepLinkHandler.ignoredCount`). Kept as a pure reducer so counting stays side-effect-free and
* JVM-testable; the app holds one instance in its state and swaps in the [record] result. The dropped
* URL is NEVER stored — only the counter — because the link is untrusted external input (§8).
*/
public data class DeepLinkTally(val ignored: Int = 0) {
/** Return a new tally: +1 when [route] is [DeepLinkRoute.Ignore], otherwise unchanged. */
public fun record(route: DeepLinkRoute): DeepLinkTally =
if (route is DeepLinkRoute.Ignore) copy(ignored = ignored + 1) else this
}

View File

@@ -0,0 +1,75 @@
package wang.yaojia.webterm.nav
/**
* # LayoutPolicy (A26) — the single adaptive-layout decision point.
*
* The Android mirror of iOS `LayoutPolicy` (`ios/App/WebTerm/Wiring/LayoutMode.swift`): the **only**
* place that turns a window size class into a layout decision. Views must never scatter
* `if windowSizeClass == …` — every branch reads this one pure predicate, so the decision is 100%
* JVM-unit-testable with plain enum inputs (no Compose, no device — plan §"iPad-equivalent", R13).
*
* The thin `WindowSizeClass → WindowWidth` extraction lives in the Compose layer
* ([wang.yaojia.webterm.screens.AdaptiveHome]); everything HERE is pure Kotlin so it stays in the
* JVM test path exactly like iOS's `LayoutPolicyTests`.
*/
/**
* A plain, Compose-free mirror of `androidx.window.core.layout.WindowWidthSizeClass` (the analogue of
* SwiftUI's `UserInterfaceSizeClass`). Kept as our own enum so [LayoutPolicy.mode] is a pure function
* unit-tested without pulling the window/Compose types onto the JVM test classpath.
*/
public enum class WindowWidth {
/** Phone portrait / small split / Slide-Over — the compact, single-pane path. */
COMPACT,
/** Large phone landscape / small foldable / medium split — wide enough for sidebar + detail. */
MEDIUM,
/** Tablet full-screen / large foldable / desktop-class window — sidebar + detail. */
EXPANDED,
}
/**
* The adaptive root's layout mode (mirrors iOS `LayoutMode.stack` / `.split`).
*
* - [STACK] — the compact path: single-pane navigation (list → push terminal), byte-for-byte the
* existing phone flow.
* - [LIST_DETAIL] — the expanded/medium path: session list (list pane) + terminal (detail pane)
* side by side, driven by Material 3 Adaptive `ListDetailPaneScaffold`.
*/
public enum class LayoutMode {
/** Compact: single pane, stack navigation (iPhone / Slide-Over analogue). */
STACK,
/** Medium / expanded: sidebar + detail split (iPad analogue). */
LIST_DETAIL,
}
/**
* The one size-class decision. [WindowWidth.COMPACT] → [LayoutMode.STACK]; [WindowWidth.MEDIUM] and
* [WindowWidth.EXPANDED] → [LayoutMode.LIST_DETAIL]. Pure; mirrors iOS
* `LayoutPolicy.mode(horizontalSizeClass:)`.
*/
public object LayoutPolicy {
public fun mode(width: WindowWidth): LayoutMode = when (width) {
WindowWidth.COMPACT -> LayoutMode.STACK
WindowWidth.MEDIUM, WindowWidth.EXPANDED -> LayoutMode.LIST_DETAIL
}
}
/**
* The pointer-context-menu gating predicate (plan §5 A26, §"iPad-equivalent", R13, §8).
*
* A right-click / trackpad-secondary context menu is a large-screen affordance only — it is enabled
* **iff** the layout is [LayoutMode.LIST_DETAIL] AND the device is a genuine tablet
* (`smallestScreenWidthDp >= 600`, the Material tablet breakpoint). A large phone in landscape can
* momentarily report an expanded width but keeps `smallestScreenWidthDp < 600`, so the two-part gate
* blocks the desktop menu there. Pure so it is JVM-unit-tested with plain inputs.
*/
public object PointerMenuPolicy {
/** The Material tablet breakpoint (dp). `smallestScreenWidthDp` at/above this = a real large screen. */
public const val TABLET_MIN_WIDTH_DP: Int = 600
public fun enabled(mode: LayoutMode, smallestScreenWidthDp: Int): Boolean =
mode == LayoutMode.LIST_DETAIL && smallestScreenWidthDp >= TABLET_MIN_WIDTH_DP
}

View File

@@ -0,0 +1,266 @@
package wang.yaojia.webterm.nav
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import androidx.window.core.layout.WindowSizeClass
import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.wiring.AppEnvironment
import wang.yaojia.webterm.wiring.ColdStartRoute
/**
* # WebTerm navigation host (app-assembly of A19A29).
*
* The single `NavHost` for the app: it maps each [NavRoutes] destination onto the real screen pane
* ([PairingPane] / [SessionsHome] / [ProjectsHome] / [TerminalHost] / [ProjectDetailPane] / [DiffPane] /
* [ClientCertPane] / [GatePane]), wires the inter-screen callbacks to `navController` navigations, and
* resolves every collaborator (ViewModels, per-host `ApiClient`, the terminal holder) off [env]. The
* start destination is chosen by [wang.yaojia.webterm.wiring.ColdStartPolicy] (surfaced through
* [NavRoutes.startRouteFor]) and passed in as [startRoute].
*
* ## Deep links go through [DeepLinkRouter], NOT `navDeepLink`
* A `navDeepLink { uriPattern = "webterminal://open?host={host}&join={join}" }` would let the OS navigate
* on ANY `{host}`/`{join}` value, bypassing the v4-UUID whitelist. Instead the launcher Activity (and the
* push tap) run [DeepLinkRouter.route] first and navigate only a whitelist-validated [NavRoutes.forDeepLink]
* route — the manifest `<intent-filter>`s deliver the intent; validation is never delegated to the graph.
* That is the single non-skippable check (plan §8 "App Links").
*
* The `NavHost` composition itself is device-QA (plan §7); the pure route mappers ([NavRoutes]) are
* JVM-unit-tested (`DeepLinkRouterTest`, `NavRoutesTest`).
*/
@Composable
public fun WebTermNavHost(
env: AppEnvironment,
startRoute: String,
navController: NavHostController = rememberNavController(),
modifier: Modifier = Modifier,
onHostPaired: (Host) -> Unit = {},
) {
NavHost(
navController = navController,
startDestination = startRoute,
modifier = modifier,
) {
composable(NavRoutes.PAIRING) {
PairingPane(
env = env,
onPaired = { host ->
onHostPaired(host)
navController.navigate(NavRoutes.SESSIONS) {
popUpTo(NavRoutes.PAIRING) { inclusive = true }
launchSingleTop = true
}
},
onImportCert = { navController.navigate(NavRoutes.CERT) },
)
}
composable(NavRoutes.SESSIONS) { SessionsHome(env = env, navController = navController) }
composable(NavRoutes.PROJECTS) { ProjectsHome(env = env, navController = navController) }
composable(NavRoutes.CERT) { ClientCertPane(env = env, onBack = { navController.popBackStack() }) }
composable(
route = TERMINAL_ROUTE,
arguments = listOf(
navArgument(NavArg.HOST_ID) { type = NavType.StringType },
navArgument(NavArg.SESSION_ID) { type = NavType.StringType },
navArgument(NavArg.CWD) {
type = NavType.StringType
nullable = true
defaultValue = null
},
),
) { entry ->
val hostId = entry.arguments?.getString(NavArg.HOST_ID)
TerminalHost(
env = env,
hostId = hostId,
sessionArg = entry.arguments?.getString(NavArg.SESSION_ID),
cwd = entry.arguments?.getString(NavArg.CWD),
onNewSessionInCwd = { req ->
if (hostId != null) navController.navigate(newTerminalRoute(hostId, req.cwd))
},
)
}
composable(
route = PROJECT_DETAIL_ROUTE,
arguments = listOf(
navArgument(NavArg.HOST_ID) { type = NavType.StringType },
navArgument(NavArg.PATH) {
type = NavType.StringType
nullable = true
defaultValue = null
},
),
) { entry ->
ProjectDetailPane(
env = env,
hostId = entry.arguments?.getString(NavArg.HOST_ID),
path = entry.arguments?.getString(NavArg.PATH),
navController = navController,
)
}
composable(
route = DIFF_ROUTE,
arguments = listOf(
navArgument(NavArg.HOST_ID) { type = NavType.StringType },
navArgument(NavArg.PATH) {
type = NavType.StringType
nullable = true
defaultValue = null
},
),
) { entry ->
DiffPane(
env = env,
hostId = entry.arguments?.getString(NavArg.HOST_ID),
path = entry.arguments?.getString(NavArg.PATH),
onBack = { navController.popBackStack() },
)
}
composable(
route = NavRoutes.GATE_PATTERN,
arguments = listOf(navArgument(NavArg.SESSION_ID) { type = NavType.StringType }),
) { entry ->
GatePane(
env = env,
sessionArg = entry.arguments?.getString(NavArg.SESSION_ID),
navController = navController,
)
}
}
}
// ── Registered routes carrying an OPTIONAL/encoded arg (kept OUT of the pure [NavRoutes] object) ──────
/** The registered terminal route: [NavRoutes.TERMINAL_PATTERN] plus an optional `cwd` query arg. */
internal val TERMINAL_ROUTE: String = "${NavRoutes.TERMINAL_PATTERN}?${NavArg.CWD}={${NavArg.CWD}}"
/** The registered project-detail route: hostId path arg + an encoded `path` query arg. */
internal val PROJECT_DETAIL_ROUTE: String = "projectDetail/{${NavArg.HOST_ID}}?${NavArg.PATH}={${NavArg.PATH}}"
/** The registered diff route: hostId path arg + an encoded `path` query arg. */
internal val DIFF_ROUTE: String = "diff/{${NavArg.HOST_ID}}?${NavArg.PATH}={${NavArg.PATH}}"
/**
* Build a concrete route that spawns a NEW session on [hostId] in [cwd] (`attach(null, cwd)`). The cwd
* is percent-encoded with [Uri.encode] here (the Android layer) so [NavRoutes] stays a pure,
* JVM-testable object with no `android.net.Uri` edge.
*/
internal fun newTerminalRoute(hostId: String, cwd: String?): String {
val base = NavRoutes.terminalNew(hostId)
return if (cwd.isNullOrEmpty()) base else "$base?${NavArg.CWD}=${Uri.encode(cwd)}"
}
/** Build a concrete project-detail route for [hostId] + [path] (path percent-encoded). */
internal fun projectDetailRoute(hostId: String, path: String): String =
"projectDetail/$hostId?${NavArg.PATH}=${Uri.encode(path)}"
/** Build a concrete diff route for [hostId] + [path] (path percent-encoded). */
internal fun diffRoute(hostId: String, path: String): String =
"diff/$hostId?${NavArg.PATH}=${Uri.encode(path)}"
/**
* Extract the layout [WindowWidth] from a Compose [WindowSizeClass] (the thin bridge kept out of the pure
* [LayoutPolicy]). Mirrors the Material width breakpoints: EXPANDED (≥840dp) → medium (≥600dp) → compact.
*/
internal fun windowWidthOf(sizeClass: WindowSizeClass): WindowWidth = when {
sizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND) -> WindowWidth.EXPANDED
sizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) -> WindowWidth.MEDIUM
else -> WindowWidth.COMPACT
}
/** Nav-arg keys, shared between the route patterns and the argument readers. */
public object NavArg {
public const val HOST_ID: String = "hostId"
public const val SESSION_ID: String = "sessionId"
/** Optional new-session working directory (query arg on the terminal route). */
public const val CWD: String = "cwd"
/** Project/diff path (encoded query arg). */
public const val PATH: String = "path"
}
/**
* The app's navigation destinations and the pure route mappers. Route strings are the ONE source of
* truth for both [WebTermNavHost] and the callers that navigate; the mappers are pure (no Android edge)
* so the cold-start and deep-link seams stay JVM-unit-testable without a device.
*/
public object NavRoutes {
/** Pairing / add-host flow (A19) — the cold-start landing when no host is paired. */
public const val PAIRING: String = "pairing"
/** Session chooser / dashboard (A20) — the cold-start landing when a host is paired. */
public const val SESSIONS: String = "sessions"
/** Projects grid (A23). */
public const val PROJECTS: String = "projects"
/** Device-certificate management (A27). */
public const val CERT: String = "cert"
/** Terminal route pattern with the required host + session path args (A21). */
public const val TERMINAL_PATTERN: String = "terminal/{${NavArg.HOST_ID}}/{${NavArg.SESSION_ID}}"
/**
* Push-gate route pattern (A30): a session id with NO host id in the payload — the notification
* handler resolves the host, then this destination surfaces the gate for [sessionId].
*/
public const val GATE_PATTERN: String = "gate/{${NavArg.SESSION_ID}}"
/**
* Sentinel session-arg meaning "spawn a NEW session" (`attach(null)`) — a fresh session has no
* server id yet, so the terminal route carries this in the [NavArg.SESSION_ID] slot and
* [attachSessionId] maps it back to `null` at the bind site.
*/
public const val NEW_SESSION_SENTINEL: String = "new"
/**
* The cold-start start destination, keyed off [ColdStartRoute] (no host → pairing, else sessions).
* Pure; mirrors the [wang.yaojia.webterm.wiring.ColdStartPolicy] decision onto a nav route.
*/
public fun startRouteFor(route: ColdStartRoute): String = when (route) {
ColdStartRoute.PAIRING -> PAIRING
ColdStartRoute.SESSIONS -> SESSIONS
}
/** Build a concrete terminal route for a resolved (hostId, sessionId) pair. */
public fun terminal(hostId: String, sessionId: String): String = "terminal/$hostId/$sessionId"
/** Build a concrete terminal route that spawns a NEW session on [hostId] ([NEW_SESSION_SENTINEL]). */
public fun terminalNew(hostId: String): String = "terminal/$hostId/$NEW_SESSION_SENTINEL"
/**
* Map a terminal-route session arg to the id to bind: the [NEW_SESSION_SENTINEL] (a fresh spawn)
* becomes `null` (`attach(null)`), any other value passes through verbatim. Pure → JVM-tested.
*/
public fun attachSessionId(sessionArg: String?): String? =
sessionArg?.takeUnless { it == NEW_SESSION_SENTINEL }
/** Build a concrete push-gate route for a resolved sessionId. */
public fun gate(sessionId: String): String = "gate/$sessionId"
/**
* The deep-link entry point: turn a [DeepLinkRouter] result into the concrete nav route to navigate
* to, or null for [DeepLinkRoute.Ignore] (the caller does nothing — the ignore is already tallied).
* This is the ONE place a validated deep link becomes a navigation, so the launcher Activity and the
* push tap share it. Pure → JVM-tested.
*/
public fun forDeepLink(route: DeepLinkRoute): String? = when (route) {
is DeepLinkRoute.OpenSession -> terminal(route.hostId, route.sessionId)
is DeepLinkRoute.GateSession -> gate(route.sessionId)
DeepLinkRoute.Ignore -> null
}
}

View File

@@ -0,0 +1,236 @@
package wang.yaojia.webterm.nav
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.navigation.NavHostController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.screens.ClientCertScreen
import wang.yaojia.webterm.screens.DiffScreen
import wang.yaojia.webterm.screens.PairingScreen
import wang.yaojia.webterm.screens.ProjectDetailScreen
import wang.yaojia.webterm.viewmodels.ApiClientProjectsGateway
import wang.yaojia.webterm.viewmodels.ClientCertViewModel
import wang.yaojia.webterm.viewmodels.DiffViewModel
import wang.yaojia.webterm.viewmodels.HttpDiffFetcher
import wang.yaojia.webterm.viewmodels.PairingViewModel
import wang.yaojia.webterm.viewmodels.ProjectDetailViewModel
import wang.yaojia.webterm.viewmodels.ProjectsGateway
import wang.yaojia.webterm.wiring.AppEnvironment
import java.util.UUID
// ── Pairing (A19) ─────────────────────────────────────────────────────────────────────────────────────
/**
* Hosts [PairingScreen] over a fresh [PairingViewModel]: the two-step probe uses the shared transports
* (resolved lazily off `Main` inside the probe), and the tunnel cert-gate reads the device identity off
* `Dispatchers.IO`. The screen does not self-bind, so this pane binds it to a lifecycle scope.
*/
@Composable
public fun PairingPane(
env: AppEnvironment,
onPaired: (Host) -> Unit,
onImportCert: () -> Unit,
modifier: Modifier = Modifier,
) {
val viewModel = remember(env) {
PairingViewModel(
hostStore = env.hostStore,
prober = env.buildPairingProber(),
hasDeviceCert = {
withContext(Dispatchers.IO) {
runCatching { env.identityRepository.hasInstalledIdentity() }.getOrDefault(false)
}
},
)
}
LaunchedEffect(viewModel) { viewModel.bind(this) }
PairingScreen(viewModel = viewModel, onPaired = onPaired, onImportCert = onImportCert, modifier = modifier)
}
// ── Device certificate (A27) ────────────────────────────────────────────────────────────────────────
/** Hosts [ClientCertScreen] over a fresh [ClientCertViewModel] on the shared mTLS device identity. */
@Composable
public fun ClientCertPane(
env: AppEnvironment,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val viewModel = remember(env) { ClientCertViewModel(repository = env.identityRepository) }
ClientCertScreen(viewModel = viewModel, onBack = onBack, modifier = modifier)
}
// ── Project detail (A23) ──────────────────────────────────────────────────────────────────────────────
/**
* The project-detail destination: resolves [hostId] → its per-host projects gateway, then renders
* [ProjectDetailContent]. "在此启动 Claude" spawns a new session in the project cwd.
*/
@Composable
public fun ProjectDetailPane(
env: AppEnvironment,
hostId: String?,
path: String?,
navController: NavHostController,
modifier: Modifier = Modifier,
) {
if (hostId == null || path == null) {
MessagePane("无效的项目。", modifier)
return
}
val host by produceState<Host?>(initialValue = null, key1 = hostId) {
value = runCatchingCancellable { env.hostStore.loadAll().firstOrNull { it.id == hostId } }.getOrNull()
}
val resolved = host
if (resolved == null) {
MessagePane("正在打开项目…", modifier)
return
}
val gateway = remember(resolved) { ApiClientProjectsGateway(env.apiClientFactory.create(resolved.endpoint)) }
ProjectDetailContent(
gateway = gateway,
path = path,
onBack = { navController.popBackStack() },
onOpenClaude = { cwd -> navController.navigate(newTerminalRoute(resolved.id, cwd)) },
modifier = modifier,
)
}
/** Shared project-detail body — reused by the stacked destination and the tablet detail pane. */
@Composable
public fun ProjectDetailContent(
gateway: ProjectsGateway,
path: String,
onBack: () -> Unit,
onOpenClaude: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val viewModel = remember(gateway, path) { ProjectDetailViewModel.forGateway(gateway, path) }
ProjectDetailScreen(viewModel = viewModel, onBack = onBack, onOpenClaude = onOpenClaude, modifier = modifier)
}
// ── Diff viewer (A24) ─────────────────────────────────────────────────────────────────────────────────
/**
* The read-only git-diff destination (A24): resolves [hostId] → the RO diff fetcher over the shared
* [HttpTransport][wang.yaojia.webterm.wire.HttpTransport]. Composed for completeness; no inbound link is
* wired yet (the project-detail screen exposes no "view diff" callback to connect).
*/
@Composable
public fun DiffPane(
env: AppEnvironment,
hostId: String?,
path: String?,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
if (hostId == null || path == null) {
MessagePane("无效的路径。", modifier)
return
}
val host by produceState<Host?>(initialValue = null, key1 = hostId) {
value = runCatchingCancellable { env.hostStore.loadAll().firstOrNull { it.id == hostId } }.getOrNull()
}
val resolved = host
if (resolved == null) {
MessagePane("正在加载 diff…", modifier)
return
}
val viewModel = remember(resolved, path) {
DiffViewModel(fetcher = HttpDiffFetcher(resolved.endpoint, env.httpTransport), path = path)
}
DiffScreen(viewModel = viewModel, modifier = modifier, onBack = onBack)
}
// ── Push-gate deep link (A30) ─────────────────────────────────────────────────────────────────────────
/**
* The push-gate destination: a valid session id arrives with NO host (payload minimization, §4.5), so this
* best-effort scans the paired hosts' live sessions for a match and redirects to that host's terminal
* (which surfaces the held gate); if none matches, it falls back to the sessions chooser. All resolution
* runs off `Main`; device-QA.
*/
@Composable
public fun GatePane(
env: AppEnvironment,
sessionArg: String?,
navController: NavHostController,
modifier: Modifier = Modifier,
) {
val sessionId = NavRoutes.attachSessionId(sessionArg)
LaunchedEffect(sessionId) {
val route = gateTargetRoute(env, sessionId)
runCatching {
navController.navigate(route) {
popUpTo(NavRoutes.SESSIONS) { inclusive = false }
launchSingleTop = true
}
}
}
MessagePane("正在定位会话…", modifier)
}
/**
* Resolve where a push-gate deep link should land: the terminal of the host that currently owns
* [sessionId] (which surfaces the held gate), or the sessions chooser when the id is missing/invalid or
* no paired host has it.
*/
private suspend fun gateTargetRoute(env: AppEnvironment, sessionId: String?): String {
if (sessionId == null) return NavRoutes.SESSIONS
val uuid = runCatching { UUID.fromString(sessionId) }.getOrNull() ?: return NavRoutes.SESSIONS
val hostId = resolveHostForSession(env, uuid) ?: return NavRoutes.SESSIONS
return NavRoutes.terminal(hostId, sessionId)
}
/** Scan paired hosts' `GET /live-sessions` for [id]; return the first host that has it (off `Main`). */
private suspend fun resolveHostForSession(env: AppEnvironment, id: UUID): String? =
withContext(Dispatchers.IO) {
for (host in runCatchingCancellable { env.hostStore.loadAll() }.getOrDefault(emptyList())) {
val hasIt = runCatchingCancellable {
env.apiClientFactory.create(host.endpoint).liveSessions().any { it.id == id }
}.getOrDefault(false)
if (hasIt) return@withContext host.id
}
null
}
// ── Shared placeholders ───────────────────────────────────────────────────────────────────────────────
/** Centered informational text for the tablet detail pane's empty state. */
@Composable
public fun DetailPlaceholder(text: String, modifier: Modifier = Modifier) {
CenteredMessage(text, modifier)
}
/** Centered informational text for a loading / invalid-argument pane. */
@Composable
public fun MessagePane(text: String, modifier: Modifier = Modifier) {
CenteredMessage(text, modifier)
}
@Composable
private fun CenteredMessage(text: String, modifier: Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(Spacing.xxl24),
)
}
}

View File

@@ -0,0 +1,103 @@
@file:OptIn(ExperimentalMaterial3AdaptiveApi::class)
package wang.yaojia.webterm.nav
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.screens.AdaptiveHome
import wang.yaojia.webterm.screens.ProjectsScreen
import wang.yaojia.webterm.viewmodels.ApiClientProjectsGateway
import wang.yaojia.webterm.viewmodels.ProjectsViewModel
import wang.yaojia.webterm.wiring.AppEnvironment
/**
* The Projects grid (A23) inside the A26 adaptive shell: [ProjectsScreen] (list pane) beside the selected
* [ProjectDetailContent] (detail pane) on large screens, or a single grid on compact where a card tap
* pushes the project-detail destination. "在此启动 Claude" (from a card or the detail) is routed through
* [ProjectsViewModel.requestOpenClaude] → a validated [ProjectOpenRequest], which this pane consumes into
* a `newTerminalRoute` navigation (`attach(null, cwd=projectPath)`).
*
* Projects run against the FIRST paired host (single-host is the common case; cross-host project selection
* is a device-QA refinement). The navigation-suite "会话" entry returns to [SessionsHome].
*/
@Composable
public fun ProjectsHome(
env: AppEnvironment,
navController: NavHostController,
modifier: Modifier = Modifier,
) {
val host by produceState<Host?>(initialValue = null) {
value = runCatchingCancellable { env.hostStore.loadAll().firstOrNull() }.getOrNull()
}
val resolved = host
if (resolved == null) {
DetailPlaceholder("尚未配对主机。", modifier)
return
}
val gateway = remember(resolved) { ApiClientProjectsGateway(env.apiClientFactory.create(resolved.endpoint)) }
val viewModel = remember(gateway) { ProjectsViewModel(gateway) }
val state by viewModel.uiState.collectAsStateWithLifecycle()
// "Open Claude here" → spawn a new session in the project cwd, then clear the one-shot signal.
LaunchedEffect(state.openRequest) {
val request = state.openRequest ?: return@LaunchedEffect
navController.navigate(newTerminalRoute(resolved.id, request.cwd))
viewModel.consumeOpenRequest()
}
val sizeClass = currentWindowAdaptiveInfo().windowSizeClass
val mode = LayoutPolicy.mode(windowWidthOf(sizeClass))
var selectedPath by rememberSaveable { mutableStateOf<String?>(null) }
AdaptiveHome(
listPane = {
ProjectsScreen(
viewModel = viewModel,
onOpenDetail = { path ->
if (mode == LayoutMode.LIST_DETAIL) {
selectedPath = path
} else {
navController.navigate(projectDetailRoute(resolved.id, path))
}
},
)
},
detailPane = {
val path = selectedPath
if (path != null) {
ProjectDetailContent(
gateway = gateway,
path = path,
onBack = { selectedPath = null },
onOpenClaude = { cwd -> navController.navigate(newTerminalRoute(resolved.id, cwd)) },
)
} else {
DetailPlaceholder("选择一个项目查看详情。")
}
},
selectedDestination = 1,
onSelectDestination = { index ->
if (index == 0) {
navController.navigate(NavRoutes.SESSIONS) {
popUpTo(NavRoutes.SESSIONS) { inclusive = false }
launchSingleTop = true
}
}
},
windowSizeClass = sizeClass,
modifier = modifier,
)
}

View File

@@ -0,0 +1,119 @@
@file:OptIn(ExperimentalMaterial3AdaptiveApi::class)
package wang.yaojia.webterm.nav
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import wang.yaojia.webterm.components.ContinueLastBanner
import wang.yaojia.webterm.components.continueLastModel
import wang.yaojia.webterm.screens.AdaptiveHome
import wang.yaojia.webterm.screens.SessionListScreen
import wang.yaojia.webterm.viewmodels.ApiClientSessionGateway
import wang.yaojia.webterm.viewmodels.SessionListViewModel
import wang.yaojia.webterm.wiring.AppEnvironment
import java.util.UUID
/**
* The sessions landing (A20) inside the A26 adaptive shell: a [SessionListScreen] (list pane) beside the
* live [TerminalHost] (detail pane) on large screens, or a single list on compact where a row tap pushes
* the terminal destination. The [ContinueLastBanner] (A29) is layered above the list. The navigation-suite
* "项目" entry navigates to [ProjectsHome].
*
* ### Adaptive open behaviour
* On [LayoutMode.LIST_DETAIL] a row tap fills the detail pane (a per-session holder keyed under THIS
* back-stack entry); on [LayoutMode.STACK] it pushes a full-screen terminal destination. New-session and
* continue-last always push, so a fresh spawn gets its own nav-scoped holder.
*
* The [SessionListViewModel] host menu drives its own active-host selection; this pane reads the resolved
* active host id from its state to build the terminal endpoint / continue-last pointer.
*/
@Composable
public fun SessionsHome(
env: AppEnvironment,
navController: NavHostController,
modifier: Modifier = Modifier,
) {
val viewModel = remember(env) {
SessionListViewModel(
hostStore = env.hostStore,
gatewayFactory = { host -> ApiClientSessionGateway(env.apiClientFactory.create(host.endpoint)) },
)
}
val state by viewModel.uiState.collectAsStateWithLifecycle()
val activeHostId = state.activeHostId
val sizeClass = currentWindowAdaptiveInfo().windowSizeClass
val mode = LayoutPolicy.mode(windowWidthOf(sizeClass))
var selectedSessionId by rememberSaveable { mutableStateOf<String?>(null) }
val openSession: (UUID) -> Unit = { id ->
val sid = id.toString()
if (mode == LayoutMode.LIST_DETAIL) {
selectedSessionId = sid
} else {
activeHostId?.let { navController.navigate(NavRoutes.terminal(it, sid)) }
}
}
val openNewSession: () -> Unit = {
activeHostId?.let { navController.navigate(NavRoutes.terminalNew(it)) }
}
AdaptiveHome(
listPane = {
Column(modifier = Modifier.fillMaxSize()) {
val lastSessionId by produceState<String?>(initialValue = null, key1 = activeHostId) {
value = activeHostId?.let { runCatchingCancellable { env.lastSessionStore.lastSessionId(it) }.getOrNull() }
}
ContinueLastBanner(
model = continueLastModel(lastSessionId),
onContinue = { sid -> activeHostId?.let { navController.navigate(NavRoutes.terminal(it, sid)) } },
)
Box(modifier = Modifier.weight(1f)) {
SessionListScreen(
viewModel = viewModel,
onOpenSession = openSession,
onNewSession = openNewSession,
onPairHost = { navController.navigate(NavRoutes.PAIRING) },
onImportCert = { navController.navigate(NavRoutes.CERT) },
)
}
}
},
detailPane = {
val sid = selectedSessionId
if (sid != null && activeHostId != null) {
TerminalHost(
env = env,
hostId = activeHostId,
sessionArg = sid,
cwd = null,
onNewSessionInCwd = { req ->
navController.navigate(newTerminalRoute(activeHostId, req.cwd))
},
holderKey = "detail/$activeHostId/$sid",
)
} else {
DetailPlaceholder("选择一个会话查看终端。")
}
},
selectedDestination = 0,
onSelectDestination = { index ->
if (index == 1) navController.navigate(NavRoutes.PROJECTS) { launchSingleTop = true }
},
windowSizeClass = sizeClass,
modifier = modifier,
)
}

View File

@@ -0,0 +1,121 @@
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
package wang.yaojia.webterm.nav
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.delay
import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.screens.NewSessionRequest
import wang.yaojia.webterm.screens.TerminalScreen
import wang.yaojia.webterm.screens.TimelineSheet
import wang.yaojia.webterm.viewmodels.TimelineViewModel
import wang.yaojia.webterm.wiring.AppEnvironment
import wang.yaojia.webterm.wiring.RetainedSessionHolder
import wang.yaojia.webterm.wiring.SessionActivityBridge
import java.util.UUID
/** How long (steps × step-ms) to wait for the holder to publish its controller before giving up. */
private const val BRIDGE_WAIT_STEPS: Int = 200
private const val BRIDGE_WAIT_STEP_MS: Long = 50L
/**
* # TerminalHost — the composition-root host for one terminal destination (A21/A22/A28/A29).
*
* Resolves [hostId] → its paired [Host] (for the dial endpoint), obtains the config-surviving
* [RetainedSessionHolder] (per (host, session) via [hiltViewModel] — the nav back-stack entry is the
* `ViewModelStoreOwner`; [holderKey] scopes a distinct holder when several are hosted under ONE owner,
* e.g. the tablet detail pane), and renders [TerminalScreen]. It adds the two composition-root wirings
* A21 left as seams:
*
* - **A29 last-session lifecycle** — a per-session [SessionActivityBridge] folds the controller's
* control-event stream into [LastSessionStore][wang.yaojia.webterm.hostregistry.LastSessionStore]
* (set-on-`Adopted`, clear-on-`Exited`), so cold-start's「继续上次会话」points at the live session.
* - **A28 timeline** — the away-digest「展开」opens the [TimelineSheet] for the current session, its
* [TimelineViewModel] built over the per-host `ApiClient.events`.
*
* All Compose/lifecycle/emulator behaviour is device-QA (plan §7).
*
* @param sessionArg the raw route session arg ([NavRoutes.NEW_SESSION_SENTINEL] = spawn a new session).
* @param cwd new-session working directory (only meaningful when spawning; ignored on re-attach).
* @param onNewSessionInCwd nav callback for the toolbar/exit "在当前目录开新会话" action.
* @param holderKey optional [hiltViewModel] key so the tablet detail pane can host a holder distinct
* from the stacked terminal destination under the SAME owner (null = the owner's default holder).
*/
@Composable
public fun TerminalHost(
env: AppEnvironment,
hostId: String?,
sessionArg: String?,
cwd: String?,
onNewSessionInCwd: (NewSessionRequest) -> Unit,
modifier: Modifier = Modifier,
holderKey: String? = null,
) {
if (hostId == null) {
MessagePane("无效的主机。", modifier)
return
}
// Resolve the paired host for its dial endpoint (untrusted at rest; a removed host stays "loading").
val host by produceState<Host?>(initialValue = null, key1 = hostId) {
value = runCatchingCancellable { env.hostStore.loadAll().firstOrNull { it.id == hostId } }.getOrNull()
}
val resolved = host
if (resolved == null) {
MessagePane("正在打开会话…", modifier)
return
}
val sessionId = NavRoutes.attachSessionId(sessionArg)
val holder: RetainedSessionHolder = hiltViewModel(key = holderKey ?: "$hostId/$sessionArg")
// A29: drive LastSessionStore off the session's control-event stream once the holder publishes its
// controller (bind() sets it during TerminalScreen's composition, after warm-up). A bounded wait
// avoids a busy-spin if warm-up fails; the collection is cancelled when this composable leaves.
LaunchedEffect(holder, hostId) {
var controller = holder.controller
var waited = 0
while (controller == null && waited < BRIDGE_WAIT_STEPS) {
delay(BRIDGE_WAIT_STEP_MS)
controller = holder.controller
waited++
}
controller?.let { live ->
SessionActivityBridge(lastSessionStore = env.lastSessionStore, hostId = hostId)
.observe(live.controlEvents())
}
}
var showTimeline by remember(hostId, sessionArg) { mutableStateOf(false) }
TerminalScreen(
holder = holder,
endpoint = resolved.endpoint,
sessionId = sessionId,
cwd = cwd,
onNewSessionInCwd = onNewSessionInCwd,
modifier = modifier,
onWarmUp = { env.warmUp() },
onDigestExpand = { if (sessionId != null) showTimeline = true },
)
if (showTimeline && sessionId != null) {
val sessionUuid = remember(sessionId) { runCatching { UUID.fromString(sessionId) }.getOrNull() }
val api = remember(resolved) { env.apiClientFactory.create(resolved.endpoint) }
// A fresh VM each time the sheet opens (this block re-enters composition) → exactly one fetch.
val timelineVm = remember { TimelineViewModel.forSession(sessionUuid) { id -> api.events(id) } }
if (timelineVm != null) {
TimelineSheet(viewModel = timelineVm, onDismiss = { showTimeline = false })
} else {
showTimeline = false
}
}
}

View File

@@ -0,0 +1,114 @@
package wang.yaojia.webterm.push
import android.os.Bundle
import android.util.Log
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricManager.Authenticators
import androidx.biometric.BiometricPrompt
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import wang.yaojia.webterm.api.models.HookDecision
import wang.yaojia.webterm.hostregistry.HostStore
import wang.yaojia.webterm.wiring.ApiClientFactory
import java.util.UUID
import javax.inject.Inject
/**
* Hosts the **Allow** action's BiometricPrompt (plan §8 / R1 mustFix). A `BroadcastReceiver` or
* `Service` CANNOT present a `BiometricPrompt` (it needs a `FragmentActivity` and would blow
* `goAsync()`'s ~10 s budget), so Allow — unlike the auth-free Deny — trampolines through this
* translucent, `excludeFromRecents` Activity (both set in the manifest). It shows only the system
* biometric sheet; on success it fires `POST /hook/decision {decision:"allow"}`, then finishes.
*
* The single-use token arrives in the `FLAG_IMMUTABLE` PendingIntent extras and is handed straight
* to [PushDecisionSubmitter] — never logged, never persisted, never rendered. Any cancel/error/no-auth
* path finishes WITHOUT posting (fail-safe: no approval without a fresh biometric).
*/
@AndroidEntryPoint
public class AllowTrampolineActivity : FragmentActivity() {
@Inject
public lateinit var hostStore: HostStore
@Inject
public lateinit var apiClientFactory: ApiClientFactory
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val sessionId = intent.getStringExtra(NotificationBuilder.EXTRA_SESSION_ID)
?.let { runCatching { UUID.fromString(it) }.getOrNull() }
val token = intent.getStringExtra(NotificationBuilder.EXTRA_TOKEN)
val notificationId = intent.getIntExtra(NotificationBuilder.EXTRA_NOTIFICATION_ID, INVALID_ID)
if (sessionId == null || token.isNullOrEmpty()) {
Log.w(TAG, "allow: missing session id or token; finishing")
finish()
return
}
if (!canAuthenticate()) {
Log.w(TAG, "allow: no biometric/credential available; finishing without approving")
finish()
return
}
promptThenAllow(sessionId, token, notificationId)
}
private fun promptThenAllow(sessionId: UUID, token: String, notificationId: Int) {
val prompt = BiometricPrompt(
this,
ContextCompat.getMainExecutor(this),
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
approveThenFinish(sessionId, token, notificationId)
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
// User cancelled / lockout / hardware error → do NOT approve. Fail-safe.
Log.d(TAG, "allow: auth error $errorCode; finishing without approving")
finish()
}
// onAuthenticationFailed (a single non-match) intentionally left to retry in-prompt.
},
)
prompt.authenticate(promptInfo())
}
private fun approveThenFinish(sessionId: UUID, token: String, notificationId: Int) {
val submitter = PushDecisionSubmitter.fromRegistry(hostStore, apiClientFactory) { Log.d(TAG, it) }
lifecycleScope.launch {
// mTLS build + network off the main thread; finish back on main.
withContext(Dispatchers.IO) { submitter.submit(sessionId, HookDecision.ALLOW, token) }
NotificationManagerCompat.from(this@AllowTrampolineActivity).cancel(notificationId)
finish()
}
}
private fun canAuthenticate(): Boolean =
BiometricManager.from(this).canAuthenticate(ALLOWED_AUTHENTICATORS) ==
BiometricManager.BIOMETRIC_SUCCESS
private fun promptInfo(): BiometricPrompt.PromptInfo =
BiometricPrompt.PromptInfo.Builder()
.setTitle(PROMPT_TITLE)
.setSubtitle(PROMPT_SUBTITLE)
.setAllowedAuthenticators(ALLOWED_AUTHENTICATORS)
.setNegativeButtonText(PROMPT_NEGATIVE)
.build()
private companion object {
const val TAG = "AllowTrampoline"
const val INVALID_ID = -1
// BIOMETRIC_STRONG only (+ a negative button) — the one combination valid on API 29+.
// Device-credential fallback is a §7 device-QA refinement (can't mix with a negative button).
const val ALLOWED_AUTHENTICATORS = Authenticators.BIOMETRIC_STRONG
const val PROMPT_TITLE = "Approve action"
const val PROMPT_SUBTITLE = "Confirm to allow the pending action"
const val PROMPT_NEGATIVE = "Cancel"
}
}

View File

@@ -0,0 +1,70 @@
package wang.yaojia.webterm.push
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.core.app.NotificationManagerCompat
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import wang.yaojia.webterm.api.models.HookDecision
import wang.yaojia.webterm.hostregistry.HostStore
import wang.yaojia.webterm.wiring.ApiClientFactory
import java.util.UUID
import javax.inject.Inject
/**
* Handles the notification **Deny** action (plan §8 notification-action-trust-split). Deny is
* **auth-free and fail-safe**: no BiometricPrompt, no UI, no Activity — it just fires the expedited
* `POST /hook/decision {decision:"deny"}` and dismisses the card. A `BroadcastReceiver` is the right
* host precisely because Deny needs no biometric gate (Allow does — [AllowTrampolineActivity]).
*
* [goAsync] extends the ~10 s receiver budget so the one-shot POST can finish off the main thread.
* The single-use token arrives via the `FLAG_IMMUTABLE` PendingIntent extras and is handed straight
* to [PushDecisionSubmitter] — never logged, never persisted.
*/
@AndroidEntryPoint
public class DenyBroadcastReceiver : BroadcastReceiver() {
@Inject
public lateinit var hostStore: HostStore
@Inject
public lateinit var apiClientFactory: ApiClientFactory
override fun onReceive(context: Context, intent: Intent) {
val sessionId = intent.getStringExtra(NotificationBuilder.EXTRA_SESSION_ID)
?.let { runCatching { UUID.fromString(it) }.getOrNull() }
val token = intent.getStringExtra(NotificationBuilder.EXTRA_TOKEN)
val notificationId = intent.getIntExtra(NotificationBuilder.EXTRA_NOTIFICATION_ID, INVALID_ID)
if (sessionId == null || token.isNullOrEmpty()) {
Log.w(TAG, "deny: missing session id or token; ignoring")
dismiss(context, notificationId)
return
}
val pending = goAsync()
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val submitter = PushDecisionSubmitter.fromRegistry(hostStore, apiClientFactory) { Log.d(TAG, it) }
scope.launch {
try {
submitter.submit(sessionId, HookDecision.DENY, token)
} finally {
dismiss(context, notificationId)
pending.finish()
}
}
}
private fun dismiss(context: Context, notificationId: Int) {
if (notificationId != INVALID_ID) NotificationManagerCompat.from(context).cancel(notificationId)
}
private companion object {
const val TAG = "DenyReceiver"
const val INVALID_ID = -1
}
}

View File

@@ -0,0 +1,75 @@
package wang.yaojia.webterm.push
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresPermission
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import dagger.hilt.android.AndroidEntryPoint
import java.util.Optional
import javax.inject.Inject
/**
* The FCM entry point (plan §1 P1 push / §4.5 / A30). The server sends **data-only** high-priority
* messages (no server `notification` block); this service builds the lock-screen UI LOCALLY:
*
* - [onMessageReceived] parses the untrusted data map into a [PushPayload], maps `cls` → a
* [NotificationBuilder.NotificationPlan], and posts it. A gate (`needs-input`) card carries the
* Allow/Deny actions with their `FLAG_IMMUTABLE` intents; `done` is informational.
* - [onNewToken] forwards the rotated registration token to the A31 [PushTokenSink] seam — this
* service does NOT implement registration (that is A31); it only calls the injected hook.
*
* Injected via Hilt (`@AndroidEntryPoint`). The single-use capability token is only ever placed into
* the action PendingIntents by [NotificationBuilder]; it is never logged here.
*/
@AndroidEntryPoint
public class FcmService : FirebaseMessagingService() {
/** A31 seam: forward token rotations to the registrar. `Optional.empty()` until A31 binds it. */
@Inject
public lateinit var pushTokenSink: Optional<PushTokenSink>
override fun onMessageReceived(message: RemoteMessage) {
val payload = PushPayload.parse(message.data)
if (payload == null) {
Log.w(TAG, "dropping malformed push payload")
return
}
val plan = NotificationBuilder.plan(payload.cls)
if (plan == null) {
Log.d(TAG, "no notification for class '${payload.cls}'")
return
}
if (!hasNotificationPermission()) {
Log.w(TAG, "POST_NOTIFICATIONS not granted; skipping push notification")
return
}
postNotification(payload, plan)
}
override fun onNewToken(token: String) {
// Do NOT implement registration here (A31). Just hand the token to the seam if A31 bound one.
if (pushTokenSink.isPresent) pushTokenSink.get().onTokenRefreshed(token)
}
@RequiresPermission(Manifest.permission.POST_NOTIFICATIONS)
private fun postNotification(payload: PushPayload, plan: NotificationBuilder.NotificationPlan) {
val notification = NotificationBuilder.build(this, payload, plan)
val id = NotificationBuilder.notificationIdFor(payload.sessionId.toString())
NotificationManagerCompat.from(this).notify(id, notification)
}
/** POST_NOTIFICATIONS is a runtime permission on API 33+; below that it is implicitly granted. */
private fun hasNotificationPermission(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
private companion object {
const val TAG = "FcmService"
}
}

View File

@@ -0,0 +1,177 @@
package wang.yaojia.webterm.push
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import wang.yaojia.webterm.MainActivity
/**
* Builds the lock-screen notification LOCALLY (FCM sends data-only; there is no server `notification`
* block — plan §4.5 / R1). The `cls` → channel/title/actions mapping is a PURE function ([plan]) so
* it is JVM-unit-testable and so the trust split is decided in one place:
*
* - `needs-input` (a held gate) → high-importance channel + **Allow** and **Deny** actions.
* - `done` (finished) → default-importance channel, informational, **no actions**.
* - anything else → null (unknown class ⇒ show nothing).
*
* ### Content minimization (plan §4.5 / §8)
* Every visible string ([NotificationPlan.title]/[NotificationPlan.text]) is a static constant derived
* from `cls` ALONE — never the token, cwd, command, or terminal bytes. The single-use token travels
* only inside the action [PendingIntent] extras (Deny → receiver, Allow → trampoline), which are
* `FLAG_IMMUTABLE` (plan §8), and is handed straight to the POST — never rendered, persisted, or logged.
*/
public object NotificationBuilder {
/** The local action buttons a notification can carry (decided by `cls` only). */
public enum class Action { ALLOW, DENY }
/**
* The Android-free description of a notification, derived purely from `cls`. [highImportance]
* maps to the channel importance in [build]; keeping it a boolean (not a `NotificationManager`
* constant) keeps this value pure and JVM-testable.
*/
public data class NotificationPlan(
public val channelId: String,
public val channelName: String,
public val highImportance: Boolean,
public val title: String,
public val text: String,
public val actions: List<Action>,
)
// ── Pure mapping (JVM-tested) ────────────────────────────────────────────────────────────
/** `cls` → [NotificationPlan], or null for an unknown class. Purely a function of [cls]. */
public fun plan(cls: String): NotificationPlan? =
when (cls) {
PushPayload.CLASS_NEEDS_INPUT -> NotificationPlan(
channelId = CHANNEL_GATE,
channelName = CHANNEL_GATE_NAME,
highImportance = true,
title = GATE_TITLE,
text = GATE_TEXT,
actions = listOf(Action.ALLOW, Action.DENY),
)
PushPayload.CLASS_DONE -> NotificationPlan(
channelId = CHANNEL_DONE,
channelName = CHANNEL_DONE_NAME,
highImportance = false,
title = DONE_TITLE,
text = DONE_TEXT,
actions = emptyList(),
)
else -> null
}
// ── Android assembly ─────────────────────────────────────────────────────────────────────
/**
* Turn a [payload] + its [plan] into a real [Notification]. Actions are attached only when the
* plan has them AND the payload actually carries a token (a gate with no token degrades to a
* tap-to-open notification). Every [PendingIntent] is `FLAG_IMMUTABLE`.
*/
public fun build(context: Context, payload: PushPayload, plan: NotificationPlan): Notification {
ensureChannel(context, plan)
val notificationId = notificationIdFor(payload.sessionId.toString())
val builder = NotificationCompat.Builder(context, plan.channelId)
.setSmallIcon(android.R.drawable.stat_notify_chat)
.setContentTitle(plan.title)
.setContentText(plan.text)
.setAutoCancel(true)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setPriority(
if (plan.highImportance) NotificationCompat.PRIORITY_HIGH else NotificationCompat.PRIORITY_DEFAULT,
)
.setContentIntent(openAppIntent(context, notificationId))
if (plan.actions.isNotEmpty() && payload.isActionableGate) {
val token = payload.token!!
for (action in plan.actions) {
builder.addAction(buildAction(context, action, payload.sessionId.toString(), token, notificationId))
}
}
return builder.build()
}
/** Stable per-session id so a newer signal replaces the older card instead of stacking. */
public fun notificationIdFor(sessionId: String): Int = sessionId.hashCode()
private fun buildAction(
context: Context,
action: Action,
sessionId: String,
token: String,
notificationId: Int,
): NotificationCompat.Action {
val (label, pendingIntent) = when (action) {
Action.DENY -> DENY_LABEL to denyIntent(context, sessionId, token, notificationId)
Action.ALLOW -> ALLOW_LABEL to allowIntent(context, sessionId, token, notificationId)
}
return NotificationCompat.Action.Builder(0, label, pendingIntent).build()
}
/** Deny → the auth-free [DenyBroadcastReceiver] (fail-safe, expedited, no UI). */
private fun denyIntent(context: Context, sessionId: String, token: String, notificationId: Int): PendingIntent {
val intent = Intent(context, DenyBroadcastReceiver::class.java).apply {
putExtra(EXTRA_SESSION_ID, sessionId)
putExtra(EXTRA_TOKEN, token)
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
}
return PendingIntent.getBroadcast(context, requestCode(notificationId, action = 2), intent, immutableFlags())
}
/** Allow → the [AllowTrampolineActivity] (hosts BiometricPrompt — a receiver cannot, R1). */
private fun allowIntent(context: Context, sessionId: String, token: String, notificationId: Int): PendingIntent {
val intent = Intent(context, AllowTrampolineActivity::class.java).apply {
putExtra(EXTRA_SESSION_ID, sessionId)
putExtra(EXTRA_TOKEN, token)
putExtra(EXTRA_NOTIFICATION_ID, notificationId)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
return PendingIntent.getActivity(context, requestCode(notificationId, action = 1), intent, immutableFlags())
}
/** Tap-the-body → open the app. Carries NO token (opening the app never needs it). */
private fun openAppIntent(context: Context, notificationId: Int): PendingIntent {
val intent = Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
return PendingIntent.getActivity(context, requestCode(notificationId, action = 0), intent, immutableFlags())
}
private fun ensureChannel(context: Context, plan: NotificationPlan) {
val manager = context.getSystemService(NotificationManager::class.java) ?: return
val importance =
if (plan.highImportance) NotificationManager.IMPORTANCE_HIGH else NotificationManager.IMPORTANCE_DEFAULT
manager.createNotificationChannel(NotificationChannel(plan.channelId, plan.channelName, importance))
}
private fun immutableFlags(): Int = PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
/** Distinct request codes per (notification, action) so Allow/Deny/tap never collide or overwrite. */
private fun requestCode(notificationId: Int, action: Int): Int = notificationId * REQUEST_CODE_STRIDE + action
// ── Intent extra keys (shared with the receiver + trampoline; single source of truth) ────────
public const val EXTRA_SESSION_ID: String = "wang.yaojia.webterm.push.SESSION_ID"
public const val EXTRA_TOKEN: String = "wang.yaojia.webterm.push.TOKEN"
public const val EXTRA_NOTIFICATION_ID: String = "wang.yaojia.webterm.push.NOTIFICATION_ID"
// ── Channels + visible copy (static; token-free by construction) ─────────────────────────────
public const val CHANNEL_GATE: String = "webterm.gate"
public const val CHANNEL_DONE: String = "webterm.done"
private const val CHANNEL_GATE_NAME = "Approval requests"
private const val CHANNEL_DONE_NAME = "Session updates"
private const val GATE_TITLE = "Claude needs your approval"
private const val GATE_TEXT = "A pending action is waiting. Allow or deny it."
private const val DONE_TITLE = "Claude finished"
private const val DONE_TEXT = "Your session is done."
private const val ALLOW_LABEL = "Allow"
private const val DENY_LABEL = "Deny"
private const val REQUEST_CODE_STRIDE = 3
}

View File

@@ -0,0 +1,49 @@
package wang.yaojia.webterm.push
import com.google.firebase.messaging.FirebaseMessaging
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import wang.yaojia.webterm.hostregistry.Host
import javax.inject.Inject
import javax.inject.Singleton
/**
* Drives [PushRegistrar] from the app's lifecycle edges (plan §5 A31, §4.5): it resolves the CURRENT
* FCM registration token from Firebase and (re)registers it with the hosts that need it.
*
* - [registerAllHosts] — **app start**: POST the current token to every paired host.
* - [registerHost] — a **newly paired host**: POST the current token to just that host.
*
* Token rotation self-heals separately through [PushRegistrarTokenSink] (`onNewToken`), so this only
* covers the "app came up" / "host added" edges. The Firebase token fetch is wrapped in `runCatching`
* so a missing `google-services.json` / uninitialised `FirebaseApp` degrades to a no-op instead of
* crashing (best-effort push per R1). The token is handed straight to the registrar and never logged.
*/
@Singleton
public class PushCoordinator @Inject constructor(
private val registrar: PushRegistrar,
) {
/** App-lifetime scope for the fire-and-forget best-effort fan-out. */
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
/** App start: register the current token with every paired host. Best-effort; safe to call repeatedly. */
public fun registerAllHosts() {
withCurrentToken { token -> scope.launch { registrar.registerAllHosts(token) } }
}
/** A newly paired [host]: register the current token with just that host. */
public fun registerHost(host: Host) {
withCurrentToken { token -> scope.launch { registrar.registerHost(host, token) } }
}
/** Resolve the current FCM token, then invoke [use]. A Firebase fault degrades to a no-op. */
private fun withCurrentToken(use: (String) -> Unit) {
runCatching {
FirebaseMessaging.getInstance().token.addOnSuccessListener { token ->
if (!token.isNullOrEmpty()) use(token)
}
}
}
}

View File

@@ -0,0 +1,93 @@
package wang.yaojia.webterm.push
import wang.yaojia.webterm.api.models.HookDecision
import wang.yaojia.webterm.api.routes.ApiClient
import wang.yaojia.webterm.api.routes.ApiClientError
import wang.yaojia.webterm.hostregistry.HostStore
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wiring.ApiClientFactory
import java.util.UUID
/**
* The single, shared decision-POST path used by BOTH the Allow trampoline (after biometric auth)
* and the Deny receiver. Extracted so the token discipline lives in exactly one place (DRY) and is
* JVM-unit-testable behind seams — no `FirebaseMessagingService`/`BiometricPrompt` needed.
*
* ### Which host?
* The push payload carries NO host (content minimization, plan §4.5). The same FCM registration is
* shared across every paired host (A31 registers the one device token per host), so on arrival we
* cannot know which host issued the token. We therefore try each paired host in turn and stop at the
* first that ACCEPTS (`204`). A host that did not issue this single-use token replies `403`
* ([ApiClientError.DecisionRejected]) and we move on — safe because the token is host-scoped and
* single-use, so a foreign host cannot act on it.
*
* ### Token discipline (plan §8, non-negotiable)
* [token] is passed ONLY to [ApiClient.hookDecision]. It is NEVER persisted and NEVER logged: the
* [diagnostics] seam receives outcomes and error *types* only, never the token (nor sessionId). A
* retried-after-success POST returns `403` (server idempotency), which this treats as "not accepted"
* and never crashes on.
*/
public class PushDecisionSubmitter(
private val loadEndpoints: suspend () -> List<HostEndpoint>,
private val clientFor: (HostEndpoint) -> ApiClient,
private val diagnostics: (String) -> Unit = {},
) {
/**
* POST `{ sessionId, decision, token }` to the issuing host. Returns true iff a host accepted.
* Never throws — every failure (no host, transport error, 403/429) is swallowed to a `false`
* so the fail-safe Deny path and the fire-and-forget Allow path stay robust.
*/
public suspend fun submit(sessionId: UUID, decision: HookDecision, token: String): Boolean {
val endpoints = runCatching { loadEndpoints() }.getOrElse {
diagnostics("decision(${decision.wire}): host lookup failed (${it.javaClass.simpleName})")
return false
}
if (endpoints.isEmpty()) {
diagnostics("decision(${decision.wire}): no paired host to post to")
return false
}
for (endpoint in endpoints) {
if (postToOne(endpoint, sessionId, decision, token)) return true
}
diagnostics("decision(${decision.wire}): no host accepted")
return false
}
/** One host attempt. Returns true only on `204`; classifies every other outcome without the token. */
private suspend fun postToOne(
endpoint: HostEndpoint,
sessionId: UUID,
decision: HookDecision,
token: String,
): Boolean =
try {
clientFor(endpoint).hookDecision(sessionId, decision, token)
diagnostics("decision(${decision.wire}): accepted")
true
} catch (_: ApiClientError.DecisionRejected) {
// Wrong host, or a stale/used token (idempotency). Not this host — try the next.
diagnostics("decision(${decision.wire}): rejected by a host")
false
} catch (_: ApiClientError.RateLimited) {
diagnostics("decision(${decision.wire}): rate-limited")
false
} catch (e: Exception) {
// Transport / unexpected status. Best-effort: log the TYPE only, move on.
diagnostics("decision(${decision.wire}): error (${e.javaClass.simpleName})")
false
}
public companion object {
/** Wire the seams from the Hilt graph (used by the Deny receiver and Allow activity). */
public fun fromRegistry(
hostStore: HostStore,
apiClientFactory: ApiClientFactory,
diagnostics: (String) -> Unit = {},
): PushDecisionSubmitter =
PushDecisionSubmitter(
loadEndpoints = { hostStore.loadAll().map { it.endpoint } },
clientFor = { endpoint -> apiClientFactory.create(endpoint) },
diagnostics = diagnostics,
)
}
}

View File

@@ -0,0 +1,53 @@
package wang.yaojia.webterm.push
import java.util.UUID
/**
* The DATA-ONLY FCM payload the server sends (plan §4.5). It carries ONLY three fields:
*
* - [sessionId] — which session the signal is about,
* - [cls] — the notify class (`"needs-input"` = a held gate, `"done"` = finished),
* - [token] — the single-use `/hook/decision` capability token, present for **needs-input only**.
*
* It NEVER carries cwd / command / terminal bytes (content minimization, plan §4.5 / §8). The raw
* `Map<String,String>` from `RemoteMessage.getData()` is untrusted external input, so [parse]
* validates at the boundary and returns null on anything malformed rather than trusting it.
*
* Pure Kotlin (no Android imports) so the parse boundary is JVM-unit-testable.
*/
public data class PushPayload(
public val sessionId: UUID,
public val cls: String,
public val token: String?,
) {
/** True only for a gate signal that actually carries a usable capability token. */
public val isActionableGate: Boolean
get() = cls == CLASS_NEEDS_INPUT && !token.isNullOrEmpty()
public companion object {
public const val KEY_SESSION_ID: String = "sessionId"
public const val KEY_CLS: String = "cls"
public const val KEY_TOKEN: String = "token"
/** A held gate awaiting Allow/Deny — carries the single-use token. */
public const val CLASS_NEEDS_INPUT: String = "needs-input"
/** An informational "session finished" signal — no token, no actions. */
public const val CLASS_DONE: String = "done"
/**
* Boundary validation. Returns null when [sessionId] is absent or not a UUID, or when
* [cls] is absent/blank. [token] stays optional (present only for `needs-input`); it is
* kept verbatim and NEVER logged — see [PushDecisionSubmitter].
*/
public fun parse(data: Map<String, String>): PushPayload? {
val rawId = data[KEY_SESSION_ID]?.trim().orEmptyToNull() ?: return null
val sessionId = runCatching { UUID.fromString(rawId) }.getOrNull() ?: return null
val cls = data[KEY_CLS]?.trim().orEmptyToNull() ?: return null
val token = data[KEY_TOKEN]?.orEmptyToNull()
return PushPayload(sessionId = sessionId, cls = cls, token = token)
}
private fun String?.orEmptyToNull(): String? = this?.takeIf { it.isNotEmpty() }
}
}

View File

@@ -0,0 +1,113 @@
package wang.yaojia.webterm.push
import wang.yaojia.webterm.api.routes.ApiClient
import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.hostregistry.HostStore
import wang.yaojia.webterm.wiring.ApiClientFactory
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.cancellation.CancellationException
/**
* The narrow per-host push-registration surface [PushRegistrar] drives (plan §5 A31, §4.5). Backed
* in production by [ApiClient] (`registerFcmToken`/`unregisterFcmToken` → `POST`/`DELETE
* /push/fcm-token`, whose loose validator + `Origin` guard live server-side, A33); a hand fake stands
* in for the JVM test. Keeping this a two-method seam — instead of coupling the registrar to
* [ApiClient]'s construction (`HostEndpoint` + OkHttp) — is what lets the fan-out / self-heal logic be
* unit-tested with no device or transport.
*/
public interface HostPushApi {
/** `POST /push/fcm-token {token}` — idempotent upsert on the server (re-registering is safe). */
public suspend fun register(token: String)
/** `DELETE /push/fcm-token {token}` — idempotent even for a token the host never knew. */
public suspend fun unregister(token: String)
}
/** Production [HostPushApi] over one host's [ApiClient]. The token is passed straight through to the
* POST/DELETE and is NEVER logged or retained here (plan §8 push-token discipline). */
public class ApiClientHostPushApi(private val api: ApiClient) : HostPushApi {
override suspend fun register(token: String): Unit = api.registerFcmToken(token)
override suspend fun unregister(token: String): Unit = api.unregisterFcmToken(token)
}
/**
* Registers this device's FCM registration token with EVERY paired host so a walked-away phone is
* reachable no matter which host is running the session that needs input (plan §5 A31, §4.5).
*
* ### Self-heal (the token must always be current on every host)
* The token can rotate and the host set can change, so registration is re-driven on every relevant
* edge — the caller (FcmService.onNewToken, the pairing flow, host-list removal, app start) invokes:
* - [registerAllHosts] — **app start** and **token rotation** (re-POST the current token to all hosts);
* - [registerHost] — a **newly paired host** (POST the current token to just that host);
* - [unregisterHost] — a **removed host** (DELETE the token from just that host).
*
* The current token is supplied by the caller (from `FirebaseMessaging.getToken()` or the
* `onNewToken` callback) rather than held here — the registrar stays a stateless, immutable
* coordinator that re-reads [HostStore] on every pass (no cached host list to drift).
*
* ### Best-effort fan-out
* One host being unreachable (a walked-away device, a host that is off) must NOT block registering the
* others. Each host is attempted independently; a non-cancellation failure is routed to [onError]
* (host + throwable, **never the token**) and the loop continues. `CancellationException` is always
* rethrown so structured-concurrency cancellation is honoured.
*
* ### `POST_NOTIFICATIONS` (API 33+) — a dependency, not a gate here
* Registration succeeds regardless of the runtime notification permission: the token is what makes the
* device *reachable*; the permission only gates whether an arriving push can be *shown*. So the
* runtime prompt (with rationale) lives in the UI (device-QA), and this class never blocks on it. The
* `POST_NOTIFICATIONS` manifest permission is already declared.
*
* ### Token discipline
* The token is passed only to the POST/DELETE call and is never logged or persisted here; [onError]
* deliberately receives no token so an error sink can never leak it (plan §8).
*/
@Singleton
public class PushRegistrar internal constructor(
private val hosts: HostStore,
private val apiFor: (Host) -> HostPushApi,
private val onError: (Host, Throwable) -> Unit,
) {
/**
* Production constructor: mints a per-host [ApiClient] over the shared transport ([ApiClientFactory])
* for each paired host. Failures are swallowed at the registrar boundary by default (best-effort
* fan-out); a caller that wants observability supplies its own [onError] via the internal ctor.
*/
@Inject
public constructor(
hosts: HostStore,
apiClientFactory: ApiClientFactory,
) : this(
hosts = hosts,
apiFor = { host -> ApiClientHostPushApi(apiClientFactory.create(host.endpoint)) },
onError = { _, _ -> },
)
/** POST the current [token] to EVERY paired host (app start + token rotation). Best-effort. */
public suspend fun registerAllHosts(token: String) {
for (host in hosts.loadAll()) {
attempt(host) { register(token) }
}
}
/** POST the current [token] to a single newly-paired [host]. Best-effort. */
public suspend fun registerHost(host: Host, token: String) {
attempt(host) { register(token) }
}
/** DELETE the [token] from a single [host] being removed. Best-effort. */
public suspend fun unregisterHost(host: Host, token: String) {
attempt(host) { unregister(token) }
}
private suspend fun attempt(host: Host, call: suspend HostPushApi.() -> Unit) {
try {
apiFor(host).call()
} catch (cancellation: CancellationException) {
throw cancellation
} catch (error: Exception) {
onError(host, error)
}
}
}

View File

@@ -0,0 +1,32 @@
package wang.yaojia.webterm.push
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
/**
* The A31 [PushTokenSink] implementation: a rotated FCM registration token (from
* [FcmService.onNewToken]) is re-registered with EVERY paired host via [PushRegistrar] — the self-heal
* path (plan §4.5). `@Binds` in `di/PushModule` publishes this as the concrete `PushTokenSink`, so the
* `Optional<PushTokenSink>` [FcmService] injects becomes present.
*
* [onTokenRefreshed] is a synchronous fire-and-forget hook (the FCM callback is not `suspend`), so it
* launches the best-effort fan-out on a long-lived app scope. [PushRegistrar.registerAllHosts] already
* swallows per-host failures and re-reads [HostStore][wang.yaojia.webterm.hostregistry.HostStore] on
* every pass, so nothing here retains the token or the host list.
*/
@Singleton
public class PushRegistrarTokenSink @Inject constructor(
private val registrar: PushRegistrar,
) : PushTokenSink {
/** App-lifetime scope for the fire-and-forget re-registration (survives the FCM callback returning). */
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun onTokenRefreshed(token: String) {
scope.launch { registrar.registerAllHosts(token) }
}
}

View File

@@ -0,0 +1,30 @@
package wang.yaojia.webterm.push
import dagger.BindsOptionalOf
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
/**
* The A31 seam. [FcmService.onNewToken] forwards a rotated FCM registration token here; the
* implementation (A31 `PushRegistrar` — `POST /push/fcm-token` per host) is NOT part of A30.
*
* A30 only DECLARES this interface and consumes it optionally, so the FCM client compiles and runs
* standalone before A31 lands. A31 supplies the behaviour with a single `@Binds PushTokenSink`.
*/
public interface PushTokenSink {
/** A new/rotated device token is available — (re)register it with every paired host (A31). */
public fun onTokenRefreshed(token: String)
}
/**
* Makes `Optional<PushTokenSink>` injectable even when NOTHING binds a [PushTokenSink] yet (A31 not
* landed) — Hilt injects `Optional.empty()`. Once A31 adds `@Binds PushTokenSink`, the same injection
* point sees the real sink. This is the clean "optional collaborator" pattern (no mutable global).
*/
@Module
@InstallIn(SingletonComponent::class)
public interface PushSeamModule {
@BindsOptionalOf
public fun optionalPushTokenSink(): PushTokenSink
}

View File

@@ -0,0 +1,120 @@
@file:OptIn(ExperimentalMaterial3AdaptiveApi::class)
package wang.yaojia.webterm.screens
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.window.core.layout.WindowSizeClass
import wang.yaojia.webterm.nav.LayoutMode
import wang.yaojia.webterm.nav.LayoutPolicy
import wang.yaojia.webterm.nav.WindowWidth
/**
* # AdaptiveHome (A26) — the Material 3 Adaptive shell (iPad-equivalent split).
*
* The Android analogue of iOS's adaptive root: a `NavigationSuiteScaffold` (bottom-bar → nav-rail →
* nav-drawer as the window grows) wrapping a layout that switches on [LayoutPolicy]:
*
* - [LayoutMode.LIST_DETAIL] (medium/expanded) → a `ListDetailPaneScaffold` showing the session
* **list pane** and the terminal **detail pane** side by side (plan §"iPad-equivalent").
* - [LayoutMode.STACK] (compact) → a single pane rendering the list; the outer `NavGraph` pushes the
* terminal on top (the existing phone flow). Full inter-screen routing is a later app-assembly step
* (A32), so this shell only exposes the list / detail **slots** — it does NOT hard-wire every screen.
*
* The size-class decision is read ONCE, here, through [LayoutPolicy] (mirroring iOS's single decision
* point); the thin `WindowSizeClass → WindowWidth` extraction is [toWindowWidth]. Everything else — pane
* rendering, navigation-suite chrome, animations — is device-QA (plan §7).
*
* @param listPane the session list / chooser (A20 `SessionListScreen`), shown in the list pane / stack.
* @param detailPane the terminal / detail (A21 `TerminalScreen`, or an empty placeholder), shown in the
* detail pane when [LayoutMode.LIST_DETAIL]. Ignored in [LayoutMode.STACK] (the NavGraph pushes it).
* @param destinations the top-level navigation entries rendered by the navigation suite.
* @param selectedDestination index of the active [destinations] entry.
* @param onSelectDestination invoked when a navigation-suite entry is tapped.
* @param windowSizeClass the current window size class; defaults to `currentWindowAdaptiveInfo()` and is
* injectable so the shell can be driven from a preview / test harness.
*/
@Composable
public fun AdaptiveHome(
listPane: @Composable () -> Unit,
detailPane: @Composable () -> Unit,
modifier: Modifier = Modifier,
destinations: List<AdaptiveDestination> = DEFAULT_DESTINATIONS,
selectedDestination: Int = 0,
onSelectDestination: (Int) -> Unit = {},
windowSizeClass: WindowSizeClass = currentWindowAdaptiveInfo().windowSizeClass,
) {
val mode = LayoutPolicy.mode(windowSizeClass.toWindowWidth())
NavigationSuiteScaffold(
navigationSuiteItems = {
destinations.forEachIndexed { index, destination ->
item(
selected = index == selectedDestination,
onClick = { onSelectDestination(index) },
icon = { Text(destination.glyph) },
label = { Text(destination.label) },
)
}
},
modifier = modifier,
) {
when (mode) {
LayoutMode.LIST_DETAIL -> ListDetailShell(listPane = listPane, detailPane = detailPane)
// Compact: single pane. The NavGraph (A32) owns list→detail push, so the shell renders only
// the list here — it must NOT hard-wire the terminal into the compact path.
LayoutMode.STACK -> Box(modifier = Modifier.fillMaxSize()) { listPane() }
}
}
}
/**
* The side-by-side pane composition. Uses the Material 3 Adaptive `ListDetailPaneScaffold` driven by a
* remembered navigator, so pane visibility / expansion follows the adaptive directive. Each pane is an
* `AnimatedPane` so show/hide transitions are stock.
*/
@Composable
private fun ListDetailShell(
listPane: @Composable () -> Unit,
detailPane: @Composable () -> Unit,
) {
val navigator = rememberListDetailPaneScaffoldNavigator()
ListDetailPaneScaffold(
navigator.scaffoldDirective,
navigator.scaffoldValue,
listPane = { AnimatedPane { listPane() } },
detailPane = { AnimatedPane { detailPane() } },
modifier = Modifier.fillMaxSize(),
)
}
/** One top-level navigation-suite entry: a glyph + a label. Glyph is a plain string to avoid a
* material-icons dependency (not in the `:app` Compose bundle — same rationale as the host menu). */
public data class AdaptiveDestination(val label: String, val glyph: String)
/** The default navigation-suite entries (sessions / projects) — a thin, replaceable default. */
public val DEFAULT_DESTINATIONS: List<AdaptiveDestination> = listOf(
AdaptiveDestination(label = "会话", glyph = ""),
AdaptiveDestination(label = "项目", glyph = ""),
)
/**
* The thin, Compose-side `WindowSizeClass → WindowWidth` extraction (kept OUT of the pure
* [LayoutPolicy] so the decision stays JVM-testable). Uses the modern `isWidthAtLeastBreakpoint`
* predicate rather than the deprecated `windowWidthSizeClass` accessor: EXPANDED (≥840dp) → medium
* (≥600dp) → compact, matching the Material width breakpoints.
*/
private fun WindowSizeClass.toWindowWidth(): WindowWidth = when {
isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND) -> WindowWidth.EXPANDED
isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) -> WindowWidth.MEDIUM
else -> WindowWidth.COMPACT
}

View File

@@ -0,0 +1,377 @@
package wang.yaojia.webterm.screens
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.WebTermColors
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.designsystem.WebTermType
import wang.yaojia.webterm.viewmodels.CertError
import wang.yaojia.webterm.viewmodels.CertPhase
import wang.yaojia.webterm.viewmodels.CertSummaryView
import wang.yaojia.webterm.viewmodels.ClientCertUiState
import wang.yaojia.webterm.viewmodels.ClientCertViewModel
/**
* # ClientCertScreen (A27) — device-certificate (mTLS) management.
*
* The Compose shell over [ClientCertViewModel], reached from the session-list host menu "设备证书"
* (A20). It lets the user **import** a `.p12` device certificate (SAF `ACTION_OPEN_DOCUMENT` + a
* passphrase field), **rotate** it, or **remove** it, and shows the installed identity's summary
* (subject-CN / issuer-CN / expiry, with an **EXPIRED** warning). A **distinct screen**, not folded
* into pairing (plan §1/§5 A27).
*
* ### Secrets & trust discipline (plan §8)
* - The passphrase is entered in a masked field, handed straight to the ViewModel with the file bytes,
* and cleared from UI state the instant the import/rotation is dispatched — it is never logged,
* persisted, or echoed. The private key lands non-exportably in AndroidKeyStore inside the
* repository (the "one key home"); this screen never sees it.
* - A bad passphrase can NOT clobber a previously-installed cert: the import validates before
* persisting, so a failure leaves the prior summary on screen and only surfaces an inert error.
* - Every cert-derived string (CNs, expiry) is inert [Text] — no autolink / markdown / `AnnotatedString`.
* Error copy is app-authored, never a server/exception string.
*
* The SAF picker + AndroidKeyStore/Tink I/O it drives are device-QA (plan §7); the state machine is
* JVM-tested in `ClientCertViewModelTest`.
*
* @param viewModel the device-cert presenter (the nav layer builds it over the shared `IdentityRepository`).
* @param onBack optional up-navigation back to the host menu.
*/
@Composable
public fun ClientCertScreen(
viewModel: ClientCertViewModel,
modifier: Modifier = Modifier,
onBack: (() -> Unit)? = null,
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
// Bind to the LaunchedEffect scope (cancelled when the screen leaves composition), then load.
LaunchedEffect(viewModel) { viewModel.bind(this) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
var passphrase by remember { mutableStateOf("") }
// Which action the pending SAF pick feeds: false = first import, true = rotate an existing cert.
var pendingRotate by remember { mutableStateOf(false) }
val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
// Capture then immediately scrub the secret from UI state — the VM now owns it for the handoff.
val secret = passphrase
val rotate = pendingRotate
passphrase = ""
scope.launch {
val bytes = withContext(Dispatchers.IO) {
runCatching { context.contentResolver.openInputStream(uri)?.use { it.readBytes() } }.getOrNull()
} ?: ByteArray(0) // an unreadable/empty pick decode-fails → surfaces as an INVALID_FILE error
if (rotate) viewModel.rotate(bytes, secret) else viewModel.import(bytes, secret)
}
}
if (state.confirmingRemove) {
RemoveConfirmDialog(onConfirm = viewModel::confirmRemove, onDismiss = viewModel::cancelRemove)
}
ClientCertScreen(
state = state,
passphrase = passphrase,
onPassphraseChange = { passphrase = it },
onImport = { pendingRotate = false; picker.launch(P12_MIME_TYPES) },
onRotate = { pendingRotate = true; picker.launch(P12_MIME_TYPES) },
onRemove = viewModel::requestRemove,
onDismissError = viewModel::clearError,
modifier = modifier,
onBack = onBack,
)
}
/** Stateless body — pure inputs so a [Preview] and any host can drive it without the SAF/VM machinery. */
@Composable
public fun ClientCertScreen(
state: ClientCertUiState,
passphrase: String,
onPassphraseChange: (String) -> Unit,
onImport: () -> Unit,
onRotate: () -> Unit,
onRemove: () -> Unit,
onDismissError: () -> Unit,
modifier: Modifier = Modifier,
onBack: (() -> Unit)? = null,
) {
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(Spacing.lg16),
verticalArrangement = Arrangement.spacedBy(Spacing.md12),
) {
Header(onBack = onBack)
when (state.phase) {
CertPhase.LOADING -> LoadingRow()
CertPhase.READY, CertPhase.BUSY -> {
val busy = state.phase == CertPhase.BUSY
val summary = state.summary
if (summary != null) {
InstalledCertCard(summary)
} else {
Text(
text = "尚未安装设备证书。隧道主机mTLS需要先在此导入 .p12 证书。",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
state.error?.let { ErrorCard(error = it, onDismiss = onDismissError) }
PassphraseField(
passphrase = passphrase,
onPassphraseChange = onPassphraseChange,
enabled = !busy,
)
CertActions(
hasCert = summary != null,
enabled = !busy,
onImport = onImport,
onRotate = onRotate,
onRemove = onRemove,
)
if (busy) LoadingRow()
}
}
}
}
}
@Composable
private fun Header(onBack: (() -> Unit)?) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
if (onBack != null) {
TextButton(onClick = onBack) { Text("返回") }
}
Text(text = "设备证书", style = MaterialTheme.typography.headlineSmall)
}
}
/** The installed identity summary: subject/issuer CN, expiry, and a prominent EXPIRED warning. */
@Composable
private fun InstalledCertCard(summary: CertSummaryView) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(Spacing.lg16),
verticalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
SummaryRow(label = "设备CN", value = summary.subjectCommonName)
SummaryRow(label = "签发方CN", value = summary.issuerCommonName)
SummaryRow(label = "到期", value = summary.expiry)
if (summary.isExpired) {
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
Text(
text = "⚠ 证书已过期,无法用于连接。请更换一份有效证书。",
style = MaterialTheme.typography.bodyMedium,
color = WebTermColors.statusStuck,
)
}
}
}
}
@Composable
private fun SummaryRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(Spacing.md12),
) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
// Cert-derived value: inert monospaced Text — no linkify/markdown (plan §8).
Text(text = value, style = WebTermType.monoTabular(13), color = MaterialTheme.colorScheme.onSurface)
}
}
@Composable
private fun ErrorCard(error: CertError, onDismiss: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(Spacing.md12),
verticalArrangement = Arrangement.spacedBy(Spacing.xs4),
) {
Text(
text = errorCopy(error),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
)
TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) { Text("知道了") }
}
}
}
@Composable
private fun PassphraseField(passphrase: String, onPassphraseChange: (String) -> Unit, enabled: Boolean) {
OutlinedTextField(
value = passphrase,
onValueChange = onPassphraseChange,
label = { Text("证书口令(.p12 密码)") },
singleLine = true,
enabled = enabled,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth(),
)
}
@Composable
private fun CertActions(
hasCert: Boolean,
enabled: Boolean,
onImport: () -> Unit,
onRotate: () -> Unit,
onRemove: () -> Unit,
) {
if (hasCert) {
Button(onClick = onRotate, enabled = enabled, modifier = Modifier.fillMaxWidth()) {
Text("更换证书…")
}
OutlinedButton(onClick = onRemove, enabled = enabled, modifier = Modifier.fillMaxWidth()) {
Text("移除证书")
}
} else {
Button(onClick = onImport, enabled = enabled, modifier = Modifier.fillMaxWidth()) {
Text("导入设备证书…")
}
}
}
@Composable
private fun RemoveConfirmDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("移除设备证书?") },
text = { Text("移除后,需要 mTLS 的隧道主机将无法连接,直到你重新导入证书。") },
confirmButton = { TextButton(onClick = onConfirm) { Text("移除") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } },
)
}
@Composable
private fun LoadingRow() {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
CircularProgressIndicator()
}
}
/** Maps the coarse [CertError] to inert, app-authored copy (never an exception/server string, §8). */
private fun errorCopy(error: CertError): String = when (error) {
CertError.BAD_PASSPHRASE -> "证书口令不正确。原有证书保持不变,请检查口令后重试。"
CertError.INVALID_FILE -> "无法读取该文件:它不是有效的 .p12 证书。"
CertError.NO_IDENTITY -> "该 .p12 里没有可用的私钥(可能只含证书)。请选择包含私钥的证书文件。"
CertError.IMPORT_FAILED -> "导入证书失败。请重试。"
CertError.REMOVE_FAILED -> "移除证书失败。请重试。"
}
/** SAF filter for PKCS#12 material; some providers tag `.p12`/`.pfx` as octet-stream, so include it. */
private val P12_MIME_TYPES = arrayOf(
"application/x-pkcs12",
"application/pkcs12",
"application/octet-stream",
)
@Preview(name = "ClientCertScreen — installed")
@Composable
private fun ClientCertScreenInstalledPreview() {
WebTermTheme {
ClientCertScreen(
state = ClientCertUiState(
summary = CertSummaryView(
subjectCommonName = "t1-android",
issuerCommonName = "webterm-device-ca",
expiry = "2027年6月15日",
isExpired = false,
),
phase = CertPhase.READY,
),
passphrase = "",
onPassphraseChange = {},
onImport = {},
onRotate = {},
onRemove = {},
onDismissError = {},
)
}
}
@Preview(name = "ClientCertScreen — expired + none")
@Composable
private fun ClientCertScreenExpiredPreview() {
WebTermTheme {
ClientCertScreen(
state = ClientCertUiState(
summary = CertSummaryView("t1-android", "webterm-device-ca", "2020年1月1日", isExpired = true),
phase = CertPhase.READY,
error = CertError.BAD_PASSPHRASE,
),
passphrase = "",
onPassphraseChange = {},
onImport = {},
onRotate = {},
onRemove = {},
onDismissError = {},
)
}
}

View File

@@ -0,0 +1,263 @@
package wang.yaojia.webterm.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.Stroke
import wang.yaojia.webterm.designsystem.WebTermColors
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.designsystem.WebTermType
import wang.yaojia.webterm.viewmodels.DiffBinaryRow
import wang.yaojia.webterm.viewmodels.DiffFileHeaderRow
import wang.yaojia.webterm.viewmodels.DiffHunkHeaderRow
import wang.yaojia.webterm.viewmodels.DiffLineKind
import wang.yaojia.webterm.viewmodels.DiffLineRow
import wang.yaojia.webterm.viewmodels.DiffPhase
import wang.yaojia.webterm.viewmodels.DiffRow
import wang.yaojia.webterm.viewmodels.DiffUiState
import wang.yaojia.webterm.viewmodels.DiffViewModel
/**
* # DiffScreen (A24) — the read-only staged/unstaged git-diff viewer.
*
* Renders the presenter's flattened files→hunks→lines list in a `LazyColumn`, with a Working/Staged
* toggle in the header. Every server-derived string (paths, hunk headers, code lines) is rendered as
* **inert monospaced [Text]** — plain `Text`, never `ClickableText`/`LinkAnnotation`/autolink/markdown
* — so a hostile diff cannot inject a tappable link or markup (plan §8). Line kinds carry the A13
* colour tokens (added → green, removed → red). Layout/interaction is device-QA (plan §7).
*/
@Composable
public fun DiffScreen(
state: DiffUiState,
onSelectStaged: (Boolean) -> Unit,
modifier: Modifier = Modifier,
onRefresh: () -> Unit = {},
onBack: (() -> Unit)? = null,
) {
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(modifier = Modifier.fillMaxSize()) {
DiffHeader(staged = state.staged, onSelectStaged = onSelectStaged, onBack = onBack)
if (state.truncated) {
DiffNotice("Diff truncated — too large to display fully.")
}
HorizontalDivider(color = MaterialTheme.colorScheme.outline, thickness = Stroke.hairline)
Box(modifier = Modifier.fillMaxSize()) {
when (state.phase) {
DiffPhase.IDLE, DiffPhase.LOADING -> CenteredContent { CircularProgressIndicator() }
DiffPhase.EMPTY -> CenteredMessage("No changes")
DiffPhase.ERROR -> DiffError(onRetry = onRefresh)
DiffPhase.LOADED -> DiffList(rows = state.rows)
}
}
}
}
}
/**
* Stateful entry point: binds [viewModel] to a lifecycle scope, collects its state, and wires the
* toggle/refresh callbacks. The nav layer supplies the already-constructed presenter (host + path).
*/
@Composable
public fun DiffScreen(
viewModel: DiffViewModel,
modifier: Modifier = Modifier,
onBack: (() -> Unit)? = null,
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
// Bind to the LaunchedEffect scope (cancelled when this screen leaves composition), then load.
LaunchedEffect(viewModel) { viewModel.bind(this) }
DiffScreen(
state = state,
onSelectStaged = viewModel::selectStaged,
modifier = modifier,
onRefresh = viewModel::refresh,
onBack = onBack,
)
}
@Composable
private fun DiffHeader(
staged: Boolean,
onSelectStaged: (Boolean) -> Unit,
onBack: (() -> Unit)?,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
if (onBack != null) {
TextButton(onClick = onBack) { Text("Back") }
}
Text(
text = "Diff",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onBackground,
)
Spacer(modifier = Modifier.width(Spacing.sm8))
FilterChip(
selected = !staged,
onClick = { onSelectStaged(false) },
label = { Text("Working") },
)
FilterChip(
selected = staged,
onClick = { onSelectStaged(true) },
label = { Text("Staged") },
)
}
}
@Composable
private fun DiffList(rows: List<DiffRow>) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(items = rows, key = { it.id }) { row ->
when (row) {
is DiffFileHeaderRow -> FileHeader(row)
is DiffHunkHeaderRow -> DiffText(row.header, MaterialTheme.colorScheme.primary)
is DiffLineRow -> DiffText(markerFor(row.kind) + row.text, lineColor(row.kind))
is DiffBinaryRow -> DiffText("Binary file", MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
@Composable
private fun FileHeader(row: DiffFileHeaderRow) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = row.path,
style = WebTermType.monoTabular(13),
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text("+${row.added}", style = WebTermType.metaMono, color = WebTermColors.statusWorking)
Text("-${row.removed}", style = WebTermType.metaMono, color = WebTermColors.statusStuck)
}
}
/** One inert monospaced diff line. No linkify/markdown — a hostile diff renders as literal text (§8). */
@Composable
private fun DiffText(text: String, color: Color) {
Text(
text = text,
style = WebTermType.monoTabular(13),
color = color,
softWrap = false,
maxLines = 1,
overflow = TextOverflow.Clip,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Spacing.md12, vertical = 1.dp),
)
}
@Composable
private fun DiffError(onRetry: () -> Unit) {
CenteredContent {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Failed to load diff.", color = MaterialTheme.colorScheme.onBackground)
TextButton(onClick = onRetry) { Text("Retry") }
}
}
}
@Composable
private fun DiffNotice(message: String) {
Text(
text = message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Spacing.md12, vertical = Spacing.xs4),
)
}
@Composable
private fun CenteredMessage(message: String) {
CenteredContent {
Text(message, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
private fun CenteredContent(content: @Composable () -> Unit) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { content() }
}
/** A13 colour token per line kind (added → green, removed → red; context/hunk/meta from the scheme). */
@Composable
private fun lineColor(kind: DiffLineKind): Color = when (kind) {
DiffLineKind.ADDED -> WebTermColors.statusWorking
DiffLineKind.REMOVED -> WebTermColors.statusStuck
DiffLineKind.META -> MaterialTheme.colorScheme.onSurfaceVariant
DiffLineKind.HUNK -> MaterialTheme.colorScheme.primary
DiffLineKind.CONTEXT -> MaterialTheme.colorScheme.onSurface
}
/** The gutter marker the server stripped off; re-added purely for readability (kept 1-col wide). */
private fun markerFor(kind: DiffLineKind): String = when (kind) {
DiffLineKind.ADDED -> "+ "
DiffLineKind.REMOVED -> "- "
else -> " "
}
@Preview(name = "DiffScreen — loaded")
@Composable
private fun DiffScreenPreview() {
val rows = listOf<DiffRow>(
DiffFileHeaderRow(0, "src/app/Main.kt", "modified", added = 2, removed = 1),
DiffHunkHeaderRow(1, "@@ -1,3 +1,4 @@"),
DiffLineRow(2, DiffLineKind.CONTEXT, "fun main() {"),
DiffLineRow(3, DiffLineKind.REMOVED, " println(\"old\")"),
DiffLineRow(4, DiffLineKind.ADDED, " println(\"new\")"),
DiffLineRow(5, DiffLineKind.ADDED, " println(\"added\")"),
DiffLineRow(6, DiffLineKind.CONTEXT, "}"),
)
WebTermTheme {
DiffScreen(
state = DiffUiState(staged = false, phase = DiffPhase.LOADED, rows = rows, truncated = true),
onSelectStaged = {},
)
}
}

View File

@@ -0,0 +1,379 @@
package wang.yaojia.webterm.screens
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.ExperimentalGetImage
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import androidx.camera.view.LifecycleCameraController
import androidx.camera.view.PreviewView
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.viewmodels.EntryError
import wang.yaojia.webterm.viewmodels.PairingUiState
import wang.yaojia.webterm.viewmodels.PairingViewModel
import wang.yaojia.webterm.viewmodels.WarningTier
import wang.yaojia.webterm.viewmodels.needsExplicitAck
/**
* # PairingScreen (A19) — QR scan / manual URL entry → confirm-before-network → probe → warning tiers.
*
* The Compose shell over [PairingViewModel]. Two ways in — a **CameraX + ML Kit** QR analyzer or a
* **manual URL field** — both funnel their string through the ViewModel's single
* [HostEndpoint.fromBaseUrl][wang.yaojia.webterm.wire.HostEndpoint] validator; NEITHER auto-connects
* (confirm-before-network, plan §5). The confirm card renders the §5.4 warning tier; a public host
* requires an explicit acknowledge, and a tunnel host routes to the device-cert screen when the probe is
* cert-gated. Every displayed URL/host is INERT [Text] (no autolink/markdown, plan §8).
*
* ### Permissions
* - **CAMERA** (QR only) is requested at runtime with a rationale; a denial degrades gracefully to
* manual entry (the QR path is never mandatory).
* - **POST_NOTIFICATIONS** is NOT requested here — push registration owns that prompt (A31); pairing
* only establishes the host.
*
* All Compose / CameraX / permission behaviour is device-QA (plan §7); the tier/gate logic it drives is
* JVM-tested in `PairingViewModelTest`.
*
* @param viewModel the pairing state machine (nav layer builds it with the real [pairingProber]).
* @param onPaired invoked once with the persisted [Host] after a successful pair.
* @param onImportCert opens the device-cert screen (A27) when a tunnel host is cert-gated.
*/
@Composable
public fun PairingScreen(
viewModel: PairingViewModel,
onPaired: (Host) -> Unit,
onImportCert: () -> Unit,
modifier: Modifier = Modifier,
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Column(
modifier = modifier
.fillMaxSize()
.padding(Spacing.lg16),
verticalArrangement = Arrangement.spacedBy(Spacing.md12),
) {
Text(text = "配对主机", style = MaterialTheme.typography.headlineSmall)
when (val s = state) {
is PairingUiState.Entry -> EntryStep(
error = s.error,
onManual = viewModel::onManualEntry,
onScanned = viewModel::onQrScanned,
)
is PairingUiState.Confirming -> ConfirmStep(
state = s,
onName = viewModel::setName,
onConfirm = viewModel::confirm,
onCancel = viewModel::reset,
)
is PairingUiState.Probing -> ProbingStep(host = s.name)
is PairingUiState.CertRequired -> CertRequiredStep(
host = s.name,
onImportCert = onImportCert,
onRetry = { viewModel.retry() },
onCancel = viewModel::reset,
)
is PairingUiState.Failed -> FailedStep(
message = s.message,
needsAck = s.tier.needsExplicitAck,
onRetry = { ack -> viewModel.retry(acknowledgedPublicRisk = ack) },
onCancel = viewModel::reset,
)
is PairingUiState.Paired -> LaunchedEffect(s.host) { onPaired(s.host) }
}
}
}
// ── Entry: manual URL or QR scan ─────────────────────────────────────────────────────────────────────
@Composable
private fun EntryStep(
error: EntryError?,
onManual: (String) -> Unit,
onScanned: (String) -> Unit,
) {
var scanning by remember { mutableStateOf(false) }
var url by remember { mutableStateOf("") }
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
TextButton(onClick = { scanning = false }) { Text(if (scanning) "手动输入" else "● 手动输入") }
TextButton(onClick = { scanning = true }) { Text(if (scanning) "● 扫码" else "扫码") }
}
if (scanning) {
QrScanner(onScanned = onScanned)
Text("将服务器分享二维码对准取景框。", style = MaterialTheme.typography.bodySmall)
return
}
OutlinedTextField(
value = url,
onValueChange = { url = it },
label = { Text("主机地址http(s)://…)") },
singleLine = true,
isError = error != null,
modifier = Modifier.fillMaxWidth(),
)
if (error == EntryError.INVALID_URL) {
Text(
text = "请输入有效的 http(s):// 地址。",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
Button(
onClick = { onManual(url) },
enabled = url.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) { Text("下一步") }
}
/**
* The CameraX + ML Kit QR analyzer, gated on the CAMERA runtime permission. A denial shows a rationale
* + re-request; the caller always offers the manual path so QR is never mandatory.
*/
@Composable
private fun QrScanner(onScanned: (String) -> Unit) {
val context = LocalContext.current
var granted by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED,
)
}
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
granted = it
}
LaunchedEffect(Unit) {
if (!granted) launcher.launch(Manifest.permission.CAMERA)
}
if (granted) {
CameraPreview(onScanned = onScanned)
} else {
Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
Text("需要相机权限才能扫描配对二维码。你也可以改用手动输入。")
Button(onClick = { launcher.launch(Manifest.permission.CAMERA) }) { Text("授予相机权限") }
}
}
}
@Composable
private fun CameraPreview(onScanned: (String) -> Unit) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val controller = remember { LifecycleCameraController(context) }
DisposableEffect(controller, lifecycleOwner) {
controller.setImageAnalysisAnalyzer(
ContextCompat.getMainExecutor(context),
QrCodeAnalyzer(onScanned),
)
controller.bindToLifecycle(lifecycleOwner)
onDispose { controller.unbind() }
}
AndroidView(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f),
factory = { ctx -> PreviewView(ctx).also { it.controller = controller } },
)
}
// ── Confirm: warning tier + name + (public) acknowledge ──────────────────────────────────────────────
@Composable
private fun ConfirmStep(
state: PairingUiState.Confirming,
onName: (String) -> Unit,
onConfirm: (Boolean) -> Unit,
onCancel: () -> Unit,
) {
var acknowledged by remember(state.endpoint) { mutableStateOf(false) }
TierWarningCard(tier = state.tier, highlight = state.awaitingAck && !acknowledged)
// The dialed URL is validated but user/QR-sourced — render INERT (plan §8).
Text(
text = state.endpoint.baseUrl,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
OutlinedTextField(
value = state.name,
onValueChange = onName,
label = { Text("名称") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
if (state.tier.needsExplicitAck) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = acknowledged, onCheckedChange = { acknowledged = it })
Text("我了解这是公网地址的风险,仍要连接。")
}
}
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text("取消") }
Button(
onClick = { onConfirm(acknowledged) },
enabled = !state.tier.needsExplicitAck || acknowledged,
modifier = Modifier.weight(1f),
) { Text("连接") }
}
}
@Composable
private fun TierWarningCard(tier: WarningTier, highlight: Boolean) {
val (title, body) = tierCopy(tier)
val container =
if (highlight || tier == WarningTier.PUBLIC) MaterialTheme.colorScheme.errorContainer
else MaterialTheme.colorScheme.surfaceVariant
Card(colors = CardDefaults.cardColors(containerColor = container), shape = RoundedCornerShape(Spacing.md12)) {
Column(modifier = Modifier.padding(Spacing.md12), verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
Text(text = title, style = MaterialTheme.typography.titleSmall)
Text(text = body, style = MaterialTheme.typography.bodySmall)
}
}
}
private fun tierCopy(tier: WarningTier): Pair<String, String> = when (tier) {
WarningTier.LOOPBACK -> "本机地址" to "指向本机,安全。"
WarningTier.PRIVATE_LAN -> "局域网地址" to "同一局域网内以 ws:// 明文传输(未加密)。"
WarningTier.TAILSCALE -> "Tailscale 地址" to "流量由 WireGuard 端到端加密。"
WarningTier.TUNNEL -> "隧道主机" to "需要已安装的设备证书mTLS才能连接。"
WarningTier.PUBLIC -> "公网地址(高风险)" to "任何能访问该地址的人都可能连到你的终端。请确认这是你信任的主机。"
}
// ── Probing / cert-required / failed ─────────────────────────────────────────────────────────────────
@Composable
private fun ProbingStep(host: String) {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
CircularProgressIndicator()
Text("正在验证 $host", style = MaterialTheme.typography.bodyMedium)
}
}
}
@Composable
private fun CertRequiredStep(
host: String,
onImportCert: () -> Unit,
onRetry: () -> Unit,
onCancel: () -> Unit,
) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
shape = RoundedCornerShape(Spacing.md12),
) {
Column(modifier = Modifier.padding(Spacing.md12), verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
Text("需要设备证书", style = MaterialTheme.typography.titleSmall)
Text("$host 是隧道主机必须先导入设备证书mTLS才能配对。", style = MaterialTheme.typography.bodySmall)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text("取消") }
Button(onClick = onImportCert, modifier = Modifier.weight(1f)) { Text("导入证书") }
}
TextButton(onClick = onRetry, modifier = Modifier.fillMaxWidth()) { Text("已导入,重试") }
}
@Composable
private fun FailedStep(
message: String,
needsAck: Boolean,
onRetry: (Boolean) -> Unit,
onCancel: () -> Unit,
) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
shape = RoundedCornerShape(Spacing.md12),
) {
// Inert copy (app-authored, never a raw server string — plan §8).
Text(text = message, modifier = Modifier.padding(Spacing.md12), style = MaterialTheme.typography.bodyMedium)
}
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) { Text("返回") }
// A prior public-host ack was consumed reaching the probe; carry it through the retry.
Button(onClick = { onRetry(needsAck) }, modifier = Modifier.weight(1f)) { Text("重试") }
}
}
// ── ML Kit QR analyzer ───────────────────────────────────────────────────────────────────────────────
/**
* Extracts the first QR raw value from a CameraX frame and reports it ONCE. `@ExperimentalGetImage` is
* required to read the backing `android.media.Image`; the analyzer always closes the [ImageProxy] so the
* pipeline never stalls.
*/
@ExperimentalGetImage
private class QrCodeAnalyzer(private val onQr: (String) -> Unit) : ImageAnalysis.Analyzer {
private val scanner = BarcodeScanning.getClient(
BarcodeScannerOptions.Builder().setBarcodeFormats(Barcode.FORMAT_QR_CODE).build(),
)
private var reported = false
override fun analyze(imageProxy: ImageProxy) {
val media = imageProxy.image
if (media == null || reported) {
imageProxy.close()
return
}
val input = InputImage.fromMediaImage(media, imageProxy.imageInfo.rotationDegrees)
scanner.process(input)
.addOnSuccessListener { barcodes ->
barcodes.firstNotNullOfOrNull { it.rawValue }?.let { value ->
if (!reported) {
reported = true
onQr(value)
}
}
}
.addOnCompleteListener { imageProxy.close() }
}
}

View File

@@ -0,0 +1,243 @@
package wang.yaojia.webterm.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.models.ProjectSessionRef
import wang.yaojia.webterm.api.models.WorktreeInfo
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.Stroke
import wang.yaojia.webterm.designsystem.WebTermCard
import wang.yaojia.webterm.designsystem.WebTermColors
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.designsystem.WebTermType
import wang.yaojia.webterm.viewmodels.ProjectDetailViewModel
import wang.yaojia.webterm.viewmodels.ProjectsCopy
/**
* # ProjectDetailScreen (A23) — one project's detail (branch · worktrees · sessions · CLAUDE.md) plus
* "open Claude here". Mirrors web `renderProjectDetail` / iOS `ProjectDetailScreen`.
*
* Every server string (name/path/branch/worktree/CLAUDE.md body) is rendered as **inert [Text]** — no
* autolink/markdown (plan §8); the CLAUDE.md body is shown verbatim in a monospaced block. The three
* failure buckets ([ProjectDetailViewModel.Failure]) map to copy + a retry action.
*
* @param onBack pop back to the projects grid.
* @param onOpenClaude open a new session in the project cwd (`attach(null, cwd)`); the nav layer routes
* it through [wang.yaojia.webterm.viewmodels.ProjectsViewModel.requestOpenClaude] (path re-validated).
*/
@Composable
public fun ProjectDetailScreen(
viewModel: ProjectDetailViewModel,
onBack: () -> Unit,
onOpenClaude: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val phase by viewModel.phase.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
LaunchedEffect(viewModel) { viewModel.load() }
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(modifier = Modifier.fillMaxSize()) {
DetailHeaderBar(onBack = onBack)
HorizontalDivider(color = MaterialTheme.colorScheme.outline, thickness = Stroke.hairline)
Box(modifier = Modifier.fillMaxSize()) {
when (val current = phase) {
is ProjectDetailViewModel.Phase.Loading -> Centered { CircularProgressIndicator() }
is ProjectDetailViewModel.Phase.Failed ->
Failure(current.failure, onRetry = { scope.launch { viewModel.load() } })
is ProjectDetailViewModel.Phase.Loaded ->
DetailBody(detail = current.detail, onOpenClaude = onOpenClaude)
}
}
}
}
}
@Composable
private fun DetailHeaderBar(onBack: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
TextButton(onClick = onBack) { Text("← 全部项目") }
}
}
@Composable
private fun DetailBody(detail: ProjectDetail, onOpenClaude: (String) -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(Spacing.md12),
verticalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
Text(
text = detail.name,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
detail.branch?.let { Text(text = it, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant) }
if (detail.dirty == true) Text(text = "", color = WebTermColors.statusWaiting)
}
Text(text = detail.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
SectionTitle(if (detail.worktrees.size > 1) "工作树" else "分支")
if (!detail.isGit) {
EmptyLine("不是 git 仓库。")
} else if (detail.worktrees.isEmpty()) {
EmptyLine(detail.branch?.let { "当前分支 $it" } ?: "无工作树信息。")
} else {
for (worktree in detail.worktrees) WorktreeRow(worktree)
}
val running = detail.sessions.filter { !it.exited }
SectionTitle("运行中的会话(${running.size}")
if (running.isEmpty()) {
EmptyLine("没有运行中的会话 —— 在下方开一个。")
} else {
for (session in running) SessionRow(session)
}
SectionTitle("CLAUDE.md")
val claudeMd = detail.claudeMd
if (detail.hasClaudeMd && claudeMd != null) {
ClaudeMdBlock(claudeMd)
} else {
EmptyLine("还没有 CLAUDE.md —— 生成一个以给 Claude 项目专属指令。")
}
TextButton(onClick = { onOpenClaude(detail.path) }) { Text("在此启动 Claude") }
}
}
@Composable
private fun WorktreeRow(worktree: WorktreeInfo) {
val label = worktree.branch ?: worktree.head?.let { "detached @ $it" } ?: "detached"
WebTermCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), verticalAlignment = Alignment.CenterVertically) {
Text(text = label, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
if (worktree.isMain) Tag("main")
if (worktree.isCurrent) Tag("current")
if (worktree.locked == true) Tag("locked")
}
Text(text = worktree.path, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
@Composable
private fun SessionRow(session: ProjectSessionRef) {
WebTermCard(modifier = Modifier.fillMaxWidth()) {
Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm8), verticalAlignment = Alignment.CenterVertically) {
Text(
text = session.title ?: "session",
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(text = "👁 ${session.clientCount}", style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@Composable
private fun ClaudeMdBlock(text: String) {
WebTermCard(modifier = Modifier.fillMaxWidth()) {
// Inert monospaced block — CLAUDE.md is server content; never linkify/markdown (§8).
Text(text = text, style = WebTermType.monoTabular(12), color = MaterialTheme.colorScheme.onSurface)
}
}
@Composable
private fun Tag(text: String) {
Text(text = text, style = WebTermType.metaMono, color = MaterialTheme.colorScheme.primary)
}
@Composable
private fun SectionTitle(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.padding(top = Spacing.sm8),
)
}
@Composable
private fun EmptyLine(text: String) {
Text(text = text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
@Composable
private fun Failure(failure: ProjectDetailViewModel.Failure, onRetry: () -> Unit) {
val message = when (failure) {
ProjectDetailViewModel.Failure.PATH_INVALID -> "项目路径为空或不合法。"
ProjectDetailViewModel.Failure.NOT_FOUND -> "项目不存在(路径可能已移动或删除)。"
ProjectDetailViewModel.Failure.UNAVAILABLE -> "读取项目详情失败,请稍后再试。"
}
Centered {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(Spacing.sm8)) {
Text(text = message, color = MaterialTheme.colorScheme.onBackground)
TextButton(onClick = onRetry) { Text("重试") }
}
}
}
@Composable
private fun Centered(content: @Composable () -> Unit) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { content() }
}
@Preview(name = "ProjectDetailScreen — loaded")
@Composable
private fun ProjectDetailScreenPreview() {
val detail = ProjectDetail(
name = "Acme.Web.frontend",
path = "/home/dev/acme-web",
isGit = true,
branch = "main",
dirty = true,
worktrees = emptyList(),
sessions = emptyList(),
hasClaudeMd = true,
claudeMd = "# CLAUDE.md\n\nProject instructions…",
)
WebTermTheme {
DetailBody(detail = detail, onOpenClaude = {})
}
}

View File

@@ -0,0 +1,350 @@
package wang.yaojia.webterm.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import wang.yaojia.webterm.api.models.ProjectInfo
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.WebTermCard
import wang.yaojia.webterm.designsystem.WebTermColors
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.designsystem.WebTermType
import wang.yaojia.webterm.viewmodels.ProjectGroup
import wang.yaojia.webterm.viewmodels.ProjectGroupKind
import wang.yaojia.webterm.viewmodels.ProjectGrouping
import wang.yaojia.webterm.viewmodels.ProjectsCopy
import wang.yaojia.webterm.viewmodels.ProjectsUiState
import wang.yaojia.webterm.viewmodels.ProjectsViewModel
/**
* # ProjectsScreen (A23) — the grouped Projects grid (mirrors web v0.6 / iOS `ProjectsScreen`).
*
* Renders the presenter's [ProjectsUiState.groups] as collapsible namespace sections of project cards.
* Every server-derived string (name/path/branch) is rendered as **inert [Text]** — plain `Text`, never
* `ClickableText`/`LinkAnnotation`/autolink/markdown — so a hostile project name/branch can't inject a
* tappable link (plan §8). The regular-width grid column count comes from the pure [ProjectsGridLayout]
* seam; A26 owns the full adaptive (ListDetail/NavigationSuite) layout. All Compose/grid/gesture
* behaviour is device-QA (plan §7).
*
* @param onOpenDetail open the [ProjectDetailScreen] for a tapped project path.
*/
@Composable
public fun ProjectsScreen(
viewModel: ProjectsViewModel,
onOpenDetail: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
// Load once on entry (prefs + projects); the screen supplies the lifecycle scope for toggles.
LaunchedEffect(viewModel) { viewModel.load() }
ProjectsScreen(
state = state,
onSearchChange = viewModel::onSearchChange,
onToggleFavourite = { path -> scope.launch { viewModel.toggleFavourite(path) } },
onToggleCollapsed = { key -> scope.launch { viewModel.toggleCollapsed(key) } },
onOpenDetail = onOpenDetail,
onOpenClaude = viewModel::requestOpenClaude,
modifier = modifier,
)
}
@Composable
public fun ProjectsScreen(
state: ProjectsUiState,
onSearchChange: (String) -> Unit,
onToggleFavourite: (String) -> Unit,
onToggleCollapsed: (String) -> Unit,
onOpenDetail: (String) -> Unit,
onOpenClaude: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Surface(modifier = modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(modifier = Modifier.fillMaxSize()) {
SearchField(value = state.searchText, onValueChange = onSearchChange)
state.fetchError?.let { InlineNotice(it) }
state.prefsSyncError?.let { InlineNotice(it) }
state.openError?.let { InlineNotice(it) }
val emptyMessage = state.emptyStateMessage
if (emptyMessage != null) {
EmptyState(emptyMessage)
} else {
ProjectsGrid(
state = state,
onToggleFavourite = onToggleFavourite,
onToggleCollapsed = onToggleCollapsed,
onOpenDetail = onOpenDetail,
onOpenClaude = onOpenClaude,
)
}
}
}
}
@Composable
private fun ProjectsGrid(
state: ProjectsUiState,
onToggleFavourite: (String) -> Unit,
onToggleCollapsed: (String) -> Unit,
onOpenDetail: (String) -> Unit,
onOpenClaude: (String) -> Unit,
) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val columns = ProjectsGridLayout.columnCount(maxWidth)
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(Spacing.md12),
verticalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
for (group in state.groups) {
if (group.kind != ProjectGroupKind.FLAT) {
item(key = "hdr:${group.key}") {
GroupHeader(
group = group,
isCollapsed = state.isCollapsed(group),
onToggle = { onToggleCollapsed(group.key) },
)
}
}
if (!state.isCollapsed(group)) {
val rows = group.projects.chunked(columns)
rows.forEachIndexed { index, rowProjects ->
item(key = "row:${group.key}:$index") {
CardRow(
rowProjects = rowProjects,
columns = columns,
groupKey = group.key,
state = state,
onToggleFavourite = onToggleFavourite,
onOpenDetail = onOpenDetail,
onOpenClaude = onOpenClaude,
)
}
}
}
}
}
}
}
@Composable
private fun CardRow(
rowProjects: List<ProjectInfo>,
columns: Int,
groupKey: String,
state: ProjectsUiState,
onToggleFavourite: (String) -> Unit,
onOpenDetail: (String) -> Unit,
onOpenClaude: (String) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
for (project in rowProjects) {
Box(modifier = Modifier.weight(1f)) {
ProjectCard(
project = project,
displayName = ProjectGrouping.displayLabel(project.name, groupKey),
isFavourite = state.isFavourite(project.path),
onToggleFavourite = { onToggleFavourite(project.path) },
onOpenDetail = { onOpenDetail(project.path) },
onOpenClaude = { onOpenClaude(project.path) },
)
}
}
// Pad a short final row so cards keep a uniform column width.
repeat(columns - rowProjects.size) { Spacer(modifier = Modifier.weight(1f)) }
}
}
@Composable
private fun ProjectCard(
project: ProjectInfo,
displayName: String,
isFavourite: Boolean,
onToggleFavourite: () -> Unit,
onOpenDetail: () -> Unit,
onOpenClaude: () -> Unit,
) {
WebTermCard(modifier = Modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs4)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Spacing.xs4),
) {
Text(
text = "",
color = if (isFavourite) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.clickable(onClick = onToggleFavourite),
)
Text(
text = displayName,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).clickable(onClick = onOpenDetail),
)
if (project.dirty == true) {
// Dirty badge: uncommitted changes. Inert glyph (§8).
Text(text = "", color = WebTermColors.statusWaiting)
}
}
project.branch?.let { branch ->
Text(
text = branch,
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
val running = project.sessions.count { !it.exited }
if (running > 0) {
Text(
text = ProjectsCopy.activeCountBadge(running),
style = WebTermType.metaMono,
color = MaterialTheme.colorScheme.primary,
)
}
TextButton(onClick = onOpenClaude) { Text(ProjectsCopy.OPEN_CLAUDE_LABEL) }
}
}
}
@Composable
private fun GroupHeader(
group: ProjectGroup,
isCollapsed: Boolean,
onToggle: () -> Unit,
) {
val caret = when {
!group.isCollapsible -> ""
isCollapsed -> ""
else -> ""
}
val base = Modifier.fillMaxWidth().padding(vertical = Spacing.xs4)
val rowModifier = if (group.isCollapsible) base.clickable(onClick = onToggle) else base
Row(
modifier = rowModifier,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
Text(text = caret, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
text = group.label,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onBackground,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(text = group.projects.size.toString(), style = WebTermType.metaMono, color = MaterialTheme.colorScheme.onSurfaceVariant)
if (group.kind != ProjectGroupKind.ACTIVE && group.activeCount > 0) {
Text(text = ProjectsCopy.activeCountBadge(group.activeCount), style = WebTermType.metaMono, color = MaterialTheme.colorScheme.primary)
}
}
}
@Composable
private fun SearchField(value: String, onValueChange: (String) -> Unit) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
singleLine = true,
placeholder = { Text(ProjectsCopy.SEARCH_PROMPT) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Spacing.md12, vertical = Spacing.sm8),
)
}
@Composable
private fun InlineNotice(message: String) {
Text(
text = message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.xs4),
)
}
@Composable
private fun EmptyState(message: String) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(Spacing.xxl24),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(Spacing.md12, Alignment.CenterVertically),
) {
Text(text = message, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
/**
* The pure regular-width → column-count seam (mirrors iOS `ProjectsGridLayout`). A23 supplies the
* regular grid; **A26 owns the full adaptive layout** (ListDetail/NavigationSuite + iPad detents), so
* this stays a single pure decision point, JVM-testable, with no view-tree conditionals.
*/
public object ProjectsGridLayout {
/** Rise to two columns at this available width (a phone stays single-column). */
public val twoColumnMinWidth: Dp = 560.dp
/** Rise to three columns on a wide tablet / desktop-class panel. */
public val threeColumnMinWidth: Dp = 840.dp
/** Always `>= 1` (never 0 → never an empty grid). */
public fun columnCount(availableWidth: Dp): Int = when {
availableWidth >= threeColumnMinWidth -> 3
availableWidth >= twoColumnMinWidth -> 2
else -> 1
}
}
@Preview(name = "ProjectsScreen — empty")
@Composable
private fun ProjectsScreenPreview() {
WebTermTheme {
ProjectsScreen(
state = ProjectsUiState(hasLoadedOnce = true),
onSearchChange = {},
onToggleFavourite = {},
onToggleCollapsed = {},
onOpenDetail = {},
onOpenClaude = {},
)
}
}

View File

@@ -0,0 +1,320 @@
@file:OptIn(ExperimentalMaterial3Api::class)
package wang.yaojia.webterm.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.launch
import wang.yaojia.webterm.components.SessionListRow
import wang.yaojia.webterm.components.SessionThumbnails
import wang.yaojia.webterm.designsystem.Radius
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.viewmodels.HostOption
import wang.yaojia.webterm.viewmodels.SessionListUiState
import wang.yaojia.webterm.viewmodels.SessionListViewModel
import wang.yaojia.webterm.viewmodels.SessionRow
import java.util.UUID
/**
* # SessionListScreen (A20) — the home session chooser + dashboard + host menu.
*
* The Compose shell over [SessionListViewModel]. It is the app's landing surface (plan §1: home is a
* session chooser, not an auto-tab) AND its only settings surface — the top-bar overflow is the host
* menu (multi-host switch with a checkmark, plus **配对新主机** → A19 pairing and **设备证书** → A27
* cert screen).
*
* ### What it wires (all logic lives in the JVM-tested ViewModel)
* - **STARTED-scoped poll** (plan §6.6): [SessionListViewModel.poll] runs inside
* `repeatOnLifecycle(STARTED)`, so `GET /live-sessions` polling stops the instant the app backgrounds
* and resumes on return — never in the background.
* - **Rows** (§1): status badge · telemetry chips · preview thumbnail · cols×rows · sanitized title ·
* unread dot — rendered by [SessionListRow]; opening a row clears its dot ([SessionListViewModel.markSeen]).
* - **Swipe-to-kill**: a trailing `SwipeToDismissBox` calls [SessionListViewModel.killSession]
* (optimistic remove; 204 and 404 = success; a real failure restores the row).
* - **Pull-to-refresh**: a `PullToRefreshBox` drives [SessionListViewModel.refresh].
*
* Every server-influenced string on this screen (titles, cwd, telemetry) is rendered inert by
* [SessionListRow] (plan §8). All Compose / gesture / thumbnail behaviour is device-QA (plan §7).
*
* @param onOpenSession open the terminal (A21) for a tapped session id (its dot is cleared first).
* @param onNewSession start a new session on the active host (`attach(null)`; A21 owns the terminal).
* @param onPairHost host-menu **配对新主机** → the pairing flow (A19).
* @param onImportCert host-menu **设备证书** → the device-cert screen (A27).
* @param thumbnails off-screen preview seam; production wires it to the active host's
* [ThumbnailPipeline][wang.yaojia.webterm.wiring.ThumbnailPipeline] (§6.7). `null` = placeholder tiles.
*/
@Composable
public fun SessionListScreen(
viewModel: SessionListViewModel,
onOpenSession: (UUID) -> Unit,
onNewSession: () -> Unit,
onPairHost: () -> Unit,
onImportCert: () -> Unit,
modifier: Modifier = Modifier,
thumbnails: SessionThumbnails? = null,
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
val lifecycleOwner = LocalLifecycleOwner.current
// STARTED-scoped poll: fetch→wait→repeat while foregrounded, cancelled on background (plan §6.6).
LaunchedEffect(viewModel, lifecycleOwner) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.poll()
}
}
Scaffold(
modifier = modifier,
topBar = {
SessionListTopBar(
state = state,
onSelectHost = { id -> scope.launch { viewModel.selectHost(id) } },
onNewSession = onNewSession,
onPairHost = onPairHost,
onImportCert = onImportCert,
)
},
) { padding ->
SessionListBody(
state = state,
contentPadding = padding,
onRefresh = { scope.launch { viewModel.refresh() } },
onOpen = { id ->
scope.launch { viewModel.markSeen(id) }
onOpenSession(id)
},
onKill = { id -> scope.launch { viewModel.killSession(id) } },
onNewSession = onNewSession,
onPairHost = onPairHost,
thumbnails = thumbnails,
)
}
}
// ── Top bar + host menu ──────────────────────────────────────────────────────────────────────────────
@Composable
private fun SessionListTopBar(
state: SessionListUiState,
onSelectHost: (String) -> Unit,
onNewSession: () -> Unit,
onPairHost: () -> Unit,
onImportCert: () -> Unit,
) {
var menuOpen by remember { mutableStateOf(false) }
val activeName = state.hosts.firstOrNull { it.isActive }?.name ?: "会话"
TopAppBar(
title = { Text(text = activeName, maxLines = 1, overflow = TextOverflow.Ellipsis) },
actions = {
TextButton(onClick = onNewSession) { Text(" 新会话") }
Box {
// "⋮" text glyph avoids a material-icons dependency (not in the :app Compose bundle).
TextButton(onClick = { menuOpen = true }) { Text("") }
HostMenu(
expanded = menuOpen,
hosts = state.hosts,
onDismiss = { menuOpen = false },
onSelectHost = { menuOpen = false; onSelectHost(it) },
onPairHost = { menuOpen = false; onPairHost() },
onImportCert = { menuOpen = false; onImportCert() },
)
}
},
)
}
/** The host menu: paired hosts (✓ on the active one, 🔒 when a device cert is installed) + the two actions. */
@Composable
private fun HostMenu(
expanded: Boolean,
hosts: List<HostOption>,
onDismiss: () -> Unit,
onSelectHost: (String) -> Unit,
onPairHost: () -> Unit,
onImportCert: () -> Unit,
) {
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
for (host in hosts) {
DropdownMenuItem(
text = { Text(text = host.name, maxLines = 1, overflow = TextOverflow.Ellipsis) },
onClick = { onSelectHost(host.id) },
leadingIcon = if (host.isActive) ({ Text("") }) else null,
trailingIcon = if (host.hasDeviceCert) ({ Text("🔒") }) else null,
)
}
if (hosts.isNotEmpty()) HorizontalDivider()
DropdownMenuItem(text = { Text("配对新主机") }, onClick = onPairHost)
DropdownMenuItem(text = { Text("设备证书") }, onClick = onImportCert)
}
}
// ── Body: pull-to-refresh over the list / empty states ─────────────────────────────────────────────────
@Composable
private fun SessionListBody(
state: SessionListUiState,
contentPadding: PaddingValues,
onRefresh: () -> Unit,
onOpen: (UUID) -> Unit,
onKill: (UUID) -> Unit,
onNewSession: () -> Unit,
onPairHost: () -> Unit,
thumbnails: SessionThumbnails?,
) {
androidx.compose.material3.pulltorefresh.PullToRefreshBox(
isRefreshing = state.isRefreshing,
onRefresh = onRefresh,
modifier = Modifier.fillMaxSize().padding(contentPadding),
) {
when {
state.activeHostId == null ->
EmptyState(message = "还没有配对主机。", actionLabel = "配对主机", onAction = onPairHost)
state.rows.isEmpty() ->
EmptyState(
message = if (state.hasLoadError) "无法加载会话列表。下拉重试。" else "该主机没有运行中的会话。",
actionLabel = "开新会话",
onAction = onNewSession,
)
else -> SessionList(state = state, onOpen = onOpen, onKill = onKill, thumbnails = thumbnails)
}
}
}
@Composable
private fun SessionList(
state: SessionListUiState,
onOpen: (UUID) -> Unit,
onKill: (UUID) -> Unit,
thumbnails: SessionThumbnails?,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(Spacing.lg16),
verticalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
if (state.hasLoadError) {
item(key = "load-error") { LoadErrorBanner() }
}
items(items = state.rows, key = { it.id.toString() }) { row ->
SwipeToKillRow(row = row, onOpen = onOpen, onKill = onKill, thumbnails = thumbnails)
}
}
}
/**
* One row wrapped in a trailing (end→start) [SwipeToDismissBox]. Swiping left confirms the kill, which the
* ViewModel handles optimistically (the row leaves the list on the next [state] emission). A start→end
* swipe is disabled so a left-handed drag can't accidentally kill.
*/
@Composable
private fun SwipeToKillRow(
row: SessionRow,
onOpen: (UUID) -> Unit,
onKill: (UUID) -> Unit,
thumbnails: SessionThumbnails?,
) {
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { value ->
if (value == SwipeToDismissBoxValue.EndToStart) {
onKill(row.id)
true
} else {
false
}
},
)
SwipeToDismissBox(
state = dismissState,
enableDismissFromStartToEnd = false,
backgroundContent = { KillBackground() },
) {
SessionListRow(row = row, onOpen = { onOpen(row.id) }, thumbnails = thumbnails)
}
}
/** The red "删除" reveal behind a swiped row (end-aligned — the swipe is right→left). */
@Composable
private fun KillBackground() {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.error, RoundedCornerShape(Radius.md12))
.padding(horizontal = Spacing.lg16),
contentAlignment = Alignment.CenterEnd,
) {
Text(text = "删除", color = MaterialTheme.colorScheme.onError, style = MaterialTheme.typography.labelLarge)
}
}
/** A transient-error banner shown above still-valid rows (last-good rows are kept; §"transient poll failure"). */
@Composable
private fun LoadErrorBanner() {
Box(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.errorContainer, RoundedCornerShape(Radius.sm8))
.padding(Spacing.md12),
) {
Text(
text = "刷新失败,显示的是上次的列表。下拉重试。",
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall,
)
}
}
/** Centered empty/error placeholder with one action; scrollable so pull-to-refresh still fires. */
@Composable
private fun EmptyState(message: String, actionLabel: String, onAction: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(Spacing.xxl24),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(Spacing.md12, Alignment.CenterVertically),
) {
Text(text = message, style = MaterialTheme.typography.bodyMedium)
Button(onClick = onAction) { Text(actionLabel) }
}
}

View File

@@ -0,0 +1,356 @@
package wang.yaojia.webterm.screens
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.view.View
import android.view.WindowManager
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import wang.yaojia.webterm.components.AwayDigestView
import wang.yaojia.webterm.components.GateBanner
import wang.yaojia.webterm.components.HardwareKeyRouter
import wang.yaojia.webterm.components.KeyBar
import wang.yaojia.webterm.components.PlanGateSheet
import wang.yaojia.webterm.components.ReconnectBanner
import wang.yaojia.webterm.components.TelemetryChips
import wang.yaojia.webterm.designsystem.LocalReduceMotion
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.WebTermColors
import wang.yaojia.webterm.terminalview.RemoteTerminalView
import wang.yaojia.webterm.wire.GateKind
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wiring.RetainedSessionHolder
import wang.yaojia.webterm.wiring.TerminalSessionControllerImpl
/**
* The arguments to open a NEW session — the pure output of the "new session in current cwd" decision.
* `sessionId` is ALWAYS null (a fresh spawn); [cwd] is the current session's directory (`attach(null,
* cwd)`, plan §1). A distinct type so the nav layer that consumes it (a NEW nav-scoped holder) reads a
* stable contract.
*/
public data class NewSessionRequest(val sessionId: String?, val cwd: String?)
/**
* The new-session-in-cwd routing decision (plan §1, §5 A21): the phone toolbar action and the exit-/
* replay-too-large banner action both produce the SAME request — spawn a new session ([sessionId] null)
* in the current session's [currentCwd]. Pure + JVM-testable.
*/
public object NewSessionInCwd {
public fun requestFor(currentCwd: String?): NewSessionRequest =
NewSessionRequest(sessionId = null, cwd = currentCwd)
}
/**
* # TerminalScreen (A21/A22) — the live terminal, banner, cockpit gate surfaces, key-bar and toolbar.
*
* Hosts the forked Termux emulator ([RemoteTerminalView]) via an [AndroidView] and wires it to the
* holder-owned [TerminalSessionControllerImpl]. Structure top→bottom: a toolbar (sanitized title +
* "在当前目录开新会话"), the [ReconnectBanner], the away-digest ([AwayDigestView]) + telemetry chips
* ([TelemetryChips]), the tool-gate card ([GateBanner]) above the terminal surface (with the privacy
* cover), the [KeyBar] pinned above the IME, and — for plan gates — the [PlanGateSheet]
* `ModalBottomSheet` overlay. Every server-derived string is rendered INERT by its component (§8).
*
* The Compose / AndroidView / lifecycle / privacy-shade / haptic behaviour here is DEVICE-QA (plan §7);
* the banner reduction, gate stale-guard and new-session-in-cwd routing are the JVM-tested cores.
*
* ### Wiring order (FIX 1/2/3 — register-before-start, no split, config-survival)
* - The controller + its config-surviving emulator + the retained presenters ([RetainedSessionHolder.gateViewModel],
* [RetainedSessionHolder.bannerState], [RetainedSessionHolder.title]) are OWNED BY THE HOLDER and
* obtained via `holder.bind()` — NEVER `remember`ed here (a remembered emulator dies on rotation).
* `bind()` registers every consumer's `controlEvents()`/`output` mailbox EAGERLY and returns the
* controller UN-STARTED; this screen then calls `controller.start()` exactly once. That
* register-before-start ordering is what guarantees no `attached`/replay drop and no event split.
* - The banner and the gate presenter each hold their OWN control mailbox (FIX 1) — collected here via
* `collectAsStateWithLifecycle` (STARTED-scoped, §6.6).
*
* ### Lifecycle & config-change survival (plan §6.6)
* - `warmUp()` runs OFF-`Main` (its own `Dispatchers.IO` hop) BEFORE the first `bind()`; if it THROWS
* (mTLS/OkHttp build failure), the screen surfaces a retry instead of hanging not-ready (FIX 5).
* - The engine + emulator + presenters survive config changes in the retained [holder]; the
* `AndroidView` is keyed by the Compose-observable `holder.generation` so a REAL background→foreground
* cycle rebuilds a fresh emulator (ring replay), while a rotation re-binds the survivor.
* `holder.onStop(isChangingConfigurations)` drives that split.
* - `FLAG_SECURE` + a cover Composable on `ON_STOP` keep terminal bytes out of the recents snapshot (§8).
* - Latest-writer-wins resize: `ON_RESUME` and window-focus re-send the remembered dims via
* `notifyForegrounded` so the active device reclaims full-screen (plan §6.4).
*
* @param holder the nav-scoped, config-surviving session holder (`hiltViewModel()` at the destination).
* @param onNewSessionInCwd nav callback that opens a new session for the produced [NewSessionRequest].
* @param onWarmUp `AppEnvironment.warmUp` — builds the shared OkHttp/mTLS stack off `Main` before bind.
* @param onDigestExpand the A28 away-digest「展开」seam — the composition root opens the timeline sheet for
* the current session (default no-op keeps the screen usable standalone). This only WIRES the existing
* [AwayDigestView.onExpand][wang.yaojia.webterm.components.AwayDigestView] callback outward; no internal
* logic changes.
*/
@Composable
public fun TerminalScreen(
holder: RetainedSessionHolder,
endpoint: HostEndpoint,
sessionId: String?,
cwd: String?,
onNewSessionInCwd: (NewSessionRequest) -> Unit,
modifier: Modifier = Modifier,
onWarmUp: suspend () -> Unit = {},
onDigestExpand: () -> Unit = {},
) {
val context = LocalContext.current
val activity = remember(context) { context.findActivity() }
// Privacy shade: FLAG_SECURE while the terminal is on screen so the recents thumbnail never leaks
// terminal bytes (plan §8). Cleared when the screen leaves.
DisposableEffect(activity) {
activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
onDispose { activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) }
}
// Warm the shared OkHttp/mTLS stack OFF-Main before the first bind (A15 / AppEnvironment.warmUp).
// FIX 5: if warmUp() throws (mTLS/OkHttp build failure off-Main), surface a retry instead of hanging
// not-ready forever. CancellationException is rethrown so a config-change cancel is never swallowed.
var ready by remember(endpoint, sessionId) { mutableStateOf(false) }
var warmUpFailed by remember(endpoint, sessionId) { mutableStateOf(false) }
var warmUpAttempt by remember(endpoint, sessionId) { mutableIntStateOf(0) }
LaunchedEffect(endpoint, sessionId, warmUpAttempt) {
warmUpFailed = false
try {
onWarmUp()
ready = true
} catch (e: CancellationException) {
throw e
} catch (_: Throwable) {
warmUpFailed = true
}
}
if (!ready) {
if (warmUpFailed) WarmUpErrorPane(modifier, onRetry = { warmUpAttempt += 1 }) else LoadingPane(modifier)
return
}
// FIX 2/FIX 3: the controller (+ its config-surviving emulator) and the retained presenters are
// OWNED BY THE HOLDER. Reading the Compose-observable generation re-keys the AndroidView on a
// genuine-background rebuild; a config change (generation unchanged) re-binds the survivor. bind() is
// idempotent for the same (endpoint, sessionId) and registers every mailbox before returning UN-STARTED.
val generation = holder.generation
val controller = holder.bind(endpoint, sessionId, cwd)
val gateViewModel = holder.gateViewModel ?: return // populated by bind(); guarded defensively
// FIX 1: banner + gate each collect their OWN retained controlEvents() mailbox (folded in the holder),
// so there is no split. Read the retained state here, STARTED-scoped (§6.6).
val banner by holder.bannerState.collectAsStateWithLifecycle()
val gateUi by gateViewModel.uiState.collectAsStateWithLifecycle()
val title by holder.title
// Start the engine ONCE, AFTER bind() registered every consumer's mailbox (register-before-start).
// Idempotent: a config-change recomposition re-obtains the SAME survivor and start() is a no-op.
LaunchedEffect(controller) { controller.start() }
// Arrival haptic (FIX 4): one buzz per NEW gate (rising edge), gated by the OS reduce-motion setting.
val haptics = LocalHapticFeedback.current
val reduceMotion = LocalReduceMotion.current
LaunchedEffect(gateViewModel, reduceMotion) {
gateViewModel.gateArrivals.collect {
if (!reduceMotion) haptics.performHapticFeedback(HapticFeedbackType.LongPress)
}
}
// Privacy cover on ON_STOP + config-change-aware holder teardown + ON_RESUME full-screen reclaim.
var covered by remember { mutableStateOf(false) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner, activity, controller) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_STOP -> {
covered = true
holder.onStop(activity?.isChangingConfigurations == true)
}
Lifecycle.Event.ON_START -> covered = false
Lifecycle.Event.ON_RESUME -> controller.notifyForegrounded(null, null)
else -> Unit
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
// Window-focus reclaim (device switch) → re-send remembered dims (latest-writer-wins, §6.4).
val windowInfo = LocalWindowInfo.current
LaunchedEffect(windowInfo, controller) {
snapshotFlow { windowInfo.isWindowFocused }.collect { focused ->
if (focused) controller.notifyForegrounded(null, null)
}
}
val requestNewSession: () -> Unit = { onNewSessionInCwd(NewSessionInCwd.requestFor(cwd)) }
// A slow wall-clock tick so the telemetry chips grey out as the last frame ages (§ staleness).
val nowMs by produceState(initialValue = System.currentTimeMillis()) {
while (true) {
delay(TELEMETRY_TICK_MS)
value = System.currentTimeMillis()
}
}
Column(modifier = modifier.fillMaxSize()) {
TerminalToolbar(title = title, onNewSessionInCwd = requestNewSession)
ReconnectBanner(model = banner, onNewSession = requestNewSession)
// Cockpit surfaces (A22): away-digest (once per reconnect) + statusLine telemetry chips. Both hide
// themselves when their state is null/empty; server strings render inert (§8).
gateUi.digest?.let { AwayDigestView(digest = it, onExpand = onDigestExpand) } // onExpand → A28 timeline (wired)
gateUi.telemetry?.let { TelemetryChips(telemetry = it, nowMs = nowMs) }
// Tool gate → an inline approve/reject card; plan gates use the ModalBottomSheet below.
gateUi.gate?.takeIf { it.kind == GateKind.TOOL }?.let { toolGate ->
GateBanner(gate = toolGate, onDecide = gateViewModel::decide)
}
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
key(generation) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
val remoteView = RemoteTerminalView(ctx)
// Hardware chords (DECCKM split): Esc/Ctrl-letter/⇧Tab app-side, arrows/Enter/Tab
// deferred to Termux's KeyHandler (returns false) — plan §6.3.
remoteView.onKeyCommand = { keyCode, keyEvent ->
HardwareKeyRouter.handle(keyCode, keyEvent, controller::sendInput)
}
// A layout/size change feeds the latest-writer-wins reclaim path (§6.4). The pixel→
// grid metric read (Termux's package-private mFontWidth) + emulator resize is
// completed in the view layer on-device; here we reclaim full-screen.
remoteView.onViewSizeChanged = { _, _ -> controller.notifyForegrounded(null, null) }
controller.remoteSession.attachView(remoteView)
remoteView.hostView()
},
// Config change / real background: unbind the view but the holder-owned emulator
// survives (FIX 3) — never close it here (that is the holder teardown's job).
onRelease = { controller.remoteSession.detachView() },
)
}
if (covered) PrivacyCover()
}
// The IME-bypass touch key-bar, pinned above the keyboard (hidden on wide screens / hardware kbd).
KeyBar(onSend = controller::sendInput)
}
// Plan gate → a three-way ModalBottomSheet overlay. Dismissing (scrim/back) resolves NOTHING — the
// gate stays held until an explicit decision (PlanGateSheet contract); the epoch stale-guard lives in
// GateViewModel.decide.
gateUi.gate?.takeIf { it.kind == GateKind.PLAN }?.let { planGate ->
PlanGateSheet(gate = planGate, onDecide = gateViewModel::decide, onDismiss = {})
}
}
/** Telemetry-chip staleness tick interval (ms) — re-reads the wall clock so chips grey as a frame ages. */
private const val TELEMETRY_TICK_MS: Long = 1_000L
/** Toolbar: the inert (already-sanitized) session title + the new-session-in-cwd action. */
@Composable
private fun TerminalToolbar(title: String, onNewSessionInCwd: () -> Unit) {
Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Spacing.sm8),
modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.md12, vertical = Spacing.xs4),
) {
Text(
text = title.ifEmpty { "终端" },
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onNewSessionInCwd) { Text("在当前目录开新会话") }
}
}
}
/** The privacy cover shown while backgrounded so the recents snapshot never shows terminal content. */
@Composable
private fun PrivacyCover() {
Surface(color = WebTermColors.terminalBackground, modifier = Modifier.fillMaxSize()) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
Text("WebTerm", color = WebTermColors.terminalForeground.copy(alpha = 0.4f))
}
}
}
/** Placeholder while the shared OkHttp/mTLS stack warms up off-Main before the first bind. */
@Composable
private fun LoadingPane(modifier: Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("连接中…", color = MaterialTheme.colorScheme.onBackground)
}
}
/**
* Shown when `warmUp()` threw building the mTLS/OkHttp stack (FIX 5) — an actionable retry instead of a
* permanent not-ready hang. The failure detail is intentionally NOT rendered (it may carry mTLS/host
* internals); the copy is a fixed, inert string.
*/
@Composable
private fun WarmUpErrorPane(modifier: Modifier, onRetry: () -> Unit) {
Column(
modifier = modifier.fillMaxSize().padding(Spacing.lg16),
verticalArrangement = Arrangement.spacedBy(Spacing.sm8, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("连接初始化失败", color = MaterialTheme.colorScheme.onBackground)
TextButton(onClick = onRetry) { Text("重试") }
}
}
/** Unwrap a [Context] to its host [Activity] (for FLAG_SECURE + isChangingConfigurations). */
private tailrec fun Context.findActivity(): Activity? = when (this) {
is Activity -> this
is ContextWrapper -> baseContext.findActivity()
else -> null
}
/**
* The [android.view.View] to add to the Compose tree — the Termux `TerminalView` behind
* [RemoteTerminalView].
*
* BOUNDARY WORKAROUND (recorded): A16's `RemoteTerminalView.terminalView` is typed
* `com.termux.view.TerminalView`, but `:terminal-view` scopes the Termux `terminal-view` artifact as
* `implementation`, so that concrete type is NOT on `:app`'s compile classpath (and neither
* `app/build.gradle.kts` nor `:terminal-view`/`RemoteTerminalView` is in A21's editable lane). We
* therefore read the already-attached stock view through its public getter as a plain [View] — a
* `TerminalView` IS a `View`, so hosting it in an `AndroidView` is correct; only the compile-time type
* name is avoided. The clean fix (out of A21's lane) is to `api`-expose the Termux view from
* `:terminal-view` or add `libs.termux.terminal.view` to `:app`, then `AndroidView { remote.terminalView }`.
*/
private fun RemoteTerminalView.hostView(): View =
RemoteTerminalView::class.java.getMethod("getTerminalView").invoke(this) as View

View File

@@ -0,0 +1,234 @@
package wang.yaojia.webterm.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import wang.yaojia.webterm.designsystem.Spacing
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.viewmodels.TimelineCopy
import wang.yaojia.webterm.viewmodels.TimelinePhase
import wang.yaojia.webterm.viewmodels.TimelineRow
import wang.yaojia.webterm.viewmodels.TimelineViewModel
/** Min sheet body height so the loading / empty / error states don't collapse to a sliver. */
private val SHEET_MIN_HEIGHT = 240.dp
/** Fixed width for the glyph column so the HH:mm / label columns line up across rows. */
private val GLYPH_COLUMN_WIDTH = 28.dp
/**
* # TimelineSheet (A28) — the full activity-timeline drill-down, as a Material3 [ModalBottomSheet].
*
* Opened by the away-digest「展开」affordance (`AwayDigestView.onExpand`, A22 seam): the caller builds a
* fresh [TimelineViewModel] via [TimelineViewModel.forSession] (id + `ApiClient::events`), shows this
* sheet, and passes [onDismiss] to tear it down. A fresh VM per presentation → exactly one fetch, kicked
* by the [LaunchedEffect] below.
*
* Rows mirror the web timeline panel — "HH:mm · glyph · label", newest-first, capped (the reducer lives
* in [TimelineViewModel]). The label is **untrusted server text**: rendered as an INERT [Text], single
* line, no linkify/markdown (§8). The Compose surface here is DEVICE-QA (plan §7); the fetch/present/
* mapping cores are the JVM-tested logic in [TimelineViewModel].
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
public fun TimelineSheet(
viewModel: TimelineViewModel,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState(),
) {
val phase by viewModel.phase.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
// One fetch per presentation (fresh VM ⇒ fresh load); the「重试」button re-runs the same path.
LaunchedEffect(viewModel) { viewModel.load() }
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) {
TimelineSheetContent(
phase = phase,
onRetry = { scope.launch { viewModel.load() } },
)
}
}
/** The phase switch + rows — split out so it previews without a live [ModalBottomSheet] host. */
@Composable
private fun TimelineSheetContent(
phase: TimelinePhase,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.heightIn(min = SHEET_MIN_HEIGHT)
.padding(horizontal = Spacing.lg16),
verticalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
Text(
text = TimelineCopy.TITLE,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(vertical = Spacing.sm8),
)
when (phase) {
is TimelinePhase.Loading -> CenteredBox { CircularProgressIndicator() }
is TimelinePhase.Empty -> EmptyState()
is TimelinePhase.Failed -> FailedState(onRetry = onRetry)
is TimelinePhase.Loaded -> TimelineRowList(rows = phase.rows)
}
}
}
@Composable
private fun TimelineRowList(rows: List<TimelineRow>) {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(Spacing.xs4),
) {
items(items = rows, key = { it.key }) { row -> TimelineRowView(row) }
}
}
@Composable
private fun TimelineRowView(row: TimelineRow) {
// Unknown/future class → theme-adaptive secondary text (colorSpec == null; §8 fallback).
val glyphColor = row.colorSpec?.let { Color(it) } ?: MaterialTheme.colorScheme.onSurfaceVariant
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = Spacing.xs2),
horizontalArrangement = Arrangement.spacedBy(Spacing.md12),
verticalAlignment = Alignment.CenterVertically,
) {
// Monospace so the HH:mm column lines up across rows (tabular direction).
Text(
text = row.time,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = row.glyph,
style = MaterialTheme.typography.bodyMedium,
color = glyphColor,
modifier = Modifier.width(GLYPH_COLUMN_WIDTH),
)
// INERT: server-derived phrase rendered verbatim, hard single-line truncation (§8).
Text(
text = row.label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun EmptyState() {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = Spacing.xl20),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(Spacing.xs4),
) {
Text(
text = TimelineCopy.EMPTY_TITLE,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = TimelineCopy.EMPTY_DETAIL,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun FailedState(onRetry: () -> Unit) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = Spacing.xl20),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(Spacing.sm8),
) {
Text(
text = TimelineCopy.LOAD_FAILED,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = TimelineCopy.LOAD_FAILED_DETAIL,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(onClick = onRetry) { Text(text = TimelineCopy.RETRY) }
}
}
@Composable
private fun CenteredBox(content: @Composable () -> Unit) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = Spacing.xl20),
contentAlignment = Alignment.Center,
) { content() }
}
// ── Preview ─────────────────────────────────────────────────────────────────────
@Preview(name = "TimelineSheet · loaded")
@Composable
private fun TimelineSheetLoadedPreview() {
WebTermTheme {
TimelineSheetContent(
phase = TimelinePhase.Loaded(
listOf(
TimelineRow(0, "13:05", "", 0xFF46D07FL, "finished"),
TimelineRow(1, "13:04", "", 0xFFF5B14CL, "waiting for approval"),
TimelineRow(2, "13:03", "🔧", 0xFF5E9EFFL, "ran Bash"),
),
),
onRetry = {},
)
}
}
@Preview(name = "TimelineSheet · empty")
@Composable
private fun TimelineSheetEmptyPreview() {
WebTermTheme { TimelineSheetContent(phase = TimelinePhase.Empty, onRetry = {}) }
}

View File

@@ -0,0 +1,254 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import wang.yaojia.webterm.clienttls.CertificateSummary
import wang.yaojia.webterm.clienttls.NoClientIdentityException
import wang.yaojia.webterm.clienttls.Pkcs12DecodeException
import wang.yaojia.webterm.tlsandroid.IdentityRepository
import java.security.UnrecoverableKeyException
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import kotlin.coroutines.CoroutineContext
/**
* # ClientCertViewModel (A27) — the device-certificate (mTLS) management presenter.
*
* Drives the "设备证书" screen reached from the session-list host menu (A20): import a `.p12`, rotate
* it, remove it, and show the installed identity's summary (subject-CN / issuer-CN / expiry, with an
* **EXPIRED** warning). Ports the iOS `ClientCertViewModel` over the shared [IdentityRepository]
* (`:client-tls-android`) — the ONE key home; this presenter never touches AndroidKeyStore/Tink
* directly, it only funnels bytes+passphrase into the repository and renders the returned
* [CertificateSummary].
*
* ### The security invariant this screen must not break (plan §8 "Cert storage")
* A `.p12` import **validates before persisting**, so a bad passphrase can NEVER clobber a
* previously-installed identity — the repository throws before mutating either store, the prior cert
* stays live, and this VM merely surfaces the error and leaves [ClientCertUiState.summary] untouched
* (the on-screen cert is unchanged). Rotate and remove additionally evict pooled connections inside
* the repository so nothing reuses the old identity; that is the repository's concern, not this VM's.
*
* ### Secrets discipline (plan §8)
* The passphrase and the `.p12` bytes are passed straight to the repository and are **never logged,
* never persisted, and never placed in fallback/error copy** — errors are a coarse [CertError] enum
* the screen maps to app-authored, inert copy. No server/cert string is ever rendered as anything but
* inert [androidx.compose.material3.Text].
*
* ### A plain presenter (JVM-testable, mirrors [PairingViewModel] / [DiffViewModel])
* Not an `androidx.lifecycle.ViewModel`, so it runs under `runTest` virtual time with no
* `Dispatchers.Main` / Robolectric. The screen calls [bind] with a lifecycle scope (which kicks the
* initial summary load) and then the import/rotate/remove actions. Because the repository's reads and
* mutations do blocking AndroidKeyStore/Tink I/O, every touch hops to [io] (`Dispatchers.IO` in
* production) so nothing blocks the UI thread; the SAF file read itself happens in the Compose shell.
*
* @param repository the shared mTLS device identity ([IdentityRepository]); a fake in tests.
* @param io the dispatcher the blocking keystore reads/mutations hop to (off `Main`); overridable in tests.
* @param zone the zone the not-after date is formatted in (default device zone; fixed in tests).
* @param now the clock used for the EXPIRED check (default wall clock; fixed in tests).
*/
public class ClientCertViewModel(
private val repository: IdentityRepository,
private val io: CoroutineContext = Dispatchers.IO,
private val zone: ZoneId = ZoneId.systemDefault(),
private val now: () -> Instant = Instant::now,
) {
private val _uiState = MutableStateFlow(ClientCertUiState())
/** The single snapshot the cert screen renders from. */
public val uiState: StateFlow<ClientCertUiState> = _uiState.asStateFlow()
private var scope: CoroutineScope? = null
// A single in-flight op handle. The initial read may be superseded by a mutation; a mutation is
// never cancelled by another (the `BUSY` guard blocks a concurrent one), so a commit can't be
// interrupted mid-flight by this VM.
private var job: Job? = null
/** Bind the scope actions launch into (the screen passes a lifecycle scope) and load the summary. */
public fun bind(scope: CoroutineScope) {
this.scope = scope
load()
}
private fun load() {
val scope = scope ?: return
job?.cancel()
_uiState.value = _uiState.value.copy(phase = CertPhase.LOADING)
job = scope.launch {
// currentSummary() is designed to degrade to null on a storage fault (never throw), but
// guard anyway so a read fault becomes "no cert shown" rather than a crash.
val summary = runCatching { withContext(io) { repository.currentSummary() } }.getOrNull()
_uiState.value = _uiState.value.copy(phase = CertPhase.READY, summary = summary?.toView())
}
}
/** Import a `.p12` as the device identity (no cert currently installed). Validates before persisting. */
public fun import(p12Bytes: ByteArray, passphrase: String) {
install(p12Bytes, passphrase, rotate = false)
}
/** Replace the installed identity with a new `.p12` (rotation). The prior cert stays live on failure. */
public fun rotate(p12Bytes: ByteArray, passphrase: String) {
install(p12Bytes, passphrase, rotate = true)
}
private fun install(p12Bytes: ByteArray, passphrase: String, rotate: Boolean) {
val scope = scope ?: return
if (_uiState.value.phase == CertPhase.BUSY) return // ignore a double-tap while an op is in flight
job?.cancel() // supersede only the (safe-to-drop) initial read; never an in-flight mutation
_uiState.value = _uiState.value.copy(phase = CertPhase.BUSY, error = null)
job = scope.launch {
val outcome = try {
val summary = withContext(io) {
if (rotate) repository.rotate(p12Bytes, passphrase)
else repository.importIdentity(p12Bytes, passphrase)
}
Result.success(summary)
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
Result.failure(error)
}
_uiState.value = outcome.fold(
// Success: the returned summary is the newly-committed identity.
onSuccess = { s -> _uiState.value.copy(phase = CertPhase.READY, summary = s.toView(), error = null) },
// Failure: validate-before-persist means the PRIOR identity is untouched — leave
// `summary` exactly as it was and only surface the classified error.
onFailure = { e -> _uiState.value.copy(phase = CertPhase.READY, error = classify(e)) },
)
}
}
/** Ask to remove the installed identity — raises the confirm dialog (destructive, so gated). */
public fun requestRemove() {
if (_uiState.value.summary == null) return // nothing to remove
_uiState.value = _uiState.value.copy(confirmingRemove = true)
}
/** Dismiss the remove-confirm dialog without removing anything. */
public fun cancelRemove() {
_uiState.value = _uiState.value.copy(confirmingRemove = false)
}
/** Confirm removal: clear the device identity (idempotent in the repository). */
public fun confirmRemove() {
val scope = scope ?: return
_uiState.value = _uiState.value.copy(confirmingRemove = false, phase = CertPhase.BUSY, error = null)
job?.cancel()
job = scope.launch {
val outcome = try {
withContext(io) { repository.remove() }
Result.success(Unit)
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
Result.failure(error)
}
_uiState.value = outcome.fold(
onSuccess = { _uiState.value.copy(phase = CertPhase.READY, summary = null) },
// A remove fault keeps whatever summary was showing (the identity may still be live).
onFailure = { _uiState.value.copy(phase = CertPhase.READY, error = CertError.REMOVE_FAILED) },
)
}
}
/** Dismiss the current error banner. */
public fun clearError() {
_uiState.value = _uiState.value.copy(error = null)
}
private fun CertificateSummary.toView(): CertSummaryView = toSummaryView(now(), zone)
}
/** Map an import/rotation throwable to the coarse, non-secret [CertError] the screen renders inertly. */
internal fun classify(error: Throwable): CertError = when (error) {
is UnrecoverableKeyException -> CertError.BAD_PASSPHRASE
is Pkcs12DecodeException -> CertError.INVALID_FILE
is NoClientIdentityException -> CertError.NO_IDENTITY
else -> CertError.IMPORT_FAILED
}
/** The load/action phase the screen renders (spinner vs. content vs. an in-flight import/rotate/remove). */
public enum class CertPhase {
/** The initial summary read is in flight. */
LOADING,
/** Idle — [ClientCertUiState.summary] is either an installed cert or `null` (none installed). */
READY,
/** An import / rotate / remove is committing. */
BUSY,
}
/** The coarse, non-secret failure taxonomy — the screen maps each to app-authored inert copy (§8). */
public enum class CertError {
/** Wrong passphrase (the `.p12` MAC/integrity check failed) — the prior identity is untouched. */
BAD_PASSPHRASE,
/** The file is not a decodable PKCS#12 blob (truncated / not a `.p12` / unsupported). */
INVALID_FILE,
/** Decoded, but the `.p12` carried no importable private-key entry (certs-only / truststore). */
NO_IDENTITY,
/** Any other import/rotation failure (keystore/storage fault). */
IMPORT_FAILED,
/** Removal failed — the identity may still be installed. */
REMOVE_FAILED,
}
/** The immutable snapshot the device-cert screen renders. */
public data class ClientCertUiState(
/** The installed device identity's display summary, or `null` when none is installed. */
val summary: CertSummaryView? = null,
/** The load/action phase. */
val phase: CertPhase = CertPhase.LOADING,
/** The last import/rotate/remove failure, or `null`. Surfaced as inert, app-authored copy. */
val error: CertError? = null,
/** Whether the destructive remove-confirm dialog is up. */
val confirmingRemove: Boolean = false,
)
/**
* The rendered device-certificate summary — the presentation view of a [CertificateSummary]. Absent
* fields degrade to [UNKNOWN] (display-only; the TLS stack, not this label, is the real gate) and the
* not-after instant is pre-formatted to a local date. [isExpired] drives the on-screen warning.
*/
public data class CertSummaryView(
val subjectCommonName: String,
val issuerCommonName: String,
val expiry: String,
val isExpired: Boolean,
) {
public companion object {
/** Shown for a CN/expiry the certificate did not carry (or that failed to parse). */
public const val UNKNOWN: String = "未知"
}
}
// Medium date style (e.g. locale-formatted "2027年1月8日" / "Jan 8, 2027") — date only; time is noise here.
private val EXPIRY_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
/**
* Pure presentation of a [CertificateSummary] (independently unit-tested): CN fields fall back to
* [CertSummaryView.UNKNOWN], the not-after instant is formatted in [zone], and [now] decides the
* EXPIRED flag (delegated to [CertificateSummary.isExpired] — unknown expiry is treated as not
* expired, matching iOS).
*/
public fun CertificateSummary.toSummaryView(now: Instant, zone: ZoneId): CertSummaryView =
CertSummaryView(
subjectCommonName = subjectCommonName ?: CertSummaryView.UNKNOWN,
issuerCommonName = issuerCommonName ?: CertSummaryView.UNKNOWN,
expiry = notAfter?.let { EXPIRY_FORMATTER.withZone(zone).format(it) } ?: CertSummaryView.UNKNOWN,
isExpired = isExpired(now),
)

View File

@@ -0,0 +1,342 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.intOrNull
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
import wang.yaojia.webterm.wire.HttpTransport
import java.net.URI
/**
* # DiffViewModel (A24) — the read-only staged/unstaged git-diff presenter.
*
* Fetches `GET /projects/diff?path=&staged=1|0` (plan §4.2), tolerantly decodes the untrusted
* [DiffResult], and **flattens** files→hunks→lines into ONE ordered [DiffRow] list a `LazyColumn`
* renders (plan §5 A24, §1 "Diff viewer"). All rendering is inert monospaced text (plan §8) —
* this presenter carries only the pre-classified line kinds; colouring lives in `DiffScreen`.
*
* ### Why the diff route/model live HERE (not in `:api-client`)
* `:api-client`'s frozen 12-route surface (A8) does not include the diff route, and A24 owns only the
* `:app` diff files. So the diff DTOs + tolerant decode + the RO request are self-contained in this
* module, consuming only the frozen [HttpTransport]/[HostEndpoint] seam — the same untrusted-boundary
* discipline `:api-client` uses (unknown keys ignored, malformed file/hunk/line dropped one by one,
* nothing throws on bad server input).
*
* ### `staged` is the STRING `"1"`/`"0"` (NOT a boolean)
* The server matches `req.query['staged'] === '1'` (`src/server.ts`), so the query value MUST be the
* literal `"1"` (staged) or `"0"` (working tree) — a `true`/`false` would silently read as working-tree
* on the server. [diffUrl] encodes exactly that; the JVM test asserts the exact query value.
*
* ### A plain presenter (JVM-testable, mirrors [PairingViewModel]/[GateViewModel])
* Not an `androidx.lifecycle.ViewModel`, so it runs under `runTest` with no `Dispatchers.Main`. The
* screen calls [bind] with a lifecycle scope, then [refresh]; the Working/Staged toggle calls
* [selectStaged]. Each (re)load cancels the in-flight fetch so a fast toggle never races.
*/
public class DiffViewModel(
private val fetcher: DiffFetcher,
private val path: String,
) {
private val _uiState = MutableStateFlow(DiffUiState())
/** The single snapshot `DiffScreen` renders from. */
public val uiState: StateFlow<DiffUiState> = _uiState.asStateFlow()
private var scope: CoroutineScope? = null
private var job: Job? = null
/** Bind the scope loads launch into (the screen passes a lifecycle scope) and kick the first load. */
public fun bind(scope: CoroutineScope) {
this.scope = scope
reload()
}
/** Switch between the working-tree (`staged=false`) and staged (`staged=true`) diff; re-fetches. */
public fun selectStaged(staged: Boolean) {
if (_uiState.value.staged == staged) return
_uiState.value = _uiState.value.copy(staged = staged)
reload()
}
/** Re-fetch the current view (pull-to-refresh / retry after an error). */
public fun refresh() {
reload()
}
private fun reload() {
val scope = scope ?: return
job?.cancel()
val staged = _uiState.value.staged
_uiState.value = _uiState.value.copy(phase = DiffPhase.LOADING)
job = scope.launch {
// Rethrow cancellation (a superseding load) so a stale fetch can't overwrite fresh state.
val outcome = try {
Result.success(fetcher.fetch(path, staged))
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
Result.failure(error)
}
val current = _uiState.value
_uiState.value = outcome.fold(
onSuccess = { result ->
current.copy(
phase = if (result.files.isEmpty()) DiffPhase.EMPTY else DiffPhase.LOADED,
rows = flattenDiff(result),
truncated = result.truncated,
)
},
onFailure = { current.copy(phase = DiffPhase.ERROR, rows = emptyList(), truncated = false) },
)
}
}
}
/** The load phase the screen renders (loading spinner / empty / error / list). */
public enum class DiffPhase { IDLE, LOADING, LOADED, EMPTY, ERROR }
/** The immutable snapshot the diff screen renders. */
public data class DiffUiState(
/** `false` = working tree, `true` = staged (index). Drives the `staged=1|0` query. */
val staged: Boolean = false,
val phase: DiffPhase = DiffPhase.IDLE,
/** files→hunks→lines flattened into one ordered list (empty until loaded). */
val rows: List<DiffRow> = emptyList(),
/** Server capped the diff (too large) — the screen shows a truncation notice. */
val truncated: Boolean = false,
)
// ── The flattened lazy-list model (files → hunks → lines, in order) ──────────────────────────────
/** One rendered row. A sealed hierarchy so each kind paints with its own token (plan §1/§8). */
public sealed interface DiffRow {
/** Stable, monotonic id — the `LazyColumn` item key (row identity survives recomposition). */
public val id: Long
}
/** A per-file header: the display path plus its `+added/-removed` numstat and status. */
public data class DiffFileHeaderRow(
override val id: Long,
val path: String,
val status: String,
val added: Int,
val removed: Int,
) : DiffRow
/** A hunk header line (`@@ -a,b +c,d @@`) — the wire `hunk` kind. */
public data class DiffHunkHeaderRow(override val id: Long, val header: String) : DiffRow
/** One diff line, pre-classified by the server (added/removed/context/meta). */
public data class DiffLineRow(override val id: Long, val kind: DiffLineKind, val text: String) : DiffRow
/** A binary file has no hunks — a single "Binary file" marker row. */
public data class DiffBinaryRow(override val id: Long) : DiffRow
/**
* Flatten a [DiffResult] into the ordered render list: for each file a [DiffFileHeaderRow], then —
* for a binary file a single [DiffBinaryRow], otherwise each hunk's [DiffHunkHeaderRow] followed by
* its [DiffLineRow]s. Order is preserved exactly (files, then hunks within a file, then lines within
* a hunk). Pure — JVM-tested.
*/
public fun flattenDiff(result: DiffResult): List<DiffRow> {
val rows = ArrayList<DiffRow>()
var id = 0L
for (file in result.files) {
rows.add(DiffFileHeaderRow(id++, headerPath(file), file.status, file.added, file.removed))
if (file.binary) {
rows.add(DiffBinaryRow(id++))
continue
}
for (hunk in file.hunks) {
rows.add(DiffHunkHeaderRow(id++, hunk.header))
for (line in hunk.lines) {
rows.add(DiffLineRow(id++, line.kind, line.text))
}
}
}
return rows
}
/** `old → new` for a real rename, else the new path (mirror of `public/diff.ts` renderDiffFile). */
private fun headerPath(file: DiffFile): String =
if (file.status == "renamed" && file.oldPath != file.newPath) {
"${file.oldPath}${file.newPath}"
} else {
file.newPath
}
// ── The diff model (plain, non-@Serializable — decoded by hand from the JSON tree) ───────────────
/** The semantic class of one diff line (`src/types.ts` `DiffLineKind`). Unknown → [CONTEXT]. */
public enum class DiffLineKind {
ADDED,
REMOVED,
CONTEXT,
META,
HUNK,
;
public companion object {
/** Map the wire string; an unknown/future kind degrades to [CONTEXT] (mirror of the web default). */
public fun fromWire(wire: String): DiffLineKind = when (wire) {
"added" -> ADDED
"removed" -> REMOVED
"context" -> CONTEXT
"meta" -> META
"hunk" -> HUNK
else -> CONTEXT
}
}
}
public data class DiffLine(val kind: DiffLineKind, val text: String)
public data class DiffHunk(val header: String, val lines: List<DiffLine>)
public data class DiffFile(
val oldPath: String,
val newPath: String,
val status: String,
val added: Int,
val removed: Int,
val binary: Boolean,
val hunks: List<DiffHunk>,
)
public data class DiffResult(val files: List<DiffFile>, val staged: Boolean, val truncated: Boolean)
/** Tolerant JSON: unknown keys ignored, lenient — the untrusted-server config (mirror of `ModelJson`). */
private val DiffJson: Json = Json {
ignoreUnknownKeys = true
isLenient = true
}
/**
* Decode an untrusted `/projects/diff` body into [DiffResult], dropping malformed files/hunks/lines
* one by one and keeping the rest (plan §4.2 lossy decode). Never throws: a non-object root or
* unparseable body yields an empty result.
*/
internal fun decodeDiffResult(bytes: ByteArray): DiffResult {
val root = runCatching { DiffJson.parseToJsonElement(bytes.decodeToString()) }
.getOrNull() as? JsonObject
?: return DiffResult(emptyList(), staged = false, truncated = false)
val files = (root["files"] as? JsonArray).orEmpty().mapNotNull(::decodeFile)
return DiffResult(files = files, staged = root.bool("staged", false), truncated = root.bool("truncated", false))
}
/** A file needs a string `newPath` to be renderable; anything else drops it (keeps the rest). */
private fun decodeFile(element: kotlinx.serialization.json.JsonElement): DiffFile? {
val obj = element as? JsonObject ?: return null
val newPath = obj.str("newPath") ?: return null
val hunks = (obj["hunks"] as? JsonArray).orEmpty().mapNotNull(::decodeHunk)
return DiffFile(
oldPath = obj.str("oldPath") ?: newPath,
newPath = newPath,
status = obj.str("status") ?: "modified",
added = obj.int("added", 0),
removed = obj.int("removed", 0),
binary = obj.bool("binary", false),
hunks = hunks,
)
}
/** A hunk needs a string `header`; a malformed hunk is dropped (its siblings survive). */
private fun decodeHunk(element: kotlinx.serialization.json.JsonElement): DiffHunk? {
val obj = element as? JsonObject ?: return null
val header = obj.str("header") ?: return null
val lines = (obj["lines"] as? JsonArray).orEmpty().mapNotNull(::decodeLine)
return DiffHunk(header = header, lines = lines)
}
/** A line needs both `kind` and `text` as strings; otherwise it is dropped. */
private fun decodeLine(element: kotlinx.serialization.json.JsonElement): DiffLine? {
val obj = element as? JsonObject ?: return null
val kind = obj.str("kind") ?: return null
val text = obj.str("text") ?: return null
return DiffLine(kind = DiffLineKind.fromWire(kind), text = text)
}
private fun JsonObject.str(key: String): String? =
(this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content
private fun JsonObject.int(key: String, default: Int): Int =
(this[key] as? JsonPrimitive)?.intOrNull ?: default
private fun JsonObject.bool(key: String, default: Boolean): Boolean =
(this[key] as? JsonPrimitive)?.booleanOrNull ?: default
// ── Fetch (RO GET — no Origin header, plan §4.2/§4.3) ────────────────────────────────────────────
/** Fetches + decodes a diff for a project path. Seam so the presenter is driven by a fake in tests. */
public interface DiffFetcher {
/** @throws DiffUnavailable on a non-200 status; transport errors propagate. */
public suspend fun fetch(path: String, staged: Boolean): DiffResult
}
/** A non-200 from the diff route (400 bad path / 404 not a repo / 500 git failed). */
public class DiffUnavailable(public val status: Int) : Exception("diff unavailable: HTTP $status")
/**
* Real [DiffFetcher]: builds the RO `GET /projects/diff` against [endpoint] and decodes tolerantly.
* Read-only, so it stamps NO `Origin` header (the CSWSH 铁律 — only guarded routes carry it, plan §4.3).
*/
public class HttpDiffFetcher(
private val endpoint: HostEndpoint,
private val http: HttpTransport,
) : DiffFetcher {
override suspend fun fetch(path: String, staged: Boolean): DiffResult {
val url = diffUrl(endpoint.baseUrl, path, staged) ?: throw DiffUnavailable(HTTP_BAD_REQUEST)
val response = http.send(HttpRequest(method = HttpMethod.GET, url = url))
if (response.status != HTTP_OK) throw DiffUnavailable(response.status)
return decodeDiffResult(response.body)
}
}
private const val HTTP_OK = 200
private const val HTTP_BAD_REQUEST = 400
/**
* Build `<scheme>://host[:port]/projects/diff?path=<enc>&staged=1|0` from the dialed base URL,
* keeping the dialed port verbatim (mirror of `ApiRoute.buildUrl`). `staged` serializes as the
* literal `"1"`/`"0"` string the server matches with `=== '1'` (NOT a boolean). Returns null if the
* base URL cannot be parsed. `internal` so the JVM test asserts the exact query value.
*/
internal fun diffUrl(baseUrl: String, path: String, staged: Boolean): String? {
val uri = runCatching { URI(baseUrl.trim()) }.getOrNull() ?: return null
val scheme = uri.scheme?.lowercase() ?: return null
val host = uri.host ?: return null
if (host.isEmpty()) return null
val serializedHost = if (host.contains(":") && !host.startsWith("[")) "[$host]" else host
val portPart = if (uri.port != -1) ":${uri.port}" else ""
val stagedValue = if (staged) "1" else "0"
return "$scheme://$serializedHost$portPart/projects/diff?path=${percentEncode(path)}&staged=$stagedValue"
}
/** Strict RFC 3986 unreserved set — everything else percent-encoded over UTF-8 (mirror of Endpoints). */
private val UNRESERVED: Set<Char> =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~".toSet()
private val HEX = "0123456789ABCDEF".toCharArray()
private fun percentEncode(value: String): String {
val sb = StringBuilder()
for (byte in value.encodeToByteArray()) {
val code = byte.toInt() and 0xFF
val ch = code.toChar()
if (ch in UNRESERVED) {
sb.append(ch)
} else {
sb.append('%').append(HEX[code ushr 4]).append(HEX[code and 0x0F])
}
}
return sb.toString()
}

View File

@@ -0,0 +1,184 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import wang.yaojia.webterm.session.AwayDigest
import wang.yaojia.webterm.session.Digest
import wang.yaojia.webterm.session.Exited
import wang.yaojia.webterm.session.Gate
import wang.yaojia.webterm.session.GateState
import wang.yaojia.webterm.session.SessionEvent
import wang.yaojia.webterm.session.Telemetry
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.GateKind
import wang.yaojia.webterm.wire.StatusTelemetry
import wang.yaojia.webterm.wiring.TerminalSessionController
/**
* # GateViewModel (A22) — the cockpit's remote approve/reject + telemetry + away-digest presenter.
*
* Folds its OWN [TerminalSessionController.controlEvents] mailbox (gate / telemetry / digest)
* into a single [collectAsStateWithLifecycle-friendly][uiState] snapshot, and resolves the user's
* decision back onto [TerminalSessionController.decideGate] behind the **two-line epoch stale-guard**
* (plan §3.2). Ports the iOS gate-cockpit surface 1:1.
*
* ### Two-line stale-guard (security-load-bearing, plan §1/§3.2)
* A held gate carries an [GateState.epoch] that increments only on a `pending` false→true rising
* edge (`GateTracker`). Every decision is tapped against the epoch of the gate that was ON SCREEN.
* [decide] drops a decision whose `epoch` no longer matches the current live gate — so a slow tap
* against a resolved gate can NEVER approve the NEXT one (防误批新 gate). This is **line 1**; the
* engine re-checks `GateTracker.canDecide` on its confinement dispatcher as **line 2**
* ([TerminalSessionController.decideGate]). Defense in depth: the UI drop is immediate, the engine
* drop is authoritative.
*
* ### Haptic on ARRIVAL only
* [gateArrivals] emits exactly once per NEW gate (a rising edge — a new [GateState.epoch]), never on
* a same-epoch refresh and never on a falling edge. A Composable collects it and fires a haptic
* gated by `LocalReduceMotion` (the OS reduce-motion setting) — the gate itself is composition-only,
* so this VM stays pure/JVM-testable and the actual haptic is device-QA.
*
* ### Testability
* A plain presenter (not an `androidx.lifecycle.ViewModel`) so it runs under `runTest` virtual time
* with no `Dispatchers.Main` / Robolectric. It is OWNED by the config-surviving
* [RetainedSessionHolder][wang.yaojia.webterm.wiring.RetainedSessionHolder] (one per session), which
* constructs it and calls [bind] on the engine's confined scope BEFORE `controller.start()`; so its
* gate/telemetry state survives a rotation instead of being re-derived from an empty fresh mailbox.
* [decide] is synchronous.
*
* ### Its OWN control mailbox (FIX 1 — no split with the reconnect banner)
* [controlEvents] is captured ONCE at construction — a dedicated [TerminalSessionController.controlEvents]
* mailbox that is INDEPENDENT of the banner's (and the A28 timeline's). Every consumer sees every
* control event; none steals from another. Capturing at construction registers the mailbox eagerly,
* before the holder calls `controller.start()`, so the first gate is never dropped.
*/
public class GateViewModel(
private val controller: TerminalSessionController,
) {
/** This VM's OWN control-event mailbox, registered eagerly at construction (before `start()`). */
private val controlEvents: Flow<SessionEvent> = controller.controlEvents()
private val _uiState = MutableStateFlow(GateUiState())
/** The single snapshot the gate/cockpit surfaces render from. */
public val uiState: StateFlow<GateUiState> = _uiState.asStateFlow()
// replay=0: a haptic is a fire-once effect, not state. DROP_OLDEST so an emit never suspends the
// event collector even when no haptic collector is attached (e.g. reduce-motion is on).
private val _gateArrivals = MutableSharedFlow<Unit>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/** One `Unit` per NEW gate (rising edge). Composable → haptic, gated by `LocalReduceMotion`. */
public val gateArrivals: SharedFlow<Unit> = _gateArrivals.asSharedFlow()
// The epoch we last fired an arrival for; guards "arrival ONCE per new gate" (not per refresh).
private var lastArrivalEpoch: Int? = null
private var bound: Boolean = false
/**
* Start folding this VM's [controlEvents] mailbox into [uiState] / [gateArrivals] in [scope] (the
* holder passes the engine's confined scope, so the fold is cancelled with the SESSION, surviving a
* config change). Idempotent — a second call is a no-op. The collector completes when the control
* stream closes (session teardown) or when [scope] is cancelled.
*/
public fun bind(scope: CoroutineScope) {
if (bound) return
bound = true
scope.launch {
controlEvents.collect(::onEvent)
}
}
private fun onEvent(event: SessionEvent) {
when (event) {
is Gate -> onGate(event.gate)
is Telemetry -> _uiState.value = _uiState.value.copy(telemetry = event.telemetry)
is Digest -> onDigest(event.digest)
// The session ended: a held gate is no longer decidable — clear it so no stale card lingers.
is Exited -> _uiState.value = _uiState.value.copy(gate = null)
else -> Unit // Output / Connection / Adopted are not cockpit concerns.
}
}
private fun onGate(gate: GateState?) {
_uiState.value = _uiState.value.copy(gate = gate)
if (gate == null) return // Falling edge: gate lifted; keep lastArrivalEpoch so it can't refire.
// Rising edge = a NEW epoch (epochs are monotonic; a refresh keeps the same epoch).
if (gate.epoch != lastArrivalEpoch) {
lastArrivalEpoch = gate.epoch
_gateArrivals.tryEmit(Unit)
}
}
private fun onDigest(digest: AwayDigest) {
// All-zero digest is suppressed (the UI's "don't render" signal) — hold null, not EMPTY.
_uiState.value = _uiState.value.copy(digest = if (digest.isEmpty) null else digest)
}
/**
* Resolve a user decision behind the two-line stale-guard (line 1). [epoch] is the epoch of the
* gate that was ON SCREEN when the button was tapped (captured by the banner/sheet). Drops the
* decision — sending nothing — when no gate is held, the epoch is stale, or the decision is not
* an affordance of the current gate kind; otherwise sends EXACTLY ONE resolved message.
*/
public fun decide(decision: GateDecision, epoch: Int) {
val held = _uiState.value.gate ?: return // canDecide line 1: no gate held → drop.
if (held.epoch != epoch) return // canDecide line 2: stale epoch → drop.
val message = resolveGateMessage(decision, held.kind) ?: return // not valid for this kind → drop.
controller.decideGate(epoch, message)
}
}
/** The three logical gate decisions the surfaces expose (tool = approve/reject; plan = 3-way). */
public enum class GateDecision {
/** Tool: plain approve (`mode` absent). Plan: approve + review (`mode:"default"`). */
APPROVE,
/** Plan only: approve + auto-accept edits (`mode:"acceptEdits"`). */
ACCEPT_EDITS,
/** Tool: reject. Plan: keep planning (both wire to `reject`). */
REJECT,
}
/** The gate/cockpit snapshot rendered by the surfaces. All fields null = nothing to show. */
public data class GateUiState(
/** The held permission gate, or `null` when none (falling edge / never risen). */
val gate: GateState? = null,
/** Latest statusLine telemetry, or `null` before the first `telemetry` frame. */
val telemetry: StatusTelemetry? = null,
/** The reattach away-digest to show, or `null` when suppressed (all-zero) or none. */
val digest: AwayDigest? = null,
)
/**
* Map a [GateDecision] to the exact wire [ClientMessage] for the current [kind], mirroring
* [GateState.Affordance.clientMessage] (public/tabs.ts:345-347). Returns `null` when the decision is
* not an affordance of [kind] (e.g. [GateDecision.ACCEPT_EDITS] on a [GateKind.TOOL] gate) — the
* caller drops it. Pure — independently unit-tested.
*/
internal fun resolveGateMessage(decision: GateDecision, kind: GateKind): ClientMessage? =
when (kind) {
GateKind.TOOL -> when (decision) {
GateDecision.APPROVE -> GateState.Affordance.APPROVE.clientMessage
GateDecision.REJECT -> GateState.Affordance.REJECT.clientMessage
GateDecision.ACCEPT_EDITS -> null // tool gates have no acceptEdits affordance.
}
GateKind.PLAN -> when (decision) {
GateDecision.APPROVE -> GateState.Affordance.APPROVE_REVIEW.clientMessage
GateDecision.ACCEPT_EDITS -> GateState.Affordance.APPROVE_AUTO.clientMessage
GateDecision.REJECT -> GateState.Affordance.KEEP_PLANNING.clientMessage
}
}

View File

@@ -0,0 +1,327 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import wang.yaojia.webterm.api.pairing.PairingError
import wang.yaojia.webterm.api.pairing.PairingProbeResult
import wang.yaojia.webterm.api.pairing.runPairingProbe
import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.hostregistry.HostStore
import wang.yaojia.webterm.wire.HostClassifier
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HostNetworkTier
import wang.yaojia.webterm.wire.HttpTransport
import wang.yaojia.webterm.wire.TermTransport
import java.util.UUID
/**
* # PairingViewModel (A19) — QR/manual URL → confirm-before-network → two-step probe → secure save.
*
* The pairing state machine, ported 1:1 from the iOS pairing flow, as a pure JVM presenter (mirrors
* [GateViewModel] — a plain class with [bind], NOT an `androidx.lifecycle.ViewModel`, so it runs under
* `runTest` virtual time with no `Dispatchers.Main`/Robolectric). The Compose/CameraX/ML-Kit/permission
* shell ([wang.yaojia.webterm.screens.PairingScreen]) is device-QA (plan §7); THIS is the JVM-tested core.
*
* ### The four hard rules this class enforces (plan §5.4 / §8)
* 1. **One validator.** Every scanned or typed string funnels through [HostEndpoint.fromBaseUrl] — the
* single frozen Origin/WS validator (:wire-protocol). A string it rejects never reaches the network.
* 2. **Confirm-before-network.** [onManualEntry]/[onQrScanned] only VALIDATE + classify; they never
* probe. The network is touched exclusively by [confirm]/[retry]. A scanned URL is never
* auto-connected.
* 3. **Public host needs an explicit acknowledge.** A [WarningTier.PUBLIC] host cannot be probed until
* the user ticks the risk acknowledge ([confirm]`(acknowledgedPublicRisk = true)`); otherwise the
* confirm is a no-op that re-arms the reminder.
* 4. **Tunnel host is cert-gated (the choke point retry can't bypass).** Both [confirm] and [retry]
* funnel through the ONE private [runProbe]; for a [WarningTier.TUNNEL] host with no installed
* device cert, [runProbe] refuses BEFORE any network I/O and re-refuses on every retry (plan §8
* "runProbe refuses to probe `*.terminal.yaojia.wang` unless a device cert is installed").
*
* On probe success the validated host is persisted via [HostStore.upsert].
*
* @param hostStore where a successfully-paired [Host] is saved.
* @param prober the two-step [wang.yaojia.webterm.api.pairing.runPairingProbe] seam; a fake in tests.
* @param hasDeviceCert `suspend` cert check (production: `IdentityRepository.hasInstalledIdentity` on IO).
* @param newId host-id allocator (default random UUID; deterministic in tests).
*/
public class PairingViewModel(
private val hostStore: HostStore,
private val prober: PairingProber,
private val hasDeviceCert: suspend () -> Boolean,
private val newId: () -> String = { UUID.randomUUID().toString() },
) {
private val _uiState = MutableStateFlow<PairingUiState>(PairingUiState.Entry())
/** The single snapshot the pairing UI renders from. */
public val uiState: StateFlow<PairingUiState> = _uiState.asStateFlow()
private var scope: CoroutineScope? = null
private var probeJob: Job? = null
/** Bind the scope probes launch into (the screen passes a lifecycle scope). Idempotent-ish: last wins. */
public fun bind(scope: CoroutineScope) {
this.scope = scope
}
// ── Input funnels (RULE 1 + RULE 2: validate + classify only, NEVER probe) ──────────────────────
/** Manual URL entry submit. Validates through the one validator; on success shows the confirm gate. */
public fun onManualEntry(rawUrl: String) {
presentCandidate(rawUrl)
}
/**
* A decoded QR payload. IDENTICAL funnel to [onManualEntry] — a scanned string is untrusted external
* input and is NEVER auto-connected (RULE 2); it only advances to the confirm gate.
*/
public fun onQrScanned(rawUrl: String) {
presentCandidate(rawUrl)
}
private fun presentCandidate(rawUrl: String) {
val endpoint = HostEndpoint.fromBaseUrl(rawUrl)
if (endpoint == null) {
_uiState.value = PairingUiState.Entry(error = EntryError.INVALID_URL)
return
}
_uiState.value = PairingUiState.Confirming(
endpoint = endpoint,
name = defaultName(endpoint),
tier = PairingTiers.tierFor(endpoint),
)
}
/** Edit the host display name on the confirm screen (no effect off the confirm state). */
public fun setName(name: String) {
val state = _uiState.value
if (state is PairingUiState.Confirming) _uiState.value = state.copy(name = name)
}
/** Discard the current candidate/result and return to the entry field. */
public fun reset() {
probeJob?.cancel()
_uiState.value = PairingUiState.Entry()
}
// ── The network choke point (RULE 3 + RULE 4) ──────────────────────────────────────────────────
/**
* Confirm the on-screen candidate and probe it. For a public host, [acknowledgedPublicRisk] MUST be
* true or this re-arms the acknowledge reminder without touching the network (RULE 3).
*/
public fun confirm(acknowledgedPublicRisk: Boolean = false) {
val candidate = _uiState.value.candidate() ?: return
runProbe(candidate, acknowledgedPublicRisk)
}
/**
* Retry after a failure or a cert-gate refusal. Funnels through the SAME [runProbe] choke point as
* [confirm] — so the tunnel cert-gate (RULE 4) cannot be bypassed by retrying.
*/
public fun retry(acknowledgedPublicRisk: Boolean = false) {
val candidate = _uiState.value.candidate() ?: return
runProbe(candidate, acknowledgedPublicRisk)
}
private fun runProbe(candidate: Candidate, acknowledgedPublicRisk: Boolean) {
val tier = candidate.tier
// RULE 3 — public host: no probe until the risk is explicitly acknowledged.
if (tier.needsExplicitAck && !acknowledgedPublicRisk) {
_uiState.value = PairingUiState.Confirming(
endpoint = candidate.endpoint,
name = candidate.name,
tier = tier,
awaitingAck = true,
)
return
}
val scope = scope ?: return
probeJob?.cancel()
probeJob = scope.launch {
// RULE 4 — tunnel cert-gate: refuse BEFORE any network I/O; retry re-hits this same guard.
if (tier.isCertGated && !hasDeviceCert()) {
_uiState.value = PairingUiState.CertRequired(candidate.endpoint, candidate.name, tier)
return@launch
}
_uiState.value = PairingUiState.Probing(candidate.endpoint, candidate.name, tier)
when (val result = prober.probe(candidate.endpoint)) {
is PairingProbeResult.Success -> onProbeSuccess(candidate)
is PairingProbeResult.Failure -> _uiState.value = PairingUiState.Failed(
endpoint = candidate.endpoint,
name = candidate.name,
tier = tier,
error = result.error,
message = PairingCopy.describe(result.error, tier),
)
}
}
}
private suspend fun onProbeSuccess(candidate: Candidate) {
val host = Host.create(id = newId(), name = candidate.name.ifBlank { defaultName(candidate.endpoint) }, baseUrl = candidate.endpoint.baseUrl)
if (host == null) {
// Unreachable in practice (the endpoint already validated), but never persist a null host.
_uiState.value = PairingUiState.Failed(
endpoint = candidate.endpoint,
name = candidate.name,
tier = candidate.tier,
error = PairingError.HttpOkButNotWebTerminal,
message = PairingCopy.describe(PairingError.HttpOkButNotWebTerminal, candidate.tier),
)
return
}
hostStore.upsert(host)
_uiState.value = PairingUiState.Paired(host)
}
private fun defaultName(endpoint: HostEndpoint): String =
HostClassifier.hostOf(endpoint).ifEmpty { endpoint.baseUrl }
}
/** The two-step pairing probe seam ([wang.yaojia.webterm.api.pairing.runPairingProbe]); faked in tests. */
public fun interface PairingProber {
public suspend fun probe(endpoint: HostEndpoint): PairingProbeResult
}
/**
* Production [PairingProber] binding: the frozen two-step [runPairingProbe] over the ONE shared
* `OkHttpClient`'s [HttpTransport] + [TermTransport] (injected in `NetworkModule`). The screen's nav
* layer resolves both transports off the DI graph and hands the resulting prober to the ViewModel —
* `warmUp()` must have run first (off-`Main`) so the first handshake here doesn't do AndroidKeyStore/Tink
* I/O on a UI thread.
*/
public fun pairingProber(http: HttpTransport, ws: TermTransport): PairingProber =
PairingProber { endpoint -> runPairingProbe(endpoint, http, ws) }
// ── Warning tiers (plan §5.4) ───────────────────────────────────────────────────────────────────────
/**
* The pairing warning tiers the UI reproduces (plan §5.4 / §8). The four [HostNetworkTier] network
* classes plus [TUNNEL] — the native tunnel domain (`*.terminal.yaojia.wang`), which classifies as
* [HostNetworkTier.PUBLIC] on the wire but is cert-gated rather than ack-gated (its warning is softened
* because the device cert IS the gate).
*/
public enum class WarningTier {
/** localhost / 127.0.0.0/8 / ::1 — no warning. */
LOOPBACK,
/** RFC1918 / link-local / mDNS — non-blocking `ws://` cleartext notice. */
PRIVATE_LAN,
/** Tailscale CGNAT / MagicDNS — WireGuard-encrypted; no cleartext warning. */
TAILSCALE,
/** Native tunnel `*.terminal.yaojia.wang` — cert-gated; warning softened, TLS failure = cert invalid. */
TUNNEL,
/** Everything else (incl. malformed — fail-safe) — strongest blocking warning, explicit ack required. */
PUBLIC,
}
/** Pure tier classification + gate policy (independently unit-tested; plan §5.4). */
public object PairingTiers {
/** The native-tunnel host suffix — a subdomain of this presents a real LE cert but is mTLS-gated. */
private const val TUNNEL_SUFFIX: String = ".terminal.yaojia.wang"
/**
* Classify [endpoint] into a [WarningTier]. The tunnel domain is detected FIRST (it would otherwise
* fall to [WarningTier.PUBLIC]); everything else defers to the frozen [HostClassifier]. Fail-safe:
* an unclassifiable host is [WarningTier.PUBLIC].
*/
public fun tierFor(endpoint: HostEndpoint): WarningTier {
val host = HostClassifier.hostOf(endpoint).lowercase()
if (host.endsWith(TUNNEL_SUFFIX)) return WarningTier.TUNNEL
return when (HostClassifier.classify(endpoint)) {
HostNetworkTier.LOOPBACK -> WarningTier.LOOPBACK
HostNetworkTier.PRIVATE_LAN -> WarningTier.PRIVATE_LAN
HostNetworkTier.TAILSCALE -> WarningTier.TAILSCALE
HostNetworkTier.PUBLIC -> WarningTier.PUBLIC
}
}
}
/** Only a public host demands the explicit risk acknowledge before probing (RULE 3). */
public val WarningTier.needsExplicitAck: Boolean get() = this == WarningTier.PUBLIC
/** Only the native-tunnel host is device-cert-gated (RULE 4). */
public val WarningTier.isCertGated: Boolean get() = this == WarningTier.TUNNEL
// ── UI state ────────────────────────────────────────────────────────────────────────────────────────
/** The pairing screen snapshot. */
public sealed interface PairingUiState {
/** The URL/QR entry field, optionally showing a validation [error]. */
public data class Entry(val error: EntryError? = null) : PairingUiState
/**
* A validated candidate awaiting the user's explicit confirm (RULE 2). [awaitingAck] is set after a
* public-host confirm without acknowledgement (RULE 3) so the UI can highlight the risk toggle.
*/
public data class Confirming(
val endpoint: HostEndpoint,
val name: String,
val tier: WarningTier,
val awaitingAck: Boolean = false,
) : PairingUiState
/** The two-step probe is running against [endpoint]. */
public data class Probing(val endpoint: HostEndpoint, val name: String, val tier: WarningTier) : PairingUiState
/**
* A tunnel host was refused for lack of a device cert (RULE 4). The user must import a cert (A27)
* then [PairingViewModel.retry] — which re-hits the same gate.
*/
public data class CertRequired(val endpoint: HostEndpoint, val name: String, val tier: WarningTier) : PairingUiState
/** The probe failed; [message] is inert, app-authored copy for [error]. Retryable. */
public data class Failed(
val endpoint: HostEndpoint,
val name: String,
val tier: WarningTier,
val error: PairingError,
val message: String,
) : PairingUiState
/** Paired + persisted. The screen navigates onward from here. */
public data class Paired(val host: Host) : PairingUiState
}
/** Client-side validation failures on the entry field. */
public enum class EntryError {
/** The typed/scanned string is not a valid `http(s)` URL with a host. */
INVALID_URL,
}
/** The endpoint+name+tier a probe runs against, recovered from whatever state currently holds one. */
private data class Candidate(val endpoint: HostEndpoint, val name: String, val tier: WarningTier)
private fun PairingUiState.candidate(): Candidate? = when (this) {
is PairingUiState.Confirming -> Candidate(endpoint, name, tier)
is PairingUiState.Probing -> Candidate(endpoint, name, tier)
is PairingUiState.CertRequired -> Candidate(endpoint, name, tier)
is PairingUiState.Failed -> Candidate(endpoint, name, tier)
is PairingUiState.Entry, is PairingUiState.Paired -> null
}
/** Maps the [PairingError] taxonomy to inert, app-authored Chinese copy (never a server string, §8). */
internal object PairingCopy {
fun describe(error: PairingError, tier: WarningTier): String = when (error) {
is PairingError.HostUnreachable ->
"无法连接到主机。请检查地址、端口,以及设备是否在同一局域网 / Tailscale 网络内。"
PairingError.HttpOkButNotWebTerminal ->
"这个端口在运行别的服务,不是 web-terminal。端口对吗"
is PairingError.OriginRejected -> error.hint // already actionable, endpoint-derived
is PairingError.CleartextBlocked ->
"系统阻止了到 ${error.host} 的明文连接。仅局域网 / Tailscale 地址允许 ws:// 明文。"
PairingError.TlsFailure ->
// Tunnel hosts present a real LE server cert, so a TLS failure there means OUR client cert
// is the problem (plan §5.4: "TLS failure re-mapped to client cert invalid/revoked").
if (tier == WarningTier.TUNNEL) "客户端证书无效或已被吊销。请在“设备证书”里重新导入后再试。"
else "TLS 握手失败:无法验证服务器证书。"
PairingError.Timeout ->
"连接超时。主机可能不可达,或响应过慢。"
}
}

View File

@@ -0,0 +1,69 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.routes.ApiClientError
/**
* # ProjectDetailViewModel (A23) — one project's detail (`GET /projects/detail?path=`), a phase state
* machine (same discipline as [DiffViewModel]/iOS `ProjectDetailViewModel`).
*
* The [fetch] closure is injected — production wraps [ProjectsGateway.projectDetail] (the builder's
* percent-encoding + 400/404/500 → typed [ApiClientError] mapping lives in `:api-client`), tests inject a
* fake. This VM only reduces the three user-visible outcomes:
* - success → [Phase.Loaded] (sessions/worktrees/hasClaudeMd/claudeMd passed through, rendered INERT);
* - 400 / [ApiClientError.InvalidRequest] → [Failure.PATH_INVALID];
* - 404 → [Failure.NOT_FOUND]; 500 / decode / transport → [Failure.UNAVAILABLE] — all retryable via [load].
*
* A plain presenter (not `androidx.lifecycle.ViewModel`) so it runs under `runTest` with no
* `Dispatchers.Main`. The screen calls [load] in a lifecycle scope; the retry action re-calls it.
*/
public class ProjectDetailViewModel(
public val path: String,
private val fetch: suspend () -> ProjectDetail,
) {
/** User-visible failure buckets (copy mapped in `ProjectDetailScreen`). */
public enum class Failure { PATH_INVALID, NOT_FOUND, UNAVAILABLE }
/** The load phase the screen renders (spinner / detail / error+retry). */
public sealed interface Phase {
public data object Loading : Phase
public data class Loaded(val detail: ProjectDetail) : Phase
public data class Failed(val failure: Failure) : Phase
}
private val _phase = MutableStateFlow<Phase>(Phase.Loading)
/** The single snapshot `ProjectDetailScreen` renders from. */
public val phase: StateFlow<Phase> = _phase.asStateFlow()
/** Fetch and present. Also the retry path: callable again after a [Phase.Failed]. */
public suspend fun load() {
_phase.value = Phase.Loading
_phase.value = try {
Phase.Loaded(fetch())
} catch (cancel: CancellationException) {
throw cancel
} catch (error: ApiClientError) {
Phase.Failed(failureFor(error))
} catch (_: Throwable) {
// Transport/decode etc. — a retryable catch-all.
Phase.Failed(Failure.UNAVAILABLE)
}
}
private fun failureFor(error: ApiClientError): Failure = when (error) {
ApiClientError.ProjectPathInvalid, ApiClientError.InvalidRequest -> Failure.PATH_INVALID
ApiClientError.ProjectNotFound -> Failure.NOT_FOUND
else -> Failure.UNAVAILABLE
}
public companion object {
/** Production assembly seam ([ProjectsViewModel.makeDetailViewModel] mints via this). */
public fun forGateway(gateway: ProjectsGateway, path: String): ProjectDetailViewModel =
ProjectDetailViewModel(path) { gateway.projectDetail(path) }
}
}

View File

@@ -0,0 +1,435 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.models.ProjectInfo
import wang.yaojia.webterm.api.models.UiPrefs
import wang.yaojia.webterm.api.routes.ApiClient
import wang.yaojia.webterm.api.routes.ApiClientError
import wang.yaojia.webterm.wire.Validation
import java.util.UUID
/**
* # ProjectsViewModel (A23) — the Projects list state (mirrors web v0.6 `public/projects.ts` +
* iOS `ProjectsViewModel`): the grouped grid over `GET /projects`, the cross-device favourites/collapse
* round-trip over `GET/PUT /prefs`, and the "open Claude here" navigation signal.
*
* ### `/prefs` is a clobber-sensitive surface (R11 · plan §4.3)
* `PUT /prefs` replaces the WHOLE blob server-side, so every write goes through [UiPrefs]'s
* unknown-key-preserving API ([UiPrefs.withFavourites]/[UiPrefs.withCollapsed]) — any top-level key a
* web/iOS/future-server client wrote is carried back verbatim. If the `/prefs` GET never succeeded
* ([prefsBase] is null), a favourite/collapse toggle changes ONLY local state and is **never PUT** (an
* empty-base PUT would wipe the server's favourites). A successful PUT adopts the server's sanitized
* echo as the new truth. Prefs load ONCE (mirrors web `mountProjects.init`); the refresh cadence
* re-fetches only projects so it never clobbers unsynced local edits.
*
* ### Untrusted server boundary (plan §8)
* The list decodes tolerantly inside [ApiClient]; this VM never crashes on a partial list. Every server
* string (name/path/branch) is carried INERT to the screen (plain `Text`, no linkify/markdown). A cwd
* is server data → re-validated through [Validation.isAbsoluteCwd] before it is minted into an open
* request (the same discipline the deep-link router uses).
*
* ### A plain presenter (JVM-testable, mirrors [SessionListViewModel]/[DiffViewModel])
* Not an `androidx.lifecycle.ViewModel`, so it runs under `runTest` virtual time with no
* `Dispatchers.Main`. Its collaborator is the [ProjectsGateway] seam, so the grouping-parity,
* favourite/collapse and prefs-round-trip logic are all JVM-tested; grid/sheet layout is device-QA.
*/
public class ProjectsViewModel(
private val gateway: ProjectsGateway,
) {
private val _uiState = MutableStateFlow(ProjectsUiState())
/** The single snapshot `ProjectsScreen` renders from. */
public val uiState: StateFlow<ProjectsUiState> = _uiState.asStateFlow()
/**
* The last known FULL prefs blob (known + unknown keys). `null` = never successfully loaded →
* NEVER PUT (R11: an empty-base write would clobber the server's favourites).
*/
private var prefsBase: UiPrefs? = null
/** Live search-box binding — re-derives [ProjectsUiState.groups] on the next render. */
public fun onSearchChange(text: String) {
_uiState.update { it.copy(searchText = text) }
}
/** First entry: prefs (once) + projects. Prefs stay in memory afterward (mirrors web `init`). */
public suspend fun load() {
loadPrefsIfNeeded()
refresh()
}
/** Re-fetch only projects (pull-to-refresh / retry). A failure keeps the old list + an error line. */
public suspend fun refresh() {
try {
val projects = gateway.projects()
_uiState.update { it.copy(projects = projects, fetchError = null, hasLoadedOnce = true) }
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
_uiState.update { it.copy(fetchError = ProjectsCopy.fetchFailed(errorDetail(error))) }
}
}
private suspend fun loadPrefsIfNeeded() {
if (prefsBase != null) return
try {
adopt(gateway.prefs())
_uiState.update { it.copy(prefsError = null) }
} catch (cancel: CancellationException) {
throw cancel
} catch (_: Throwable) {
_uiState.update { it.copy(prefsError = ProjectsCopy.PREFS_LOAD_FAILED) }
}
}
// ── Favourites / collapse (cross-device prefs round-trip) ────────────────────────────────────
public suspend fun toggleFavourite(path: String) {
val current = _uiState.value.favourites
val next = if (current.contains(path)) current.filterNot { it == path } else current + path
_uiState.update { it.copy(favourites = next) }
persistPrefs()
}
public suspend fun toggleCollapsed(key: String) {
val current = _uiState.value.collapsedGroups
// Store only the "collapsed" fact; expanded is the default (matches web/server sanitizers).
val next = if (current[key] == true) current.filterKeys { it != key } else current + (key to true)
_uiState.update { it.copy(collapsedGroups = next) }
persistPrefs()
}
/**
* Rewrite the two known keys on the last-known full blob (unknown keys carried through), PUT, then
* adopt the server echo. No base (prefs never loaded) → local-only, never PUT (R11).
*/
private suspend fun persistPrefs() {
val base = prefsBase ?: return
val next = base
.withFavourites(_uiState.value.favourites)
.withCollapsed(_uiState.value.collapsedGroups)
prefsBase = next // optimistic: stack consecutive toggles on the same base
try {
adopt(gateway.putPrefs(next))
_uiState.update { it.copy(prefsSyncError = null) }
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
_uiState.update { it.copy(prefsSyncError = ProjectsCopy.prefsSyncFailed(errorDetail(error))) }
}
}
private fun adopt(prefs: UiPrefs) {
prefsBase = prefs
_uiState.update { it.copy(favourites = prefs.favourites, collapsedGroups = prefs.collapsed) }
}
// ── Open Claude here (the core action — attach(null, cwd=projectPath)) ────────────────────────
/**
* Mint an [ProjectOpenRequest] for a fresh session in [cwd] (`attach(null, cwd)` + a `claude\r`
* bootstrap). The path is server data → validated first; an invalid one surfaces an inert error and
* mints nothing. A fresh [UUID] per request re-triggers `onChange` even for a repeated tap.
*/
public fun requestOpenClaude(cwd: String) {
if (!Validation.isAbsoluteCwd(cwd)) {
_uiState.update { it.copy(openError = ProjectsCopy.OPEN_CLAUDE_INVALID_PATH) }
return
}
_uiState.update {
it.copy(
openError = null,
openRequest = ProjectOpenRequest(
id = UUID.randomUUID(),
cwd = cwd,
bootstrapInput = ProjectsCopy.CLAUDE_BOOTSTRAP_INPUT,
),
)
}
}
/** The nav layer clears the signal once it has routed the attach (so it does not re-fire). */
public fun consumeOpenRequest() {
_uiState.update { it.copy(openRequest = null) }
}
// ── Detail assembly (the detail-screen seam) ─────────────────────────────────────────────────
public fun makeDetailViewModel(path: String): ProjectDetailViewModel =
ProjectDetailViewModel.forGateway(gateway, path)
private fun errorDetail(error: Throwable): String =
(error as? ApiClientError)?.userMessage ?: error.message ?: error.toString()
}
// ── ProjectGrouping — the byte-parity grouping core (mirrors public/projects.ts + iOS) ───────────
/**
* Pure namespace grouping — a LINE-FOR-LINE port of web v0.6 `public/projects.ts`
* (`filterProjects`/`sortProjects`/`groupProjects`/`displayLabel`) so phone, iPad and web see the SAME
* sections.
*
* **The frozen contract: group [ProjectGroup.key]s are byte-identical to web/iOS.** Collapse state is
* persisted BY group key into the cross-device `/prefs.collapsed` blob, so a key drift = two ends losing
* each other's collapse state. The three key shapes are frozen:
* - the sentinels [ACTIVE_GROUP_KEY] `" active"` / [OTHER_GROUP_KEY] `" other"` (leading space → can't
* collide with a real `First.Second` namespace);
* - a namespace group's key is its **first-seen casing** (`"Acme.Web"`, not the lower-cased bucket key).
* [ProjectGroup.label] is local UI text (Chinese sentinels; namespace label = the key itself) — only the
* KEY is frozen, so labels may diverge per platform without breaking collapse sync.
*/
public object ProjectGrouping {
/** Sentinel keys (leading space, mirrors public/projects.ts:83-84). Frozen — cross-device shared. */
public const val ACTIVE_GROUP_KEY: String = " active"
public const val OTHER_GROUP_KEY: String = " other"
/** A namespace needs at least this many members to earn its own section (web `MIN_GROUP_SIZE`). */
private const val MIN_GROUP_SIZE = 2
/** Filter by name/path substring, case-insensitive (mirrors `filterProjects`). Never mutates input. */
public fun filter(projects: List<ProjectInfo>, query: String): List<ProjectInfo> {
val trimmed = query.trim()
if (trimmed.isEmpty()) return projects
val lower = trimmed.lowercase()
return projects.filter {
it.name.lowercase().contains(lower) || it.path.lowercase().contains(lower)
}
}
/**
* Favourites-first, then `lastActiveMs` descending (mirrors `sortProjects`). Kotlin `sortedWith` is
* stable, so ties keep server order — matching JS's stable `Array.sort` byte-for-byte.
*/
public fun sort(projects: List<ProjectInfo>, favourites: Set<String>): List<ProjectInfo> =
projects.sortedWith(
compareBy<ProjectInfo> { if (favourites.contains(it.path)) 0 else 1 }
.thenByDescending { it.lastActiveMs ?: 0L },
)
/**
* The card label inside a namespace group: the tail after the `<key>.` prefix (case-insensitive), so
* `Billo.Platform.` isn't shouted on every card. Sentinel groups keep the full name (mirrors
* `displayLabel`).
*/
public fun displayLabel(name: String, groupKey: String): String {
if (groupKey == ACTIVE_GROUP_KEY || groupKey == OTHER_GROUP_KEY) return name
val prefix = "$groupKey."
return if (name.lowercase().startsWith(prefix.lowercase())) name.substring(prefix.length) else name
}
/** Running = any non-exited session (same predicate as web `hasRunningSession`). */
public fun hasRunningSession(project: ProjectInfo): Boolean = project.sessions.any { !it.exited }
/**
* Group into collapsible namespace sections (mirrors `groupProjects`). Running projects duplicate
* into a pinned "Active now"; sub-[MIN_GROUP_SIZE] namespaces fall into "Other"; no real namespace
* groups → a single header-less `flat` group (keyed [OTHER_GROUP_KEY]). Never mutates input.
*/
public fun group(projects: List<ProjectInfo>, favourites: Set<String>): List<ProjectGroup> {
val (namespaceGroups, other) = bucketByNamespace(projects, favourites)
// Grouping bought nothing — one flat, header-less grid (web's `flat` fallback).
if (namespaceGroups.isEmpty()) {
return listOf(
makeGroup(OTHER_GROUP_KEY, ProjectsCopy.ALL_GROUP_LABEL, ProjectGroupKind.FLAT, projects, favourites),
)
}
val orderedNamespaces = namespaceGroups.sortedWith(
compareByDescending<ProjectGroup> { maxLastActive(it.projects) }.thenBy { it.label },
)
val groups = ArrayList<ProjectGroup>()
val active = projects.filter(::hasRunningSession)
if (active.isNotEmpty()) {
groups.add(makeGroup(ACTIVE_GROUP_KEY, ProjectsCopy.ACTIVE_GROUP_LABEL, ProjectGroupKind.ACTIVE, active, favourites))
}
groups.addAll(orderedNamespaces)
if (other.isNotEmpty()) {
groups.add(makeGroup(OTHER_GROUP_KEY, ProjectsCopy.OTHER_GROUP_LABEL, ProjectGroupKind.OTHER, other, favourites))
}
return groups
}
/**
* Bucket by lower-cased namespace key (de-dup), keeping the first-seen casing as the display/key and
* first-seen insertion order (mirrors JS `Map` iteration). Sub-[MIN_GROUP_SIZE] buckets collapse into
* "Other". Returns (namespace groups in insertion order, the "other" pile).
*/
private fun bucketByNamespace(
projects: List<ProjectInfo>,
favourites: Set<String>,
): Pair<List<ProjectGroup>, List<ProjectInfo>> {
val bucketOrder = ArrayList<String>()
val buckets = HashMap<String, Bucket>()
val other = ArrayList<ProjectInfo>()
for (project in projects) {
val namespace = namespaceKey(project.name)
if (namespace == null) {
other.add(project)
continue
}
val lowerKey = namespace.lowercase()
val existing = buckets[lowerKey]
if (existing != null) {
existing.items.add(project)
} else {
buckets[lowerKey] = Bucket(namespace, arrayListOf(project))
bucketOrder.add(lowerKey)
}
}
val groups = ArrayList<ProjectGroup>()
for (lowerKey in bucketOrder) {
val bucket = buckets[lowerKey] ?: continue
if (bucket.items.size < MIN_GROUP_SIZE) {
other.addAll(bucket.items)
} else {
groups.add(makeGroup(bucket.display, bucket.display, ProjectGroupKind.NAMESPACE, bucket.items, favourites))
}
}
return groups to other
}
/** First two dot-segments (`'a.b.c'` → `'a.b'`; < 2 segments → null). Empty segments kept (JS `split`). */
private fun namespaceKey(name: String): String? {
val segments = name.split(".")
if (segments.size < 2) return null
return segments.take(2).joinToString(".")
}
private fun maxLastActive(items: List<ProjectInfo>): Long =
items.fold(0L) { acc, p -> maxOf(acc, p.lastActiveMs ?: 0L) }
private fun makeGroup(
key: String,
label: String,
kind: ProjectGroupKind,
items: List<ProjectInfo>,
favourites: Set<String>,
): ProjectGroup = ProjectGroup(
key = key,
label = label,
kind = kind,
projects = sort(items, favourites),
activeCount = items.count(::hasRunningSession),
)
private class Bucket(val display: String, val items: MutableList<ProjectInfo>)
}
/** Section kind (mirrors web `ProjectGroupKind`). */
public enum class ProjectGroupKind { ACTIVE, NAMESPACE, OTHER, FLAT }
/** One collapsible section snapshot (mirrors web `ProjectGroup`). [key] is the frozen cross-device id. */
public data class ProjectGroup(
/** Collapse-state persistence key — byte-identical to web/iOS (`/prefs` shared). */
val key: String,
val label: String,
val kind: ProjectGroupKind,
/** Already sorted (favourites-first → recency). */
val projects: List<ProjectInfo>,
/** Projects with a running session (a collapsed section never silently buries active work). */
val activeCount: Int,
) {
/** "Active now" is always shown; `flat` has no chrome — only namespace/other collapse. */
val isCollapsible: Boolean get() = kind == ProjectGroupKind.NAMESPACE || kind == ProjectGroupKind.OTHER
}
/** "Open Claude here" nav signal (attach(null, cwd) + a bootstrap input). Consumed by the nav layer. */
public data class ProjectOpenRequest(
val id: UUID,
/** New session working directory (already validated as an absolute path). */
val cwd: String,
/** First input injected after attach (`null` = plain shell). */
val bootstrapInput: String?,
)
/**
* The immutable Projects snapshot. Raw inputs ([projects]/[favourites]/[collapsedGroups]/[searchText])
* are stored; [groups] derives the byte-parity grouping on demand (mirrors iOS's computed `groups`).
*/
public data class ProjectsUiState(
val searchText: String = "",
val hasLoadedOnce: Boolean = false,
/** Last project refresh failed (old list kept — one dropped poll never clears the screen). */
val fetchError: String? = null,
/** Prefs load failed: favourites/collapse are local-only this session, never written back. */
val prefsError: String? = null,
/** Last `PUT /prefs` failed (the local change is kept; the next toggle retries). */
val prefsSyncError: String? = null,
/** "Open Claude here" rejected (invalid path). */
val openError: String? = null,
/** Favourited project paths (order preserved: a new favourite appends). */
val favourites: List<String> = emptyList(),
/** Group key → collapsed (only `true` stored; expanded is the default). */
val collapsedGroups: Map<String, Boolean> = emptyMap(),
val projects: List<ProjectInfo> = emptyList(),
val openRequest: ProjectOpenRequest? = null,
) {
/** Search-filtered → web/iOS-identical grouping (favourites-first sort happens inside each group). */
val groups: List<ProjectGroup>
get() = ProjectGrouping.group(ProjectGrouping.filter(projects, searchText), favourites.toSet())
val isSearching: Boolean get() = searchText.trim().isNotEmpty()
/** Searching force-expands every matching section (results never hide behind a collapsed caret). */
public fun isCollapsed(group: ProjectGroup): Boolean =
group.isCollapsible && !isSearching && collapsedGroups[group.key] == true
public fun isFavourite(path: String): Boolean = favourites.contains(path)
/** Empty-state copy: no projects vs no search match (mirrors web `renderGrid`'s two messages). */
val emptyStateMessage: String?
get() {
if (!hasLoadedOnce || fetchError != null) return null
if (projects.isEmpty()) return ProjectsCopy.EMPTY_NO_PROJECTS
if (ProjectGrouping.filter(projects, searchText).isEmpty()) return ProjectsCopy.EMPTY_NO_MATCH
return null
}
}
// ── Gateway seam (abstracts ApiClient so the VM is JVM-tested against a fake) ─────────────────────
/** Per-host projects gateway. Production is [ApiClientProjectsGateway]; tests queue canned responses. */
public interface ProjectsGateway {
public suspend fun projects(): List<ProjectInfo>
public suspend fun prefs(): UiPrefs
public suspend fun putPrefs(prefs: UiPrefs): UiPrefs
public suspend fun projectDetail(path: String): ProjectDetail
}
/** Production [ProjectsGateway] delegating to a per-host [ApiClient] over the shared mTLS transport. */
public class ApiClientProjectsGateway(private val api: ApiClient) : ProjectsGateway {
override suspend fun projects(): List<ProjectInfo> = api.projects()
override suspend fun prefs(): UiPrefs = api.prefs()
override suspend fun putPrefs(prefs: UiPrefs): UiPrefs = api.putPrefs(prefs)
override suspend fun projectDetail(path: String): ProjectDetail = api.projectDetail(path)
}
/** User-visible copy (Chinese named constants; labels are local UI text — only group KEYS are frozen). */
public object ProjectsCopy {
public const val TITLE: String = "项目"
public const val SEARCH_PROMPT: String = "筛选项目…"
public const val ACTIVE_GROUP_LABEL: String = "活跃中"
public const val OTHER_GROUP_LABEL: String = "其他"
public const val ALL_GROUP_LABEL: String = "全部项目"
public const val DIRTY_BADGE: String = "未提交"
public const val OPEN_CLAUDE_LABEL: String = "Claude"
public const val EMPTY_NO_PROJECTS: String = "未发现项目。请检查主机的 PROJECT_ROOTS 配置。"
public const val EMPTY_NO_MATCH: String = "没有匹配的项目。"
public const val PREFS_LOAD_FAILED: String = "云端收藏/折叠状态加载失败,本次修改仅在本机生效。"
public const val OPEN_CLAUDE_INVALID_PATH: String = "项目路径无效,无法开新会话。"
/** Enter is `\r` (0x0D), not `\n` — the classic synthesize-input gotcha (CLAUDE.md Gotchas). */
public const val CLAUDE_BOOTSTRAP_INPUT: String = "claude\r"
public fun fetchFailed(detail: String): String = "刷新项目列表失败:$detail"
public fun prefsSyncFailed(detail: String): String = "收藏/折叠状态同步失败:$detail"
public fun activeCountBadge(count: Int): String = "$count 活跃"
}

View File

@@ -0,0 +1,309 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import wang.yaojia.webterm.api.models.LiveSessionInfo
import wang.yaojia.webterm.api.routes.ApiClient
import wang.yaojia.webterm.api.routes.ApiClientError
import wang.yaojia.webterm.designsystem.DisplayStatus
import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.hostregistry.HostStore
import wang.yaojia.webterm.session.TitleSanitizer
import wang.yaojia.webterm.session.UnreadLedger
import wang.yaojia.webterm.wire.Tunables
import java.util.UUID
import kotlin.time.Duration
/**
* # SessionListViewModel (A20) — the session chooser + dashboard + host-menu presenter.
*
* Folds `GET /live-sessions` for the ACTIVE host into a single [collectAsStateWithLifecycle-friendly]
* [uiState] snapshot of [SessionRow]s (status badge · telemetry · thumbnail key · cols×rows · sanitized
* title · unread dot), and owns the four list actions: STARTED-scoped [poll], [refresh]
* (pull-to-refresh), [selectHost] (multi-host switch), [markSeen] (clear a dot on open) and [killSession]
* (optimistic swipe-to-kill). Ports the iOS session-list surface.
*
* ### Untrusted server boundary (plan §4 / §8)
* The list decodes tolerantly inside [ApiClient] (malformed entries dropped, unknown `status` →
* [wang.yaojia.webterm.wire.ClaudeStatus.UNKNOWN]); this VM never crashes on a partial list. Every
* server string ([SessionRow.title] via [TitleSanitizer], cwd, telemetry) is carried INERT for the row
* to render as plain `Text` — no linkify/markdown (§8).
*
* ### STARTED-scoped poll (plan §6.6)
* [poll] is a `suspend` loop the screen runs inside `repeatOnLifecycle(STARTED)`: it fetches, waits
* [pollInterval] ([Tunables.LIST_POLL_INTERVAL]), repeats — and is cancelled the instant the app leaves
* the foreground (no background polling). One-shot actions ([refresh]/[selectHost]/[markSeen]/
* [killSession]) are `suspend` too, so the screen launches each in its own scope; a [fetchMutex]
* serialises the network reads so a manual refresh and a poll tick never interleave-corrupt [uiState].
*
* ### Testability
* A plain presenter (not an `androidx.lifecycle.ViewModel`) so it runs under `runTest` virtual time with
* no `Dispatchers.Main`/Robolectric. Its collaborators are seams ([SessionListGatewayFactory],
* [UnreadWatermarkStore]) so the fetch→row mapping, unread-dot logic and optimistic-kill path are all
* JVM-tested; swipe/thumbnail/pull gestures are device-QA (plan §7).
*/
public class SessionListViewModel(
private val hostStore: HostStore,
private val gatewayFactory: SessionListGatewayFactory,
private val watermarkStore: UnreadWatermarkStore = InMemoryUnreadWatermarkStore(),
private val pollInterval: Duration = Tunables.LIST_POLL_INTERVAL,
) {
private val _uiState = MutableStateFlow(SessionListUiState())
/** The single snapshot the session-list / dashboard renders from. */
public val uiState: StateFlow<SessionListUiState> = _uiState.asStateFlow()
/** Serialises network reads so a poll tick and a manual refresh never race on [uiState]. */
private val fetchMutex = Mutex()
/** The paired hosts (with their [Host.endpoint]), by id — the UI only sees the id/name [HostOption]s. */
private var hostsById: Map<String, Host> = emptyMap()
/** The active host id; sticky across polls, defaults to the first host until the user switches. */
private var activeHostId: String? = null
/** Local unread watermarks (persisted through [watermarkStore]); loaded once, lazily. */
private var ledger: UnreadLedger = UnreadLedger()
private var ledgerLoaded = false
/**
* STARTED-scoped poll loop (plan §6.6): fetch the active host's list, wait [pollInterval], repeat.
* Cancellation (app backgrounded) stops it cleanly. Run inside `repeatOnLifecycle(STARTED)`.
*/
public suspend fun poll() {
while (true) {
fetch(markRefreshing = false)
delay(pollInterval)
}
}
/** Pull-to-refresh: one immediate fetch with the refresh spinner shown. */
public suspend fun refresh() {
fetch(markRefreshing = true)
}
/**
* Switch the active host (multi-host menu) and fetch it immediately. An unknown id is ignored (the
* host may have been removed between render and tap).
*/
public suspend fun selectHost(hostId: String) {
if (!hostsById.containsKey(hostId)) return
activeHostId = hostId
fetch(markRefreshing = true)
}
/**
* Clear the unread dot for [sessionId] when the user opens it: record the row's current
* `lastOutputAt` as the seen-watermark (monotonic), persist it, and re-derive the rows so the dot
* drops immediately. A no-op when the row is unknown or has no output timestamp (nothing to clear).
*/
public suspend fun markSeen(sessionId: UUID) {
val row = _uiState.value.rows.firstOrNull { it.id == sessionId } ?: return
val at = row.info.lastOutputAt ?: return
ledger = ledger.record(sessionId.toString(), at)
runCatching { watermarkStore.save(ledger) }
_uiState.update { it.copy(rows = it.rows.map { r -> r.withUnread(ledger) }) }
}
/**
* Optimistic swipe-to-kill: remove the row from [uiState] at once, then `DELETE /live-sessions/:id`.
* 204 and 404 (already gone) are BOTH success — the row stays removed. Any other failure restores the
* row (the session is still alive) and the next poll reconciles authoritatively.
*/
public suspend fun killSession(sessionId: UUID) {
val index = _uiState.value.rows.indexOfFirst { it.id == sessionId }
if (index < 0) return
val removed = _uiState.value.rows[index]
_uiState.update { it.copy(rows = it.rows.filterNot { r -> r.id == sessionId }) }
val gateway = activeGateway() ?: return
try {
gateway.killSession(sessionId)
} catch (_: ApiClientError.SessionNotFound) {
// 404 = already gone = success (plan §4.3). Keep it removed.
} catch (e: CancellationException) {
throw e
} catch (_: Throwable) {
restoreRow(index, removed)
}
}
// ── Internals ────────────────────────────────────────────────────────────────────────────────
private suspend fun fetch(markRefreshing: Boolean) {
fetchMutex.withLock {
ensureLedgerLoaded()
if (markRefreshing) _uiState.update { it.copy(isRefreshing = true) }
val hosts = runCatching { hostStore.loadAll() }.getOrDefault(emptyList())
hostsById = hosts.associateBy { it.id }
val active = resolveActiveHost(hosts)
val hostOptions = hosts.map {
HostOption(id = it.id, name = it.name, isActive = it.id == active?.id, hasDeviceCert = it.hasDeviceCert)
}
if (active == null) {
// No paired host (or none matches) → an empty chooser; the screen offers pairing.
_uiState.value = SessionListUiState(hosts = hostOptions, activeHostId = null)
return@withLock
}
val result = runCatching { gatewayFactory.create(active).liveSessions() }
result.fold(
onSuccess = { list ->
_uiState.value = SessionListUiState(
hosts = hostOptions,
activeHostId = active.id,
rows = sessionRowsOf(list, ledger),
isRefreshing = false,
hasLoadError = false,
)
},
onFailure = { error ->
if (error is CancellationException) throw error
// Keep the last-good rows on a transient poll failure — only flag the error banner.
_uiState.update {
it.copy(
hosts = hostOptions,
activeHostId = active.id,
isRefreshing = false,
hasLoadError = true,
)
}
},
)
}
}
/** Keep the current active host if it still exists; otherwise fall back to the first paired host. */
private fun resolveActiveHost(hosts: List<Host>): Host? {
val current = activeHostId?.let { id -> hosts.firstOrNull { it.id == id } }
val resolved = current ?: hosts.firstOrNull()
activeHostId = resolved?.id
return resolved
}
private fun activeGateway(): SessionListGateway? =
activeHostId?.let { hostsById[it] }?.let(gatewayFactory::create)
private fun restoreRow(index: Int, row: SessionRow) {
_uiState.update { state ->
if (state.rows.any { it.id == row.id }) return@update state // a poll already re-added it.
val at = index.coerceIn(0, state.rows.size)
state.copy(rows = state.rows.toMutableList().apply { add(at, row) })
}
}
private suspend fun ensureLedgerLoaded() {
if (ledgerLoaded) return
ledger = runCatching { watermarkStore.load() }.getOrDefault(UnreadLedger())
ledgerLoaded = true
}
}
/**
* Map the tolerant-decoded `GET /live-sessions` list to display rows: sanitize the OSC title, resolve
* the status badge, and light the unread dot from the [ledger]. Pure + deterministic — the JVM-tested
* core of the poll (order-preserving, one row per entry, never throws on a partial/edge-case list).
*/
public fun sessionRowsOf(sessions: List<LiveSessionInfo>, ledger: UnreadLedger): List<SessionRow> =
sessions.map { info ->
SessionRow(
info = info,
displayStatus = if (info.exited) DisplayStatus.Exited else DisplayStatus.from(info.status),
title = TitleSanitizer.sanitize(info.title.orEmpty()),
isUnread = ledger.isUnread(info.id.toString(), info.lastOutputAt),
)
}
/** The session-list / dashboard snapshot. Empty [rows] + null [activeHostId] = the empty (pair-a-host) state. */
public data class SessionListUiState(
/** Paired hosts for the multi-host switch (checkmark on [HostOption.isActive]). */
val hosts: List<HostOption> = emptyList(),
/** The host currently listed, or null when no host is paired. */
val activeHostId: String? = null,
/** One row per running/just-exited session on the active host, in server order. */
val rows: List<SessionRow> = emptyList(),
/** Pull-to-refresh spinner state. */
val isRefreshing: Boolean = false,
/** The last fetch failed (transport error) — the screen shows an inline retry, rows kept as-is. */
val hasLoadError: Boolean = false,
)
/**
* One session row. [info] is the raw tolerant-decoded record (the thumbnail key `(id, lastOutputAt)`,
* telemetry, cwd, clientCount, geometry pass straight through to the row Composable); the derived fields
* are the sanitized/resolved display values the row renders and the tests assert.
*/
public data class SessionRow(
val info: LiveSessionInfo,
val displayStatus: DisplayStatus,
/** Sanitized OSC title ([TitleSanitizer]); "" = no title (the row shows the id/cwd instead). */
val title: String,
val isUnread: Boolean,
) {
/** The session id (thumbnail key + open/kill target). */
val id: UUID get() = info.id
/** "161×50" — tabular geometry label for the row (× is U+00D7, not the letter x). */
val dimensions: String get() = "${info.cols}×${info.rows}"
/** Re-derive [isUnread] against a new [ledger] (used after [SessionListViewModel.markSeen]). */
internal fun withUnread(ledger: UnreadLedger): SessionRow =
copy(isUnread = ledger.isUnread(id.toString(), info.lastOutputAt))
}
/** One host in the multi-host switch menu (id/name only — the endpoint stays inside the VM). */
public data class HostOption(
val id: String,
val name: String,
val isActive: Boolean,
val hasDeviceCert: Boolean,
)
/**
* Per-host list gateway — abstracts [ApiClient] so the VM is JVM-testable against a fake. Production is
* [ApiClientSessionGateway] over the shared mTLS `HttpTransport`; tests queue canned lists.
*/
public interface SessionListGateway {
/** `GET /live-sessions` — tolerant-decoded list for the active host. */
public suspend fun liveSessions(): List<LiveSessionInfo>
/** `DELETE /live-sessions/:id` — 204 success; 404 (already gone) surfaces as [ApiClientError.SessionNotFound]. */
public suspend fun killSession(id: UUID)
}
/** Mints a [SessionListGateway] for the active [Host] (wiring binds it to [ApiClient] per host endpoint). */
public fun interface SessionListGatewayFactory {
public fun create(host: Host): SessionListGateway
}
/** Production [SessionListGateway] delegating to a per-host [ApiClient]. */
public class ApiClientSessionGateway(private val api: ApiClient) : SessionListGateway {
override suspend fun liveSessions(): List<LiveSessionInfo> = api.liveSessions()
override suspend fun killSession(id: UUID): Unit = api.killSession(id)
}
/**
* Persistence seam for the unread watermarks (plan §2 — DataStore-backed in production, wired outside
* A20's lane). [InMemoryUnreadWatermarkStore] is the default so the VM works before that store exists.
*/
public interface UnreadWatermarkStore {
public suspend fun load(): UnreadLedger
public suspend fun save(ledger: UnreadLedger)
}
/** In-memory [UnreadWatermarkStore] (default). Watermarks live for the process only until DataStore lands. */
public class InMemoryUnreadWatermarkStore(initial: UnreadLedger = UnreadLedger()) : UnreadWatermarkStore {
@Volatile private var current: UnreadLedger = initial
override suspend fun load(): UnreadLedger = current
override suspend fun save(ledger: UnreadLedger) {
current = ledger
}
}

View File

@@ -0,0 +1,201 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import wang.yaojia.webterm.designsystem.DesignSpec
import wang.yaojia.webterm.wire.TimelineEvent
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
import java.util.UUID
/**
* # TimelineViewModel (A28) — state for the full activity-timeline drill-down sheet.
*
* Mirrors the iOS `TimelineViewModel` and the web timeline panel (`public/timeline.ts`): fetches
* `GET /live-sessions/:id/events` (plan §4.2), then presents rows **newest-first, capped at
* [MAX_EVENTS]** — line-for-line the web `render()` pipeline (`slice(0, maxEvents)` THEN reverse).
*
* One VM per presentation (the away-digest「展开」builds a fresh one), so every open re-fetches. The
* fetch closure is injected — production wraps `ApiClient.events` via [forSession]; the JVM test
* injects fakes. A plain presenter (NOT `androidx.lifecycle.ViewModel`, like [DiffViewModel]/
* [PairingViewModel]) so it runs under `runTest` with no `Dispatchers.Main`.
*
* ### Untrusted boundary + lossy decode (plan §4.2 / §8)
* `ApiClient.events` already dropped malformed JSON entries and mapped a non-array body to `[]`, but
* it deliberately lets **unknown-class** entries survive shape-decode ("filtered downstream"). This VM
* is that downstream: [presentation] drops any event whose class the server doesn't currently emit
* ([TimelineEvent.hasKnownClass]) and keeps the rest — the plan §3.1 "consumers drop unknowns" rule.
* The class→glyph/color mapping ([TimelineEventStyle]) stays **total** anyway (defense in depth): an
* unknown class degrades to a neutral glyph/color rather than trapping. Labels are server text →
* rendered INERT by the sheet (no linkify/markdown, §8).
*
* ### Empty is NEVER an error
* The server replies `[]` both for "no activity yet" AND for timeline capture disabled host-side
* (`TIMELINE_ENABLED=0`); a thrown fetch is [TimelinePhase.Failed] (explicit, retryable via [load]).
*/
public class TimelineViewModel(
private val fetch: suspend () -> List<TimelineEvent>,
private val zone: ZoneId = ZoneId.systemDefault(),
) {
private val _phase = MutableStateFlow<TimelinePhase>(TimelinePhase.Loading)
/** The single snapshot [TimelineSheet] renders from. */
public val phase: StateFlow<TimelinePhase> = _phase.asStateFlow()
/**
* Fetch and present. Also the「重试」path — callable again from [TimelinePhase.Failed]. A thrown
* fetch becomes [TimelinePhase.Failed]; cancellation (a superseding load) propagates so a stale
* fetch can't overwrite fresh state.
*/
public suspend fun load() {
_phase.value = TimelinePhase.Loading
val outcome = try {
Result.success(fetch())
} catch (cancel: CancellationException) {
throw cancel
} catch (error: Throwable) {
Result.failure(error)
}
_phase.value = outcome.fold(
onSuccess = { presentation(it, zone) },
onFailure = { TimelinePhase.Failed },
)
}
public companion object {
/** Web parity: `DEFAULT_MAX_EVENTS` (`public/timeline.ts:20`). */
public const val MAX_EVENTS: Int = 50
/**
* Assembly seam for the digest「展开」entry point (plan §5 A28 "wired to away-digest expand").
* No adopted `sessionId` yet → no sheet (defensive — a digest only arrives after `attached`, so
* the id is normally known). Otherwise [sessionId] is passed to [source] verbatim on every load;
* production supplies `ApiClient::events` as [source].
*/
public fun forSession(
sessionId: UUID?,
source: suspend (UUID) -> List<TimelineEvent>,
): TimelineViewModel? =
sessionId?.let { id -> TimelineViewModel(fetch = { source(id) }) }
/**
* Pure presentation reducer, mirroring the web `render()` pipeline (`public/timeline.ts:174-189`)
* plus the Android downstream unknown-class filter: drop unknown-class events (keep the rest),
* take the first [MAX_EVENTS] (web `slice(0, 50)`), THEN reverse (newest-first). All-empty (or
* all-unknown) → [TimelinePhase.Empty], never an error.
*/
public fun presentation(
events: List<TimelineEvent>,
zone: ZoneId = ZoneId.systemDefault(),
): TimelinePhase {
val rows = events.asSequence()
.filter { it.hasKnownClass } // lossy: drop unknown/future classes, keep the rest.
.take(MAX_EVENTS) // web slice(0, maxEvents) on the filtered list.
.toList()
.reversed() // newest-first (server returns oldest-first).
.mapIndexed { index, event -> TimelineRow.from(index, event, zone) }
return if (rows.isEmpty()) TimelinePhase.Empty else TimelinePhase.Loaded(rows)
}
}
}
/** Rendering phase — explicit so the sheet can never show an error and rows at the same time. */
public sealed interface TimelinePhase {
/** Before/while the fetch runs. */
public data object Loading : TimelinePhase
/** Server returned `[]` (no activity) OR every event was unknown-class after filtering. */
public data object Empty : TimelinePhase
/** Fetch threw — retryable via [TimelineViewModel.load]. */
public data object Failed : TimelinePhase
/** Display-ready rows: filtered, capped and newest-first. */
public data class Loaded(val rows: List<TimelineRow>) : TimelinePhase
}
/**
* One display-ready timeline row: "HH:mm · glyph · label". [colorSpec] is a raw ARGB constant (the
* A13 timeline/status token value) or `null` for the theme-adaptive secondary-text fallback; the sheet
* lifts it into a Compose `Color`. [label] is untrusted server text — rendered INERT (§8).
*/
public data class TimelineRow(
/** Stable list key = position in the displayed list (offset identity; rows are static per load). */
val key: Int,
val time: String,
val glyph: String,
val colorSpec: Long?,
val label: String,
) {
public companion object {
internal fun from(key: Int, event: TimelineEvent, zone: ZoneId): TimelineRow = TimelineRow(
key = key,
time = TimelineRowFormat.timeLabel(event.at, zone),
glyph = TimelineEventStyle.glyph(event.eventClass),
colorSpec = TimelineEventStyle.colorSpec(event.eventClass),
label = event.label,
)
}
}
/**
* class → glyph / color mapping. Glyphs mirror the web `timelineIcon` verbatim
* (`public/timeline.ts:88-96`); colors are the frozen [DesignSpec] tokens (A13) — the same values as
* `WebTermColors.timelineTool`/`timelineUser` and the semantic status colors. Total functions: the
* server is untrusted, so an unknown class degrades to a neutral glyph / theme-secondary color instead
* of trapping (even though [TimelineViewModel.presentation] already drops unknown classes).
*/
public object TimelineEventStyle {
/** Neutral glyph for an unknown/future class. */
public const val FALLBACK_GLYPH: String = ""
public fun glyph(eventClass: String): String = when (eventClass) {
"tool" -> "🔧"
"waiting" -> ""
"done" -> ""
"stuck" -> ""
"user" -> "💬"
else -> FALLBACK_GLYPH
}
/**
* The ARGB token constant for a class, or `null` to signal "use the theme-adaptive secondary text
* color" (the unknown fallback — the only theme-dependent case). tool/user use the timeline-specific
* tokens; waiting/stuck/done reuse the semantic status tokens (same convention as the session list).
*/
public fun colorSpec(eventClass: String): Long? = when (eventClass) {
"tool" -> DesignSpec.TIMELINE_TOOL
"waiting" -> DesignSpec.STATUS_WAITING
"done" -> DesignSpec.STATUS_WORKING
"stuck" -> DesignSpec.STATUS_STUCK
"user" -> DesignSpec.TIMELINE_USER
else -> null
}
}
/**
* Row time formatting — mirrors web `formatHHMM` (`public/timeline.ts:101-106`): 24-hour wall-clock
* "HH:mm". [Locale.ROOT] keeps the digits fixed regardless of the user's locale; [zone] is injectable
* for deterministic tests.
*/
public object TimelineRowFormat {
private val FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm", Locale.ROOT)
public fun timeLabel(atMs: Long, zone: ZoneId = ZoneId.systemDefault()): String =
Instant.ofEpochMilli(atMs).atZone(zone).format(FORMATTER)
}
/** User-visible copy (named constants — plan engineering standard; empty ≠ error). */
public object TimelineCopy {
public const val TITLE: String = "活动时间线"
public const val EMPTY_TITLE: String = "暂无活动"
public const val EMPTY_DETAIL: String =
"会话还没有可展示的事件主机关闭时间线TIMELINE_ENABLED=0时也会显示为空。"
public const val LOAD_FAILED: String = "时间线加载失败"
public const val LOAD_FAILED_DETAIL: String = "无法从主机获取活动时间线,请检查连接后重试。"
public const val RETRY: String = "重试"
}

View File

@@ -0,0 +1,37 @@
package wang.yaojia.webterm.wiring
import dagger.Lazy
import wang.yaojia.webterm.api.routes.ApiClient
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpTransport
import javax.inject.Inject
import javax.inject.Singleton
/**
* Builds an [ApiClient] bound to a specific [HostEndpoint] over the ONE shared [HttpTransport] (A15
* wiring). [ApiClient] is per-host (it stamps `Origin` from its endpoint), so it cannot be a plain
* singleton — this factory injects the shared transport once and mints a client per host on demand
* (session list, projects, timeline, cert-gated probe, FCM registration).
*
* ### The transport is [Lazy] (FIX 3 — no AndroidKeyStore/Tink I/O on the injection/Main thread)
* Resolving [HttpTransport] builds the shared `OkHttpClient` (which first-touches the mTLS material:
* slow AndroidKeyStore + Tink I/O). A `dagger.Lazy<HttpTransport>` keeps constructing this factory (and
* `AppEnvironment`) cheap; the heavy build happens off `Main` in [warm] (called from
* `AppEnvironment.warmUp()` on `Dispatchers.IO`). `HttpTransport` and `TermTransport` share the SAME
* client singleton, so warming either builds it once for both WS and REST.
*/
@Singleton
public class ApiClientFactory @Inject constructor(
private val http: Lazy<HttpTransport>,
) {
/**
* Resolve the shared [HttpTransport] (→ builds the shared `OkHttpClient`) so the slow mTLS I/O runs
* on the CALLER's thread. Call ONLY from a background dispatcher (`AppEnvironment.warmUp()`).
*/
public fun warm() {
http.get()
}
/** Resolves the [Lazy] transport; call [warm] off `Main` first so this is a cheap singleton read. */
public fun create(endpoint: HostEndpoint): ApiClient = ApiClient(endpoint = endpoint, http = http.get())
}

View File

@@ -0,0 +1,94 @@
package wang.yaojia.webterm.wiring
import dagger.Lazy
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import wang.yaojia.webterm.api.pairing.runPairingProbe
import wang.yaojia.webterm.hostregistry.HostStore
import wang.yaojia.webterm.hostregistry.LastSessionStore
import wang.yaojia.webterm.tlsandroid.IdentityRepository
import wang.yaojia.webterm.viewmodels.PairingProber
import wang.yaojia.webterm.wire.HttpTransport
import wang.yaojia.webterm.wire.TermTransport
import javax.inject.Inject
import javax.inject.Singleton
/**
* The composition root (plan §3 `:app` wiring): the single typed handle to the frozen object graph
* that AW4 screens/ViewModels resolve their collaborators through. Hilt assembles the graph via the
* per-feature `di/` Hilt module boundaries; this aggregate is what the app-level nav / ViewModels inject
* to reach the app-scoped singletons without naming every provider.
*
* ### Confinement contract (invariant #4 — recorded here at the seam)
* The object graph enforces exactly two cross-boundary paths, and NOTHING here widens them:
* - **UI→engine:** through [TerminalSessionController] (`sendInput`/`resize`/`notifyForegrounded`/
* `decideGate`), whose engine runs on its own `limitedParallelism(1)` confinement.
* - **engine→UI:** through the PER-SESSION [EventBus]'s per-consumer channels (R10). The bus is minted
* inside [RetainedSessionHolder.bind]; it is NOT an app singleton (FIX 1) so sessions never cross-talk.
* Engine state is never touched off-confinement and emulator/scrollback state never off-`Main`.
*
* ### Off-`Main` warm-up (FIX 3)
* The mTLS transports and [identityRepository] are behind `dagger.Lazy` so constructing this root does
* NO AndroidKeyStore/Tink/`OkHttpClient` I/O on the injection (often `Main`) thread. [warmUp] resolves
* them on `Dispatchers.IO`, so the shared client is built and the device identity first-touched off the
* UI thread. A21/A27 call [warmUp] before the first `bind()` / cert-screen open.
*
* What lives here (all app singletons):
* - [hostStore] / [lastSessionStore] — persisted, non-secret UI state (DataStore).
* - [identityRepository] — the mTLS device identity bridged onto the ONE shared `OkHttpClient` (lazy).
* - [apiClientFactory] / [sessionEngineFactory] — per-host / per-session builders over the shared
* transports (lazy).
* - [coldStartPolicy] — the cold-launch route seam (A29).
* Per-session state (the [SessionEngine][wang.yaojia.webterm.session.SessionEngine] + emulator + its
* [EventBus]) is NOT here — it lives in the config-surviving [RetainedSessionHolder].
*/
@Singleton
public class AppEnvironment @Inject constructor(
public val hostStore: HostStore,
public val lastSessionStore: LastSessionStore,
public val apiClientFactory: ApiClientFactory,
public val sessionEngineFactory: SessionEngineFactory,
public val coldStartPolicy: ColdStartPolicy,
private val identityRepositoryLazy: Lazy<IdentityRepository>,
private val httpTransportLazy: Lazy<HttpTransport>,
private val termTransportLazy: Lazy<TermTransport>,
) {
/**
* The mTLS device identity (A27 cert screen). Resolving the [Lazy] merely constructs the repository
* (I/O-free per A11); its FIRST touch (`currentSummary`/`hasInstalledIdentity`/handshake) does the
* slow AndroidKeyStore + Tink I/O and MUST run off `Main` — [warmUp] does that first touch, and A27
* likewise touches it off the UI thread.
*/
public val identityRepository: IdentityRepository get() = identityRepositoryLazy.get()
/**
* The shared REST [HttpTransport] (A24 diff-fetcher seam). Resolving the [Lazy] builds the shared
* `OkHttpClient` (mTLS I/O) — call ONLY after [warmUp] has run off `Main`, else this first-touch
* does AndroidKeyStore/Tink work on the calling thread.
*/
public val httpTransport: HttpTransport get() = httpTransportLazy.get()
/**
* Build the two-step pairing prober (A19) over the ONE shared transports. The transports are
* resolved LAZILY inside `probe()` (not at build time) so constructing the prober on the UI thread
* never triggers the mTLS/OkHttp build — the actual resolve happens in the probe's own coroutine,
* after [warmUp].
*/
public fun buildPairingProber(): PairingProber =
PairingProber { endpoint -> runPairingProbe(endpoint, httpTransportLazy.get(), termTransportLazy.get()) }
/**
* Build the shared `OkHttpClient` and first-touch the device identity OFF `Main` (FIX 3). Resolving
* either transport builds the one shared client (which reads the mTLS material); touching the
* identity forces its first AndroidKeyStore/Tink read. Idempotent at the singleton level — the
* client/identity are built once and cached. Safe to call from any coroutine; hops to
* `Dispatchers.IO` internally.
*/
public suspend fun warmUp() {
withContext(Dispatchers.IO) {
sessionEngineFactory.warm() // builds TermTransport → the shared OkHttpClient → reads SSL material
apiClientFactory.warm() // HttpTransport reuses the SAME already-built client
runCatching { identityRepositoryLazy.get().hasInstalledIdentity() } // first AndroidKeyStore/Tink touch
}
}
}

View File

@@ -0,0 +1,35 @@
package wang.yaojia.webterm.wiring
import wang.yaojia.webterm.hostregistry.HostStore
import javax.inject.Inject
/** Where the app should land on a cold launch (plan §1 P1 continue-last-session; A29). */
public enum class ColdStartRoute {
/** No paired host yet → the pairing flow (A19). */
PAIRING,
/** At least one paired host → the session chooser / dashboard (A20). */
SESSIONS,
}
/**
* The cold-start routing seam A29 consumes (plan §5 A15/A29): decide the FIRST destination from
* persisted state alone — no host → pairing, else sessions. Kept behind an interface so A29's nav
* wiring and its tests depend on the decision, not on the store.
*/
public interface ColdStartPolicy {
/** The initial route, derived from whether any host is paired. Suspends for the [HostStore] read. */
public suspend fun initialRoute(): ColdStartRoute
}
/**
* Default [ColdStartPolicy]: route on host presence via [HostStore]. The "continue-last-session"
* re-entry banner (A29) is layered ON TOP of a [ColdStartRoute.SESSIONS] landing from
* `LastSessionStore` — this policy only chooses pairing-vs-sessions, keeping the decision minimal.
*/
public class DefaultColdStartPolicy @Inject constructor(
private val hostStore: HostStore,
) : ColdStartPolicy {
override suspend fun initialRoute(): ColdStartRoute =
if (hostStore.loadAll().isEmpty()) ColdStartRoute.PAIRING else ColdStartRoute.SESSIONS
}

View File

@@ -0,0 +1,147 @@
package wang.yaojia.webterm.wiring
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import wang.yaojia.webterm.session.Output
import wang.yaojia.webterm.session.SessionEvent
import java.util.concurrent.CopyOnWriteArrayList
/**
* The engine→UI event fan-out (A15 wiring freeze; plan §0 invariant #4, §9 R10). The Android analogue
* of the iOS **per-subscriber `AsyncStream`**: the [SessionEngine][wang.yaojia.webterm.session.SessionEngine]
* publishes a SINGLE `SessionEvent` stream (its `events` is a `receiveAsFlow` — one consumer only),
* and this bus fans it out to the two UI consumers so the terminal and the cockpit each observe it
* independently and losslessly.
*
* ### PER-SESSION, never a `@Singleton` (FIX 1 — cross-talk fix)
* A [SessionEvent] carries NO session id, so an app-wide singleton bus shared by every
* `RetainedSessionHolder` would let one session's `Output`/`Gate` reach another session's UI. This bus
* is therefore constructed INSIDE the session unit (one bus per engine, alongside the engine's confined
* scope in `RetainedSessionHolder.bind()`) and dies with the session. There is NO Hilt binding for it.
*
* ### Two TYPED sub-streams, not one generic subscribe (FIX 4 — no cross-buffering)
* The bus exposes exactly two consumer streams:
* - [outputBytes] — `Output(String)` frames only, UTF-8-decoded to `ByteArray` on [decodeDispatcher]
* (`Dispatchers.Default`) so the multi-MB ring-buffer replay decode never runs on `Main` (§6.6).
* Consumed ONLY by the terminal emulator.
* - [controlEvents] — every NON-`Output` event (connection/gate/telemetry/digest/exit). Consumed
* ONLY by the cockpit (banner / gate / telemetry chips / away digest).
* Because [publish] offers each event only to the consumers that WANT it, the terminal never buffers a
* gate it ignores and the cockpit never buffers the multi-MB `Output` replay it ignores.
*
* ### Per-consumer `Channel`s and NOT a single `SharedFlow` (R10 — the load-bearing decision)
* Each consumer gets its OWN unbounded [Channel] (`Channel.UNLIMITED`). Fan-out is a non-suspending
* [trySend] to each channel:
* - **never stalls another consumer** — `trySend` on an unbounded channel never suspends, so one slow
* collector cannot back-pressure the pump or the fast collector;
* - **never drops** — an unbounded buffer holds a slow consumer's backlog (including a `Gate`) until
* it drains, so nothing is lost.
* A single `SharedFlow` cannot provide this: `onBufferOverflow=SUSPEND` head-of-line-blocks OUTPUT for
* every consumer; `DROP_*` could silently lose a `Gate` (a correctness bug).
*
* ### Eager registration (FIX 2a — no pre-subscription drop)
* [outputBytes]/[controlEvents] register their per-consumer mailbox EAGERLY at call time (not deferred
* to first collection). The `RetainedSessionHolder` constructs the controller — which calls both — and
* returns it UN-STARTED; only later does the terminal screen call `controller.start()` (FIX 2b), which
* starts the engine→bus pump AND `engine.start()`. Since both mailboxes exist before ANYTHING is
* published, the `attached` frame and the multi-MB replay land in an already-registered mailbox and are
* never dropped.
*
* ### Mailbox lives for the SESSION, not the collector (FIX 1 — config-change re-collection)
* A mailbox is tied to the BUS (session) lifetime, NOT to any single collection. There are exactly TWO
* fixed per-session consumers (output + control) registered once at controller construction — there is
* no dynamic subscribe/unsubscribe — so the returned flows are RE-COLLECTABLE: when a config change
* (rotation/fold) cancels the terminal screen's collector, the mailbox is NOT closed; the recomposed
* screen re-collects the SAME flow and resumes draining the events buffered during the gap AND every new
* one, with ZERO loss. (Had the flow closed its mailbox on cancellation, the re-collect would iterate a
* dead channel and the terminal would be blank forever after one rotation.) The mailboxes are released
* exactly when the session ends, by [close] (called from `RetainedSessionHolder`'s teardown after the
* engine close-join). An uncollected stream simply keeps its mailbox buffering for the session lifetime.
*
* ### Purity (invariant #4 / testability)
* PURE Kotlin — coroutines only, no Android import — so the fan-out property is unit-testable under
* `runTest` ([decodeDispatcher] is injectable so the decode runs on virtual time in tests). The engine's
* confinement (`limitedParallelism(1)`) and the UI's `Main.immediate` never touch each other's state;
* this bus is the only engine→UI seam, and [publish] is a plain non-suspending hand-off, safe to call
* from the engine's confined dispatcher.
*/
public class EventBus(
private val decodeDispatcher: CoroutineDispatcher = Dispatchers.Default,
) {
/** One consumer mailbox plus the predicate selecting the events it wants (FIX 4 typing). */
private class Consumer(
val mailbox: Channel<SessionEvent>,
val wants: (SessionEvent) -> Boolean,
)
/**
* Live consumer mailboxes. Copy-on-write so [publish] (called from the engine's confined
* dispatcher) iterates a stable snapshot while registration/cancellation add/remove concurrently.
*/
private val consumers = CopyOnWriteArrayList<Consumer>()
/**
* Register a [wants]-filtered mailbox EAGERLY (FIX 2a) and return a RE-COLLECTABLE stream that
* drains it (FIX 1). The mailbox is added to [consumers] the instant this is called and lives for
* the BUS (session) lifetime — cancelling a collection does NOT close or unregister it, so a
* config-change re-collect resumes on the SAME mailbox and loses nothing. `receiveAsFlow` (not
* `consumeAsFlow`) is what makes the flow collectable more than once. Mailboxes are released only by
* [close] when the session ends.
*/
private fun register(wants: (SessionEvent) -> Boolean): Flow<SessionEvent> {
val consumer = Consumer(Channel(Channel.UNLIMITED), wants)
consumers.add(consumer) // EAGER: mailbox exists before start() → nothing published can be dropped
return consumer.mailbox.receiveAsFlow()
}
/**
* The terminal's stream: `Output` frames only, UTF-8-decoded to bytes on [decodeDispatcher] so the
* multi-MB replay decode is off `Main` (§6.6). Fed verbatim to the emulator (`:terminal-view`).
*/
public fun outputBytes(): Flow<ByteArray> =
register { it is Output }
.map { (it as Output).data.toByteArray(Charsets.UTF_8) }
.flowOn(decodeDispatcher)
/** The cockpit's stream: every NON-`Output` event (connection/gate/telemetry/digest/exit). */
public fun controlEvents(): Flow<SessionEvent> = register { it !is Output }
/**
* Fan [event] out to every registered consumer that WANTS it (FIX 4). Non-suspending and lossless:
* [trySend] on an unbounded channel never suspends (no head-of-line stall) and never fails while the
* channel is open (no drop). A `trySend` to an already-closing mailbox (racing cancellation) fails
* harmlessly and is ignored — that consumer is going away anyway.
*/
public fun publish(event: SessionEvent) {
consumers.forEach { if (it.wants(event)) it.mailbox.trySend(event) }
}
/**
* Launch the engine→UI pump: collect the engine's single-consumer [source] stream and [publish]
* each event to the fan-out. Runs until [source] completes (the engine closed its channel) or
* [scope] is cancelled. Returns the pump [Job] so the caller can tie it to the session lifetime.
* Called from `controller.start()` AFTER the mailboxes are registered, so no event is missed.
*/
public fun connect(scope: CoroutineScope, source: Flow<SessionEvent>): Job =
scope.launch { source.collect { publish(it) } }
/**
* End-of-session release (FIX 1): close every per-consumer mailbox and clear the registry. Closing a
* mailbox ends its `receiveAsFlow` iteration normally (any live collector completes), and a later
* [publish] to a closed mailbox `trySend`s harmlessly. Called from `RetainedSessionHolder`'s teardown
* AFTER the engine close-join, so the mailboxes are freed exactly when the session dies — never on a
* mere config-change collector cancellation (which must leave the mailbox re-collectable).
*/
public fun close() {
consumers.forEach { it.mailbox.close() }
consumers.clear()
}
}

View File

@@ -0,0 +1,238 @@
package wang.yaojia.webterm.wiring
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import wang.yaojia.webterm.components.BannerModel
import wang.yaojia.webterm.components.bannerModel
import wang.yaojia.webterm.session.SessionEngine
import wang.yaojia.webterm.terminalview.RemoteTerminalSession
import wang.yaojia.webterm.viewmodels.GateViewModel
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.HostEndpoint
import javax.inject.Inject
/**
* The config-surviving home for a session's ENTIRE per-session unit — the [SessionEngine], its
* per-session [EventBus], the [RemoteTerminalSession] emulator (via [TerminalSessionControllerImpl]),
* and the retained UI presenters ([GateViewModel] + [bannerState] + [title]) — plan §6.6, the
* load-bearing lifecycle contract for A16/A21/A22. A `@HiltViewModel` retained across Activity
* recreation, so a **rotation / fold / multi-window / dark-mode** change disposes the Compose tree but
* NOT this unit: the surviving emulator is re-bound to the recreated `AndroidView` with no detach, no
* ~2 MB replay round-trip, and no scroll-position loss.
*
* ### Config change vs real background (the distinction that drives everything)
* - **Config change** — the Activity is recreated but the ViewModel survives (Android guarantees this).
* [onStop] with `isChangingConfigurations == true` does NOTHING: the engine + emulator keep running,
* [generation] does NOT bump, and the recreated Compose tree calls [bind] again to reuse the survivor
* (re-binding the same emulator to the new `AndroidView`).
* - **Real background** — `ON_STOP` that is NOT a config change. [onStop] with
* `isChangingConfigurations == false` detaches cleanly (server PTY survives) and bumps [generation]
* so the NEXT foreground return rebuilds a fresh emulator that replays the ring and re-fires resize.
*
* ### The emulator lives HERE, not in composition (FIX 3 — §6.6 config-survival)
* [TerminalSessionControllerImpl] (which owns the [RemoteTerminalSession] emulator + scrollback) is
* constructed and OWNED here, in the config-surviving `viewModelScope`, NOT `remember`ed in the
* composition — a composition-remembered emulator dies on every rotation. `TerminalScreen` obtains the
* controller FROM [bind] and re-binds the surviving emulator; the output→emulator collector runs in the
* engine's confined [engineScope], so it is cancelled with the SESSION, not the composition (§6.6).
*
* ### [generation] is Compose-observable (FIX 2)
* Backed by [mutableIntStateOf], so a real-background rebuild (a bump) RECOMPOSES `TerminalScreen` and
* re-keys its `AndroidView` (fresh emulator + ring replay); a config change (no bump) leaves it unchanged
* so the recreated `AndroidView` re-binds the SAME surviving emulator.
*
* ### Retained control-event consumers (FIX 1/FIX 4 — no split, config-survival)
* The banner AND the [GateViewModel] each need the FULL control-event stream. Each is created here and
* subscribes to its OWN [TerminalSessionController.controlEvents] mailbox — so they never split events —
* and folds in [engineScope], so their state survives a rotation (a composition-scoped fold would reset
* to `Hidden`/empty and, worse, leak a fresh orphaned mailbox on every rotation). All consumers register
* their mailbox EAGERLY inside [bind], which returns the controller UN-STARTED — so `controller.start()`
* (called by the screen) can never drop the `attached` frame or the multi-MB replay.
*
* ### close() BEFORE scope-cancel — made STRUCTURAL (FIX 5 — do NOT reorder)
* [SessionEngine] does **not** close its live WS on scope cancellation, so cancelling the confinement
* scope alone would leak the socket to a timeout death — never a clean RFC-6455 `1000` detach. Teardown
* therefore calls [SessionEngine.close] FIRST and **awaits the returned [kotlinx.coroutines.Job]**
* (`closeJob.join()`) so the `1000` close frame is handed to the transport writer BEFORE the scope is
* cancelled, THEN releases the per-session [EventBus] mailboxes and cancels the scope. The
* [RemoteTerminalSession] append thread is released too (session end).
*
* ### Single-session-for-life (no silent mis-route)
* A holder binds exactly ONE (endpoint, sessionId) for its lifetime. A re-[bind] with the SAME key is
* the config-change reuse path; a re-[bind] with a DIFFERENT key is a nav-scoping programming error and
* throws (a new session needs a new nav-scoped holder), so a mis-scoped multi-tab UI can never show the
* wrong session behind a silently-ignored argument.
*/
@HiltViewModel
public class RetainedSessionHolder @Inject constructor(
private val engineFactory: SessionEngineFactory,
) : ViewModel() {
private var engine: SessionEngine? = null
private var engineScope: CoroutineScope? = null
private var eventBus: EventBus? = null
private var controllerImpl: TerminalSessionControllerImpl? = null
private var gateVm: GateViewModel? = null
/** The (endpoint, sessionId) this holder is bound to for life; a re-[bind] must match it. */
private var boundKey: BoundKey? = null
/** Identity of the single session a holder owns for life; [cwd] is excluded (spawn-only, not identity). */
private data class BoundKey(val endpoint: HostEndpoint, val sessionId: String?)
/**
* Bumped ONLY on a genuine background detach (never on a config change), and Compose-observable (FIX
* 2): the terminal `AndroidView` is keyed by this so a real background→foreground cycle RECOMPOSES
* and rebuilds a fresh emulator (ring replay), while a rotation re-binds the SAME survivor.
*/
private val generationState = mutableIntStateOf(0)
public val generation: Int get() = generationState.intValue
/** The live controller for the bound session, or null before the first [bind] / after teardown. */
public val controller: TerminalSessionController? get() = controllerImpl
/** The retained gate/telemetry/digest presenter (A22), or null before [bind] / after teardown. */
public val gateViewModel: GateViewModel? get() = gateVm
/** The sanitized OSC 0/2 title (inert), reset per session. Read by the toolbar; survives a rotation. */
private val titleState = mutableStateOf("")
public val title: State<String> get() = titleState
/** The retained reconnect-banner model, folded from its OWN control mailbox; survives a rotation. */
private val bannerFlow = MutableStateFlow<BannerModel>(BannerModel.Hidden)
public val bannerState: StateFlow<BannerModel> = bannerFlow.asStateFlow()
/**
* Where the `output`→`feedRemote` collector runs (default `Main.immediate`, §6.6). Null → resolve
* `Dispatchers.Main.immediate` lazily in [bind] (NOT eagerly at construction — that would touch
* `Dispatchers.Main` and throw in a `runTest` without `setMain`). A JVM test injects a virtual-time
* dispatcher so the holder lifecycle drives off `Main`. `internal` — not frozen surface.
*/
internal var mainDispatcher: CoroutineDispatcher? = null
/**
* Emulator factory seam. Production builds a real [RemoteTerminalSession]; a JVM test substitutes a
* virtual-time one so the holder lifecycle test never spins a real append thread. `internal`.
*/
internal var remoteSessionFactory:
(engineSend: (ClientMessage) -> Unit, onTitle: (String) -> Unit) -> RemoteTerminalSession =
{ engineSend, onTitle -> RemoteTerminalSession(engineSend = engineSend, onTitleChanged = onTitle) }
/**
* Bind the session for [endpoint], or REUSE the survivor across a config change. Called by the
* terminal screen on (re)composition. [sessionId] null spawns a new session (with [cwd]); a
* non-null id re-attaches for ring replay. Idempotent for the SAME (endpoint, sessionId): a second
* call while that session is live returns the existing controller (config-change re-bind) and never
* spawns a duplicate engine or emulator.
*
* A holder is **single-session-for-life**: a re-bind whose (endpoint, sessionId) does NOT match the
* bound one throws [IllegalStateException] — a nav-scoping programming error, since the short-circuit
* would otherwise silently ignore the new args and show the WRONG session. A different session must
* get its own nav-scoped holder.
*
* Returns the controller **UN-STARTED**: every consumer (output + banner + gate mailboxes) is already
* registered EAGERLY here, and the terminal screen calls `controller.start()` afterwards. This
* register-before-start ordering guarantees no `attached`/replay frame is dropped and no control
* event is split (FIX 1).
*/
public fun bind(
endpoint: HostEndpoint,
sessionId: String? = null,
cwd: String? = null,
): TerminalSessionControllerImpl {
val requested = BoundKey(endpoint, sessionId)
controllerImpl?.let { existing ->
check(requested == boundKey) {
"RetainedSessionHolder is single-session-for-life: already bound to $boundKey but bind() " +
"requested $requested. A different session needs a new nav-scoped holder."
}
return existing // same key → config-change reuse of the live engine/emulator/presenters
}
val scope = engineFactory.newConfinedScope()
val bus = EventBus() // one bus per engine, lives on the engine's confined scope
val newEngine = engineFactory.create(endpoint, scope, sessionId, cwd)
val base = DefaultTerminalSessionController(newEngine, bus, scope)
// FIX 3: the render controller (+ its emulator) is owned HERE; the output collector runs in the
// confined [scope] on [mainDispatcher], so it dies with the session, not the composition (§6.6).
val controller = TerminalSessionControllerImpl(
base = base,
onSanitizedTitle = { titleState.value = it },
outputCollectorScope = scope,
outputDispatcher = mainDispatcher ?: Dispatchers.Main.immediate,
remoteSessionFactory = remoteSessionFactory,
)
// FIX 1/FIX 4: each retained consumer registers its OWN control mailbox EAGERLY (before start)
// and folds in the confined [scope] so its state survives a config change.
val gate = GateViewModel(controller) // registers the gate mailbox at construction
gate.bind(scope)
val bannerEvents = controller.controlEvents() // registers the banner mailbox
scope.launch { bannerEvents.collect { bannerFlow.value = bannerModel(bannerFlow.value, it) } }
engine = newEngine
engineScope = scope
eventBus = bus
controllerImpl = controller
gateVm = gate
boundKey = requested
// DO NOT start here — the terminal screen calls controller.start() after obtaining the presenters.
return controller
}
/**
* Drive from the Activity's `onStop`. [isChangingConfigurations] (`Activity.isChangingConfigurations`)
* separates a rotation/config change (survive) from a real background (clean detach + generation bump).
*/
public fun onStop(isChangingConfigurations: Boolean) {
if (isChangingConfigurations) return // config change → the survivor is re-bound on recreate
teardown(bumpGeneration = true) // real background → clean detach; server PTY survives
}
/** Genuine destruction (nav pop / not a config change): detach cleanly; the VM won't be re-bound. */
override fun onCleared() {
teardown(bumpGeneration = false)
super.onCleared()
}
private fun teardown(bumpGeneration: Boolean) {
val liveEngine = engine ?: return
val scope = engineScope
val bus = eventBus
val impl = controllerImpl
// (1) Clean detach FIRST — the server-side PTY keeps running (invariant #2, §6.6 follow-up).
val closeJob = liveEngine.close()
// (2) AWAIT the clean close (the RFC-6455 `1000` frame handed to the transport writer), THEN
// release the per-session EventBus mailboxes, and ONLY THEN cancel the confinement scope (FIX 5).
// Joining makes close-before-cancel structural rather than relying on implicit same-dispatcher
// FIFO — the socket is never leaked to a timeout death; closing the bus here (session end, not a
// config-change cancel) frees the mailboxes exactly when the session dies.
scope?.launch {
closeJob.join()
bus?.close()
scope.cancel()
}
// Release the emulator's append thread (session end); the output collector then no-ops on the
// closed command channel until the scope cancel above stops it.
impl?.remoteSession?.close()
engine = null
engineScope = null
eventBus = null
controllerImpl = null
gateVm = null
boundKey = null
// Reset the retained presenters so a generation-bump rebuild starts clean.
titleState.value = ""
bannerFlow.value = BannerModel.Hidden
if (bumpGeneration) generationState.intValue += 1
}
}

View File

@@ -0,0 +1,57 @@
package wang.yaojia.webterm.wiring
import kotlinx.coroutines.flow.Flow
import wang.yaojia.webterm.hostregistry.LastSessionStore
import wang.yaojia.webterm.session.Adopted
import wang.yaojia.webterm.session.Exited
import wang.yaojia.webterm.session.SessionEvent
/**
* # SessionActivityBridge (A29) — drives the [LastSessionStore] off the engine event stream.
*
* The lifecycle half of the continue-last-session cold-start UX (plan §1, §5 A29). One bridge is minted
* PER SESSION, bound to the [hostId] the session dials (like [EventBus], it is NOT an app singleton and
* has no Hilt binding — it is constructed inside the session unit and dies with it). It observes the
* session's control-event stream and folds exactly two lifecycle events into the store:
*
* - **`Adopted` → SET** — persist the SERVER-ISSUED id ([Adopted.sessionId]). The server always adopts
* its own id (a reconnect may hand back a new one), so the LATEST adopted id is always the one to
* offer as "continue last"; re-adopting simply overwrites.
* - **`Exited` → CLEAR** — a dead shell must NOT be offered as "continue last" (else cold-start would
* silently spawn a brand-new session), so the shell exiting clears the stored id ([LastSessionStore]).
*
* Every OTHER event — including `Connection(Closed)` (a mere DETACH: the server-side PTY keeps running,
* so the session is STILL continuable) — leaves the store untouched. Only a real `Exited` clears it.
*
* Keyed by [hostId] so distinct hosts never clobber each other's last-session pointer.
*
* ### Purity / testability
* PURE Kotlin (coroutines only, no Android import) so the set-on-adopted / clear-on-exited behaviour is
* unit-testable under `runTest` by driving [observe] with a `flowOf(...)` and a real
* [InMemoryLastSessionStore][wang.yaojia.webterm.hostregistry.InMemoryLastSessionStore] double.
*/
public class SessionActivityBridge(
private val lastSessionStore: LastSessionStore,
private val hostId: String,
) {
/**
* Observe [events] for the whole session lifetime, applying each to the [LastSessionStore]. Returns
* when [events] completes (the engine closed its stream) or the collecting scope is cancelled. The
* caller launches this on the session's scope and collects the controller's control-event stream.
*/
public suspend fun observe(events: Flow<SessionEvent>) {
events.collect { record(it) }
}
/**
* Fold a single [event] into the store: `Adopted` sets the server-issued id, `Exited` clears it, and
* every other event (output / connection / gate / telemetry / digest) is a no-op.
*/
public suspend fun record(event: SessionEvent) {
when (event) {
is Adopted -> lastSessionStore.setLastSessionId(event.sessionId, hostId)
is Exited -> lastSessionStore.setLastSessionId(null, hostId)
else -> Unit
}
}
}

View File

@@ -0,0 +1,86 @@
package wang.yaojia.webterm.wiring
import dagger.Lazy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import wang.yaojia.webterm.session.SessionEngine
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.TermTransport
import wang.yaojia.webterm.wire.TimelineEvent
import javax.inject.Inject
import javax.inject.Singleton
/**
* Mints a [SessionEngine] per session over the ONE shared [TermTransport] (A15 wiring; plan §0
* invariant #4, §6.6). A [SessionEngine] is per-session (its [HostEndpoint] + confinement scope are
* fixed at creation), so it is built on demand by the [RetainedSessionHolder], never a singleton.
*
* ### The transport is [Lazy] (FIX 3 — no AndroidKeyStore/Tink I/O on the injection/Main thread)
* Resolving [TermTransport] builds the shared `OkHttpClient`, which reads the mTLS SSL material — a
* path that first-touches AndroidKeyStore + Tink (slow, MUST be off `Main`). Injecting a
* `dagger.Lazy<TermTransport>` means merely constructing this factory (and therefore `AppEnvironment`)
* does NOT build the client. The heavy resolution happens exactly once, off `Main`, in [warm] (called
* from `AppEnvironment.warmUp()` on `Dispatchers.IO`); afterwards [create] resolves the already-built
* singleton instantly.
*
* ### Confinement (invariant #4)
* [newConfinedScope] creates the engine's `limitedParallelism(1)` dispatcher — the single-thread
* confinement that replaces the Swift actor. Each engine gets its OWN scope so the holder can cancel
* it on teardown WITHOUT touching other sessions; ALL engine state is then touched only on that
* dispatcher. The scope's root is a [SupervisorJob] so one failed child never tears the engine down.
*/
@Singleton
public class SessionEngineFactory @Inject constructor(
private val transport: Lazy<TermTransport>,
) {
/**
* Test seam: builds the engine's confinement scope. Production creates a real
* `Dispatchers.Default.limitedParallelism(1)` scope; a unit test substitutes a virtual-time
* `TestDispatcher` so the holder/engine lifecycle runs deterministically under `runTest`.
* `internal` — not part of the frozen public surface.
*/
internal var confinedScopeFactory: () -> CoroutineScope = {
CoroutineScope(Dispatchers.Default.limitedParallelism(1) + SupervisorJob())
}
/**
* A fresh single-thread confinement scope for one engine. Owned by the caller
* ([RetainedSessionHolder]) which cancels it AFTER `engine.close()` on teardown (§6.6).
*/
public fun newConfinedScope(): CoroutineScope = confinedScopeFactory()
/**
* Resolve the shared [TermTransport] (→ builds the shared `OkHttpClient` → first mTLS touch) so the
* slow AndroidKeyStore/Tink I/O runs on the CALLER's thread. Call ONLY from a background
* dispatcher (`AppEnvironment.warmUp()` on `Dispatchers.IO`), never on `Main`.
*/
public fun warm() {
transport.get()
}
/**
* Build a [SessionEngine] for [endpoint] on [scope]. [sessionId] null → a NEW session (with [cwd]
* for new-session-in-cwd); a non-null id re-attaches so the server replays the ring buffer.
* [awayTimeline] supplies the once-per-reconnect digest source (A21 wires it to `ApiClient.events`
* for the adopted id); it defaults to empty so the freeze stays honest — the engine still emits an
* (empty, UI-suppressed) digest.
*
* Resolves the [Lazy] transport; call [warm] off `Main` first so this is a cheap singleton read.
*/
public fun create(
endpoint: HostEndpoint,
scope: CoroutineScope,
sessionId: String? = null,
cwd: String? = null,
awayTimeline: suspend (sinceMs: Long) -> List<TimelineEvent> = { emptyList() },
): SessionEngine =
SessionEngine(
transport = transport.get(),
endpoint = endpoint,
scope = scope,
initialSessionId = sessionId,
initialCwd = cwd,
awayTimeline = awayTimeline,
)
}

View File

@@ -0,0 +1,156 @@
package wang.yaojia.webterm.wiring
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import wang.yaojia.webterm.session.SessionEngine
import wang.yaojia.webterm.session.SessionEvent
import wang.yaojia.webterm.wire.ClientMessage
/**
* The FROZEN surface AW4 / A21 depend on to drive ONE terminal session (plan §5 A15, §6.2/§6.3). It
* owns a [SessionEngine]'s lifecycle and is the single seam between the terminal UI (`:terminal-view`,
* A16) and the engine:
* - **UI→engine:** [sendInput] / [resize] / [notifyForegrounded] / [decideGate] map straight onto
* `engine.send`/`notifyForegrounded`/`decideGate` (invariant #4 — the only UI→engine calls).
* - **engine→UI:** [controlEvents] is the cockpit's control-event stream (banner / gate / telemetry /
* digest); [output] is the decoded terminal-byte stream the emulator consumes. The two are DISJOINT
* typed sub-streams of [EventBus] (FIX 4) — neither consumer buffers what it does not need.
*
* ### Control events are MULTI-SUBSCRIBABLE (FIX 1 — no split across consumers)
* The reconnect banner AND the [GateViewModel][wang.yaojia.webterm.viewmodels.GateViewModel] (AND later
* the A28 timeline) each need the FULL control-event stream. A single shared `Channel.receiveAsFlow()`
* mailbox is a MAILBOX, not a broadcast: two competing `collect()` on it would SPLIT events (each event
* reaches only one collector). So control events are exposed as a FUNCTION, not a single `val`: EACH
* call to [controlEvents] mints its OWN per-consumer [EventBus] mailbox (the bus already registers a
* fresh unbounded channel per call), so every consumer receives every control event, losslessly and
* without stalling the others (R10). [output], by contrast, stays a SINGLE `val` stream: it has exactly
* ONE consumer (the emulator feed), and the multi-MB replay must never be fanned out twice.
*
* ### START is decoupled from bind (FIX 2 — no pre-subscription event drop)
* `RetainedSessionHolder.bind()` constructs and returns this controller UN-STARTED, having already
* registered every consumer's mailbox: [output] registers ONE mailbox at construction, and the holder
* calls [controlEvents] once per retained consumer (banner + gate) — all EAGERLY, before [start].
* ALL consumers therefore MUST subscribe (call [controlEvents] / collect [output]) BEFORE [start];
* [start] then wires the engine→[EventBus] pump AND `engine.start()`. Nothing is published to the bus
* before [start], and every mailbox already exists, so the `attached` frame + the multi-MB ring-buffer
* replay can never be dropped, and no event is split.
*
* ### Boundary note (plan §3 / §6.2 — decode lives HERE, not in `:terminal-view`)
* `:terminal-view` depends only on `:wire-protocol` and consumes raw `ByteArray`. The
* `SessionEvent.Output(String) → ByteArray` (UTF-8) decode therefore happens on the [EventBus]
* [output][EventBus.outputBytes] sub-stream (off `Main` via `flowOn`), keeping the terminal module free
* of any `SessionEvent`/`:session-core` edge.
*
* A21 writes the full implementation (pendingOutput ordering, banner precedence, new-session-in-cwd,
* privacy shade). [DefaultTerminalSessionController] is the minimal skeleton that freezes the shape.
*/
public interface TerminalSessionController {
/**
* The cockpit's control-event stream: every NON-`Output` event (connection/gate/telemetry/digest/
* exit). Each call mints its OWN per-consumer [EventBus] mailbox (FIX 1), so multiple consumers
* (banner + gate + A28 timeline) each receive every control event without splitting — a single
* shared `val` mailbox would let two `collect()` steal events from each other. The mailbox is
* registered EAGERLY at call time, so callers MUST call this BEFORE [start] (no pre-subscription
* drop). Every returned flow is independent, lossless, and re-collectable across a config-change
* cancel (the mailbox lives for the session, not the collection).
*/
public fun controlEvents(): Flow<SessionEvent>
/**
* The decoded terminal output stream: [SessionEvent.Output] frames only, UTF-8-decoded to bytes on
* `Dispatchers.Default` (off `Main`), fed verbatim to the emulator (`:terminal-view`). All other
* event types are routed to [events], never buffered here.
*/
public val output: Flow<ByteArray>
/**
* Begin connecting (attach-first): start the engine→[EventBus] pump AND `engine.start()`. MUST be
* called AFTER the screen has wired its [events]/[output] collectors (FIX 2b). Idempotent — a
* second call is a no-op.
*/
public fun start()
/** Typed / key-bar bytes → `engine.send(Input(data))`, passed through VERBATIM (invariant #1/#9). */
public fun sendInput(data: String)
/** Latest-writer-wins PTY sizing → `engine.send(Resize(cols, rows))` (plan §6.4). */
public fun resize(cols: Int, rows: Int)
/**
* The app/pane returned to the foreground → reclaim full-screen (re-send dims) or connect-now
* without resetting the back-off ladder (plan §6.4/§6.6). Null dims leave the remembered size.
*/
public fun notifyForegrounded(cols: Int?, rows: Int?)
/**
* Resolve a held gate with the two-line epoch stale-guard: a stale [epoch] is dropped so a slow
* tap never approves the NEXT gate (`GateTracker.canDecide`, plan §3.2). [message] is the
* affordance's wire message (`GateState.Affordance.clientMessage`).
*/
public fun decideGate(epoch: Int, message: ClientMessage)
/** Detach: close the WS (the server-side PTY keeps running). NEVER a kill (invariant #2). */
public fun close()
}
/**
* Minimal skeleton [TerminalSessionController] over a single [SessionEngine] + its PER-SESSION
* [EventBus] (FIX 1). It establishes the seam for A21 (which wraps it via [TerminalSessionControllerImpl]):
* registers the SINGLE [output] mailbox at construction and mints a FRESH control mailbox per
* [controlEvents] call, and on [start] wires the engine→bus pump BEFORE `engine.start()` so the replay
* is never dropped and no control event is split (FIX 1/FIX 2).
*
* [fanOutScope] runs the engine→UI pump; it must live as long as the session (the
* [RetainedSessionHolder] passes the engine's confinement scope). The pump completes on its own when
* the engine closes its event channel (detach/exit) or when [fanOutScope] is cancelled.
*/
public class DefaultTerminalSessionController(
private val engine: SessionEngine,
private val eventBus: EventBus,
private val fanOutScope: CoroutineScope,
) : TerminalSessionController {
// The SINGLE output mailbox is registered EAGERLY at construction (FIX 2a) — exactly one consumer
// (the emulator feed), so the multi-MB replay is never fanned out twice. Control events are NOT a
// single `val`: each [controlEvents] call mints its own mailbox (FIX 1) so banner + gate + A28
// timeline never split the stream. Every mailbox lives for the SESSION (bus) lifetime, not a
// collection's: a config-change cancel leaves it re-collectable, an uncollected mailbox simply
// buffers, and all are released by EventBus.close() in RetainedSessionHolder.teardown().
override val output: Flow<ByteArray> = eventBus.outputBytes()
// FIX 1: a FRESH per-consumer mailbox on every call (the bus mints one each time). Callers MUST
// call this BEFORE start() — the mailbox registers eagerly, so nothing published after start drops.
override fun controlEvents(): Flow<SessionEvent> = eventBus.controlEvents()
// Guards start() idempotency. The intended caller is the UI thread (the terminal screen), but
// @Volatile removes that unstated single-thread precondition so a stray off-thread start() cannot
// observe a stale false and double-start the engine.
@Volatile
private var started: Boolean = false
private var pumpJob: Job? = null
override fun start() {
if (started) return
started = true
// Connect the engine→bus pump FIRST (mailboxes already registered), THEN start the engine, so
// the 'attached' frame + multi-MB replay land in the already-registered per-consumer mailboxes.
pumpJob = eventBus.connect(fanOutScope, engine.events)
engine.start()
}
override fun sendInput(data: String): Unit = engine.send(ClientMessage.Input(data))
override fun resize(cols: Int, rows: Int): Unit = engine.send(ClientMessage.Resize(cols, rows))
override fun notifyForegrounded(cols: Int?, rows: Int?): Unit =
engine.notifyForegrounded(cols, rows)
override fun decideGate(epoch: Int, message: ClientMessage): Unit =
engine.decideGate(epoch, message)
// Fire-and-forget detach; the awaitable teardown path is RetainedSessionHolder.teardown() (FIX 5).
override fun close() {
engine.close()
}
}

View File

@@ -0,0 +1,106 @@
package wang.yaojia.webterm.wiring
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import wang.yaojia.webterm.session.TitleSanitizer
import wang.yaojia.webterm.terminalview.RemoteTerminalSession
import wang.yaojia.webterm.wire.ClientMessage
/**
* # TerminalSessionControllerImpl (A21) — the real terminal-session controller.
*
* A15 froze the [TerminalSessionController] INTERFACE and shipped [DefaultTerminalSessionController] as
* a minimal skeleton that owns the [SessionEngine][wang.yaojia.webterm.session.SessionEngine]↔[EventBus]
* ↔UI wiring (start/pump/`output`/`events`/`sendInput`/`resize`/`notifyForegrounded`/`decideGate`/`close`).
* A21 adds the terminal-render half: the [RemoteTerminalSession] (the forked Termux emulator), the
* output→emulator feed, and the OSC-title → [TitleSanitizer] wiring.
*
* ### Config-survival: OWNED BY THE HOLDER, not the composition (§6.6 / FIX 3)
* The [RemoteTerminalSession] (emulator + scrollback + scroll position) MUST survive a config change
* (rotation/fold): §6.6 requires the emulator re-bind to a recreated `AndroidView` with NO replay
* round-trip. So this whole controller is constructed and OWNED by the config-surviving
* [RetainedSessionHolder] (its `viewModelScope`), NEVER `remember`ed in the composition (a
* composition-remembered emulator dies on every rotation). `TerminalScreen` obtains it FROM the holder
* and re-binds the surviving emulator on rotation; only a genuine background (generation bump) rebuilds
* a fresh emulator that replays the ring.
*
* ### Why it WRAPS the skeleton (a constraint-driven, recorded shape)
* The engine + per-session [EventBus] live in the holder alongside this controller. The real impl layers
* the terminal-render wiring ON TOP of the [base] [DefaultTerminalSessionController] via interface
* delegation (`by base`): every lifecycle/UI→engine call still routes through the skeleton to the
* engine, and A21 only ADDS the two new responsibilities the plan assigns it:
* - **`output` → [RemoteTerminalSession.feedRemote]** — the decoded terminal-byte stream (`base.output`
* is already `Output→ByteArray` UTF-8-decoded off `Main` by [EventBus.outputBytes]) is fed verbatim
* to the emulator. The collector is wired in [start] BEFORE `base.start()`, so the `attached` frame +
* multi-MB ring replay are never dropped (plan §6.2), and it runs on `Main.immediate` (§6.6).
* - **OSC title → [TitleSanitizer]** — `:terminal-view` has no `:session-core` edge, so the raw title
* from the emulator is sanitized HERE (in `:app`) before it ever reaches the UI (plan §6.5 / §8).
*
* Emulator-originated bytes (DA/DSR replies, resize) flow back out through [routeEmulatorMessage] into
* `base` → the engine's ordered send pump, so typing, key-bar taps and emulator replies share one wire
* order (plan §6.3).
*
* @param base the holder-owned skeleton controller (a [DefaultTerminalSessionController]), UN-STARTED.
* @param onSanitizedTitle receives the already-[TitleSanitizer]-sanitized OSC 0/2 title (inert display).
* @param outputCollectorScope the scope the `output`→`feedRemote` collector runs in. The holder passes
* its config-surviving engine scope, so the collector is cancelled with the SESSION (not the
* composition) — a recompose never drops a frame (§6.6) and the surviving emulator keeps consuming.
* @param outputDispatcher where the `output`→`feedRemote` collector runs (default `Main.immediate`,
* §6.6; injected as a virtual-time test dispatcher so the holder lifecycle is JVM-testable off `Main`).
* @param remoteSessionFactory inject seam for the emulator-backed [RemoteTerminalSession] — the holder
* supplies it (defaulting to a real emulator) so a JVM test can substitute a virtual-time session.
*/
public class TerminalSessionControllerImpl(
private val base: TerminalSessionController,
onSanitizedTitle: (String) -> Unit,
private val outputCollectorScope: CoroutineScope,
private val outputDispatcher: CoroutineDispatcher = Dispatchers.Main.immediate,
remoteSessionFactory: (engineSend: (ClientMessage) -> Unit, onTitle: (String) -> Unit) -> RemoteTerminalSession =
{ engineSend, onTitle -> RemoteTerminalSession(engineSend = engineSend, onTitleChanged = onTitle) },
) : TerminalSessionController by base {
/**
* The forked Termux emulator for this session (no local process). The screen puts it on-screen via
* `attachView` and drives `updateSize`; A21 feeds it the remote output and routes its replies out.
*/
public val remoteSession: RemoteTerminalSession =
remoteSessionFactory(::routeEmulatorMessage) { raw -> onSanitizedTitle(TitleSanitizer.sanitize(raw)) }
private var outputWired: Boolean = false
/**
* Wire `output` → [RemoteTerminalSession.feedRemote] on `Main.immediate` (idempotent), THEN start the
* engine via `base.start()`. Wiring the feed before the engine starts guarantees the `attached` frame
* and the multi-MB replay land in the already-registered [EventBus] output mailbox (plan §6.2).
*/
override fun start() {
if (!outputWired) {
outputWired = true
outputCollectorScope.launch(outputDispatcher) {
base.output.collect { remoteSession.feedRemote(it) }
}
}
base.start()
}
/** Detach (base) AND release the emulator's append thread. Idempotent — safe to call more than once. */
override fun close() {
base.close()
remoteSession.close()
}
/**
* The emulator's outbound sink → the engine's ordered send pump (via [base]). The Termux
* `TerminalOutput` only ever emits [ClientMessage.Input] (DA/DSR/mouse replies) and, from
* `updateSize`, [ClientMessage.Resize]; approve/reject/attach never originate here.
*/
private fun routeEmulatorMessage(message: ClientMessage) {
when (message) {
is ClientMessage.Input -> base.sendInput(message.data)
is ClientMessage.Resize -> base.resize(message.cols, message.rows)
else -> Unit
}
}
}

View File

@@ -0,0 +1,161 @@
package wang.yaojia.webterm.wiring
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Typeface
import com.termux.terminal.TerminalEmulator
import com.termux.terminal.TerminalOutput
import com.termux.terminal.TerminalSession
import com.termux.terminal.TerminalSessionClient
import com.termux.terminal.TextStyle
import wang.yaojia.webterm.api.models.SessionPreview
import kotlin.math.ceil
/**
* The off-screen preview painter (plan §6.7) — the device-verified half of the thumbnail pipeline.
*
* Instantiates a [TerminalEmulator] with NO `TerminalView` and NO subprocess (the same "remove the JNI
* process, keep the VT parser + `TerminalBuffer`" seam A16 proved), appends the preview bytes, then
* rasterises the buffer cell-by-cell onto a `Bitmap`-backed [Canvas] with a monospace [Paint] — a direct
* port of Termux `TerminalRenderer.render`'s per-cell draw: for each cell, fill its background rect then
* stroke its glyph in the cell's foreground colour, decoding fg/bg/effect from the cell's `TextStyle`
* (24-bit truecolor / 256 / ANSI, with the bold→bright bump and inverse-video swap).
*
* `TerminalRenderer`'s own `mFontWidth`/`mFontLineSpacing` are package-private, so this computes its own
* cell metrics from the [Paint] (`ceil(measureText("X"))` and the ascent-to-descent span), exactly as
* `TerminalRenderer` derives them.
*
* android.graphics is stubbed in JVM unit tests, so this class is exercised by device QA (plan §7,
* `:terminal-view` instrumented "thumbnail rasterisation non-null"); the pipeline policy around it is
* JVM-tested via the [ThumbnailRasterizer] seam.
*/
public class CanvasThumbnailRasterizer(
private val fontSizePx: Float = DEFAULT_FONT_SIZE_PX,
typeface: Typeface = Typeface.MONOSPACE,
) : ThumbnailRasterizer {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
this.typeface = typeface
textSize = fontSizePx
}
private val cellWidth: Int = ceil(paint.measureText("X").toDouble()).toInt().coerceAtLeast(1)
private val cellHeight: Int = (paint.fontMetricsInt.descent - paint.fontMetricsInt.ascent).coerceAtLeast(1)
private val baselineOffset: Int = -paint.fontMetricsInt.ascent
override fun rasterize(preview: SessionPreview): Bitmap {
val cols = preview.cols.coerceIn(1, MAX_COLS)
val rows = preview.rows.coerceIn(1, MAX_ROWS)
val emulator = TerminalEmulator(NoOpOutput, cols, rows, TerminalEmulator.TERMINAL_TRANSCRIPT_ROWS_MIN, NoOpClient)
val bytes = preview.data.toByteArray(Charsets.UTF_8)
emulator.append(bytes, bytes.size)
val bitmap = Bitmap.createBitmap(cols * cellWidth, rows * cellHeight, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val palette = emulator.mColors.mCurrentColors
val defaultBg = palette[TextStyle.COLOR_INDEX_BACKGROUND]
canvas.drawColor(defaultBg)
for (row in 0 until rows) {
drawRow(canvas, emulator, row, cols, palette, defaultBg)
}
return bitmap
}
override fun placeholder(): Bitmap {
val bitmap = Bitmap.createBitmap(cellWidth, cellHeight, Bitmap.Config.ARGB_8888)
bitmap.eraseColor(PLACEHOLDER_COLOR)
return bitmap
}
private fun drawRow(canvas: Canvas, emulator: TerminalEmulator, row: Int, cols: Int, palette: IntArray, defaultBg: Int) {
val screen = emulator.screen
val line = screen.allocateFullLineIfNecessary(screen.externalToInternalRow(row))
val text = line.mText
val top = (row * cellHeight).toFloat()
val baseline = (row * cellHeight + baselineOffset).toFloat()
for (col in 0 until cols) {
val style = line.getStyle(col)
val effect = TextStyle.decodeEffect(style)
val bold = (effect and (TextStyle.CHARACTER_ATTRIBUTE_BOLD or TextStyle.CHARACTER_ATTRIBUTE_BLINK)) != 0
val inverse = (effect and TextStyle.CHARACTER_ATTRIBUTE_INVERSE) != 0
val invisible = (effect and TextStyle.CHARACTER_ATTRIBUTE_INVISIBLE) != 0
var fg = resolveColor(TextStyle.decodeForeColor(style), palette, boldBright = bold)
var bg = resolveColor(TextStyle.decodeBackColor(style), palette, boldBright = false)
if (inverse) { val swap = fg; fg = bg; bg = swap }
val left = (col * cellWidth).toFloat()
if (bg != defaultBg) {
paint.color = bg
paint.style = Paint.Style.FILL
canvas.drawRect(left, top, left + cellWidth, top + cellHeight, paint)
}
val start = line.findStartOfColumn(col)
val end = line.findStartOfColumn(col + 1)
if (!invisible && end > start) {
paint.color = fg
paint.isFakeBoldText = bold
canvas.drawText(text, start, end - start, left, baseline, paint)
}
}
}
/**
* Resolve a decoded colour to an ARGB int: a truecolor value (its `0xff000000` marker set) passes
* through; an indexed value is looked up in the emulator's live [palette], with a bold foreground in
* the first 8 ANSI colours bumped to its bright (8..15) variant — matching `TerminalRenderer`.
*/
private fun resolveColor(color: Int, palette: IntArray, boldBright: Boolean): Int {
if ((color and TRUECOLOR_MASK) == TRUECOLOR_MASK) return color
val index = if (boldBright && color in 0..7) color + 8 else color
return palette[index]
}
public companion object {
/** Default glyph size for a thumbnail (px). Small — the card downscales anyway. */
public const val DEFAULT_FONT_SIZE_PX: Float = 12f
/** Clamp the off-screen grid so a rogue preview geometry can't allocate an enormous bitmap. */
public const val MAX_COLS: Int = 400
public const val MAX_ROWS: Int = 200
/** Opaque dark-grey placeholder for the fetch/render-failure fallback. */
public const val PLACEHOLDER_COLOR: Int = -0xdfdfe0 // 0xFF202020
/** A truecolor `TextStyle` value carries the 0xff000000 marker in its high byte. */
private const val TRUECOLOR_MASK: Int = -0x1000000 // 0xFF000000
}
}
/** Off-screen emulator sink: the preview emulator produces no wire output and needs no delegates. */
private object NoOpOutput : TerminalOutput() {
override fun write(data: ByteArray?, offset: Int, count: Int) = Unit
override fun titleChanged(oldTitle: String?, newTitle: String?) = Unit
override fun onCopyTextToClipboard(text: String?) = Unit
override fun onPasteTextFromClipboard() = Unit
override fun onBell() = Unit
override fun onColorsChanged() = Unit
}
/**
* No-op [TerminalSessionClient] for the off-screen emulator (the constructor requires a non-null client;
* Termux calls it on rare diagnostic escape paths without a null-check). Logs NOTHING — preview bytes are
* attacker-influenced and must never reach logcat.
*/
private object NoOpClient : TerminalSessionClient {
override fun onTextChanged(changedSession: TerminalSession?) = Unit
override fun onTitleChanged(changedSession: TerminalSession?) = Unit
override fun onSessionFinished(finishedSession: TerminalSession?) = Unit
override fun onCopyTextToClipboard(session: TerminalSession?, text: String?) = Unit
override fun onPasteTextFromClipboard(session: TerminalSession?) = Unit
override fun onBell(session: TerminalSession?) = Unit
override fun onColorsChanged(session: TerminalSession?) = Unit
override fun onTerminalCursorStateChange(state: Boolean) = Unit
override fun getTerminalCursorStyle(): Int = TerminalEmulator.TERMINAL_CURSOR_STYLE_BLOCK
override fun logError(tag: String?, message: String?) = Unit
override fun logWarn(tag: String?, message: String?) = Unit
override fun logInfo(tag: String?, message: String?) = Unit
override fun logDebug(tag: String?, message: String?) = Unit
override fun logVerbose(tag: String?, message: String?) = Unit
override fun logStackTraceWithMessage(tag: String?, message: String?, e: Exception?) = Unit
override fun logStackTrace(tag: String?, e: Exception?) = Unit
}

View File

@@ -0,0 +1,195 @@
package wang.yaojia.webterm.wiring
import android.graphics.Bitmap
import android.util.LruCache
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import wang.yaojia.webterm.api.models.LiveSessionInfo
import wang.yaojia.webterm.api.models.SessionPreview
import wang.yaojia.webterm.api.routes.ApiClient
import java.util.UUID
/**
* Off-screen terminal-preview rasterisation for the session-list / manage-page thumbnails (plan §6.7).
*
* A read-only [ApiClient] preview fetch (`GET /live-sessions/:id/preview`) over the ONE shared mTLS
* OkHttp client is fed into an off-screen [com.termux.terminal.TerminalEmulator] (no `TerminalView`, no
* subprocess) and rasterised cell-by-cell onto a `Bitmap` — see [CanvasThumbnailRasterizer]. This class
* owns the *policy* around that expensive work; the actual `Canvas`/`Bitmap` draw is delegated to the
* [ThumbnailRasterizer] seam so this policy (cache, concurrency, dedup, failure fallback) stays
* JVM-unit-testable while the pixel raster is verified on-device (android.graphics is stubbed off-device).
*
* ### The four policies (plan §6.7)
* - **Cache** — an [LruCache] keyed on `(sessionId, lastOutputAt)`. `lastOutputAt` is the server's
* last-PTY-output timestamp, so an UNCHANGED `lastOutputAt` means the screen is byte-identical and we
* return the cached bitmap WITHOUT re-fetching or re-rendering. Keying goes through the [BitmapCache]
* seam so the "unchanged ⇒ no re-render" invariant is testable against a plain-map fake.
* - **Concurrency cap** — a fair (FIFO) [Semaphore] with 2 permits bounds the fetch+render fan-out so a
* grid of many cards never spawns dozens of concurrent emulators / large-bitmap allocations.
* - **In-flight dedup** — a `Map<Key, Deferred<Bitmap>>` under a [Mutex]: a second request for a key
* already rendering AWAITS the first's [Deferred] instead of starting a duplicate fetch/render.
* - **Failure fallback** — any fetch/render failure caches the rasterizer's PLACEHOLDER bitmap under the
* same key, so a broken/oversized session is not retried in a hot scroll loop.
*
* The render coroutine runs in this pipeline's own [scope] (not the caller's), so a caller that scrolls
* a card off-screen and cancels its collect never cancels a shared render other cards still await.
*/
public class ThumbnailPipeline(
private val previewSource: PreviewSource,
private val rasterizer: ThumbnailRasterizer,
private val cache: BitmapCache = LruBitmapCache(DEFAULT_CACHE_BYTES),
dispatcher: kotlinx.coroutines.CoroutineDispatcher = Dispatchers.Default,
maxConcurrent: Int = MAX_CONCURRENT_RENDERS,
) {
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
/** Fair (FIFO) permit gate around the fetch+render of ONE thumbnail (plan §6.7). */
private val renderGate = Semaphore(maxConcurrent)
/** In-flight renders by key; guarded by [inFlightMutex]. A second same-key request shares the entry. */
private val inFlight = HashMap<ThumbnailKey, Deferred<Bitmap>>()
private val inFlightMutex = Mutex()
/**
* The cached (or freshly rendered) thumbnail for [session]. Returns the cached bitmap immediately when
* `(id, lastOutputAt)` is unchanged; otherwise fetches the preview over mTLS, rasterises off-screen,
* caches, and returns it. Never throws for a fetch/render failure — a placeholder is cached and
* returned instead (only cooperative cancellation of [scope] propagates).
*/
public suspend fun thumbnail(session: LiveSessionInfo): Bitmap {
val key = ThumbnailKey(session.id, session.lastOutputAt)
// Fast path: an unchanged lastOutputAt ⇒ identical screen ⇒ never re-render (§6.7).
cache.get(key)?.let { return it }
val deferred = inFlightMutex.withLock {
// Re-check under the lock: a render that finished between the fast path and here has already
// populated the cache and removed its in-flight entry.
cache.get(key)?.let { return it }
inFlight[key] ?: startRender(key).also { inFlight[key] = it }
}
return deferred.await()
}
/** Cancel all in-flight renders and release the pipeline scope (call from the owner's teardown). */
public fun close() {
scope.cancel()
}
private fun startRender(key: ThumbnailKey): Deferred<Bitmap> = scope.async {
val bitmap = renderGuarded(key.sessionId)
// Publish under the lock so a concurrent [thumbnail] re-check sees the result the instant this
// key stops being in-flight — success AND placeholder are cached identically (§6.7 no-retry-storm).
inFlightMutex.withLock {
cache.put(key, bitmap)
inFlight.remove(key)
}
bitmap
}
private suspend fun renderGuarded(sessionId: UUID): Bitmap =
try {
renderGate.withPermit {
val preview = previewSource.fetch(sessionId)
rasterizer.rasterize(preview)
}
} catch (e: CancellationException) {
throw e // never swallow cooperative cancellation
} catch (t: Throwable) {
// Any fetch/render failure (network, oversized body, malformed escape) → cache a placeholder
// so the broken session isn't retried on every scroll frame (§6.7).
rasterizer.placeholder()
}
public companion object {
/** Plan §6.7: the render/fetch fan-out is capped at 2 concurrent renders. */
public const val MAX_CONCURRENT_RENDERS: Int = 2
/** Default LRU budget for cached thumbnails (bytes). Small — thumbnails are transient UI cache. */
public const val DEFAULT_CACHE_BYTES: Int = 8 * 1024 * 1024
}
}
/**
* Cache key: an unchanged `(sessionId, lastOutputAt)` pair means the server's last PTY output is
* unchanged ⇒ the preview is byte-identical ⇒ the cached bitmap is reused verbatim (plan §6.7). A `null`
* [lastOutputAt] (pre-P1 server that omits it) is a stable key too, so such a session renders once and is
* then served from cache until its id changes.
*/
public data class ThumbnailKey(val sessionId: UUID, val lastOutputAt: Long?)
/** Fetches the read-only preview bytes for a session (plan §6.7: over the shared mTLS OkHttp client). */
public fun interface PreviewSource {
public suspend fun fetch(sessionId: UUID): SessionPreview
}
/**
* Rasterises a [SessionPreview] into a `Bitmap` (the off-screen-emulator → `Canvas` cell painter). The
* seam boundary: this is the ONLY android.graphics-touching surface, kept behind an interface so the
* pipeline's cache/concurrency/dedup policy is JVM-unit-testable and the pixel raster is device-verified.
*/
public interface ThumbnailRasterizer {
/** Off-screen emulator append + per-cell `Canvas` draw. */
public fun rasterize(preview: SessionPreview): Bitmap
/** A cheap solid bitmap cached on any fetch/render failure (no retry storm). */
public fun placeholder(): Bitmap
}
/** The bitmap cache seam. Production wraps [LruCache]; tests use a plain map so keying stays JVM-testable. */
public interface BitmapCache {
public fun get(key: ThumbnailKey): Bitmap?
public fun put(key: ThumbnailKey, bitmap: Bitmap)
}
/**
* Production [BitmapCache] backed by an [LruCache] sized by each bitmap's allocation byte count (plan
* §6.7). Instantiated only in production (the pipeline's default arg); unit tests inject a map-backed fake
* so `android.util.LruCache` — a stub off-device — is never touched under JVM test.
*/
public class LruBitmapCache(maxBytes: Int) : BitmapCache {
private val lru = object : LruCache<ThumbnailKey, Bitmap>(maxBytes) {
override fun sizeOf(key: ThumbnailKey, value: Bitmap): Int = value.allocationByteCount.coerceAtLeast(1)
}
override fun get(key: ThumbnailKey): Bitmap? = lru.get(key)
override fun put(key: ThumbnailKey, bitmap: Bitmap) {
lru.put(key, bitmap)
}
}
/**
* Production [PreviewSource] over an [ApiClient] (which sends through the shared mTLS `HttpTransport`, so
* tunnel-host previews traverse nginx). Re-caps the body at 256 KiB client-side — defence-in-depth over
* the server's own cap (plan §6.7 / §4.2).
*/
public class ApiPreviewSource(private val apiClient: ApiClient) : PreviewSource {
override suspend fun fetch(sessionId: UUID): SessionPreview =
PreviewCap.cap(apiClient.preview(sessionId))
}
/** Client-side preview-size guard (plan §6.7: cap data at 256 KiB). Pure — JVM-testable. */
public object PreviewCap {
/** 256 KiB — mirrors the server's `GET /live-sessions/:id/preview` data cap. */
public const val MAX_PREVIEW_BYTES: Int = 256 * 1024
/**
* Cap [preview]'s UTF-8 byte length at [MAX_PREVIEW_BYTES], keeping the TAIL (the most recent screen)
* so a rogue/huge body can't drive an unbounded emulator append. A tail cut on a UTF-8 boundary at
* worst mangles one leading glyph — harmless for a thumbnail.
*/
public fun cap(preview: SessionPreview): SessionPreview {
val bytes = preview.data.toByteArray(Charsets.UTF_8)
if (bytes.size <= MAX_PREVIEW_BYTES) return preview
val tail = bytes.copyOfRange(bytes.size - MAX_PREVIEW_BYTES, bytes.size)
return preview.copy(data = String(tail, Charsets.UTF_8))
}
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">WebTerm</string>
</resources>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Window chrome only (dark-first) — the actual UI colors come from Compose's
WebTermTheme. Parent is the PLATFORM Material theme so no Material
Components (com.google.android.material) XML dependency is pulled in;
WebTerm is a pure-Compose app. The dark windowBackground avoids a white
flash before the first Compose frame. -->
<style name="Theme.WebTerm" parent="@android:style/Theme.Material.NoActionBar">
<item name="android:windowBackground">#FF14130F</item>
<item name="android:statusBarColor">#FF14130F</item>
<item name="android:navigationBarColor">#FF14130F</item>
</style>
<!-- The Allow-action trampoline (A30, push/AllowTrampolineActivity): a fully transparent,
no-title, no-animation window that hosts ONLY the system BiometricPrompt sheet. Paired with
android:excludeFromRecents in the manifest so it never leaves a recents card. -->
<style name="Theme.WebTerm.Translucent" parent="@android:style/Theme.Material.Dialog.NoActionBar">
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowIsFloating">true</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:backgroundDimEnabled">false</item>
<item name="android:windowAnimationStyle">@null</item>
</style>
</resources>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- STUB (plan §8 / A19). Cleartext is OFF globally. A19 will add
<domain-config cleartextTrafficPermitted="true"> entries for the private-LAN
and Tailscale CIDRs that legitimately need ws:// (10/8, 172.16/12,
192.168/16, 100.64/10). Tunnel (*.terminal.yaojia.wang) and Tailscale
MagicDNS hosts present real LE certs and use default system trust (§6.9),
so they stay under this base-config. -->
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
</network-security-config>

View File

@@ -0,0 +1,25 @@
package wang.yaojia.webterm.components
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.session.AwayDigest
/**
* [AwayDigestView] pure summary logic (A22): the one-line count phrase. The card layout / expand
* affordance and the all-zero "render nothing" branch are device-QA (the VM already holds `null` for
* an empty digest, [wang.yaojia.webterm.viewmodels.GateViewModelTest]).
*/
class AwayDigestViewTest {
@Test
fun `counts and flags join into the away summary`() {
val digest = AwayDigest(toolRuns = 3, waitingCount = 1, sawDone = true, sawStuck = false, recent = emptyList())
assertEquals("离开期间3 次工具调用 · 1 次等待 · 已完成", summaryLine(digest))
}
@Test
fun `zero-count parts are omitted`() {
val digest = AwayDigest(toolRuns = 0, waitingCount = 2, sawDone = false, sawStuck = true, recent = emptyList())
assertEquals("离开期间2 次等待 · 疑似卡住", summaryLine(digest))
}
}

View File

@@ -0,0 +1,28 @@
package wang.yaojia.webterm.components
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
/**
* The [continueLastModel] derivation (plan §5 A29 Verify): the banner is shown IFF a last session exists.
* A non-blank persisted id yields a [ContinueLastModel]; `null`/blank yields `null` (banner hidden). The
* composable's "render nothing on null" branch + layout are device-QA (plan §7); this is the JVM core.
*/
class ContinueLastBannerTest {
@Test
fun `a persisted last session id yields a shown banner model`() {
assertEquals(ContinueLastModel("sess-9"), continueLastModel("sess-9"))
}
@Test
fun `no last session id yields no banner`() {
assertNull(continueLastModel(null))
}
@Test
fun `a blank id is treated as no last session`() {
assertNull(continueLastModel(" "))
}
}

View File

@@ -0,0 +1,179 @@
package wang.yaojia.webterm.components
import android.view.KeyEvent
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.session.KeyByteMap
/**
* A17 core-contract guard (pure JVM — no device). Two things are verified here; the
* Compose layout, IME-inset behaviour and real key events are device-QA (plan §7):
*
* 1. **Key-bar byte contract** — every button funnels its key through [KeyByteMap]
* to a fake send sink and produces the EXACT web-parity byte string.
* 2. **DECCKM split** — [HardwareKeyRouter.resolve] routes Esc / Ctrl-chord / ⇧Tab
* app-side, and LEAVES arrows / Enter / plain Tab to Termux's `KeyHandler`.
*
* [EXPECTED_BYTES] is transcribed INDEPENDENTLY from public/keybar.ts `KEY_MAP`
* (ESC rebuilt from `Char(0x1B)` = web `\x1b`), so any drift in [KeyByteMap] or the
* [KEYBAR_LAYOUT] wiring fails here — not silently on a device.
*/
class KeyBarTest {
private val esc = Char(0x1B).toString()
/** KeyByteMap.Key → byte string, transcribed straight from keybar.ts `KEY_MAP`. */
private val EXPECTED_BYTES: Map<KeyByteMap.Key, String> = mapOf(
KeyByteMap.Key.ESC to esc, // '\x1b'
KeyByteMap.Key.ESC_ESC to esc + esc, // '\x1b\x1b'
KeyByteMap.Key.SHIFT_TAB to esc + "[Z", // '\x1b[Z'
KeyByteMap.Key.ARROW_UP to esc + "[A", // '\x1b[A'
KeyByteMap.Key.ARROW_DOWN to esc + "[B", // '\x1b[B'
KeyByteMap.Key.ARROW_LEFT to esc + "[D", // '\x1b[D'
KeyByteMap.Key.ARROW_RIGHT to esc + "[C", // '\x1b[C'
KeyByteMap.Key.ENTER to "\r", // '\r' — NOT '\n'
KeyByteMap.Key.CTRL_C to Char(0x03).toString(), // '\x03'
KeyByteMap.Key.CTRL_R to Char(0x12).toString(), // '\x12'
KeyByteMap.Key.CTRL_O to Char(0x0F).toString(), // '\x0f'
KeyByteMap.Key.CTRL_L to Char(0x0C).toString(), // '\x0c'
KeyByteMap.Key.CTRL_T to Char(0x14).toString(), // '\x14'
KeyByteMap.Key.CTRL_B to Char(0x02).toString(), // '\x02'
KeyByteMap.Key.CTRL_D to Char(0x04).toString(), // '\x04'
KeyByteMap.Key.TAB to "\t", // '\t'
KeyByteMap.Key.SLASH to "/", // '/'
)
// ── 1. Key-bar byte contract ─────────────────────────────────────────────────
@Test
fun `every key-bar button emits its exact web-parity bytes through the sink`() {
for (button in KEYBAR_LAYOUT) {
// Arrange: a fake IME-bypass sink capturing every send.
val captured = mutableListOf<String>()
// Act: tap the button (exactly what onClick does).
emitKey(button.key, captured::add)
// Assert: one send, byte-for-byte the expected sequence.
val expected = EXPECTED_BYTES[button.key] ?: error("no expected bytes for ${button.key}")
assertEquals(1, captured.size, "one send per tap for ${button.label}")
assertEquals(expected, captured.single(), "byte mismatch for ${button.label}")
}
}
@Test
fun `layout order, glyphs and keys mirror the web key-bar`() {
val expectedKeys = listOf(
KeyByteMap.Key.ESC, KeyByteMap.Key.ESC_ESC, KeyByteMap.Key.SHIFT_TAB,
KeyByteMap.Key.ARROW_UP, KeyByteMap.Key.ARROW_DOWN, KeyByteMap.Key.ENTER,
KeyByteMap.Key.CTRL_C, KeyByteMap.Key.CTRL_R, KeyByteMap.Key.CTRL_O,
KeyByteMap.Key.CTRL_L, KeyByteMap.Key.CTRL_T, KeyByteMap.Key.CTRL_B,
KeyByteMap.Key.CTRL_D, KeyByteMap.Key.TAB, KeyByteMap.Key.ARROW_LEFT,
KeyByteMap.Key.ARROW_RIGHT, KeyByteMap.Key.SLASH,
)
assertEquals(expectedKeys, KEYBAR_LAYOUT.map { it.key })
assertEquals("Esc", KEYBAR_LAYOUT.first().label)
assertTrue(KEYBAR_LAYOUT.first().isPrimary, "Esc is the primary (interrupt) key")
}
@Test
fun `enter emits carriage-return not linefeed`() {
val captured = mutableListOf<String>()
emitKey(KeyByteMap.Key.ENTER, captured::add)
assertEquals("\r", captured.single())
}
// ── 2. DECCKM split routing (plan §6.3 source #3) ────────────────────────────
@Test
fun `esc is app-handled with the ESC byte`() {
val result = HardwareKeyRouter.resolve(KeyEvent.KEYCODE_ESCAPE, ctrl = false, shift = false, alt = false)
assertEquals(HardwareKeyResult.AppHandled(esc), result)
}
@Test
fun `arrows are left to Termux KeyHandler so DECCKM stays correct`() {
val arrows = listOf(
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN,
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT,
)
for (code in arrows) {
assertEquals(
HardwareKeyResult.DeferToTerminal,
HardwareKeyRouter.resolve(code, ctrl = false, shift = false, alt = false),
"arrow keyCode=$code must defer (DECCKM emits ESC O A, never a hardcoded ESC [ A)",
)
}
}
@Test
fun `enter and plain Tab are left to Termux KeyHandler`() {
assertEquals(
HardwareKeyResult.DeferToTerminal,
HardwareKeyRouter.resolve(KeyEvent.KEYCODE_ENTER, ctrl = false, shift = false, alt = false),
)
assertEquals(
HardwareKeyResult.DeferToTerminal,
HardwareKeyRouter.resolve(KeyEvent.KEYCODE_TAB, ctrl = false, shift = false, alt = false),
)
}
@Test
fun `shift+Tab is app-handled with the reverse-tab sequence`() {
val result = HardwareKeyRouter.resolve(KeyEvent.KEYCODE_TAB, ctrl = false, shift = true, alt = false)
assertEquals(HardwareKeyResult.AppHandled(esc + "[Z"), result)
}
@Test
fun `each mapped Ctrl-letter chord is app-handled with its control byte`() {
val expected = mapOf(
KeyEvent.KEYCODE_C to KeyByteMap.Key.CTRL_C,
KeyEvent.KEYCODE_R to KeyByteMap.Key.CTRL_R,
KeyEvent.KEYCODE_O to KeyByteMap.Key.CTRL_O,
KeyEvent.KEYCODE_L to KeyByteMap.Key.CTRL_L,
KeyEvent.KEYCODE_T to KeyByteMap.Key.CTRL_T,
KeyEvent.KEYCODE_B to KeyByteMap.Key.CTRL_B,
KeyEvent.KEYCODE_D to KeyByteMap.Key.CTRL_D,
)
for ((code, key) in expected) {
assertEquals(
HardwareKeyResult.AppHandled(KeyByteMap.bytes(key)),
HardwareKeyRouter.resolve(code, ctrl = true, shift = false, alt = false),
"Ctrl+ keyCode=$code should map to $key",
)
}
}
@Test
fun `an unmapped Ctrl-letter defers to Termux`() {
// Ctrl+A is not one of the web key-bar chords → Termux handles it.
assertEquals(
HardwareKeyResult.DeferToTerminal,
HardwareKeyRouter.resolve(KeyEvent.KEYCODE_A, ctrl = true, shift = false, alt = false),
)
}
@Test
fun `a plain letter is left to Termux (typed via IME, not a chord)`() {
assertEquals(
HardwareKeyResult.DeferToTerminal,
HardwareKeyRouter.resolve(KeyEvent.KEYCODE_C, ctrl = false, shift = false, alt = false),
)
}
// ── Visibility heuristic (web-parity mobile-only + hardware-keyboard auto-hide) ─
@Test
fun `key-bar shows on a narrow phone with no hardware keyboard`() {
assertTrue(shouldShowKeyBar(screenWidthDp = 360, hardwareKeyboardPresent = false))
assertTrue(shouldShowKeyBar(screenWidthDp = WIDE_SCREEN_MAX_DP, hardwareKeyboardPresent = false))
}
@Test
fun `key-bar hides on a wide screen or when a hardware keyboard is present`() {
assertFalse(shouldShowKeyBar(screenWidthDp = 900, hardwareKeyboardPresent = false))
assertFalse(shouldShowKeyBar(screenWidthDp = 360, hardwareKeyboardPresent = true))
}
}

View File

@@ -0,0 +1,226 @@
package wang.yaojia.webterm.components
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* A25 core-contract guard (pure JVM — no device). The Compose chip layout, the
* float animation and real taps are device-QA (plan §7); what is load-bearing and
* verified here:
*
* 1. **Palette CRUD** — [QuickReplyStore] add / edit / remove / reorder each return
* a NEW list (immutable), with sensible defaults, blank-input guards and a
* persistence round-trip through a fake `DataStore<Preferences>`.
* 2. **Codec** — [QuickReplyCodec] round-trips (incl. quotes / backslash / newline)
* and is defensive against a corrupt blob at rest.
* 3. **Tap → exact bytes** — [sendQuickReply] emits the chip text VERBATIM;
* [shouldShowQuickReply] floats the row only while a gate is held with chips.
*/
class QuickReplyTest {
// ── An in-memory DataStore<Preferences> double (no Android Context needed) ─────
private class FakePreferencesDataStore(
initial: Preferences = emptyPreferences(),
) : DataStore<Preferences> {
private val state = MutableStateFlow(initial)
override val data: Flow<Preferences> = state
override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences {
val updated = transform(state.value)
state.value = updated
return updated
}
}
private var idSeq = 0
private fun sequentialId(): () -> String = { "id-${idSeq++}" }
// ── 1. Defaults ───────────────────────────────────────────────────────────────
@Test
fun `first read yields the sensible defaults`() = runTest {
val store = DataStoreQuickReplyStore(FakePreferencesDataStore())
assertEquals(QuickReplyDefaults.chips, store.loadAll())
assertTrue(QuickReplyDefaults.chips.isNotEmpty(), "defaults must not be empty")
}
// ── 1. CRUD returns new lists (immutable) + persistence ───────────────────────
@Test
fun `add appends a chip, persists, and returns a new list`() = runTest {
val backing = FakePreferencesDataStore()
val store = DataStoreQuickReplyStore(backing, newId = sequentialId())
val before = store.loadAll()
val after = store.add("run tests")
assertEquals(before.size + 1, after.size)
assertEquals(QuickReplyChip("id-0", "run tests"), after.last())
// A fresh store over the SAME backing sees the persisted list (round-trip).
assertEquals(after, DataStoreQuickReplyStore(backing).loadAll())
}
@Test
fun `add stores text verbatim (surrounding whitespace preserved)`() = runTest {
val store = DataStoreQuickReplyStore(FakePreferencesDataStore(), newId = sequentialId())
val after = store.add(" spaced ")
assertEquals(" spaced ", after.last().text)
}
@Test
fun `blank add is a no-op`() = runTest {
val store = DataStoreQuickReplyStore(FakePreferencesDataStore())
val before = store.loadAll()
assertEquals(before, store.add(" "))
assertEquals(before, store.add(""))
}
@Test
fun `edit replaces text in place by id, leaving others untouched`() = runTest {
val store = DataStoreQuickReplyStore(FakePreferencesDataStore())
val after = store.edit("qr-yes", "yes please")
val edited = after.single { it.id == "qr-yes" }
assertEquals("yes please", edited.text)
// Position preserved and siblings unchanged.
assertEquals(QuickReplyDefaults.chips.map { it.id }, after.map { it.id })
assertEquals("continue", after.first { it.id == "qr-continue" }.text)
}
@Test
fun `edit of an unknown id or blank text is a no-op`() = runTest {
val store = DataStoreQuickReplyStore(FakePreferencesDataStore())
val defaults = store.loadAll()
assertEquals(defaults, store.edit("does-not-exist", "x"))
assertEquals(defaults, store.edit("qr-yes", " "))
}
@Test
fun `remove drops the chip by id, persists, and unknown id is a no-op`() = runTest {
val backing = FakePreferencesDataStore()
val store = DataStoreQuickReplyStore(backing)
val after = store.remove("qr-no")
assertFalse(after.any { it.id == "qr-no" })
assertEquals(QuickReplyDefaults.chips.size - 1, after.size)
assertEquals(after, DataStoreQuickReplyStore(backing).loadAll())
// Removing again (now unknown) changes nothing.
assertEquals(after, store.remove("qr-no"))
}
@Test
fun `deleting every chip persists an empty list and does NOT re-seed defaults`() = runTest {
val backing = FakePreferencesDataStore()
val store = DataStoreQuickReplyStore(backing)
var list = store.loadAll()
list.forEach { store.remove(it.id) }
assertTrue(store.loadAll().isEmpty(), "delete-all must persist as empty")
assertTrue(DataStoreQuickReplyStore(backing).loadAll().isEmpty(), "empty must not re-seed defaults")
}
@Test
fun `move reorders immutably and out-of-range is a no-op`() = runTest {
val store = DataStoreQuickReplyStore(FakePreferencesDataStore())
val ids = store.loadAll().map { it.id }
val moved = store.move(0, ids.lastIndex)
val expected = ids.drop(1) + ids.first()
assertEquals(expected, moved.map { it.id })
// Out-of-range indices leave the list untouched.
assertEquals(moved, store.move(-1, 0))
assertEquals(moved, store.move(0, 99))
}
// ── 1. In-memory double parity ────────────────────────────────────────────────
@Test
fun `in-memory double supports the same CRUD and defaults`() = runTest {
val store = InMemoryQuickReplyStore(newId = sequentialId())
assertEquals(QuickReplyDefaults.chips, store.loadAll())
val added = store.add("deploy")
assertEquals("deploy", added.last().text)
val removed = store.remove(added.last().id)
assertFalse(removed.any { it.text == "deploy" })
// Seeding with an explicit initial list is honoured (no default re-seed).
val empty = InMemoryQuickReplyStore(initial = emptyList())
assertTrue(empty.loadAll().isEmpty())
}
// ── 2. Codec round-trip + defensiveness ───────────────────────────────────────
@Test
fun `codec round-trips including quotes, backslashes and newlines`() = runTest {
val chips = listOf(
QuickReplyChip("a", "plain"),
QuickReplyChip("b", "he said \"hi\""),
QuickReplyChip("c", "path\\to\\thing"),
QuickReplyChip("d", "line1\nline2\t/slash"),
)
assertEquals(chips, QuickReplyCodec.decode(QuickReplyCodec.encode(chips)))
}
@Test
fun `codec is defensive against corrupt or malformed blobs`() {
assertTrue(QuickReplyCodec.decode(null).isEmpty())
assertTrue(QuickReplyCodec.decode("").isEmpty())
assertTrue(QuickReplyCodec.decode("not json {").isEmpty())
// A non-array top level is rejected wholesale.
assertTrue(QuickReplyCodec.decode("""{"id":"a","text":"b"}""").isEmpty())
// Chips missing a string id/text are dropped; valid ones survive.
val mixed = QuickReplyCodec.decode(
"""[{"text":"noid"},{"id":"ok","text":"keep"},{"id":"x"},{"id":5,"text":"num"}]""",
)
assertEquals(listOf(QuickReplyChip("ok", "keep")), mixed)
}
// ── 3. Tap → exact bytes, and the float-visibility heuristic ───────────────────
@Test
fun `tapping a chip emits its exact text verbatim, once`() {
val captured = mutableListOf<String>()
sendQuickReply(QuickReplyChip("id", "git status\n"), captured::add)
assertEquals(1, captured.size)
assertEquals("git status\n", captured.single())
}
@Test
fun `the row floats only while a gate is held with at least one chip`() {
assertTrue(shouldShowQuickReply(gateHeld = true, hasChips = true))
assertFalse(shouldShowQuickReply(gateHeld = false, hasChips = true))
assertFalse(shouldShowQuickReply(gateHeld = true, hasChips = false))
}
// ── Pure transforms never mutate the receiver ─────────────────────────────────
@Test
fun `pure transforms return fresh lists and never mutate the receiver`() {
val original = QuickReplyDefaults.chips
assertEquals(original.size + 1, original.adding("x", "nid").size)
assertEquals("nid", original.adding("x", "nid").last().id)
assertEquals(original, original.adding(" ", "nid")) // blank guarded
assertEquals("edited", original.editing("qr-yes", "edited").single { it.id == "qr-yes" }.text)
assertEquals(original.size - 1, original.removingId("qr-yes").size)
// move(first → end): the original first chip becomes the last.
assertEquals(original.first().id, original.moving(0, original.lastIndex).last().id)
// The receiver is unchanged after every transform above.
assertEquals(QuickReplyDefaults.chips, original)
assertNull(original.firstOrNull { it.text == "edited" })
}
}

View File

@@ -0,0 +1,112 @@
package wang.yaojia.webterm.components
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.session.Connection
import wang.yaojia.webterm.session.ConnectionState
import wang.yaojia.webterm.session.Exited
import wang.yaojia.webterm.session.FailureReason
import wang.yaojia.webterm.session.Output
import wang.yaojia.webterm.wire.WireConstants
import kotlin.time.Duration.Companion.seconds
/**
* The [bannerModel] reducer contract (plan §1, §5 A21 Verify). Pure JVM — no device:
* 1. **Precedence** — terminal phases (`exited`/`failed`) OUTRANK transient ones (`connecting`/
* `reconnecting`): once terminal, a later transient connection event never overwrites the banner.
* 2. **Spawn failure** — `exit(-1)` is a spawn failure (reason required), no spinner, offers new session.
* 3. **replayTooLarge** — non-retryable: NO spinner + actionable + a "new session" affordance.
* 4. **Normal exit** — `exit(code)` (code ≥ 0) is not a spawn failure but still offers a new session.
*/
class ReconnectBannerTest {
// ── 1. Precedence: terminal OUTRANKS transient ───────────────────────────────
@Test
fun `exited outranks a later connecting event`() {
val exited = bannerModel(BannerModel.Hidden, Exited(code = 0, reason = null))
assertTrue(exited is BannerModel.Exited)
// A stray transient connect arriving after the shell exited must NOT clear the exit banner.
val after = bannerModel(exited, Connection(ConnectionState.Connecting))
assertEquals(exited, after)
}
@Test
fun `failed outranks a later reconnecting event`() {
val failed = bannerModel(
BannerModel.Hidden,
Connection(ConnectionState.Failed(FailureReason.REPLAY_TOO_LARGE)),
)
assertTrue(failed is BannerModel.Failed)
val after = bannerModel(failed, Connection(ConnectionState.Reconnecting(attempt = 3, next = 8.seconds)))
assertEquals(failed, after)
}
@Test
fun `a fresh exit replaces an earlier failure (terminal replaces terminal)`() {
val failed = bannerModel(BannerModel.Hidden, Connection(ConnectionState.Failed(FailureReason.REPLAY_TOO_LARGE)))
val exited = bannerModel(failed, Exited(code = 137, reason = "killed"))
assertEquals(BannerModel.Exited(137, "killed"), exited)
}
// ── 2. Spawn-failure copy (code == -1) ───────────────────────────────────────
@Test
fun `exit code -1 is a spawn failure and carries the reason`() {
val model = bannerModel(BannerModel.Hidden, Exited(code = WireConstants.SPAWN_FAILED_EXIT_CODE, reason = "no shell"))
val exited = model as BannerModel.Exited
assertEquals(-1, exited.code)
assertTrue(exited.isSpawnFailure)
assertEquals("no shell", exited.reason)
assertFalse(exited.showsSpinner)
assertTrue(exited.offersNewSession)
}
// ── 3. replayTooLarge: no spinner, actionable, new-session action ─────────────
@Test
fun `replayTooLarge shows no spinner and offers a new session`() {
val model = bannerModel(BannerModel.Hidden, Connection(ConnectionState.Failed(FailureReason.REPLAY_TOO_LARGE)))
val failed = model as BannerModel.Failed
assertEquals(FailureReason.REPLAY_TOO_LARGE, failed.reason)
assertFalse(failed.showsSpinner) // never a spinner — reconnecting would hit the same wall forever
assertTrue(failed.offersNewSession)
}
// ── 4. Normal exit ───────────────────────────────────────────────────────────
@Test
fun `a normal exit is not a spawn failure but still offers a new session`() {
val exited = bannerModel(BannerModel.Hidden, Exited(code = 0, reason = null)) as BannerModel.Exited
assertFalse(exited.isSpawnFailure)
assertFalse(exited.showsSpinner)
assertTrue(exited.offersNewSession)
}
// ── Transient reductions (no terminal state in play) ─────────────────────────
@Test
fun `connecting then reconnecting then connected walks the transient states`() {
var model: BannerModel = BannerModel.Hidden
model = bannerModel(model, Connection(ConnectionState.Connecting))
assertEquals(BannerModel.Connecting, model)
assertTrue(model.showsSpinner)
model = bannerModel(model, Connection(ConnectionState.Reconnecting(attempt = 1, next = 2.seconds)))
assertEquals(BannerModel.Reconnecting(1, 2.seconds), model)
assertTrue(model.showsSpinner)
model = bannerModel(model, Connection(ConnectionState.Connected))
assertEquals(BannerModel.Hidden, model) // connected → banner hidden
}
@Test
fun `non-connection events leave the banner unchanged`() {
val connecting = bannerModel(BannerModel.Hidden, Connection(ConnectionState.Connecting))
assertEquals(connecting, bannerModel(connecting, Output("some bytes")))
}
}

View File

@@ -0,0 +1,91 @@
package wang.yaojia.webterm.components
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.wire.PrInfo
import wang.yaojia.webterm.wire.RateInfo
import wang.yaojia.webterm.wire.StatusTelemetry
import wang.yaojia.webterm.wire.Tunables
/**
* [TelemetryChips] pure logic (A22): the stale-TTL threshold ([isTelemetryStale]) and the chip
* derivation ([telemetryChipModels]) — formatting, warning thresholds, absent-metric skipping. The
* Compose row (greying, scroll) is device-QA.
*/
class TelemetryChipsTest {
private val ttl = Tunables.TELEMETRY_STALE_TTL_MS
// ── Staleness threshold ──────────────────────────────────────────────────────
@Test
fun `fresh telemetry is not stale`() {
assertFalse(isTelemetryStale(atMs = 1_000L, nowMs = 1_000L))
assertFalse(isTelemetryStale(atMs = 1_000L, nowMs = 1_000L + ttl / 2))
}
@Test
fun `exactly at the TTL boundary is not yet stale (older-than, not at)`() {
assertFalse(isTelemetryStale(atMs = 1_000L, nowMs = 1_000L + ttl))
}
@Test
fun `one ms past the TTL is stale`() {
assertTrue(isTelemetryStale(atMs = 1_000L, nowMs = 1_000L + ttl + 1))
}
// ── Chip derivation ──────────────────────────────────────────────────────────
@Test
fun `absent metrics produce no chips`() {
assertEquals(emptyList<TelemetryChipModel>(), telemetryChipModels(StatusTelemetry(at = 0L)))
}
@Test
fun `context cost model PR and rate render in order with formatting`() {
val models = telemetryChipModels(
StatusTelemetry(
contextUsedPct = 41.6,
costUsd = 0.5,
model = "opus-4.8",
pr = PrInfo(number = 7, url = "", reviewState = null),
rate = RateInfo(fiveHourPct = 12.0),
at = 0L,
),
)
assertEquals(
listOf("ctx 42%", "$0.50", "opus-4.8", "PR #7", "5h 12%"),
models.map { it.text },
)
assertTrue(models.none { it.isWarning }) // all below thresholds
}
@Test
fun `near-full context turns the chip amber`() {
val models = telemetryChipModels(StatusTelemetry(contextUsedPct = 90.0, at = 0L))
assertEquals("ctx 90%", models.single().text)
assertTrue(models.single().isWarning)
}
@Test
fun `near-limit rate turns the chip amber`() {
val models = telemetryChipModels(StatusTelemetry(rate = RateInfo(fiveHourPct = 95.0), at = 0L))
assertTrue(models.single().isWarning)
}
@Test
fun `a changes-requested PR turns the chip amber`() {
val models = telemetryChipModels(
StatusTelemetry(pr = PrInfo(number = 3, url = "", reviewState = "CHANGES_REQUESTED"), at = 0L),
)
assertEquals("PR #3", models.single().text)
assertTrue(models.single().isWarning)
}
@Test
fun `a blank model string is skipped`() {
assertEquals(emptyList<TelemetryChipModel>(), telemetryChipModels(StatusTelemetry(model = " ", at = 0L)))
}
}

View File

@@ -0,0 +1,62 @@
package wang.yaojia.webterm.designsystem
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
/**
* Regression guard for the FROZEN design-system spec (plan §"Design system",
* mirrors iOS `DS`). The expected values are transcribed INDEPENDENTLY from the
* spec, so any accidental drift in [DesignSpec] fails here. Pure JVM — no device.
*/
class DesignSpecTest {
@Test
fun `amber-gold accent hex matches the frozen desktop palette`() {
// #E3A64A dark (web --accent) / #C9892F light (web --accent-2); gold needs
// dark ink #1A1305 on top (web --on-accent).
assertEquals(0xFFE3A64AL, DesignSpec.ACCENT_DARK)
assertEquals(0xFFC9892FL, DesignSpec.ACCENT_LIGHT)
assertEquals(0xFF1A1305L, DesignSpec.ON_ACCENT)
}
@Test
fun `semantic status colors match the frozen web status set`() {
assertEquals(0xFF46D07FL, DesignSpec.STATUS_WORKING) // web --green
assertEquals(0xFFF5B14CL, DesignSpec.STATUS_WAITING) // web --amber
assertEquals(0xFFFF6B6BL, DesignSpec.STATUS_STUCK) // web --red
}
@Test
fun `terminal canvas colors are the fixed desktop values`() {
assertEquals(0xFF100F0DL, DesignSpec.TERMINAL_BACKGROUND) // web --bg
assertEquals(0xFFECE9E3L, DesignSpec.TERMINAL_FOREGROUND) // web --text
assertEquals(0xFFE3A64AL, DesignSpec.TERMINAL_CARET) // gold caret == accent
}
@Test
fun `spacing scale is exactly 2-4-8-12-16-20-24`() {
val scale = listOf(
DesignSpec.SPACE_XS2,
DesignSpec.SPACE_XS4,
DesignSpec.SPACE_SM8,
DesignSpec.SPACE_MD12,
DesignSpec.SPACE_LG16,
DesignSpec.SPACE_XL20,
DesignSpec.SPACE_XXL24,
)
assertEquals(listOf(2f, 4f, 8f, 12f, 16f, 20f, 24f), scale)
}
@Test
fun `radius, stroke, opacity and hit-target tokens match the spec`() {
assertEquals(8f, DesignSpec.RADIUS_SM8)
assertEquals(12f, DesignSpec.RADIUS_MD12)
assertEquals(16f, DesignSpec.RADIUS_LG16)
assertEquals(999f, DesignSpec.RADIUS_PILL)
assertEquals(1f, DesignSpec.STROKE_HAIRLINE)
assertEquals(0.45f, DesignSpec.OPACITY_STALE)
assertEquals(0.55f, DesignSpec.OPACITY_EXITED)
assertEquals(0.72f, DesignSpec.OPACITY_PRESSED)
assertEquals(44f, DesignSpec.MIN_HIT_TARGET)
}
}

View File

@@ -0,0 +1,242 @@
package wang.yaojia.webterm.nav
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.wiring.ColdStartRoute
/**
* A32 · DeepLinkRouter (pure whitelist parser) + NavGraph route mappers.
*
* Android port of iOS `DeepLinkRouterTests`: `webterminal://open?host=&join=` (custom scheme) AND the
* verified `https://terminal.yaojia.wang/open?…` App Link both parse; every field is v4-UUID
* whitelisted; ANY invalid / ambiguous / missing part → [DeepLinkRoute.Ignore] (never partially
* applied) + tallied; push-tap payloads share the same router. Plain-string inputs — no Compose, no
* device (NavHost composition is device-QA).
*/
class DeepLinkRouterTest {
// v4 UUIDs (version nibble = 4, variant nibble = 8/9/a/b) → pass Validation.isValidSessionId.
private val validHostId = "11111111-2222-4333-8444-555555555555"
private val validSessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a"
// version nibble = 1: a syntactic UUID that the server's v4-only SESSION_ID_RE rejects — pins
// "reuse Validation, never re-regex".
private val v1StyleId = "11111111-2222-1333-8444-555555555555"
// variant nibble = c (a legal v4 requires 8/9/a/b).
private val badVariantId = "11111111-2222-4333-c444-555555555555"
private fun customUrl(host: String = validHostId, join: String = validSessionId): String =
"webterminal://open?host=$host&join=$join"
private fun appLinkUrl(host: String = validHostId, join: String = validSessionId): String =
"https://terminal.yaojia.wang/open?host=$host&join=$join"
// ── Custom scheme: valid paths ───────────────────────────────────────────────────────────────────
@Test
fun `valid custom-scheme link routes to OpenSession with both ids`() {
assertEquals(
DeepLinkRoute.OpenSession(hostId = validHostId, sessionId = validSessionId),
DeepLinkRouter.route(customUrl()),
)
}
@Test
fun `valid https App Link routes to OpenSession with both ids`() {
assertEquals(
DeepLinkRoute.OpenSession(hostId = validHostId, sessionId = validSessionId),
DeepLinkRouter.route(appLinkUrl()),
)
}
@Test
fun `uppercase UUIDs are accepted (Validation is case-insensitive)`() {
assertEquals(
DeepLinkRoute.OpenSession(hostId = validHostId.uppercase(), sessionId = validSessionId.uppercase()),
DeepLinkRouter.route(customUrl(validHostId.uppercase(), validSessionId.uppercase())),
)
}
@Test
fun `unknown extra query keys are ignored and the link still routes`() {
assertEquals(
DeepLinkRoute.OpenSession(hostId = validHostId, sessionId = validSessionId),
DeepLinkRouter.route("webterminal://open?host=$validHostId&join=$validSessionId&utm=x&foo=bar"),
)
}
@Test
fun `query key order does not matter`() {
assertEquals(
DeepLinkRoute.OpenSession(hostId = validHostId, sessionId = validSessionId),
DeepLinkRouter.route("webterminal://open?join=$validSessionId&host=$validHostId"),
)
}
@Test
fun `trailing-slash action path is accepted`() {
assertEquals(
DeepLinkRoute.OpenSession(hostId = validHostId, sessionId = validSessionId),
DeepLinkRouter.route("webterminal://open/?host=$validHostId&join=$validSessionId"),
)
}
@Test
fun `App Link trailing-slash path is accepted`() {
assertEquals(
DeepLinkRoute.OpenSession(hostId = validHostId, sessionId = validSessionId),
DeepLinkRouter.route("https://terminal.yaojia.wang/open/?host=$validHostId&join=$validSessionId"),
)
}
// ── Whitelist rejections (any invalid part → Ignore) ─────────────────────────────────────────────
@Test
fun `wrong scheme is ignored`() {
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route("myapp://open?host=$validHostId&join=$validSessionId"))
}
@Test
fun `wrong custom-scheme action is ignored`() {
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route("webterminal://kill?host=$validHostId&join=$validSessionId"))
}
@Test
fun `custom-scheme with a non-empty path is ignored`() {
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route("webterminal://open/extra?host=$validHostId&join=$validSessionId"))
}
@Test
fun `App Link on a foreign host is ignored`() {
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route("https://evil.example/open?host=$validHostId&join=$validSessionId"))
}
@Test
fun `App Link on the wrong path is ignored`() {
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route("https://terminal.yaojia.wang/kill?host=$validHostId&join=$validSessionId"))
}
@Test
fun `https with the custom-scheme action as host does not leak through`() {
// https://open?... parses to host=open — must NOT be mistaken for the custom-scheme action.
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route("https://open?host=$validHostId&join=$validSessionId"))
}
@Test
fun `missing host key is ignored`() {
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route("webterminal://open?join=$validSessionId"))
}
@Test
fun `missing join key is ignored`() {
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route("webterminal://open?host=$validHostId"))
}
@Test
fun `duplicate host key is ambiguous and ignored (never partial)`() {
assertEquals(
DeepLinkRoute.Ignore,
DeepLinkRouter.route("webterminal://open?host=$validHostId&host=$validHostId&join=$validSessionId"),
)
}
@Test
fun `non-v4 UUID in either field is ignored (reuses Validation, never re-regexed)`() {
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route(customUrl(host = v1StyleId)))
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route(customUrl(join = v1StyleId)))
}
@Test
fun `bad UUID variant nibble is ignored`() {
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route(customUrl(join = badVariantId)))
}
@Test
fun `a valid host with an invalid session is never partially applied`() {
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route(customUrl(host = validHostId, join = "not-a-uuid")))
}
@Test
fun `an invalid host with a valid session is never partially applied`() {
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route(customUrl(host = "not-a-uuid", join = validSessionId)))
}
@Test
fun `empty values, empty query, and garbage are ignored without crashing`() {
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route("webterminal://open?host=&join="))
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route("webterminal://open"))
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route(customUrl(host = "not-a-uuid")))
// Illegal characters make java.net.URI throw — must be caught → Ignore, never a crash.
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route("webterminal://open?host=x&join='; DROP TABLE sessions;--"))
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route(""))
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route("not even a uri"))
}
// ── Push-tap payload (A30 reuses the same router) ────────────────────────────────────────────────
@Test
fun `push payload with a valid sessionId routes to GateSession (extra keys ignored)`() {
val payload = mapOf("sessionId" to validSessionId, "cls" to "gate", "token" to "opaque")
assertEquals(DeepLinkRoute.GateSession(sessionId = validSessionId), DeepLinkRouter.route(payload))
}
@Test
fun `push payload without sessionId or non-v4 is ignored`() {
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route(emptyMap()))
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route(mapOf("cls" to "gate")))
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route(mapOf("sessionId" to v1StyleId)))
assertEquals(DeepLinkRoute.Ignore, DeepLinkRouter.route(mapOf("sessionId" to "")))
}
// ── DeepLinkTally: invalid links are counted, valid ones are not ─────────────────────────────────
@Test
fun `tally increments only on Ignore and is immutable`() {
val start = DeepLinkTally()
val afterIgnore = start.record(DeepLinkRoute.Ignore)
assertEquals(1, afterIgnore.ignored)
assertEquals(0, start.ignored) // original untouched (immutable reducer)
val afterOpen = afterIgnore.record(DeepLinkRoute.OpenSession(validHostId, validSessionId))
val afterGate = afterOpen.record(DeepLinkRoute.GateSession(validSessionId))
assertEquals(1, afterGate.ignored) // valid routes never bump the counter
val afterSecondIgnore = afterGate.record(DeepLinkRouter.route("webterminal://bad"))
assertEquals(2, afterSecondIgnore.ignored)
}
// ── NavGraph pure route mappers ──────────────────────────────────────────────────────────────────
@Test
fun `cold-start route maps to the pairing or sessions destination`() {
assertEquals(NavRoutes.PAIRING, NavRoutes.startRouteFor(ColdStartRoute.PAIRING))
assertEquals(NavRoutes.SESSIONS, NavRoutes.startRouteFor(ColdStartRoute.SESSIONS))
}
@Test
fun `forDeepLink builds the terminal route for an OpenSession`() {
assertEquals(
"terminal/$validHostId/$validSessionId",
NavRoutes.forDeepLink(DeepLinkRoute.OpenSession(validHostId, validSessionId)),
)
}
@Test
fun `forDeepLink builds the gate route for a GateSession`() {
assertEquals("gate/$validSessionId", NavRoutes.forDeepLink(DeepLinkRoute.GateSession(validSessionId)))
}
@Test
fun `forDeepLink returns null for Ignore (caller does nothing)`() {
assertNull(NavRoutes.forDeepLink(DeepLinkRoute.Ignore))
}
@Test
fun `terminal route pattern args line up with the built route`() {
// The concrete route the mapper builds must fill exactly the pattern's declared segments.
assertEquals("terminal/{hostId}/{sessionId}", NavRoutes.TERMINAL_PATTERN)
assertEquals("gate/{sessionId}", NavRoutes.GATE_PATTERN)
}
}

View File

@@ -0,0 +1,61 @@
package wang.yaojia.webterm.nav
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
/**
* A26 · the single adaptive-layout decision + the pointer-menu gate, both pure (plan §5 A26, R13).
*
* Mirrors iOS `LayoutPolicyTests`: compact → stack, medium/expanded → list-detail; and the
* pointer-context-menu gate (list-detail AND `smallestScreenWidthDp >= 600`). Plain enum / Int inputs —
* no Compose, no device.
*/
class LayoutPolicyTest {
// ── LayoutPolicy.mode: one decision per width class ──────────────────────────────────────────────
@Test
fun `compact width falls back to the single-pane stack`() {
assertEquals(LayoutMode.STACK, LayoutPolicy.mode(WindowWidth.COMPACT))
}
@Test
fun `medium width opens the sidebar plus detail split`() {
assertEquals(LayoutMode.LIST_DETAIL, LayoutPolicy.mode(WindowWidth.MEDIUM))
}
@Test
fun `expanded width opens the sidebar plus detail split`() {
assertEquals(LayoutMode.LIST_DETAIL, LayoutPolicy.mode(WindowWidth.EXPANDED))
}
// ── PointerMenuPolicy.enabled: list-detail AND a real tablet width ───────────────────────────────
@Test
fun `pointer menu is enabled in list-detail on a tablet at exactly the breakpoint`() {
assertTrue(PointerMenuPolicy.enabled(LayoutMode.LIST_DETAIL, smallestScreenWidthDp = 600))
}
@Test
fun `pointer menu is enabled in list-detail on a wide tablet`() {
assertTrue(PointerMenuPolicy.enabled(LayoutMode.LIST_DETAIL, smallestScreenWidthDp = 840))
}
@Test
fun `pointer menu is disabled just below the tablet breakpoint`() {
assertFalse(PointerMenuPolicy.enabled(LayoutMode.LIST_DETAIL, smallestScreenWidthDp = 599))
}
@Test
fun `pointer menu is disabled in the compact stack even on a wide screen`() {
assertFalse(PointerMenuPolicy.enabled(LayoutMode.STACK, smallestScreenWidthDp = 600))
assertFalse(PointerMenuPolicy.enabled(LayoutMode.STACK, smallestScreenWidthDp = 1000))
}
@Test
fun `the tablet breakpoint is the Material 600dp smallest-width`() {
assertEquals(600, PointerMenuPolicy.TABLET_MIN_WIDTH_DP)
}
}

View File

@@ -0,0 +1,45 @@
package wang.yaojia.webterm.nav
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
/**
* The new pure route mappers added in app-assembly (the "spawn a new session" seam). The cold-start and
* deep-link mappers are covered in `DeepLinkRouterTest`; this pins the [NavRoutes.NEW_SESSION_SENTINEL]
* round-trip: [NavRoutes.terminalNew] builds a route whose session slot is the sentinel, and
* [NavRoutes.attachSessionId] maps that slot back to `null` (`attach(null)`) while passing a real id
* through verbatim. Plain-string inputs — no Compose, no device.
*/
class NavRoutesTest {
private val hostId = "11111111-2222-4333-8444-555555555555"
private val sessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a"
@Test
fun `terminalNew builds a route carrying the new-session sentinel`() {
assertEquals("terminal/$hostId/${NavRoutes.NEW_SESSION_SENTINEL}", NavRoutes.terminalNew(hostId))
}
@Test
fun `attachSessionId maps the sentinel to null (a fresh spawn)`() {
assertNull(NavRoutes.attachSessionId(NavRoutes.NEW_SESSION_SENTINEL))
}
@Test
fun `attachSessionId passes a real session id through verbatim`() {
assertEquals(sessionId, NavRoutes.attachSessionId(sessionId))
}
@Test
fun `attachSessionId maps a null arg to null`() {
assertNull(NavRoutes.attachSessionId(null))
}
@Test
fun `terminalNew route and attachSessionId round-trip to a null spawn id`() {
// The session slot the new-session route puts on the wire must decode back to "spawn a new one".
val sessionArg = NavRoutes.terminalNew(hostId).substringAfterLast('/')
assertNull(NavRoutes.attachSessionId(sessionArg))
}
}

View File

@@ -0,0 +1,223 @@
package wang.yaojia.webterm.push
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.HookDecision
import wang.yaojia.webterm.api.routes.ApiClient
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import java.util.UUID
/**
* A30 JVM contract (plan §5 A30 / §8): the pure `cls`→notification mapping, content minimization,
* the decision-POST payload shape via a fake `ApiClient`, and the token-never-logged discipline.
* BiometricPrompt / notification rendering / FCM delivery are device QA (§7).
*/
class PushDecisionTest {
private val sessionId: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
private val token = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
private val endpoint: HostEndpoint = HostEndpoint.fromBaseUrl("http://192.168.1.10:3000")!!
private val decisionUrl = "http://192.168.1.10:3000/hook/decision"
// ── NotificationBuilder.plan: cls → actions mapping ─────────────────────────────────────────
@Test
fun `needs-input maps to a high-importance gate with Allow and Deny actions`() {
val plan = NotificationBuilder.plan(PushPayload.CLASS_NEEDS_INPUT)!!
assertEquals(listOf(NotificationBuilder.Action.ALLOW, NotificationBuilder.Action.DENY), plan.actions)
assertTrue(plan.highImportance)
assertEquals(NotificationBuilder.CHANNEL_GATE, plan.channelId)
}
@Test
fun `done maps to a default-importance informational card with no actions`() {
val plan = NotificationBuilder.plan(PushPayload.CLASS_DONE)!!
assertTrue(plan.actions.isEmpty())
assertFalse(plan.highImportance)
assertEquals(NotificationBuilder.CHANNEL_DONE, plan.channelId)
}
@Test
fun `an unknown class produces no notification`() {
assertNull(NotificationBuilder.plan("stuck"))
assertNull(NotificationBuilder.plan(""))
}
// ── Content minimization: no cwd/command/terminal bytes, no token in visible text ───────────
@Test
fun `visible notification text never leaks the token, cwd, command, or terminal bytes`() {
val leaks = listOf(token, "/home/user/secret-project", "rm -rf /", "", sessionId.toString())
for (cls in listOf(PushPayload.CLASS_NEEDS_INPUT, PushPayload.CLASS_DONE)) {
val plan = NotificationBuilder.plan(cls)!!
val visible = plan.title + "\n" + plan.text
for (secret in leaks) {
assertFalse(visible.contains(secret), "cls=$cls visible text leaked: $secret")
}
}
}
// ── Decision-POST payload shape via a fake ApiClient ────────────────────────────────────────
@Test
fun `deny posts sessionId+decision+token with the guarded Origin header`() = runTest {
val http = FakeHttpTransport()
http.queueSuccess(HttpMethod.POST, decisionUrl, status = 204)
val submitter = singleHostSubmitter(http)
val accepted = submitter.submit(sessionId, HookDecision.DENY, token)
assertTrue(accepted)
val request = http.recordedRequests.single()
assertEquals(HttpMethod.POST, request.method)
assertEquals(decisionUrl, request.url)
assertEquals(endpoint.originHeader, request.headers["Origin"])
val body = String(request.body!!, Charsets.UTF_8)
assertTrue(body.contains("\"sessionId\":\"$sessionId\""), body)
assertTrue(body.contains("\"decision\":\"deny\""), body)
assertTrue(body.contains("\"token\":\"$token\""), body)
}
@Test
fun `allow posts decision=allow with the token`() = runTest {
val http = FakeHttpTransport()
http.queueSuccess(HttpMethod.POST, decisionUrl, status = 204)
val accepted = singleHostSubmitter(http).submit(sessionId, HookDecision.ALLOW, token)
assertTrue(accepted)
val body = String(http.recordedRequests.single().body!!, Charsets.UTF_8)
assertTrue(body.contains("\"decision\":\"allow\""), body)
assertTrue(body.contains("\"token\":\"$token\""), body)
}
@Test
fun `a 403 (stale or used token, idempotency) is swallowed to a non-accept, never a throw`() = runTest {
val http = FakeHttpTransport()
http.queueSuccess(HttpMethod.POST, decisionUrl, status = 403)
val accepted = singleHostSubmitter(http).submit(sessionId, HookDecision.ALLOW, token)
assertFalse(accepted)
}
@Test
fun `no paired host means no POST and a non-accept`() = runTest {
val http = FakeHttpTransport()
val submitter = PushDecisionSubmitter(
loadEndpoints = { emptyList() },
clientFor = { ApiClient(it, http) },
)
assertFalse(submitter.submit(sessionId, HookDecision.DENY, token))
assertTrue(http.recordedRequests.isEmpty())
}
// ── Multi-host: the payload carries no host, so try each until one accepts ───────────────────
@Test
fun `iterates past a rejecting host to the host that issued the token`() = runTest {
val other = HostEndpoint.fromBaseUrl("http://10.0.0.5:3000")!!
val http = FakeHttpTransport()
http.queueSuccess(HttpMethod.POST, "http://10.0.0.5:3000/hook/decision", status = 403) // wrong host
http.queueSuccess(HttpMethod.POST, decisionUrl, status = 204) // issuing host
val submitter = PushDecisionSubmitter(
loadEndpoints = { listOf(other, endpoint) },
clientFor = { ApiClient(it, http) },
)
assertTrue(submitter.submit(sessionId, HookDecision.ALLOW, token))
assertEquals(2, http.recordedRequests.size)
}
@Test
fun `stops at the first accepting host (no token blasted to later hosts)`() = runTest {
val other = HostEndpoint.fromBaseUrl("http://10.0.0.5:3000")!!
val http = FakeHttpTransport()
http.queueSuccess(HttpMethod.POST, decisionUrl, status = 204) // first host accepts
val submitter = PushDecisionSubmitter(
loadEndpoints = { listOf(endpoint, other) },
clientFor = { ApiClient(it, http) },
)
assertTrue(submitter.submit(sessionId, HookDecision.DENY, token))
assertEquals(1, http.recordedRequests.size)
assertEquals(decisionUrl, http.recordedRequests.single().url) // second host never contacted
}
// ── Token discipline: never handed to the diagnostics/log seam ──────────────────────────────
@Test
fun `the diagnostics seam never receives the token across accept, reject, and error paths`() = runTest {
val logged = mutableListOf<String>()
val http = FakeHttpTransport()
// accept, reject, then no-queued-response (error) — exercise every diagnostics branch.
http.queueSuccess(HttpMethod.POST, decisionUrl, status = 204)
http.queueSuccess(HttpMethod.POST, decisionUrl, status = 403)
val submitter = PushDecisionSubmitter(
loadEndpoints = { listOf(endpoint) },
clientFor = { ApiClient(it, http) },
diagnostics = { logged.add(it) },
)
submitter.submit(sessionId, HookDecision.ALLOW, token) // accepted
submitter.submit(sessionId, HookDecision.DENY, token) // rejected by the only host
submitter.submit(sessionId, HookDecision.DENY, token) // transport error (no queued response)
assertTrue(logged.isNotEmpty())
for (line in logged) {
assertFalse(line.contains(token), "diagnostics leaked the token: $line")
}
}
// ── PushPayload boundary validation ─────────────────────────────────────────────────────────
@Test
fun `parse keeps the gate token and marks it actionable`() {
val payload = PushPayload.parse(
mapOf(
PushPayload.KEY_SESSION_ID to sessionId.toString(),
PushPayload.KEY_CLS to PushPayload.CLASS_NEEDS_INPUT,
PushPayload.KEY_TOKEN to token,
),
)!!
assertEquals(sessionId, payload.sessionId)
assertEquals(token, payload.token)
assertTrue(payload.isActionableGate)
}
@Test
fun `parse of a done signal has no token and is not an actionable gate`() {
val payload = PushPayload.parse(
mapOf(
PushPayload.KEY_SESSION_ID to sessionId.toString(),
PushPayload.KEY_CLS to PushPayload.CLASS_DONE,
),
)!!
assertNull(payload.token)
assertFalse(payload.isActionableGate)
}
@Test
fun `parse rejects a missing or non-UUID sessionId and a missing cls`() {
assertNull(PushPayload.parse(mapOf(PushPayload.KEY_CLS to PushPayload.CLASS_DONE)))
assertNull(
PushPayload.parse(
mapOf(PushPayload.KEY_SESSION_ID to "not-a-uuid", PushPayload.KEY_CLS to PushPayload.CLASS_DONE),
),
)
assertNull(PushPayload.parse(mapOf(PushPayload.KEY_SESSION_ID to sessionId.toString())))
}
private fun singleHostSubmitter(http: FakeHttpTransport): PushDecisionSubmitter =
PushDecisionSubmitter(
loadEndpoints = { listOf(endpoint) },
clientFor = { ApiClient(it, http) },
)
}

View File

@@ -0,0 +1,163 @@
package wang.yaojia.webterm.push
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.hostregistry.InMemoryHostStore
/**
* [PushRegistrar] fan-out + self-heal contract (plan §5 A31, §4.5). Pure JVM under `runTest`: a
* recording [HostPushApi] fake stands in for [ApiClient][wang.yaojia.webterm.api.routes.ApiClient]
* (one per host via the `apiFor` seam) and an [InMemoryHostStore] is the paired-host set. Asserts:
* - every paired host gets exactly one POST carrying the token;
* - self-heal — token rotation re-registers all, a new host registers just it, removal DELETEs just it;
* - re-registering the same token is idempotent (client re-POSTs, no crash);
* - best-effort — one host's failure never blocks the others, and the error sink never sees the token.
*/
class PushRegistrarTest {
private sealed interface Call {
val hostId: String
val token: String
data class Register(override val hostId: String, override val token: String) : Call
data class Unregister(override val hostId: String, override val token: String) : Call
}
/** Records every register/unregister, tagged with the host it was minted for. */
private class RecordingApi(
private val hostId: String,
private val calls: MutableList<Call>,
private val failWith: Throwable? = null,
) : HostPushApi {
override suspend fun register(token: String) {
calls.add(Call.Register(hostId, token))
failWith?.let { throw it }
}
override suspend fun unregister(token: String) {
calls.add(Call.Unregister(hostId, token))
failWith?.let { throw it }
}
}
private fun host(id: String): Host =
Host.create(id = id, name = "host-$id", baseUrl = "http://192.168.1.$id:3000")!!
private val token = "fMr0ck3t-token_AB-cd"
private val rotated = "n3w-r0tat3d_token-XY"
@Test
fun `registers the token to every paired host, once each`() = runTest {
val calls = mutableListOf<Call>()
val hosts = InMemoryHostStore(listOf(host("10"), host("11"), host("12")))
val registrar = PushRegistrar(hosts, apiFor = { RecordingApi(it.id, calls) }, onError = { _, _ -> })
registrar.registerAllHosts(token)
assertEquals(
listOf(
Call.Register("10", token),
Call.Register("11", token),
Call.Register("12", token),
),
calls,
)
}
@Test
fun `token rotation re-registers every host with the new token`() = runTest {
val calls = mutableListOf<Call>()
val hosts = InMemoryHostStore(listOf(host("10"), host("11")))
val registrar = PushRegistrar(hosts, apiFor = { RecordingApi(it.id, calls) }, onError = { _, _ -> })
registrar.registerAllHosts(token)
calls.clear()
registrar.registerAllHosts(rotated) // FcmService.onNewToken → self-heal
assertEquals(
listOf(Call.Register("10", rotated), Call.Register("11", rotated)),
calls,
)
}
@Test
fun `a newly paired host is registered on its own`() = runTest {
val calls = mutableListOf<Call>()
val hosts = InMemoryHostStore(listOf(host("10")))
val registrar = PushRegistrar(hosts, apiFor = { RecordingApi(it.id, calls) }, onError = { _, _ -> })
registrar.registerHost(host("20"), token)
assertEquals(listOf(Call.Register("20", token)), calls)
}
@Test
fun `a removed host is unregistered (DELETE) on its own`() = runTest {
val calls = mutableListOf<Call>()
val hosts = InMemoryHostStore(listOf(host("10")))
val registrar = PushRegistrar(hosts, apiFor = { RecordingApi(it.id, calls) }, onError = { _, _ -> })
registrar.unregisterHost(host("10"), token)
assertEquals(listOf(Call.Unregister("10", token)), calls)
}
@Test
fun `re-registering the same token is idempotent (re-POSTs, never crashes)`() = runTest {
val calls = mutableListOf<Call>()
val hosts = InMemoryHostStore(listOf(host("10")))
val registrar = PushRegistrar(hosts, apiFor = { RecordingApi(it.id, calls) }, onError = { _, _ -> })
registrar.registerAllHosts(token)
registrar.registerAllHosts(token) // app-start again on top of an already-registered token
assertEquals(
listOf(Call.Register("10", token), Call.Register("10", token)),
calls,
)
}
@Test
fun `one host failing does not block the others and never leaks the token to onError`() = runTest {
val calls = mutableListOf<Call>()
val errors = mutableListOf<Pair<String, Throwable>>()
val boom = IllegalStateException("host-11 unreachable")
val hosts = InMemoryHostStore(listOf(host("10"), host("11"), host("12")))
val registrar = PushRegistrar(
hosts,
apiFor = { h -> RecordingApi(h.id, calls, failWith = if (h.id == "11") boom else null) },
onError = { h, e -> errors.add(h.id to e) },
)
registrar.registerAllHosts(token)
// All three attempted (best-effort); the middle one failed but 10 and 12 still went out.
assertEquals(
listOf(
Call.Register("10", token),
Call.Register("11", token),
Call.Register("12", token),
),
calls,
)
assertEquals(1, errors.size)
assertEquals("11", errors.single().first)
assertTrue(errors.single().second === boom)
// The error sink is host+throwable only — the token can never be reconstructed from it.
assertFalse(errors.single().second.message!!.contains(token))
}
@Test
fun `an empty host list is a no-op, never crashes`() = runTest {
val calls = mutableListOf<Call>()
val registrar = PushRegistrar(InMemoryHostStore(), apiFor = { RecordingApi(it.id, calls) }, onError = { _, _ -> })
registrar.registerAllHosts(token)
assertTrue(calls.isEmpty())
}
}

View File

@@ -0,0 +1,32 @@
package wang.yaojia.webterm.screens
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
/**
* The new-session-in-cwd routing decision (plan §1, §5 A21 Verify): the phone toolbar action and the
* exit-/replay-too-large banner action both produce `attach(null, cwd)` — a fresh spawn (sessionId null)
* in the current session's directory. Pure JVM — no device.
*/
class NewSessionInCwdTest {
@Test
fun `requestFor carries the current cwd and always spawns a new session`() {
val request = NewSessionInCwd.requestFor("/home/dev/project")
assertNull(request.sessionId, "a new session never re-attaches — sessionId must be null")
assertEquals("/home/dev/project", request.cwd)
}
@Test
fun `requestFor passes a null cwd through unchanged`() {
val request = NewSessionInCwd.requestFor(null)
assertNull(request.sessionId)
assertNull(request.cwd)
}
@Test
fun `the produced request equals attach null cwd`() {
assertEquals(NewSessionRequest(sessionId = null, cwd = "/srv/app"), NewSessionInCwd.requestFor("/srv/app"))
}
}

View File

@@ -0,0 +1,272 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.clienttls.CertificateSummary
import wang.yaojia.webterm.clienttls.NoClientIdentityException
import wang.yaojia.webterm.clienttls.Pkcs12DecodeException
import wang.yaojia.webterm.tlsandroid.ClientSslMaterial
import wang.yaojia.webterm.tlsandroid.IdentityRepository
import java.security.UnrecoverableKeyException
import java.time.Instant
import java.time.ZoneId
import kotlin.coroutines.EmptyCoroutineContext
/**
* [ClientCertViewModel] / [toSummaryView] / [classify] (A27) — the JVM-tested device-cert core.
*
* Covers the presentation of an installed [CertificateSummary] (subject/issuer/expiry formatting +
* the EXPIRED warning flag), the security-load-bearing "a bad passphrase can't clobber the prior
* identity" rule (import validates before persisting → error surfaced, on-screen cert unchanged), and
* the rotate/remove state transitions. The SAF picker / AndroidKeyStore / Compose shell is device-QA
* (plan §7); THIS is the pure core. `runTest` + [UnconfinedTestDispatcher] so a launched load/mutation
* runs inline; [EmptyCoroutineContext] as the "io" hop keeps everything in virtual time.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class ClientCertViewModelTest {
/** A canned [IdentityRepository]: `installed` is the live summary; imports validate-before-persist. */
private class FakeIdentityRepository(initial: CertificateSummary? = null) : IdentityRepository {
var installed: CertificateSummary? = initial
/** What the next import/rotation yields; throwing simulates a rejected passphrase / bad file. */
var nextResult: () -> CertificateSummary = { error("no import configured") }
val passphrasesSeen = mutableListOf<String>()
var removeCount = 0
override fun sslMaterial(): ClientSslMaterial = error("sslMaterial is not exercised in unit tests")
override fun currentSummary(): CertificateSummary? = installed
override fun hasInstalledIdentity(): Boolean = installed != null
override suspend fun importIdentity(p12Bytes: ByteArray, passphrase: String): CertificateSummary {
// Validate-before-persist: nextResult() may throw BEFORE `installed` is touched.
val result = nextResult()
installed = result
passphrasesSeen += passphrase
return result
}
override suspend fun rotate(p12Bytes: ByteArray, passphrase: String): CertificateSummary =
importIdentity(p12Bytes, passphrase)
override suspend fun remove() {
installed = null
removeCount++
}
}
private val notExpiredNow = Instant.parse("2026-01-01T00:00:00Z")
private val utc = ZoneId.of("UTC")
private fun summary(
subject: String? = "t1-android",
issuer: String? = "webterm-device-ca",
notAfter: Instant? = Instant.parse("2027-06-15T12:00:00Z"),
) = CertificateSummary(subjectCommonName = subject, issuerCommonName = issuer, notAfter = notAfter)
private fun newVm(repo: IdentityRepository) = ClientCertViewModel(
repository = repo,
io = EmptyCoroutineContext,
zone = utc,
now = { notExpiredNow },
)
private val bytes = byteArrayOf(1, 2, 3)
// ── Pure summary presentation ─────────────────────────────────────────────────────────────────────
@Test
fun `a valid summary formats subject, issuer and expiry and is not expired`() {
val view = summary().toSummaryView(now = notExpiredNow, zone = utc)
assertEquals("t1-android", view.subjectCommonName)
assertEquals("webterm-device-ca", view.issuerCommonName)
assertTrue(view.expiry.contains("2027"), "expiry should be a formatted date carrying the year")
assertFalse(view.isExpired)
}
@Test
fun `a not-after in the past flags the summary as expired`() {
val view = summary(notAfter = Instant.parse("2020-01-01T00:00:00Z")).toSummaryView(notExpiredNow, utc)
assertTrue(view.isExpired, "a past not-after must raise the expired warning")
}
@Test
fun `absent CN or expiry degrade to UNKNOWN and unknown expiry is not expired`() {
val view = summary(subject = null, issuer = null, notAfter = null).toSummaryView(notExpiredNow, utc)
assertEquals(CertSummaryView.UNKNOWN, view.subjectCommonName)
assertEquals(CertSummaryView.UNKNOWN, view.issuerCommonName)
assertEquals(CertSummaryView.UNKNOWN, view.expiry)
assertFalse(view.isExpired, "unknown expiry is treated as not expired (display-only, fail-open)")
}
// ── Load ────────────────────────────────────────────────────────────────────────────────────────
@Test
fun `bind loads the installed identity summary`() = runTest(UnconfinedTestDispatcher()) {
val repo = FakeIdentityRepository(initial = summary())
val vm = newVm(repo)
vm.bind(backgroundScope)
val state = vm.uiState.value
assertEquals(CertPhase.READY, state.phase)
assertEquals("t1-android", state.summary?.subjectCommonName)
assertEquals("webterm-device-ca", state.summary?.issuerCommonName)
}
@Test
fun `bind with no installed identity shows an empty summary`() = runTest(UnconfinedTestDispatcher()) {
val repo = FakeIdentityRepository(initial = null)
val vm = newVm(repo)
vm.bind(backgroundScope)
assertEquals(CertPhase.READY, vm.uiState.value.phase)
assertNull(vm.uiState.value.summary)
}
// ── Import ────────────────────────────────────────────────────────────────────────────────────────
@Test
fun `importing a p12 installs it and shows its summary`() = runTest(UnconfinedTestDispatcher()) {
val repo = FakeIdentityRepository(initial = null)
repo.nextResult = { summary(subject = "new-device") }
val vm = newVm(repo)
vm.bind(backgroundScope)
vm.import(bytes, "correct-passphrase")
val state = vm.uiState.value
assertEquals(CertPhase.READY, state.phase)
assertEquals("new-device", state.summary?.subjectCommonName)
assertNull(state.error)
assertEquals(listOf("correct-passphrase"), repo.passphrasesSeen)
}
@Test
fun `a bad-passphrase import surfaces the error and keeps the prior identity`() =
runTest(UnconfinedTestDispatcher()) {
val prior = summary(subject = "prior-device")
val repo = FakeIdentityRepository(initial = prior)
repo.nextResult = { throw UnrecoverableKeyException("MAC check failed") }
val vm = newVm(repo)
vm.bind(backgroundScope)
vm.import(bytes, "wrong-passphrase")
val state = vm.uiState.value
assertEquals(CertError.BAD_PASSPHRASE, state.error, "a rejected passphrase must be surfaced")
// The on-screen cert is UNCHANGED — validate-before-persist means the prior cert stays live.
assertEquals("prior-device", state.summary?.subjectCommonName)
assertSame(prior, repo.installed, "the repository's live identity must be untouched")
assertTrue(repo.passphrasesSeen.isEmpty(), "a rejected import never reaches the persist step")
}
@Test
fun `clearError dismisses a surfaced error`() = runTest(UnconfinedTestDispatcher()) {
val repo = FakeIdentityRepository(initial = summary())
repo.nextResult = { throw Pkcs12DecodeException("corrupt") }
val vm = newVm(repo)
vm.bind(backgroundScope)
vm.import(bytes, "pw")
assertEquals(CertError.INVALID_FILE, vm.uiState.value.error)
vm.clearError()
assertNull(vm.uiState.value.error)
}
// ── Rotate ────────────────────────────────────────────────────────────────────────────────────────
@Test
fun `rotating replaces the installed identity with the new summary`() =
runTest(UnconfinedTestDispatcher()) {
val repo = FakeIdentityRepository(initial = summary(subject = "old-device"))
repo.nextResult = { summary(subject = "rotated-device") }
val vm = newVm(repo)
vm.bind(backgroundScope)
vm.rotate(bytes, "pw")
assertEquals("rotated-device", vm.uiState.value.summary?.subjectCommonName)
assertNull(vm.uiState.value.error)
}
@Test
fun `a failed rotation keeps the prior identity on screen`() = runTest(UnconfinedTestDispatcher()) {
val prior = summary(subject = "old-device")
val repo = FakeIdentityRepository(initial = prior)
repo.nextResult = { throw UnrecoverableKeyException("bad") }
val vm = newVm(repo)
vm.bind(backgroundScope)
vm.rotate(bytes, "pw")
assertEquals("old-device", vm.uiState.value.summary?.subjectCommonName)
assertEquals(CertError.BAD_PASSPHRASE, vm.uiState.value.error)
}
// ── Remove (confirm-gated) ──────────────────────────────────────────────────────────────────────
@Test
fun `requestRemove then confirm clears the identity`() = runTest(UnconfinedTestDispatcher()) {
val repo = FakeIdentityRepository(initial = summary())
val vm = newVm(repo)
vm.bind(backgroundScope)
vm.requestRemove()
assertTrue(vm.uiState.value.confirmingRemove, "requestRemove raises the confirm dialog")
vm.confirmRemove()
val state = vm.uiState.value
assertFalse(state.confirmingRemove)
assertNull(state.summary, "the identity is gone after a confirmed remove")
assertEquals(1, repo.removeCount)
assertNull(repo.installed)
}
@Test
fun `cancelRemove dismisses the dialog without removing`() = runTest(UnconfinedTestDispatcher()) {
val repo = FakeIdentityRepository(initial = summary())
val vm = newVm(repo)
vm.bind(backgroundScope)
vm.requestRemove()
vm.cancelRemove()
assertFalse(vm.uiState.value.confirmingRemove)
assertEquals(0, repo.removeCount, "cancelling must not remove the identity")
assertEquals("t1-android", vm.uiState.value.summary?.subjectCommonName)
}
@Test
fun `requestRemove is a no-op when nothing is installed`() = runTest(UnconfinedTestDispatcher()) {
val repo = FakeIdentityRepository(initial = null)
val vm = newVm(repo)
vm.bind(backgroundScope)
vm.requestRemove()
assertFalse(vm.uiState.value.confirmingRemove)
}
// ── Error classification (pure) ──────────────────────────────────────────────────────────────────
@Test
fun `error classification maps each throwable to its coarse CertError`() {
assertEquals(CertError.BAD_PASSPHRASE, classify(UnrecoverableKeyException("x")))
assertEquals(CertError.INVALID_FILE, classify(Pkcs12DecodeException("x")))
assertEquals(CertError.NO_IDENTITY, classify(NoClientIdentityException("x")))
assertEquals(CertError.IMPORT_FAILED, classify(IllegalStateException("x")))
}
}

View File

@@ -0,0 +1,157 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* A24 DiffViewModel — the JVM-testable read-only diff logic (plan §4.2 / §1): the STRING staged flag
* flows through the fetcher, files→hunks→lines flatten in order, and the untrusted body decodes
* lossily (a malformed file/hunk/line is dropped, its siblings survive). Compose render → device QA.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class DiffViewModelTest {
// ── DiffLineKind.fromWire ───────────────────────────────────────────────────────────────────
@Test
fun `fromWire maps known kinds and degrades an unknown kind to CONTEXT`() {
assertEquals(DiffLineKind.ADDED, DiffLineKind.fromWire("added"))
assertEquals(DiffLineKind.REMOVED, DiffLineKind.fromWire("removed"))
assertEquals(DiffLineKind.CONTEXT, DiffLineKind.fromWire("context"))
assertEquals(DiffLineKind.META, DiffLineKind.fromWire("meta"))
assertEquals(DiffLineKind.HUNK, DiffLineKind.fromWire("hunk"))
// A future/unknown wire kind must degrade, never crash (mirror of the web default).
assertEquals(DiffLineKind.CONTEXT, DiffLineKind.fromWire("future-kind"))
}
// ── flattenDiff ordering ────────────────────────────────────────────────────────────────────
@Test
fun `flattenDiff emits file then hunk then lines in order, and a single binary row for a binary file`() {
val text = DiffFile(
oldPath = "a.kt", newPath = "a.kt", status = "modified", added = 1, removed = 1, binary = false,
hunks = listOf(
DiffHunk("@@ -1 +1 @@", listOf(
DiffLine(DiffLineKind.REMOVED, "old"),
DiffLine(DiffLineKind.ADDED, "new"),
)),
),
)
val bin = DiffFile("img.png", "img.png", "modified", 0, 0, binary = true, hunks = emptyList())
val rows = flattenDiff(DiffResult(listOf(text, bin), staged = false, truncated = false))
assertEquals(6, rows.size)
assertTrue(rows[0] is DiffFileHeaderRow)
assertTrue(rows[1] is DiffHunkHeaderRow)
assertEquals(DiffLineKind.REMOVED, (rows[2] as DiffLineRow).kind)
assertEquals(DiffLineKind.ADDED, (rows[3] as DiffLineRow).kind)
assertTrue(rows[4] is DiffFileHeaderRow) // binary file's header
assertTrue(rows[5] is DiffBinaryRow) // then exactly one binary marker (no hunks)
// Ids are stable + monotonic (LazyColumn keys).
assertEquals(listOf(0L, 1L, 2L, 3L, 4L, 5L), rows.map { it.id })
}
@Test
fun `a renamed file shows old to new in its header path`() {
val renamed = DiffFile("old.kt", "new.kt", status = "renamed", added = 0, removed = 0, binary = false, hunks = emptyList())
val header = flattenDiff(DiffResult(listOf(renamed), false, false)).first() as DiffFileHeaderRow
assertEquals("old.kt → new.kt", header.path)
}
// ── decodeDiffResult lossy decode ───────────────────────────────────────────────────────────
@Test
fun `decode drops a file with no newPath and a hunk with no header, keeping the rest`() {
val json = """
{ "staged": true, "truncated": true, "files": [
{ "status": "modified" },
{ "newPath": "keep.kt", "added": 2, "removed": 1, "hunks": [
{ "lines": [ { "kind": "added", "text": "x" } ] },
{ "header": "@@ -1 +1 @@", "lines": [
{ "kind": "added", "text": "ok" },
{ "kind": "added" }
] }
] }
] }
""".trimIndent().toByteArray()
val result = decodeDiffResult(json)
assertTrue(result.staged)
assertTrue(result.truncated)
assertEquals(1, result.files.size) // the newPath-less file was dropped
val file = result.files.single()
assertEquals("keep.kt", file.newPath)
assertEquals(1, file.hunks.size) // the header-less hunk was dropped
assertEquals("@@ -1 +1 @@", file.hunks.single().header)
assertEquals(1, file.hunks.single().lines.size) // the text-less line was dropped
assertEquals("ok", file.hunks.single().lines.single().text)
}
@Test
fun `decode of a non-object or unparseable body yields an empty result, never throws`() {
assertEquals(0, decodeDiffResult("[]".toByteArray()).files.size)
assertEquals(0, decodeDiffResult("not json".toByteArray()).files.size)
assertEquals(0, decodeDiffResult(ByteArray(0)).files.size)
}
// ── DiffViewModel phase transitions + staged re-fetch ───────────────────────────────────────
private class FakeFetcher(private val result: DiffResult?, private val error: Throwable? = null) : DiffFetcher {
val calls = mutableListOf<Boolean>() // records the staged arg of each fetch
override suspend fun fetch(path: String, staged: Boolean): DiffResult {
calls += staged
error?.let { throw it }
return result!!
}
}
private fun oneFileResult(staged: Boolean) = DiffResult(
files = listOf(DiffFile("a", "a", "modified", 1, 0, false, listOf(DiffHunk("@@", listOf(DiffLine(DiffLineKind.ADDED, "x")))))),
staged = staged, truncated = false,
)
@Test
fun `bind loads then transitions to LOADED with flattened rows`() = runTest {
val fetcher = FakeFetcher(oneFileResult(false))
val vm = DiffViewModel(fetcher, path = "/repo")
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
vm.bind(scope)
advanceUntilIdle()
assertEquals(DiffPhase.LOADED, vm.uiState.value.phase)
assertTrue(vm.uiState.value.rows.isNotEmpty())
assertEquals(listOf(false), fetcher.calls) // fetched the working tree (staged=false)
}
@Test
fun `an empty diff is EMPTY and a fetch error is ERROR`() = runTest {
val emptyVm = DiffViewModel(FakeFetcher(DiffResult(emptyList(), false, false)), "/repo")
val errVm = DiffViewModel(FakeFetcher(null, error = RuntimeException("boom")), "/repo")
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
emptyVm.bind(scope); errVm.bind(scope)
advanceUntilIdle()
assertEquals(DiffPhase.EMPTY, emptyVm.uiState.value.phase)
assertEquals(DiffPhase.ERROR, errVm.uiState.value.phase)
assertTrue(errVm.uiState.value.rows.isEmpty())
}
@Test
fun `selectStaged toggles the flag and re-fetches with staged true, but is a no-op for the same value`() = runTest {
val fetcher = FakeFetcher(oneFileResult(true))
val vm = DiffViewModel(fetcher, "/repo")
val scope = CoroutineScope(StandardTestDispatcher(testScheduler))
vm.bind(scope); advanceUntilIdle() // initial fetch: staged=false
vm.selectStaged(true); advanceUntilIdle() // toggles → re-fetch staged=true
vm.selectStaged(true); advanceUntilIdle() // same value → NO re-fetch
assertTrue(vm.uiState.value.staged)
assertEquals(listOf(false, true), fetcher.calls) // exactly two fetches, not three
}
}

View File

@@ -0,0 +1,227 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.session.AwayDigest
import wang.yaojia.webterm.session.Digest
import wang.yaojia.webterm.session.Exited
import wang.yaojia.webterm.session.Gate
import wang.yaojia.webterm.session.GateState
import wang.yaojia.webterm.session.SessionEvent
import wang.yaojia.webterm.session.Telemetry
import wang.yaojia.webterm.wire.ApproveMode
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.GateKind
import wang.yaojia.webterm.wire.StatusTelemetry
import wang.yaojia.webterm.wire.TimelineEvent
import wang.yaojia.webterm.wiring.TerminalSessionController
/**
* [GateViewModel] (A22) — the two-line epoch stale-guard, arrival-only haptics, telemetry hold, and
* digest suppression. Pure JVM under `runTest`; an [UnconfinedTestDispatcher] runs the event
* collector eagerly so an [FakeController.emit] is processed inline before the assertion. A hand-fake
* [TerminalSessionController] both drives the control stream and records every `decideGate` so
* "exactly one, right mode" is assertable. Compose sheets/cards/haptic firing are device-QA (§7).
*/
@OptIn(ExperimentalCoroutinesApi::class)
class GateViewModelTest {
/**
* Fake control-stream source + decision recorder. [controlEvents] is backed by an UNBOUNDED channel
* (mirrors the real per-consumer [EventBus] mailbox) so an [emit] before the collector subscribes is
* buffered, not dropped, and the collect loop yields per element (arrivals interleave correctly). The
* VM captures ONE mailbox at construction (FIX 1), so returning `channel.receiveAsFlow()` per call is
* faithful — this fake models a single VM's mailbox.
*/
private class FakeController : TerminalSessionController {
private val channel = Channel<SessionEvent>(Channel.UNLIMITED)
val decisions = mutableListOf<Pair<Int, ClientMessage>>()
override fun controlEvents(): Flow<SessionEvent> = channel.receiveAsFlow()
override val output: Flow<ByteArray> = emptyFlow()
override fun start() = Unit
override fun sendInput(data: String) = Unit
override fun resize(cols: Int, rows: Int) = Unit
override fun notifyForegrounded(cols: Int?, rows: Int?) = Unit
override fun decideGate(epoch: Int, message: ClientMessage) {
decisions += epoch to message
}
override fun close() = Unit
fun emit(event: SessionEvent) {
channel.trySend(event) // UNLIMITED → never fails, never suspends.
}
}
private fun toolGate(epoch: Int) = Gate(GateState(kind = GateKind.TOOL, detail = "Bash", epoch = epoch))
private fun planGate(epoch: Int) = Gate(GateState(kind = GateKind.PLAN, detail = "plan", epoch = epoch))
// ── Two-line stale-guard ─────────────────────────────────────────────────────
@Test
fun `a decision for a stale epoch is dropped`() = runTest(UnconfinedTestDispatcher()) {
val fake = FakeController()
val vm = GateViewModel(fake)
vm.bind(backgroundScope)
// The live gate has already advanced to epoch 2 (the old gate resolved, a new one rose).
fake.emit(toolGate(2))
// A slow tap against the RESOLVED epoch-1 gate must approve NOTHING (防误批新 gate).
vm.decide(GateDecision.APPROVE, epoch = 1)
assertTrue(fake.decisions.isEmpty())
}
@Test
fun `a decision with no gate held is dropped`() = runTest(UnconfinedTestDispatcher()) {
val fake = FakeController()
val vm = GateViewModel(fake)
vm.bind(backgroundScope)
// Gate rose then fell (falling edge → no gate held).
fake.emit(toolGate(1))
fake.emit(Gate(null))
vm.decide(GateDecision.APPROVE, epoch = 1)
assertTrue(fake.decisions.isEmpty())
}
@Test
fun `a current-epoch tool approve sends exactly one plain approve`() = runTest(UnconfinedTestDispatcher()) {
val fake = FakeController()
val vm = GateViewModel(fake)
vm.bind(backgroundScope)
fake.emit(toolGate(2))
vm.decide(GateDecision.APPROVE, epoch = 2)
assertEquals(listOf(2 to ClientMessage.Approve(mode = null)), fake.decisions)
}
@Test
fun `a current-epoch tool reject sends exactly one reject`() = runTest(UnconfinedTestDispatcher()) {
val fake = FakeController()
val vm = GateViewModel(fake)
vm.bind(backgroundScope)
fake.emit(toolGate(4))
vm.decide(GateDecision.REJECT, epoch = 4)
assertEquals(listOf<Pair<Int, ClientMessage>>(4 to ClientMessage.Reject), fake.decisions)
}
@Test
fun `acceptEdits on a tool gate is dropped (not an affordance of that kind)`() =
runTest(UnconfinedTestDispatcher()) {
val fake = FakeController()
val vm = GateViewModel(fake)
vm.bind(backgroundScope)
fake.emit(toolGate(1))
vm.decide(GateDecision.ACCEPT_EDITS, epoch = 1)
assertTrue(fake.decisions.isEmpty())
}
@Test
fun `plan decisions resolve to the top-level approve mode key`() = runTest(UnconfinedTestDispatcher()) {
val fake = FakeController()
val vm = GateViewModel(fake)
vm.bind(backgroundScope)
fake.emit(planGate(3))
vm.decide(GateDecision.APPROVE, epoch = 3) // default review
vm.decide(GateDecision.ACCEPT_EDITS, epoch = 3) // auto-accept
vm.decide(GateDecision.REJECT, epoch = 3) // keep planning
assertEquals(
listOf(
3 to ClientMessage.Approve(mode = ApproveMode.DEFAULT),
3 to ClientMessage.Approve(mode = ApproveMode.ACCEPT_EDITS),
3 to ClientMessage.Reject,
),
fake.decisions,
)
}
// ── Haptic arrival (rising-edge only) ────────────────────────────────────────
@Test
fun `gateArrivals fires once per new gate — never on refresh or falling edge`() =
runTest(UnconfinedTestDispatcher()) {
val fake = FakeController()
val vm = GateViewModel(fake)
val arrivals = mutableListOf<Unit>()
backgroundScope.launch { vm.gateArrivals.collect { arrivals += it } }
vm.bind(backgroundScope)
fake.emit(toolGate(1)) // rising edge → arrival #1
fake.emit(toolGate(1)) // same epoch refresh → NO arrival
fake.emit(Gate(null)) // falling edge → NO arrival
fake.emit(toolGate(2)) // new gate → arrival #2
assertEquals(2, arrivals.size)
}
// ── Telemetry hold + digest suppression ──────────────────────────────────────
@Test
fun `latest telemetry is held in ui state`() = runTest(UnconfinedTestDispatcher()) {
val fake = FakeController()
val vm = GateViewModel(fake)
vm.bind(backgroundScope)
val telemetry = StatusTelemetry(model = "opus", at = 1_000L)
fake.emit(Telemetry(telemetry))
assertEquals(telemetry, vm.uiState.value.telemetry)
}
@Test
fun `a non-empty away digest is shown and an all-zero digest is suppressed`() =
runTest(UnconfinedTestDispatcher()) {
val fake = FakeController()
val vm = GateViewModel(fake)
vm.bind(backgroundScope)
val nonEmpty = AwayDigest(
toolRuns = 2,
waitingCount = 1,
sawDone = false,
sawStuck = false,
recent = listOf(TimelineEvent(at = 5L, eventClass = "tool", label = "ran Bash")),
)
fake.emit(Digest(nonEmpty))
assertEquals(nonEmpty, vm.uiState.value.digest)
// A later reconnect with nothing to report suppresses the banner (holds null, not EMPTY).
fake.emit(Digest(AwayDigest.EMPTY))
assertNull(vm.uiState.value.digest)
}
@Test
fun `an exit clears a held gate so no stale card lingers`() = runTest(UnconfinedTestDispatcher()) {
val fake = FakeController()
val vm = GateViewModel(fake)
vm.bind(backgroundScope)
fake.emit(toolGate(1))
assertEquals(1, vm.uiState.value.gate?.epoch)
fake.emit(Exited(code = 0))
assertNull(vm.uiState.value.gate)
}
}

View File

@@ -0,0 +1,224 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertInstanceOf
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.pairing.PairingError
import wang.yaojia.webterm.api.pairing.PairingProbeResult
import wang.yaojia.webterm.hostregistry.InMemoryHostStore
import wang.yaojia.webterm.wire.HostEndpoint
/**
* [PairingViewModel] (A19) — the JVM-tested pairing core: §5.4 warning-tier mapping, the
* confirm-before-network gate, the public-host explicit-ack gate, and the tunnel-host cert-gate choke
* point that retry cannot bypass. Camera/Compose/permission UI is device-QA (plan §7). Pure `runTest`
* with an [UnconfinedTestDispatcher] so a probe launched into `backgroundScope` runs inline.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class PairingViewModelTest {
/** Records every probe call so "no probe before confirm / cert-gate refuses" is directly assertable. */
private class FakeProber(var result: (HostEndpoint) -> PairingProbeResult) : PairingProber {
val calls = mutableListOf<HostEndpoint>()
override suspend fun probe(endpoint: HostEndpoint): PairingProbeResult {
calls += endpoint
return result(endpoint)
}
}
private fun endpoint(url: String): HostEndpoint =
requireNotNull(HostEndpoint.fromBaseUrl(url)) { "fixture URL must validate: $url" }
private fun success(): (HostEndpoint) -> PairingProbeResult = { PairingProbeResult.Success(it) }
private fun newVm(
prober: PairingProber,
hasCert: Boolean = false,
store: InMemoryHostStore = InMemoryHostStore(),
) = PairingViewModel(
hostStore = store,
prober = prober,
hasDeviceCert = { hasCert },
newId = { "fixed-id" },
)
// ── §5.4 warning-tier mapping (each host class → tier + which needs explicit ack) ────────────────
@Test
fun `each host class maps to the correct warning tier`() {
val table = mapOf(
"http://localhost:3000" to WarningTier.LOOPBACK,
"http://127.0.0.1:3000" to WarningTier.LOOPBACK,
"http://10.0.0.5:3000" to WarningTier.PRIVATE_LAN,
"http://192.168.1.10:3000" to WarningTier.PRIVATE_LAN,
"http://172.16.9.9:3000" to WarningTier.PRIVATE_LAN,
"http://mac.local:3000" to WarningTier.PRIVATE_LAN,
"http://100.64.0.1:3000" to WarningTier.TAILSCALE,
"https://box.ts.net" to WarningTier.TAILSCALE,
"https://star.terminal.yaojia.wang" to WarningTier.TUNNEL,
"https://example.org:3000" to WarningTier.PUBLIC,
"https://8.8.8.8" to WarningTier.PUBLIC,
)
table.forEach { (url, expected) ->
assertEquals(expected, PairingTiers.tierFor(endpoint(url)), "tier for $url")
}
}
@Test
fun `an unclassifiable host is public (fail-safe)`() {
assertEquals(WarningTier.PUBLIC, PairingTiers.tierFor(endpoint("https://weird-unknown.internal")))
}
@Test
fun `only a public host needs the explicit acknowledge and only the tunnel host is cert-gated`() {
assertFalse(WarningTier.LOOPBACK.needsExplicitAck)
assertFalse(WarningTier.PRIVATE_LAN.needsExplicitAck)
assertFalse(WarningTier.TAILSCALE.needsExplicitAck)
assertFalse(WarningTier.TUNNEL.needsExplicitAck)
assertTrue(WarningTier.PUBLIC.needsExplicitAck)
assertTrue(WarningTier.TUNNEL.isCertGated)
assertFalse(WarningTier.PUBLIC.isCertGated)
assertFalse(WarningTier.PRIVATE_LAN.isCertGated)
}
// ── Confirm-before-network gate ─────────────────────────────────────────────────────────────────
@Test
fun `a scanned URL is validated and shown for confirmation but never probed`() =
runTest(UnconfinedTestDispatcher()) {
val prober = FakeProber(success())
val vm = newVm(prober)
vm.bind(backgroundScope)
vm.onQrScanned("http://10.0.0.5:3000")
val state = vm.uiState.value
assertInstanceOf(PairingUiState.Confirming::class.java, state)
assertEquals(WarningTier.PRIVATE_LAN, (state as PairingUiState.Confirming).tier)
assertTrue(prober.calls.isEmpty(), "confirm-before-network: no probe until confirm")
}
@Test
fun `an invalid URL yields an entry error and no probe`() = runTest(UnconfinedTestDispatcher()) {
val prober = FakeProber(success())
val vm = newVm(prober)
vm.bind(backgroundScope)
vm.onManualEntry("not a url")
assertEquals(PairingUiState.Entry(EntryError.INVALID_URL), vm.uiState.value)
assertTrue(prober.calls.isEmpty())
}
@Test
fun `confirming a LAN host probes it and saves on success`() = runTest(UnconfinedTestDispatcher()) {
val prober = FakeProber(success())
val store = InMemoryHostStore()
val vm = newVm(prober, store = store)
vm.bind(backgroundScope)
vm.onManualEntry("http://10.0.0.5:3000")
vm.confirm()
assertEquals(listOf(endpoint("http://10.0.0.5:3000")), prober.calls)
val state = vm.uiState.value
assertInstanceOf(PairingUiState.Paired::class.java, state)
val saved = store.loadAll()
assertEquals(1, saved.size)
assertEquals("http://10.0.0.5:3000", saved.single().endpoint.baseUrl)
assertEquals("fixed-id", saved.single().id)
}
@Test
fun `a probe failure maps to inert copy and is retryable`() = runTest(UnconfinedTestDispatcher()) {
val prober = FakeProber({ PairingProbeResult.Failure(PairingError.HttpOkButNotWebTerminal) })
val vm = newVm(prober)
vm.bind(backgroundScope)
vm.onManualEntry("http://10.0.0.5:3000")
vm.confirm()
val state = vm.uiState.value
assertInstanceOf(PairingUiState.Failed::class.java, state)
assertEquals(PairingError.HttpOkButNotWebTerminal, (state as PairingUiState.Failed).error)
assertTrue(state.message.isNotBlank())
}
// ── Public-host explicit acknowledge ────────────────────────────────────────────────────────────
@Test
fun `a public host is not probed until the risk is explicitly acknowledged`() =
runTest(UnconfinedTestDispatcher()) {
val prober = FakeProber(success())
val vm = newVm(prober)
vm.bind(backgroundScope)
vm.onManualEntry("https://example.org:3000")
// Confirm WITHOUT acknowledgement → no probe, ack reminder re-armed.
vm.confirm(acknowledgedPublicRisk = false)
assertTrue(prober.calls.isEmpty(), "public host must not probe without an explicit ack")
assertTrue((vm.uiState.value as PairingUiState.Confirming).awaitingAck)
// Confirm WITH acknowledgement → probes.
vm.confirm(acknowledgedPublicRisk = true)
assertEquals(1, prober.calls.size)
}
// ── Tunnel-host cert-gate (the choke point retry cannot bypass) ──────────────────────────────────
@Test
fun `a tunnel host is refused without a device cert and retry cannot bypass the gate`() =
runTest(UnconfinedTestDispatcher()) {
val prober = FakeProber(success())
val vm = newVm(prober, hasCert = false)
vm.bind(backgroundScope)
vm.onManualEntry("https://star.terminal.yaojia.wang")
vm.confirm()
assertInstanceOf(PairingUiState.CertRequired::class.java, vm.uiState.value)
assertTrue(prober.calls.isEmpty(), "cert-gate: no network I/O without a device cert")
// Retrying funnels through the SAME choke point — it still refuses, still no probe.
vm.retry()
assertInstanceOf(PairingUiState.CertRequired::class.java, vm.uiState.value)
assertTrue(prober.calls.isEmpty(), "retry must not bypass the tunnel cert-gate")
}
@Test
fun `a tunnel host with a device cert installed probes normally`() =
runTest(UnconfinedTestDispatcher()) {
val prober = FakeProber(success())
val store = InMemoryHostStore()
val vm = newVm(prober, hasCert = true, store = store)
vm.bind(backgroundScope)
vm.onManualEntry("https://star.terminal.yaojia.wang")
vm.confirm()
assertEquals(1, prober.calls.size)
assertInstanceOf(PairingUiState.Paired::class.java, vm.uiState.value)
assertEquals(1, store.loadAll().size)
}
@Test
fun `a tunnel TLS failure is re-mapped to a client-cert message`() =
runTest(UnconfinedTestDispatcher()) {
val prober = FakeProber({ PairingProbeResult.Failure(PairingError.TlsFailure) })
val vm = newVm(prober, hasCert = true)
vm.bind(backgroundScope)
vm.onManualEntry("https://star.terminal.yaojia.wang")
vm.confirm()
val state = vm.uiState.value as PairingUiState.Failed
assertTrue(state.message.contains("客户端证书"), "tunnel TLS failure → client-cert copy")
}
}

View File

@@ -0,0 +1,225 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.models.ProjectInfo
import wang.yaojia.webterm.api.models.ProjectSessionRef
import wang.yaojia.webterm.api.models.UiPrefs
import wang.yaojia.webterm.wire.ClaudeStatus
import java.util.UUID
/**
* A23 Projects logic (JVM). The byte-parity contract: [ProjectGrouping] produces the SAME group KEYS as
* web `public/projects.ts` / iOS `ProjectGrouping` (collapse state is persisted by key into the shared
* `/prefs`), and the [ProjectsViewModel] prefs round-trip preserves unknown top-level keys + never PUTs
* when `/prefs` never loaded (R11). Grid/sheet layout is device-QA (plan §7).
*/
class ProjectsViewModelTest {
// ── ProjectGrouping: exact group-key parity ─────────────────────────────────────────────────
@Test
fun `group keys are byte-identical to web - namespace first-seen casing, case-insensitive bucket, sentinel other`() {
val projects = listOf(
proj("Acme.Web.frontend", "/a1", lastActive = 10),
proj("Acme.Web.backend", "/a2", lastActive = 20),
// lower-cased name → SAME bucket as "Acme.Web" (case-insensitive), first-seen casing wins.
proj("acme.web.tools", "/a3", lastActive = 5),
proj("solo", "/s1", lastActive = 1), // no dot → "other"
proj("Foo.Bar", "/f1", lastActive = 1), // single-member namespace → collapses into "other"
)
val groups = ProjectGrouping.group(projects, favourites = emptySet())
// Frozen keys: the first-seen-cased namespace, then the leading-space sentinel " other".
assertEquals(listOf("Acme.Web", " other"), groups.map { it.key })
assertEquals(ProjectGroupKind.NAMESPACE, groups[0].kind)
assertEquals(3, groups[0].projects.size) // all three Acme.Web.* including the lower-cased one
assertEquals(ProjectGroupKind.OTHER, groups[1].kind)
assertEquals(2, groups[1].projects.size) // solo + Foo.Bar
}
@Test
fun `displayLabel strips the namespace prefix case-insensitively, sentinels keep the full name`() {
assertEquals("frontend", ProjectGrouping.displayLabel("Acme.Web.frontend", "Acme.Web"))
assertEquals("tools", ProjectGrouping.displayLabel("acme.web.tools", "Acme.Web"))
// A non-matching name is returned whole; sentinel groups never strip.
assertEquals("other", ProjectGrouping.displayLabel("other", ProjectGrouping.OTHER_GROUP_KEY))
}
@Test
fun `a running session pins an Active now group with the frozen space-active key`() {
val groups = ProjectGrouping.group(
listOf(
proj("Acme.Web.frontend", "/a1", running = true),
proj("Acme.Web.backend", "/a2"),
),
favourites = emptySet(),
)
assertEquals(ProjectGrouping.ACTIVE_GROUP_KEY, groups.first().key) // " active"
assertEquals(ProjectGroupKind.ACTIVE, groups.first().kind)
assertTrue(groups.any { it.key == "Acme.Web" })
}
@Test
fun `no shared namespace falls back to a single flat group keyed space-other`() {
val groups = ProjectGrouping.group(listOf(proj("solo", "/s1"), proj("plain", "/p1")), emptySet())
assertEquals(1, groups.size)
assertEquals(ProjectGrouping.OTHER_GROUP_KEY, groups.single().key) // " other"
assertEquals(ProjectGroupKind.FLAT, groups.single().kind)
}
@Test
fun `sort puts favourites first then recency descending, stable on ties`() {
val a = proj("a", "/a", lastActive = 100)
val b = proj("b", "/b", lastActive = 50)
val sorted = ProjectGrouping.sort(listOf(a, b), favourites = setOf("/b"))
assertEquals(listOf("/b", "/a"), sorted.map { it.path }) // fav /b first despite lower recency
}
@Test
fun `filter matches name or path substring case-insensitively`() {
val projects = listOf(proj("Acme.Web", "/srv/acme"), proj("Widget", "/srv/widget"))
assertEquals(listOf("/srv/acme"), ProjectGrouping.filter(projects, "ACME").map { it.path })
assertEquals(listOf("/srv/widget"), ProjectGrouping.filter(projects, "widget").map { it.path })
assertEquals(2, ProjectGrouping.filter(projects, " ").size) // blank → all
}
// ── /prefs unknown-key round-trip (R11) ─────────────────────────────────────────────────────
@Test
fun `toggling a favourite preserves unknown top-level keys and keeps an Int an Int, not a Double`() = runTest {
val json = """{"favourites":["/a"],"collapsed":{"Acme.Web":true},"schemaVersion":3,"nested":{"x":1}}"""
val gateway = FakeGateway(prefsValue = UiPrefs.decode(json.toByteArray())!!)
val vm = ProjectsViewModel(gateway)
vm.load()
vm.toggleFavourite("/b")
assertEquals(1, gateway.putCalls.size)
val body = gateway.putCalls.single().encodeBody().decodeToString()
// Unknown top-level keys are carried through verbatim …
assertTrue(body.contains("\"schemaVersion\":3"), "unknown Int key preserved as an Int: $body")
assertTrue(body.contains("\"nested\""), "unknown nested key preserved: $body")
// … and the Int never widened to a Double.
assertFalse(body.contains("3.0"), "Int must not re-encode as a Double: $body")
// The known key was rewritten (new favourite appended, order preserved).
val echoed = UiPrefs.decode(gateway.putCalls.single().encodeBody())!!
assertEquals(listOf("/a", "/b"), echoed.favourites)
assertEquals(mapOf("Acme.Web" to true), echoed.collapsed)
}
@Test
fun `a toggle NEVER PUTs when prefs never loaded - it stays local-only (R11 anti-clobber)`() = runTest {
val gateway = FakeGateway(prefsThrows = true) // GET /prefs failed → no base blob
val vm = ProjectsViewModel(gateway)
vm.load()
vm.toggleFavourite("/x")
assertEquals(0, gateway.putCalls.size, "an empty-base PUT would wipe the server's favourites")
assertTrue(vm.uiState.value.favourites.contains("/x")) // still applied locally
assertEquals(ProjectsCopy.PREFS_LOAD_FAILED, vm.uiState.value.prefsError)
}
// ── favourite / collapse logic ──────────────────────────────────────────────────────────────
@Test
fun `toggleCollapsed adds then removes the key and PUTs each change`() = runTest {
val gateway = FakeGateway(prefsValue = UiPrefs.create())
val vm = ProjectsViewModel(gateway)
vm.load()
vm.toggleCollapsed("Acme.Web")
assertEquals(mapOf("Acme.Web" to true), vm.uiState.value.collapsedGroups)
vm.toggleCollapsed("Acme.Web") // expanded is the default → the key is dropped, not set false
assertTrue(vm.uiState.value.collapsedGroups.isEmpty())
assertEquals(2, gateway.putCalls.size)
assertTrue(gateway.putCalls.last().collapsed.isEmpty())
}
@Test
fun `isCollapsed honours state but a search force-expands every section`() {
val group = ProjectGroup("Acme.Web", "Acme.Web", ProjectGroupKind.NAMESPACE, emptyList(), activeCount = 0)
val collapsed = ProjectsUiState(collapsedGroups = mapOf("Acme.Web" to true))
assertTrue(collapsed.isCollapsed(group))
// Searching forces the section open so results never hide behind a collapsed caret.
assertFalse(collapsed.copy(searchText = "acme").isCollapsed(group))
}
@Test
fun `toggleFavourite is a set-like toggle and adopts the server echo`() = runTest {
val gateway = FakeGateway(prefsValue = UiPrefs.create())
val vm = ProjectsViewModel(gateway)
vm.load()
vm.toggleFavourite("/a")
assertEquals(listOf("/a"), vm.uiState.value.favourites)
vm.toggleFavourite("/a") // toggle off
assertTrue(vm.uiState.value.favourites.isEmpty())
}
// ── open Claude here ────────────────────────────────────────────────────────────────────────
@Test
fun `requestOpenClaude mints an attach-null-cwd request for an absolute path and rejects a relative one`() = runTest {
val vm = ProjectsViewModel(FakeGateway())
vm.requestOpenClaude("relative/path")
assertNull(vm.uiState.value.openRequest)
assertEquals(ProjectsCopy.OPEN_CLAUDE_INVALID_PATH, vm.uiState.value.openError)
vm.requestOpenClaude("/home/dev/app")
val request = vm.uiState.value.openRequest
assertEquals("/home/dev/app", request?.cwd)
assertEquals("claude\r", request?.bootstrapInput) // Enter is \r, not \n
assertNull(vm.uiState.value.openError)
}
// ── Fakes / helpers ─────────────────────────────────────────────────────────────────────────
private class FakeGateway(
private val projectList: List<ProjectInfo> = emptyList(),
private val prefsValue: UiPrefs? = UiPrefs.create(),
private val prefsThrows: Boolean = false,
) : ProjectsGateway {
val putCalls = mutableListOf<UiPrefs>()
override suspend fun projects(): List<ProjectInfo> = projectList
override suspend fun prefs(): UiPrefs {
if (prefsThrows) throw RuntimeException("prefs unavailable")
return prefsValue!!
}
override suspend fun putPrefs(prefs: UiPrefs): UiPrefs {
putCalls += prefs
return prefs // the sanitized server echo == input in this fake
}
override suspend fun projectDetail(path: String): ProjectDetail = throw NotImplementedError()
}
private fun proj(
name: String,
path: String,
lastActive: Long? = null,
running: Boolean = false,
): ProjectInfo = ProjectInfo(
name = name,
path = path,
isGit = false,
branch = null,
dirty = null,
lastActiveMs = lastActive,
sessions = if (running) {
listOf(ProjectSessionRef(UUID.randomUUID(), null, ClaudeStatus.UNKNOWN, clientCount = 1, createdAt = 0L, exited = false))
} else {
emptyList()
},
)
}

View File

@@ -0,0 +1,303 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.models.LiveSessionInfo
import wang.yaojia.webterm.api.routes.ApiClient
import wang.yaojia.webterm.api.routes.ApiClientError
import wang.yaojia.webterm.designsystem.DisplayStatus
import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.hostregistry.HostStore
import wang.yaojia.webterm.session.UnreadLedger
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.ClaudeStatus
import wang.yaojia.webterm.wire.HostEndpoint
import java.util.UUID
/**
* [SessionListViewModel] (A20) — the poll→row mapping, unread-dot logic (watermark vs `lastOutputAt`,
* cleared on open), optimistic swipe-kill (404 = already-gone = success), lossy-list tolerance, and the
* multi-host switch. Pure JVM under `runTest`; the poll loop launches on an [UnconfinedTestDispatcher] so
* the first fetch runs inline before the first `delay`. Compose/swipe/thumbnails are device-QA (plan §7).
*/
@OptIn(ExperimentalCoroutinesApi::class)
class SessionListViewModelTest {
private companion object {
const val BASE_A = "http://a:3000"
const val BASE_B = "http://b:3000"
val ID_1: UUID = UUID.fromString("11111111-2222-4333-8444-555555555555")
val ID_2: UUID = UUID.fromString("22222222-3333-4444-8555-666666666666")
fun host(id: String, name: String, baseUrl: String, cert: Boolean = false): Host =
Host.create(id = id, name = name, baseUrl = baseUrl, hasDeviceCert = cert)!!
fun session(
id: UUID,
status: ClaudeStatus = ClaudeStatus.WORKING,
exited: Boolean = false,
title: String? = null,
cols: Int = 161,
rows: Int = 50,
lastOutputAt: Long? = null,
) = LiveSessionInfo(
id = id,
createdAt = 1_700_000_000_000L,
clientCount = 1,
status = status,
exited = exited,
cwd = null,
title = title,
cols = cols,
rows = rows,
telemetry = null,
lastOutputAt = lastOutputAt,
)
}
/** In-memory [HostStore] whose [loadAll] returns a fixed list (mutations unused by the VM's reads). */
private class FakeHostStore(private val hosts: List<Host>) : HostStore {
override suspend fun loadAll(): List<Host> = hosts
override suspend fun upsert(host: Host): List<Host> = hosts
override suspend fun remove(id: String): List<Host> = hosts
}
/** Fake gateway: returns a canned list and records kills; [killError] scripts the DELETE outcome. */
private class FakeGateway(
private val list: List<LiveSessionInfo>,
private val killError: Throwable? = null,
) : SessionListGateway {
val killed = mutableListOf<UUID>()
override suspend fun liveSessions(): List<LiveSessionInfo> = list
override suspend fun killSession(id: UUID) {
killed += id
killError?.let { throw it }
}
}
// ── poll → row mapping ─────────────────────────────────────────────────────────────────────
@Test
fun `poll maps live sessions to rows with status title and dimensions`() =
runTest(UnconfinedTestDispatcher()) {
val gateway = FakeGateway(
listOf(
session(ID_1, status = ClaudeStatus.WORKING, title = "web-terminal", cols = 161, rows = 50),
session(ID_2, exited = true, title = " gone ", cols = 80, rows = 24),
),
)
val vm = SessionListViewModel(
hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
gatewayFactory = { gateway },
)
backgroundScope.launch { vm.poll() }
val rows = vm.uiState.value.rows
assertEquals(2, rows.size)
assertEquals(DisplayStatus.Working, rows[0].displayStatus)
assertEquals("web-terminal", rows[0].title)
assertEquals("161×50", rows[0].dimensions)
// exited outranks status; the zero-width char is stripped by TitleSanitizer.
assertEquals(DisplayStatus.Exited, rows[1].displayStatus)
assertEquals("gone", rows[1].title)
}
// ── unread dot ─────────────────────────────────────────────────────────────────────────────
@Test
fun `a session with output newer than its watermark is unread and clears on open`() =
runTest(UnconfinedTestDispatcher()) {
val gateway = FakeGateway(listOf(session(ID_1, lastOutputAt = 100L)))
val vm = SessionListViewModel(
hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
gatewayFactory = { gateway },
watermarkStore = InMemoryUnreadWatermarkStore(), // empty → watermark 0 → unread
)
vm.refresh()
assertTrue(vm.uiState.value.rows.single().isUnread)
// Opening the session marks it seen at its lastOutputAt → the dot clears immediately.
vm.markSeen(ID_1)
assertFalse(vm.uiState.value.rows.single().isUnread)
}
@Test
fun `a null lastOutputAt is never unread`() = runTest(UnconfinedTestDispatcher()) {
val vm = SessionListViewModel(
hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
gatewayFactory = { FakeGateway(listOf(session(ID_1, lastOutputAt = null))) },
)
vm.refresh()
assertFalse(vm.uiState.value.rows.single().isUnread)
}
@Test
fun `an already-seen session (watermark at or past lastOutputAt) shows no dot`() =
runTest(UnconfinedTestDispatcher()) {
val seen = UnreadLedger().record(ID_1.toString(), 100L)
val vm = SessionListViewModel(
hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
gatewayFactory = { FakeGateway(listOf(session(ID_1, lastOutputAt = 100L))) },
watermarkStore = InMemoryUnreadWatermarkStore(seen),
)
vm.refresh()
assertFalse(vm.uiState.value.rows.single().isUnread)
}
// ── swipe-to-kill ──────────────────────────────────────────────────────────────────────────
@Test
fun `killSession removes the row optimistically and issues the delete`() =
runTest(UnconfinedTestDispatcher()) {
val gateway = FakeGateway(listOf(session(ID_1), session(ID_2)))
val vm = SessionListViewModel(
hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
gatewayFactory = { gateway },
)
vm.refresh()
assertEquals(2, vm.uiState.value.rows.size)
vm.killSession(ID_1)
assertEquals(listOf(ID_2), vm.uiState.value.rows.map { it.id })
assertEquals(listOf(ID_1), gateway.killed)
}
@Test
fun `a 404 on kill is treated as success — the row stays removed`() =
runTest(UnconfinedTestDispatcher()) {
val gateway = FakeGateway(listOf(session(ID_1)), killError = ApiClientError.SessionNotFound)
val vm = SessionListViewModel(
hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
gatewayFactory = { gateway },
)
vm.refresh()
vm.killSession(ID_1)
assertTrue(vm.uiState.value.rows.isEmpty())
}
@Test
fun `a real kill failure restores the row`() = runTest(UnconfinedTestDispatcher()) {
val gateway = FakeGateway(listOf(session(ID_1), session(ID_2)), killError = ApiClientError.Forbidden)
val vm = SessionListViewModel(
hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
gatewayFactory = { gateway },
)
vm.refresh()
vm.killSession(ID_1)
// The DELETE was rejected (403) → the session is still alive → the row is restored, in place.
assertEquals(listOf(ID_1, ID_2), vm.uiState.value.rows.map { it.id })
}
// ── lossy list decode tolerance (real ApiClient + fake transport) ────────────────────────────
@Test
fun `a malformed entry is dropped end-to-end and the rest become rows`() =
runTest(UnconfinedTestDispatcher()) {
val transport = FakeHttpTransport()
val body = """
[
{"id":"${ID_1}","createdAt":1,"clientCount":1,"status":"working","exited":false,"cols":80,"rows":24},
{"id":"not-a-uuid","createdAt":1,"clientCount":1,"status":"idle","exited":false,"cols":80,"rows":24},
{"id":"${ID_2}","createdAt":1,"clientCount":1,"status":"reticulating","exited":false,"cols":80,"rows":24}
]
""".trimIndent()
transport.queueSuccess(url = "$BASE_A/live-sessions", body = body.toByteArray())
val api = ApiClient(HostEndpoint.fromBaseUrl(BASE_A)!!, transport)
val vm = SessionListViewModel(
hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
gatewayFactory = { ApiClientSessionGateway(api) },
)
vm.refresh()
val rows = vm.uiState.value.rows
assertEquals(listOf(ID_1, ID_2), rows.map { it.id }) // bad entry dropped, order kept
assertEquals(DisplayStatus.Unknown, rows[1].displayStatus) // unknown wire status survives
assertFalse(vm.uiState.value.hasLoadError)
}
@Test
fun `a transport failure flags the error banner without wiping the last-good rows`() =
runTest(UnconfinedTestDispatcher()) {
var failNext = false
val gateway = object : SessionListGateway {
override suspend fun liveSessions(): List<LiveSessionInfo> {
if (failNext) throw ApiClientError.UnexpectedStatus(500)
return listOf(session(ID_1))
}
override suspend fun killSession(id: UUID) = Unit
}
val vm = SessionListViewModel(
hostStore = FakeHostStore(listOf(host("h1", "laptop", BASE_A))),
gatewayFactory = { gateway },
)
vm.refresh()
assertEquals(1, vm.uiState.value.rows.size)
failNext = true
vm.refresh()
assertTrue(vm.uiState.value.hasLoadError)
assertEquals(1, vm.uiState.value.rows.size) // last-good rows retained
}
// ── multi-host switch ──────────────────────────────────────────────────────────────────────
@Test
fun `the first host is active by default and selectHost switches the list and the checkmark`() =
runTest(UnconfinedTestDispatcher()) {
val gwA = FakeGateway(listOf(session(ID_1)))
val gwB = FakeGateway(listOf(session(ID_2)))
val vm = SessionListViewModel(
hostStore = FakeHostStore(
listOf(host("h1", "laptop", BASE_A), host("h2", "desktop", BASE_B, cert = true)),
),
gatewayFactory = { h -> if (h.id == "h1") gwA else gwB },
)
vm.refresh()
assertEquals("h1", vm.uiState.value.activeHostId)
assertEquals(listOf(ID_1), vm.uiState.value.rows.map { it.id })
assertTrue(vm.uiState.value.hosts.first { it.id == "h1" }.isActive)
assertFalse(vm.uiState.value.hosts.first { it.id == "h2" }.isActive)
vm.selectHost("h2")
assertEquals("h2", vm.uiState.value.activeHostId)
assertEquals(listOf(ID_2), vm.uiState.value.rows.map { it.id })
assertTrue(vm.uiState.value.hosts.first { it.id == "h2" }.isActive)
assertTrue(vm.uiState.value.hosts.first { it.id == "h2" }.hasDeviceCert)
}
@Test
fun `no paired host yields an empty chooser`() = runTest(UnconfinedTestDispatcher()) {
val vm = SessionListViewModel(
hostStore = FakeHostStore(emptyList()),
gatewayFactory = { error("no host → gateway must never be built") },
)
vm.refresh()
assertNull(vm.uiState.value.activeHostId)
assertTrue(vm.uiState.value.rows.isEmpty())
assertTrue(vm.uiState.value.hosts.isEmpty())
}
}

View File

@@ -0,0 +1,212 @@
package wang.yaojia.webterm.viewmodels
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertInstanceOf
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.designsystem.DesignSpec
import wang.yaojia.webterm.wire.TimelineEvent
import java.time.ZoneId
import java.util.UUID
/**
* A28 TimelineViewModel — the JVM-testable activity-timeline logic (plan §5 A28, §4.2, §8): fetch →
* present, the class→glyph/color mapping, lossy downstream drop of unknown-class events (keep the rest),
* and the empty/failed/retry state machine. The Compose sheet render is DEVICE-QA (plan §7).
*/
@OptIn(ExperimentalCoroutinesApi::class)
class TimelineViewModelTest {
private val utc = ZoneId.of("UTC")
private fun event(at: Long, cls: String = "tool", label: String = "ran Bash") =
TimelineEvent(at = at, eventClass = cls, toolName = null, label = label)
private class FakeSource(private val error: Throwable? = null, private val events: List<TimelineEvent> = emptyList()) {
val ids = mutableListOf<UUID>()
suspend fun fetch(id: UUID): List<TimelineEvent> {
ids += id
error?.let { throw it }
return events
}
}
// ── Phase state machine ───────────────────────────────────────────────────────────────────────
@Test
fun `initial phase is Loading before load runs`() {
val vm = TimelineViewModel(fetch = { emptyList() }, zone = utc)
assertEquals(TimelinePhase.Loading, vm.phase.value)
}
@Test
fun `load presents server oldest-first as newest-first, mirroring web reverse`() = runTest {
val oldestFirst = listOf(
event(at = 1L, label = "ran Bash"),
event(at = 2L, cls = "waiting", label = "waiting for approval"),
event(at = 3L, cls = "done", label = "finished"),
)
val vm = TimelineViewModel(fetch = { oldestFirst }, zone = utc)
vm.load()
val loaded = assertInstanceOf(TimelinePhase.Loaded::class.java, vm.phase.value)
assertEquals(listOf("finished", "waiting for approval", "ran Bash"), loaded.rows.map { it.label })
assertEquals(listOf(0, 1, 2), loaded.rows.map { it.key }) // stable positional keys.
}
@Test
fun `over maxEvents caps to first 50 then reverses, exactly like web slice-then-reverse`() = runTest {
val sixty = (1L..60L).map { event(at = it, label = "e$it") }
val vm = TimelineViewModel(fetch = { sixty }, zone = utc)
vm.load()
val loaded = assertInstanceOf(TimelinePhase.Loaded::class.java, vm.phase.value)
assertEquals(TimelineViewModel.MAX_EVENTS, loaded.rows.size)
assertEquals("e50", loaded.rows.first().label) // newest of the first-50 slice.
assertEquals("e1", loaded.rows.last().label) // oldest kept, at the tail after reverse.
}
@Test
fun `maxEvents is pinned to the web parity value 50`() {
assertEquals(50, TimelineViewModel.MAX_EVENTS)
}
// ── Empty state (never an error) ──────────────────────────────────────────────────────────────
@Test
fun `an empty array becomes Empty, never Failed`() = runTest {
val vm = TimelineViewModel(fetch = { emptyList() }, zone = utc)
vm.load()
assertEquals(TimelinePhase.Empty, vm.phase.value)
}
@Test
fun `a list of only unknown-class events filters to Empty`() = runTest {
val onlyUnknown = listOf(event(at = 1L, cls = "future-class"), event(at = 2L, cls = ""))
val vm = TimelineViewModel(fetch = { onlyUnknown }, zone = utc)
vm.load()
assertEquals(TimelinePhase.Empty, vm.phase.value)
}
// ── Lossy decode: drop unknown-class, keep the rest ───────────────────────────────────────────
@Test
fun `presentation drops unknown-class events and keeps the known ones`() {
val mixed = listOf(
event(at = 1L, cls = "tool", label = "keep-tool"),
event(at = 2L, cls = "future-class", label = "drop-me"),
event(at = 3L, cls = "user", label = "keep-user"),
event(at = 4L, cls = "", label = "drop-blank"),
)
val phase = TimelineViewModel.presentation(mixed, utc)
val loaded = assertInstanceOf(TimelinePhase.Loaded::class.java, phase)
// Two known events survive; reversed → newest (user) first.
assertEquals(listOf("keep-user", "keep-tool"), loaded.rows.map { it.label })
}
// ── Failure + retry ───────────────────────────────────────────────────────────────────────────
@Test
fun `a thrown fetch becomes Failed`() = runTest {
val vm = TimelineViewModel(fetch = { throw RuntimeException("boom") }, zone = utc)
vm.load()
assertEquals(TimelinePhase.Failed, vm.phase.value)
}
@Test
fun `retry after a failure recovers to Loaded`() = runTest {
var firstCall = true
val events = listOf(event(at = 7L, label = "recovered"))
val vm = TimelineViewModel(
fetch = {
if (firstCall) {
firstCall = false
throw RuntimeException("transient")
}
events
},
zone = utc,
)
vm.load()
assertEquals(TimelinePhase.Failed, vm.phase.value)
vm.load() // the sheet's「重试」button path.
val loaded = assertInstanceOf(TimelinePhase.Loaded::class.java, vm.phase.value)
assertEquals(listOf("recovered"), loaded.rows.map { it.label })
}
// ── forSession assembly seam (digest「展开」entry point) ───────────────────────────────────────
@Test
fun `forSession with a null id returns null so no sheet is shown`() {
assertNull(TimelineViewModel.forSession(null, source = { emptyList() }))
}
@Test
fun `forSession passes the sessionId through to the events source on load`() = runTest {
val expected = UUID.randomUUID()
val source = FakeSource(events = listOf(event(at = 42L, label = "hit")))
val vm = TimelineViewModel.forSession(expected, source = source::fetch)!!
vm.load()
assertEquals(listOf(expected), source.ids)
val loaded = assertInstanceOf(TimelinePhase.Loaded::class.java, vm.phase.value)
assertEquals(listOf("hit"), loaded.rows.map { it.label })
}
// ── class → glyph mapping (verbatim web timelineIcon) ─────────────────────────────────────────
@Test
fun `glyph mirrors the web timelineIcon set`() {
assertEquals("🔧", TimelineEventStyle.glyph("tool"))
assertEquals("", TimelineEventStyle.glyph("waiting"))
assertEquals("", TimelineEventStyle.glyph("done"))
assertEquals("", TimelineEventStyle.glyph("stuck"))
assertEquals("💬", TimelineEventStyle.glyph("user"))
}
@Test
fun `an unknown class degrades to the fallback glyph, never throwing`() {
assertEquals(TimelineEventStyle.FALLBACK_GLYPH, TimelineEventStyle.glyph("future-class"))
assertEquals(TimelineEventStyle.FALLBACK_GLYPH, TimelineEventStyle.glyph(""))
}
// ── class → color mapping (frozen A13/DesignSpec tokens; unknown → theme secondary = null) ─────
@Test
fun `colorSpec maps each known class to its frozen token and unknown to null`() {
assertEquals(DesignSpec.TIMELINE_TOOL, TimelineEventStyle.colorSpec("tool"))
assertEquals(DesignSpec.STATUS_WAITING, TimelineEventStyle.colorSpec("waiting"))
assertEquals(DesignSpec.STATUS_WORKING, TimelineEventStyle.colorSpec("done"))
assertEquals(DesignSpec.STATUS_STUCK, TimelineEventStyle.colorSpec("stuck"))
assertEquals(DesignSpec.TIMELINE_USER, TimelineEventStyle.colorSpec("user"))
assertNull(TimelineEventStyle.colorSpec("future-class")) // → theme-adaptive secondary text.
}
// ── HH:mm 24-hour formatting (mirror web formatHHMM), deterministic under a fixed zone ─────────
@Test
fun `timeLabel is 24-hour HH-mm and deterministic under a fixed timezone`() {
val onePm = (13L * 3_600 + 5 * 60) * 1_000 // 1970-01-01 13:05 UTC
assertEquals("00:00", TimelineRowFormat.timeLabel(0L, utc))
assertEquals("13:05", TimelineRowFormat.timeLabel(onePm, utc))
}
// ── Copy: empty ≠ error, all present ──────────────────────────────────────────────────────────
@Test
fun `empty and error copy exist, are non-empty and distinct`() {
assertTrue(TimelineCopy.TITLE.isNotEmpty())
assertTrue(TimelineCopy.EMPTY_TITLE.isNotEmpty())
assertTrue(TimelineCopy.LOAD_FAILED.isNotEmpty())
assertTrue(TimelineCopy.RETRY.isNotEmpty())
assertTrue(TimelineCopy.EMPTY_TITLE != TimelineCopy.LOAD_FAILED)
}
}

View File

@@ -0,0 +1,33 @@
package wang.yaojia.webterm.wiring
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.hostregistry.Host
import wang.yaojia.webterm.hostregistry.InMemoryHostStore
/**
* The [DefaultColdStartPolicy] routing rule (plan §5 A29 Verify): the FIRST cold-launch destination is
* derived from persisted host presence ALONE — no paired host → [ColdStartRoute.PAIRING], at least one →
* [ColdStartRoute.SESSIONS]. The "continue-last-session" banner is layered on top of a SESSIONS landing
* (A29), so the policy itself only chooses pairing-vs-sessions. Pure JVM over [InMemoryHostStore].
*/
class ColdStartPolicyTest {
private fun host(id: String): Host =
Host.create(id = id, name = "dev", baseUrl = "http://localhost:3000")!!
@Test
fun `no paired host routes to pairing`() = runTest {
val policy = DefaultColdStartPolicy(InMemoryHostStore())
assertEquals(ColdStartRoute.PAIRING, policy.initialRoute())
}
@Test
fun `a paired host routes to sessions`() = runTest {
val policy = DefaultColdStartPolicy(InMemoryHostStore(listOf(host("h1"))))
assertEquals(ColdStartRoute.SESSIONS, policy.initialRoute())
}
}

View File

@@ -0,0 +1,211 @@
package wang.yaojia.webterm.wiring
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.session.Gate
import wang.yaojia.webterm.session.GateState
import wang.yaojia.webterm.session.Output
import wang.yaojia.webterm.session.SessionEvent
import wang.yaojia.webterm.wire.GateKind
/**
* The per-session [EventBus] contract (plan §9 R10, A15 FIXes 1/2/4):
* - TWO typed sub-streams — [EventBus.outputBytes] (Output-only, decoded) and [EventBus.controlEvents]
* (non-Output) — so no consumer buffers what it ignores (FIX 4);
* - EAGER mailbox registration so an event published before collection begins is not dropped (FIX 2a);
* - per-consumer unbounded channels so a slow consumer neither stalls a fast one nor drops its own
* later event (R10 — the property a single `SharedFlow` cannot give);
* - a mailbox that lives for the SESSION, not a collection: the returned flow is re-collectable across
* a config-change (rotation) cancel with zero loss, and released only by [EventBus.close] (FIX 1).
*/
@OptIn(ExperimentalCoroutinesApi::class)
class EventBusTest {
private val gate1: SessionEvent = Gate(GateState(kind = GateKind.TOOL, detail = "bash", epoch = 1))
private val gate2: SessionEvent = Gate(GateState(kind = GateKind.TOOL, detail = "ls", epoch = 2))
private val gate3: SessionEvent = Gate(GateState(kind = GateKind.TOOL, detail = "cat", epoch = 3))
@Test
fun `outputBytes carries only decoded Output and controlEvents only non-Output`() = runTest {
// Arrange: decode on the test scheduler so flowOn(decodeDispatcher) runs under virtual time.
val bus = EventBus(decodeDispatcher = StandardTestDispatcher(testScheduler))
val output = bus.outputBytes() // EAGER registration at call time
val control = bus.controlEvents()
val gotBytes = mutableListOf<ByteArray>()
val gotControl = mutableListOf<SessionEvent>()
// Act
bus.publish(Output("hi"))
bus.publish(gate1)
val jb = launch { output.collect { gotBytes += it } }
val jc = launch { control.collect { gotControl += it } }
advanceUntilIdle()
// Assert: the terminal stream sees ONLY the decoded Output bytes; the cockpit stream sees ONLY
// the gate — neither buffers the other's traffic.
assertEquals("hi", gotBytes.single().toString(Charsets.UTF_8))
assertEquals(listOf(gate1), gotControl)
jb.cancel()
jc.cancel()
}
@Test
fun `an event published before collection begins is still delivered (eager registration)`() = runTest {
val bus = EventBus(decodeDispatcher = StandardTestDispatcher(testScheduler))
val control = bus.controlEvents() // mailbox registered NOW, before any collect
val received = mutableListOf<SessionEvent>()
bus.publish(gate1) // published BEFORE the collector runs — must be buffered, not dropped
val job = launch { control.collect { received += it } }
runCurrent()
assertEquals(listOf(gate1), received)
job.cancel()
}
@Test
fun `slow consumer neither stalls a fast consumer nor drops a buffered event`() = runTest {
val bus = EventBus(decodeDispatcher = StandardTestDispatcher(testScheduler))
val fast = bus.controlEvents()
val slow = bus.controlEvents()
val fastReceived = mutableListOf<SessionEvent>()
val slowReceived = mutableListOf<SessionEvent>()
val release = CompletableDeferred<Unit>()
val fastJob = launch { fast.collect { fastReceived += it } }
val slowJob = launch {
slow.collect { event ->
slowReceived += event
if (event === gate1) release.await() // stall AFTER the first event, before the second
}
}
runCurrent()
bus.publish(gate1)
bus.publish(gate2)
runCurrent()
// (a) NO STALL: the fast consumer received BOTH events even though the slow one is blocked.
assertEquals(listOf(gate1, gate2), fastReceived)
assertEquals(listOf(gate1), slowReceived) // slow parked; gate2 buffered in its own mailbox
release.complete(Unit)
runCurrent()
// (b) NO DROP: the slow consumer eventually receives the buffered gate2 — nothing was lost.
assertEquals(listOf(gate1, gate2), slowReceived)
fastJob.cancel()
slowJob.cancel()
}
@Test
fun `the same flow is re-collectable across a config-change cancel with zero loss (rotation)`() = runTest {
// The controller registers ONE control mailbox once, at construction. A rotation cancels the
// terminal screen's collector and the recomposed screen re-collects that SAME flow — it must
// resume on the SAME mailbox (not iterate a dead channel), or the terminal goes blank forever.
val bus = EventBus(decodeDispatcher = StandardTestDispatcher(testScheduler))
val control = bus.controlEvents() // registered ONCE — collected, cancelled, then re-collected
val received = mutableListOf<SessionEvent>()
// First collection (pre-rotation): drains gate1.
val first = launch { control.collect { received += it } }
runCurrent()
bus.publish(gate1)
runCurrent()
assertEquals(listOf(gate1), received)
// Rotation: the collector is cancelled. The mailbox MUST survive (not close/unregister).
first.cancel()
runCurrent()
// Events published DURING the gap (no live collector) must buffer in the mailbox, not drop.
bus.publish(gate2)
runCurrent()
// Recomposed screen RE-COLLECTS the SAME flow → resumes on the SAME mailbox.
val second = launch { control.collect { received += it } }
runCurrent()
bus.publish(gate3) // and a fresh event after re-collection
runCurrent()
// ZERO loss, not a dead stream: the gap event AND the post-rotation event both arrive, in order.
assertEquals(listOf(gate1, gate2, gate3), received)
second.cancel()
}
@Test
fun `outputBytes is re-collectable across a rotation with zero loss (the replay path)`() = runTest {
// The MOTIVATING failure: if the OUTPUT mailbox closed on the terminal collector's rotation
// cancel, the recomposed terminal would re-collect a dead stream and stay BLANK FOREVER. The
// decoded byte stream (outputBytes = register().map().flowOn(decodeDispatcher)) must survive
// collector churn exactly like controlEvents — this locks the property on the output path,
// whose .map/.flowOn composition a future edit could otherwise silently break.
val bus = EventBus(decodeDispatcher = StandardTestDispatcher(testScheduler))
val output = bus.outputBytes() // registered ONCE, at construction
val got = mutableListOf<String>()
val first = launch { output.collect { got += it.toString(Charsets.UTF_8) } }
bus.publish(Output("a"))
advanceUntilIdle()
assertEquals(listOf("a"), got)
// Rotation: cancel the terminal collector; the output mailbox must NOT close/unregister.
first.cancel()
advanceUntilIdle()
// A ring-replay-sized frame lands DURING the gap (no live collector) → buffered, not dropped.
bus.publish(Output("REPLAY"))
advanceUntilIdle()
// Recomposed terminal RE-COLLECTS the SAME flow → resumes on the SAME mailbox.
val second = launch { output.collect { got += it.toString(Charsets.UTF_8) } }
bus.publish(Output("c"))
advanceUntilIdle()
// ZERO loss on the output path, not a dead stream: the gap replay AND the post-rotation frame.
assertEquals(listOf("a", "REPLAY", "c"), got)
second.cancel()
}
@Test
fun `close closes every mailbox so collectors complete and a later publish is a no-op`() = runTest {
// EventBus.close() is the session-end release (called from RetainedSessionHolder.teardown after
// the engine close-join). It closes each mailbox — completing any live collector — and clears
// the registry so a late publish reaches no one.
val bus = EventBus(decodeDispatcher = StandardTestDispatcher(testScheduler))
val control = bus.controlEvents()
val received = mutableListOf<SessionEvent>()
var completed = false
val job = launch {
control.collect { received += it }
completed = true // reached only when the mailbox is closed and the flow ends normally
}
runCurrent()
bus.publish(gate1)
runCurrent()
assertEquals(listOf(gate1), received)
assertFalse(completed) // still live before close
bus.close()
runCurrent()
assertTrue(completed) // channel closed → receiveAsFlow completed → collector returned
assertTrue(job.isCompleted)
bus.publish(gate2) // registry cleared → harmless no-op
runCurrent()
assertEquals(listOf(gate1), received) // nothing more delivered
}
}

View File

@@ -0,0 +1,157 @@
package wang.yaojia.webterm.wiring
import dagger.Lazy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertSame
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.terminalview.RemoteTerminalSession
import wang.yaojia.webterm.testsupport.FakeTermTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.TermTransport
/**
* The [RetainedSessionHolder] lifecycle invariant (plan §6.6, A15 FIX 5/6). Exercised at JVM speed by
* driving a REAL [wang.yaojia.webterm.session.SessionEngine] over a [FakeTermTransport], with the
* engine's confinement scope pinned to a virtual-time [StandardTestDispatcher] (via the factory's
* `confinedScopeFactory` test seam) so config-change vs real-background teardown is deterministic.
*
* Proves:
* - a config change ([onStop] `isChangingConfigurations=true`) SURVIVES — no detach, no generation bump;
* - [bind] REUSES the survivor across the config change (no second engine);
* - a genuine background ([onStop] `false`) closes BEFORE cancelling the scope (the detach actually
* happens) and bumps `generation`;
* - `onCleared()` (nav pop) closes but does NOT bump `generation`.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class RetainedSessionHolderTest {
private fun endpoint(): HostEndpoint = HostEndpoint.fromBaseUrl("http://localhost:3000")!!
/**
* Build a holder whose engine confinement, output collector and (fake) emulator all run on [scope]'s
* virtual-time scheduler — so the holder now-owns-the-emulator lifecycle (FIX 3) drives off `Main`
* with no real append thread.
*/
private fun TestScope.newHolder(): Pair<RetainedSessionHolder, FakeTermTransport> {
val transport = FakeTermTransport()
val factory = SessionEngineFactory(Lazy<TermTransport> { transport })
val dispatcher = StandardTestDispatcher(testScheduler)
factory.confinedScopeFactory = { CoroutineScope(dispatcher + SupervisorJob()) }
val holder = RetainedSessionHolder(factory).apply {
mainDispatcher = dispatcher // output→feedRemote collector on virtual time, never real Main
remoteSessionFactory = { engineSend, _ ->
RemoteTerminalSession(
engineSend = engineSend,
appendDispatcher = dispatcher,
mainDispatcher = dispatcher,
)
}
}
return holder to transport
}
@Test
fun `config change survives and rebind reuses the same engine without detaching`() = runTest {
val (holder, transport) = newHolder()
val c1 = holder.bind(endpoint())
c1.start()
advanceUntilIdle()
assertEquals(1, transport.connectAttempts.size) // exactly one engine connected
// Config change (rotation): the survivor must stay live — no detach, no generation bump.
holder.onStop(isChangingConfigurations = true)
advanceUntilIdle()
assertEquals(0, holder.generation)
assertSame(c1, holder.controller)
assertEquals(0, transport.closeCallCount)
// Recreated Activity re-binds: reuse the SAME controller, never spawn a second engine.
val c2 = holder.bind(endpoint())
assertSame(c1, c2)
assertEquals(1, transport.connectAttempts.size)
// Clean up the still-live engine so no coroutine outlives the test.
holder.onStop(isChangingConfigurations = false)
advanceUntilIdle()
}
@Test
fun `rebind with a different session key throws instead of mis-routing (single-session-for-life)`() = runTest {
val (holder, _) = newHolder()
val c1 = holder.bind(endpoint(), sessionId = "sess-1")
c1.start()
advanceUntilIdle()
// Same endpoint, DIFFERENT sessionId: the short-circuit must NOT silently return the wrong
// (already-bound) session — it is a nav-scoping programming error, so it throws.
val mismatch = assertThrows(IllegalStateException::class.java) {
holder.bind(endpoint(), sessionId = "sess-2")
}
assertTrue(mismatch.message!!.contains("single-session-for-life"))
// A different endpoint likewise throws.
val other = HostEndpoint.fromBaseUrl("http://localhost:3001")!!
assertThrows(IllegalStateException::class.java) {
holder.bind(other, sessionId = "sess-1")
}
// The originally-bound controller is untouched by the rejected re-binds.
assertSame(c1, holder.controller)
holder.onStop(isChangingConfigurations = false)
advanceUntilIdle()
}
@Test
fun `genuine background detaches before cancel and bumps generation`() = runTest {
val (holder, transport) = newHolder()
val c = holder.bind(endpoint())
c.start()
advanceUntilIdle()
assertEquals(1, transport.connectAttempts.size)
assertEquals(0, transport.closeCallCount)
holder.onStop(isChangingConfigurations = false) // genuine background
advanceUntilIdle()
// close() ran to completion (the detach happened) — proving close-before-cancel: had the scope
// been cancelled first, the launched handleClose() would never have closed the connection.
assertEquals(1, transport.closeCallCount)
assertEquals(1, holder.generation) // bumped ONLY on genuine background
assertNull(holder.controller)
}
@Test
fun `onCleared detaches without bumping generation`() = runTest {
val (holder, transport) = newHolder()
val c = holder.bind(endpoint())
c.start()
advanceUntilIdle()
assertEquals(1, transport.connectAttempts.size)
// onCleared() is protected on ViewModel; invoke it reflectively to model a genuine nav pop.
RetainedSessionHolder::class.java
.getDeclaredMethod("onCleared")
.apply { isAccessible = true }
.invoke(holder)
advanceUntilIdle()
assertEquals(1, transport.closeCallCount) // clean detach still happens
assertEquals(0, holder.generation) // nav pop is terminal → never bumps generation
assertNull(holder.controller)
}
}

View File

@@ -0,0 +1,93 @@
package wang.yaojia.webterm.wiring
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.hostregistry.InMemoryLastSessionStore
import wang.yaojia.webterm.session.Adopted
import wang.yaojia.webterm.session.Connection
import wang.yaojia.webterm.session.ConnectionState
import wang.yaojia.webterm.session.Exited
import wang.yaojia.webterm.session.Output
/**
* The [SessionActivityBridge] lifecycle contract (plan §5 A29 Verify): it drives the
* [InMemoryLastSessionStore][wang.yaojia.webterm.hostregistry.InMemoryLastSessionStore] off the engine
* event stream —
* - `Adopted` → SET the server-issued id (so cold-start can offer "continue last");
* - `Exited` → CLEAR it (a dead shell must never be offered);
* - a mere detach (`Connection(Closed)`) and plain `Output` leave it untouched (still continuable);
* - distinct hosts are keyed independently.
* Pure JVM, driven under `runTest` with a `flowOf(...)` — no device.
*/
class SessionActivityBridgeTest {
private val hostA = "host-A"
private val hostB = "host-B"
@Test
fun `adopted sets the last session id for the bound host`() = runTest {
val store = InMemoryLastSessionStore()
val bridge = SessionActivityBridge(store, hostId = hostA)
bridge.observe(flowOf(Adopted("sess-42")))
assertEquals("sess-42", store.lastSessionId(hostA))
}
@Test
fun `exited clears the last session id`() = runTest {
val store = InMemoryLastSessionStore()
store.setLastSessionId("sess-42", hostA) // a previously-adopted session
val bridge = SessionActivityBridge(store, hostId = hostA)
bridge.observe(flowOf(Exited(code = 0)))
assertNull(store.lastSessionId(hostA))
}
@Test
fun `adopt-then-exit leaves no continuable session`() = runTest {
val store = InMemoryLastSessionStore()
val bridge = SessionActivityBridge(store, hostId = hostA)
bridge.observe(flowOf(Adopted("sess-99"), Output("some bytes"), Exited(code = 137, reason = "killed")))
assertNull(store.lastSessionId(hostA))
}
@Test
fun `a detach (connection closed) does NOT clear the last session`() = runTest {
val store = InMemoryLastSessionStore()
val bridge = SessionActivityBridge(store, hostId = hostA)
// Adopt, then a mere WS close (detach) — the server-side PTY keeps running, so it stays offerable.
bridge.observe(flowOf(Adopted("sess-7"), Connection(ConnectionState.Closed), Output("bytes")))
assertEquals("sess-7", store.lastSessionId(hostA))
}
@Test
fun `only the bound host is keyed`() = runTest {
val store = InMemoryLastSessionStore()
val bridge = SessionActivityBridge(store, hostId = hostA)
bridge.observe(flowOf(Adopted("sess-A")))
assertEquals("sess-A", store.lastSessionId(hostA))
assertNull(store.lastSessionId(hostB)) // a sibling host is never clobbered
}
@Test
fun `a later adopt overwrites the earlier server-issued id`() = runTest {
val store = InMemoryLastSessionStore()
val bridge = SessionActivityBridge(store, hostId = hostA)
// A reconnect can hand back a fresh id — the LATEST adopted id is the one to offer.
bridge.observe(flowOf(Adopted("sess-old"), Adopted("sess-new")))
assertEquals("sess-new", store.lastSessionId(hostA))
}
}

Some files were not shown because too many files have changed in this diff Show More