From e254918b1c86ad93959ce8187d7976f10a7057d9 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Fri, 10 Jul 2026 16:41:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(android):=20native=20Android=20client=20?= =?UTF-8?q?=E2=80=94=20full=20app=20parity=20build=20(A1=E2=80=93A36=20+?= =?UTF-8?q?=20S1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- android/DEVICE_QA_CHECKLIST.md | 76 +++ android/PROGRESS_ANDROID.md | 416 ++++++++++++++++ android/README.md | 15 +- android/api-client/build.gradle.kts | 12 + .../webterm/api/models/LiveSessionInfo.kt | 4 + android/app/build.gradle.kts | 108 +++- android/app/proguard-rules.pro | 4 + android/app/src/main/AndroidManifest.xml | 74 +++ .../java/wang/yaojia/webterm/MainActivity.kt | 176 +++++++ .../java/wang/yaojia/webterm/WebTermApp.kt | 12 + .../webterm/components/AwayDigestView.kt | 92 ++++ .../webterm/components/ContinueLastBanner.kt | 145 ++++++ .../yaojia/webterm/components/GateBanner.kt | 89 ++++ .../wang/yaojia/webterm/components/KeyBar.kt | 278 +++++++++++ .../webterm/components/PlanGateSheet.kt | 101 ++++ .../webterm/components/PointerContextMenu.kt | 97 ++++ .../yaojia/webterm/components/QuickReply.kt | 111 +++++ .../webterm/components/QuickReplyStore.kt | 237 +++++++++ .../webterm/components/ReconnectBanner.kt | 216 ++++++++ .../webterm/components/SessionListRow.kt | 243 +++++++++ .../webterm/components/TelemetryChips.kt | 114 +++++ .../yaojia/webterm/designsystem/DesignSpec.kt | 94 ++++ .../yaojia/webterm/designsystem/Primitives.kt | 179 +++++++ .../wang/yaojia/webterm/designsystem/Theme.kt | 94 ++++ .../yaojia/webterm/designsystem/Tokens.kt | 116 +++++ .../yaojia/webterm/designsystem/Typography.kt | 47 ++ .../wang/yaojia/webterm/di/NetworkModule.kt | 77 +++ .../java/wang/yaojia/webterm/di/PushModule.kt | 22 + .../wang/yaojia/webterm/di/SessionModule.kt | 33 ++ .../wang/yaojia/webterm/di/StorageModule.kt | 47 ++ .../java/wang/yaojia/webterm/di/TlsModule.kt | 56 +++ .../yaojia/webterm/nav/CancellationSafe.kt | 18 + .../wang/yaojia/webterm/nav/DeepLinkRouter.kt | 170 +++++++ .../wang/yaojia/webterm/nav/LayoutPolicy.kt | 75 +++ .../java/wang/yaojia/webterm/nav/NavGraph.kt | 266 ++++++++++ .../java/wang/yaojia/webterm/nav/Panes.kt | 236 +++++++++ .../wang/yaojia/webterm/nav/ProjectsHome.kt | 103 ++++ .../wang/yaojia/webterm/nav/SessionsHome.kt | 119 +++++ .../wang/yaojia/webterm/nav/TerminalHost.kt | 121 +++++ .../webterm/push/AllowTrampolineActivity.kt | 114 +++++ .../webterm/push/DenyBroadcastReceiver.kt | 70 +++ .../wang/yaojia/webterm/push/FcmService.kt | 75 +++ .../webterm/push/NotificationBuilder.kt | 177 +++++++ .../yaojia/webterm/push/PushCoordinator.kt | 49 ++ .../webterm/push/PushDecisionSubmitter.kt | 93 ++++ .../wang/yaojia/webterm/push/PushPayload.kt | 53 ++ .../wang/yaojia/webterm/push/PushRegistrar.kt | 113 +++++ .../webterm/push/PushRegistrarTokenSink.kt | 32 ++ .../wang/yaojia/webterm/push/PushTokenSink.kt | 30 ++ .../yaojia/webterm/screens/AdaptiveHome.kt | 120 +++++ .../webterm/screens/ClientCertScreen.kt | 377 ++++++++++++++ .../wang/yaojia/webterm/screens/DiffScreen.kt | 263 ++++++++++ .../yaojia/webterm/screens/PairingScreen.kt | 379 ++++++++++++++ .../webterm/screens/ProjectDetailScreen.kt | 243 +++++++++ .../yaojia/webterm/screens/ProjectsScreen.kt | 350 +++++++++++++ .../webterm/screens/SessionListScreen.kt | 320 ++++++++++++ .../yaojia/webterm/screens/TerminalScreen.kt | 356 +++++++++++++ .../yaojia/webterm/screens/TimelineSheet.kt | 234 +++++++++ .../webterm/viewmodels/ClientCertViewModel.kt | 254 ++++++++++ .../webterm/viewmodels/DiffViewModel.kt | 342 +++++++++++++ .../webterm/viewmodels/GateViewModel.kt | 184 +++++++ .../webterm/viewmodels/PairingViewModel.kt | 327 ++++++++++++ .../viewmodels/ProjectDetailViewModel.kt | 69 +++ .../webterm/viewmodels/ProjectsViewModel.kt | 435 ++++++++++++++++ .../viewmodels/SessionListViewModel.kt | 309 ++++++++++++ .../webterm/viewmodels/TimelineViewModel.kt | 201 ++++++++ .../yaojia/webterm/wiring/ApiClientFactory.kt | 37 ++ .../yaojia/webterm/wiring/AppEnvironment.kt | 94 ++++ .../yaojia/webterm/wiring/ColdStartPolicy.kt | 35 ++ .../wang/yaojia/webterm/wiring/EventBus.kt | 147 ++++++ .../webterm/wiring/RetainedSessionHolder.kt | 238 +++++++++ .../webterm/wiring/SessionActivityBridge.kt | 57 +++ .../webterm/wiring/SessionEngineFactory.kt | 86 ++++ .../wiring/TerminalSessionController.kt | 156 ++++++ .../wiring/TerminalSessionControllerImpl.kt | 106 ++++ .../wiring/TerminalThumbnailRasterizer.kt | 161 ++++++ .../webterm/wiring/ThumbnailPipeline.kt | 195 ++++++++ .../kotlin/wang/yaojia/webterm/app/.gitkeep | 0 android/app/src/main/res/values/strings.xml | 4 + android/app/src/main/res/values/themes.xml | 25 + .../main/res/xml/network_security_config.xml | 10 + .../webterm/components/AwayDigestViewTest.kt | 25 + .../components/ContinueLastBannerTest.kt | 28 ++ .../yaojia/webterm/components/KeyBarTest.kt | 179 +++++++ .../webterm/components/QuickReplyTest.kt | 226 +++++++++ .../webterm/components/ReconnectBannerTest.kt | 112 +++++ .../webterm/components/TelemetryChipsTest.kt | 91 ++++ .../webterm/designsystem/DesignSpecTest.kt | 62 +++ .../yaojia/webterm/nav/DeepLinkRouterTest.kt | 242 +++++++++ .../yaojia/webterm/nav/LayoutPolicyTest.kt | 61 +++ .../wang/yaojia/webterm/nav/NavRoutesTest.kt | 45 ++ .../yaojia/webterm/push/PushDecisionTest.kt | 223 +++++++++ .../yaojia/webterm/push/PushRegistrarTest.kt | 163 ++++++ .../webterm/screens/NewSessionInCwdTest.kt | 32 ++ .../viewmodels/ClientCertViewModelTest.kt | 272 ++++++++++ .../webterm/viewmodels/DiffViewModelTest.kt | 157 ++++++ .../webterm/viewmodels/GateViewModelTest.kt | 227 +++++++++ .../viewmodels/PairingViewModelTest.kt | 224 +++++++++ .../viewmodels/ProjectsViewModelTest.kt | 225 +++++++++ .../viewmodels/SessionListViewModelTest.kt | 303 ++++++++++++ .../viewmodels/TimelineViewModelTest.kt | 212 ++++++++ .../webterm/wiring/ColdStartPolicyTest.kt | 33 ++ .../yaojia/webterm/wiring/EventBusTest.kt | 211 ++++++++ .../wiring/RetainedSessionHolderTest.kt | 157 ++++++ .../wiring/SessionActivityBridgeTest.kt | 93 ++++ .../wiring/TerminalSessionControllerTest.kt | 96 ++++ .../webterm/wiring/ThumbnailPipelineTest.kt | 214 ++++++++ android/client-tls-android/build.gradle.kts | 51 +- .../tlsandroid/AndroidKeyStoreImporterTest.kt | 73 +++ .../yaojia/webterm/tlsandroid/Fixtures.kt | 31 ++ .../tlsandroid/IdentityRepositoryTest.kt | 209 ++++++++ .../tlsandroid/AndroidKeyStoreImporter.kt | 130 +++++ .../webterm/tlsandroid/IdentityRepository.kt | 252 ++++++++++ .../tlsandroid/ReReadingX509KeyManager.kt | 93 ++++ .../tlsandroid/StoredIdentityMetadata.kt | 108 ++++ .../webterm/tlsandroid/TinkCertStore.kt | 135 +++++ android/client-tls/build.gradle.kts | 12 + android/gradle/libs.versions.toml | 138 ++++++ android/host-registry/build.gradle.kts | 60 ++- .../hostregistry/DataStoreHostStoreTest.kt | 66 +++ .../DataStoreLastSessionStoreTest.kt | 71 +++ .../hostregistry/DataStoreHostStore.kt | 44 ++ .../hostregistry/DataStoreLastSessionStore.kt | 42 ++ .../wang/yaojia/webterm/hostregistry/Host.kt | 42 ++ .../yaojia/webterm/hostregistry/HostCodec.kt | 62 +++ .../yaojia/webterm/hostregistry/HostStore.kt | 49 ++ .../webterm/hostregistry/InMemoryHostStore.kt | 37 ++ .../hostregistry/InMemoryLastSessionStore.kt | 23 + .../webterm/hostregistry/LastSessionStore.kt | 28 ++ .../webterm/hostregistry/HostCodecTest.kt | 70 +++ .../hostregistry/HostStoreTransformsTest.kt | 86 ++++ .../hostregistry/InMemoryHostStoreTest.kt | 59 +++ .../InMemoryLastSessionStoreTest.kt | 50 ++ android/session-core/build.gradle.kts | 12 + .../yaojia/webterm/session/SessionEngine.kt | 415 ++++++++++++++++ .../webterm/session/SessionEngineTest.kt | 468 ++++++++++++++++++ android/settings.gradle.kts | 26 +- android/terminal-view/build.gradle.kts | 81 ++- .../wang/yaojia/webterm/terminalview/.gitkeep | 0 .../yaojia/webterm/terminalview/LinkPolicy.kt | 29 ++ .../terminalview/NoOpTerminalSessionClient.kt | 42 ++ .../terminalview/RemoteTerminalSession.kt | 308 ++++++++++++ .../terminalview/RemoteTerminalView.kt | 56 +++ .../webterm/terminalview/TerminalGridMath.kt | 57 +++ .../webterm/terminalview/DecckmKeyTest.kt | 64 +++ .../webterm/terminalview/LinkPolicyTest.kt | 33 ++ .../webterm/terminalview/PendingOutputTest.kt | 108 ++++ .../RemoteTerminalSessionSeamTest.kt | 122 +++++ .../terminalview/TerminalGridMathTest.kt | 71 +++ android/transport-okhttp/build.gradle.kts | 30 ++ .../webterm/transport/OkHttpClientFactory.kt | 98 ++++ .../webterm/transport/OkHttpHttpTransport.kt | 88 ++++ .../webterm/transport/OkHttpTermTransport.kt | 69 +++ .../transport/OkHttpWebSocketConnection.kt | 153 ++++++ .../transport/OkHttpClientFactoryTest.kt | 58 +++ .../transport/OkHttpHttpTransportTest.kt | 126 +++++ .../transport/OkHttpTermTransportTest.kt | 227 +++++++++ android/wire-protocol/build.gradle.kts | 12 + .../yaojia/webterm/wire/HostClassifierTest.kt | 87 ++++ 159 files changed, 20114 insertions(+), 73 deletions(-) create mode 100644 android/DEVICE_QA_CHECKLIST.md create mode 100644 android/PROGRESS_ANDROID.md create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/java/wang/yaojia/webterm/MainActivity.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/WebTermApp.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/components/AwayDigestView.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/components/ContinueLastBanner.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/components/GateBanner.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/components/KeyBar.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/components/PlanGateSheet.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/components/PointerContextMenu.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/components/QuickReply.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/components/QuickReplyStore.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/components/ReconnectBanner.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/components/SessionListRow.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/components/TelemetryChips.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/designsystem/DesignSpec.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/designsystem/Primitives.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/designsystem/Theme.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/designsystem/Tokens.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/designsystem/Typography.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/di/NetworkModule.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/di/PushModule.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/di/SessionModule.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/di/StorageModule.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/di/TlsModule.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/nav/CancellationSafe.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/nav/DeepLinkRouter.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/nav/LayoutPolicy.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/nav/NavGraph.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/nav/Panes.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/nav/ProjectsHome.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/nav/SessionsHome.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/nav/TerminalHost.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/push/AllowTrampolineActivity.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/push/DenyBroadcastReceiver.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/push/FcmService.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/push/NotificationBuilder.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/push/PushCoordinator.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/push/PushDecisionSubmitter.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/push/PushPayload.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/push/PushRegistrar.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/push/PushRegistrarTokenSink.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/push/PushTokenSink.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/screens/AdaptiveHome.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/screens/ClientCertScreen.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/screens/DiffScreen.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/screens/ProjectDetailScreen.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/screens/ProjectsScreen.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/screens/SessionListScreen.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/screens/TerminalScreen.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/screens/TimelineSheet.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/viewmodels/ClientCertViewModel.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/viewmodels/DiffViewModel.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/viewmodels/GateViewModel.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectDetailViewModel.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectsViewModel.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/viewmodels/SessionListViewModel.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/viewmodels/TimelineViewModel.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/wiring/ApiClientFactory.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/wiring/ColdStartPolicy.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/wiring/EventBus.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/wiring/RetainedSessionHolder.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/wiring/SessionActivityBridge.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/wiring/SessionEngineFactory.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalSessionController.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalSessionControllerImpl.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalThumbnailRasterizer.kt create mode 100644 android/app/src/main/java/wang/yaojia/webterm/wiring/ThumbnailPipeline.kt delete mode 100644 android/app/src/main/kotlin/wang/yaojia/webterm/app/.gitkeep create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/themes.xml create mode 100644 android/app/src/main/res/xml/network_security_config.xml create mode 100644 android/app/src/test/java/wang/yaojia/webterm/components/AwayDigestViewTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/components/ContinueLastBannerTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/components/KeyBarTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/components/QuickReplyTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/components/ReconnectBannerTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/components/TelemetryChipsTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/designsystem/DesignSpecTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/nav/DeepLinkRouterTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/nav/LayoutPolicyTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/nav/NavRoutesTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/push/PushDecisionTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/push/PushRegistrarTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/screens/NewSessionInCwdTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/viewmodels/ClientCertViewModelTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/viewmodels/DiffViewModelTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/viewmodels/GateViewModelTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/viewmodels/PairingViewModelTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/viewmodels/ProjectsViewModelTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/viewmodels/SessionListViewModelTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/viewmodels/TimelineViewModelTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/wiring/ColdStartPolicyTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/wiring/EventBusTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/wiring/RetainedSessionHolderTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/wiring/SessionActivityBridgeTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/wiring/TerminalSessionControllerTest.kt create mode 100644 android/app/src/test/java/wang/yaojia/webterm/wiring/ThumbnailPipelineTest.kt create mode 100644 android/client-tls-android/src/androidTest/kotlin/wang/yaojia/webterm/tlsandroid/AndroidKeyStoreImporterTest.kt create mode 100644 android/client-tls-android/src/androidTest/kotlin/wang/yaojia/webterm/tlsandroid/Fixtures.kt create mode 100644 android/client-tls-android/src/androidTest/kotlin/wang/yaojia/webterm/tlsandroid/IdentityRepositoryTest.kt create mode 100644 android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/AndroidKeyStoreImporter.kt create mode 100644 android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/IdentityRepository.kt create mode 100644 android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/ReReadingX509KeyManager.kt create mode 100644 android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/StoredIdentityMetadata.kt create mode 100644 android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/TinkCertStore.kt create mode 100644 android/host-registry/src/androidTest/kotlin/wang/yaojia/webterm/hostregistry/DataStoreHostStoreTest.kt create mode 100644 android/host-registry/src/androidTest/kotlin/wang/yaojia/webterm/hostregistry/DataStoreLastSessionStoreTest.kt create mode 100644 android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/DataStoreHostStore.kt create mode 100644 android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/DataStoreLastSessionStore.kt create mode 100644 android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/Host.kt create mode 100644 android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/HostCodec.kt create mode 100644 android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/HostStore.kt create mode 100644 android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/InMemoryHostStore.kt create mode 100644 android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/InMemoryLastSessionStore.kt create mode 100644 android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/LastSessionStore.kt create mode 100644 android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/HostCodecTest.kt create mode 100644 android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/HostStoreTransformsTest.kt create mode 100644 android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/InMemoryHostStoreTest.kt create mode 100644 android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/InMemoryLastSessionStoreTest.kt create mode 100644 android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEngine.kt create mode 100644 android/session-core/src/test/kotlin/wang/yaojia/webterm/session/SessionEngineTest.kt delete mode 100644 android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/.gitkeep create mode 100644 android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/LinkPolicy.kt create mode 100644 android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/NoOpTerminalSessionClient.kt create mode 100644 android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalSession.kt create mode 100644 android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalView.kt create mode 100644 android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalGridMath.kt create mode 100644 android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/DecckmKeyTest.kt create mode 100644 android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/LinkPolicyTest.kt create mode 100644 android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/PendingOutputTest.kt create mode 100644 android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalSessionSeamTest.kt create mode 100644 android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalGridMathTest.kt create mode 100644 android/transport-okhttp/build.gradle.kts create mode 100644 android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt create mode 100644 android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpHttpTransport.kt create mode 100644 android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpTermTransport.kt create mode 100644 android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpWebSocketConnection.kt create mode 100644 android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactoryTest.kt create mode 100644 android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/OkHttpHttpTransportTest.kt create mode 100644 android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/OkHttpTermTransportTest.kt create mode 100644 android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/HostClassifierTest.kt diff --git a/android/DEVICE_QA_CHECKLIST.md b/android/DEVICE_QA_CHECKLIST.md new file mode 100644 index 0000000..e946410 --- /dev/null +++ b/android/DEVICE_QA_CHECKLIST.md @@ -0,0 +1,76 @@ +# Android client — device-QA checklist (A36 / plan §7) + +Everything below is **device/emulator-only** — it could NOT run in the build environment (no emulator, +no Firebase project, no real host). The pure logic underneath each item is JVM-unit-tested (484 tests, +Kover ≥80% on the pure modules); this checklist is what a human runs on real hardware before shipping. + +## Deploy artifacts to provide first (not built here) +- [ ] `app/google-services.json` — the Firebase client config for FCM (`PushCoordinator` guards its + absence with `runCatching`, so the app runs without it; push just won't register). +- [ ] `google-services` Gradle plugin re-enabled once `google-services.json` exists (A13 deliberately + omitted it — applying it without the json fails the build). +- [ ] `https://terminal.yaojia.wang/.well-known/assetlinks.json` with the **release** signing-cert + SHA-256 (for the verified App Link `autoVerify`). Server-side: run `A33` `src/push/fcm.ts` with the + `FCM_*` env group (service-account key path + project id). + +## A34 — instrumented E2E vs a real `npm start` host (write as `androidTest`, run on device) +- [ ] `attach → attached → output` round-trip timing. +- [ ] reconnect replays the ring buffer (F5/F6); no dropped bytes on the multi-MB replay. +- [ ] spawn-failure → `exit(-1)` shows the spawn-failure banner copy. +- [ ] kill via `DELETE /live-sessions/:id` (swipe-to-kill) removes the row (404 = already-gone = success). +- [ ] `POST /hook/decision` resolves a held gate (from a push Allow/Deny). +- [ ] **bad Origin rejected** (F9) — the one non-skippable CSWSH defence; a foreign Origin 401s the WS. + +## A35 — one macrobenchmark / Espresso happy path +- [ ] pair → attach → type → approve, on a real device. + +## Terminal (A16/A17/A21) +- [ ] glyph rendering + 24-bit true-color + cursor against a live Claude Code TUI (decide the §6.8 + WebView fallback ONLY if fidelity diverges — not expected). +- [ ] IME/CJK composition; text selection → `ActionMode` → clipboard; http/https link tap. +- [ ] **real font-metric → grid resize** (`TerminalRenderer.mFontWidth/mFontLineSpacing` are + package-private → measured on-device, fed to the JVM-tested `TerminalGridMath`); resize parity vs + web/iOS on the same device sizes (R5). +- [ ] **config-change (rotation/fold) re-binds the surviving emulator** with no blank + no replay + round-trip (scrollback CONTENT survives; scroll OFFSET resets — accepted); real-background → + generation bump → fresh emulator replays. +- [ ] key-bar above the IME (soft keyboard never pops on a key-bar tap); hardware chords; DECCKM arrows + emit `ESC O A` under app-cursor mode (vim/htop). +- [ ] `FLAG_SECURE` + privacy cover blanks the recents thumbnail on `ON_STOP`. +- [ ] off-main chunked append does not ANR on a multi-MB replay. + +## Push (A30/A31 · R1/S2) +- [ ] **S2 spike:** data-only high-priority delivery latency/loss on ≥2 real handsets (incl. one + Xiaomi/Huawei/Samsung) under Doze / OEM battery managers / force-stop — quantify loss, drive the + "delivery not guaranteed while backgrounded" user-facing copy. +- [ ] **Deny** from the lock screen (BroadcastReceiver, no app open, expedited POST). +- [ ] **Allow** → translucent `excludeFromRecents` trampoline hosts `BiometricPrompt` → POST on auth + success; cancel/error/no-enrolled-auth → no POST (fail-safe). +- [ ] single-use token: a retried-after-success decision POST returns 403 (idempotency). +- [ ] `POST_NOTIFICATIONS` runtime prompt (API 33+); token registers to every paired host + self-heals + on rotation / new host / removal. + +## Pairing / cert / storage (A19/A27/A11/A12) +- [ ] CameraX QR scan + ML-Kit decode; CAMERA-denied → manual URL entry. +- [ ] §5.4 warning tiers on real hosts; tunnel host cert-gate refuses without a device cert (retry can't + bypass); public host explicit-ack. +- [ ] SAF `.p12` import → **real AndroidKeyStore** (non-exportable) + Tink AEAD; bad passphrase keeps the + prior identity (validate-before-persist); rotate presents the new cert on the **next handshake with + no relaunch** (re-reading `X509KeyManager` + `connectionPool.evictAll()`); remove. +- [ ] DataStore host list / `LastSessionStore` set-on-adopted / clear-on-exited. + +## Nav / deep links / adaptive (A32/A26/A29) +- [ ] cold-start AND warm deep link `webterminal://open?host=&join=` + the verified App Link route to the + right destination; invalid UUID ignored. +- [ ] cold-start route (no host → pairing, else sessions); continue-last-session banner re-opens. +- [ ] adaptive: compact = stack, expanded/tablet = list+detail (`NavigationSuiteScaffold` + + `ListDetailPaneScaffold`); pointer secondary-click context menu on a tablet (sw≥600). + +## Known minor gaps (tracked, non-blocking — see PROGRESS_ANDROID.md) +- [ ] push body-tap opens the app (not yet the specific gate — the notification `openAppIntent` doesn't + carry the sessionId; the gate is still visible in the terminal). MEDIUM. +- [ ] no "view diff" affordance from ProjectDetail → the `DiffScreen` nav destination has no inbound link. +- [ ] no host-remove UI action (so `PushRegistrar.unregisterHost` isn't invoked). +- [ ] A21 hosts the Termux `TerminalView` via a reflection getter (works; `com.termux.view.TerminalView` + is `implementation`-scoped in `:terminal-view`) — clean fix = add `libs.termux.terminal.view` to + `:app` and delete the shim. diff --git a/android/PROGRESS_ANDROID.md b/android/PROGRESS_ANDROID.md new file mode 100644 index 0000000..4a3a23c --- /dev/null +++ b/android/PROGRESS_ANDROID.md @@ -0,0 +1,416 @@ +# Android Client — Progress (this-session working log) + +> **Why this file exists (temporary):** `docs/PROGRESS_LOG.md` (the canonical cross-session +> memory) was being **concurrently edited by another live session** (the tunnel-automation +> workstream on branch `feat/tunnel-automation`) while this Android work ran on the SAME working +> tree. To avoid a read-modify-write clobber of that session's log, the Android orchestrator +> records progress here instead. **FOLD THESE ENTRIES INTO `docs/PROGRESS_LOG.md` once the +> concurrent session is done** (they belong under the `🤖 ANDROID CLIENT` section). +> +> **Git status note:** nothing below is committed. The Android lane (`android/**`, +> `src/push/fcm.ts`, `test/push-fcm.test.ts`, `src/server.ts` FCM wiring, `package.json` +> google-auth-library) is file-disjoint from the tunnel work, but the shared `.git`/index means +> **no `git add -A`** — commit the Android lane by explicit path once branch strategy is settled. + +Plan: [`../docs/ANDROID_CLIENT_PLAN.md`](../docs/ANDROID_CLIENT_PLAN.md). 36 tasks, waves AW0–AW6. +Prior state (commit `4ea8f78`/`4fe1981`): AW0 + AW1 pure-Kotlin foundation (218 tests) + Android +SDK/AGP 9.2.1 toolchain proven. + +--- + +## [x] Wave R1 — A7 + A14 + A33 (DONE, orchestrator-verified 2026-07-08) + +One implementation Workflow (3 TDD builders in parallel) → 6-agent adversarial cross-review (2 +independent lenses/task) → fix Workflow (must-fixes + regression tests) → adversarial re-verify → +**orchestrator independent unified gate** (not just agent self-report). + +**Unified verification (measured):** +- `cd android && ./gradlew test` → **BUILD SUCCESSFUL, 250 tests, 0 failures** (wire-protocol 47 / + session-core 75 / api-client 69 / client-tls 27 / test-support 15 / **transport-okhttp 17**). +- `npx vitest run test/` → **54 files, 1533 tests, 0 failures** (incl. new `test/push-fcm.test.ts` + 60 tests; no regression of the existing server suite). +- `npm run typecheck` (tsc main + web) → clean. + +### [x] A7 `:transport-okhttp` — OkHttp WS + REST transport (pure JVM; MockWebServer-tested) +- Files: `transport-okhttp/src/main/.../transport/{OkHttpClientFactory, OkHttpTermTransport, + OkHttpWebSocketConnection, OkHttpHttpTransport}.kt` + 3 test files. Implements frozen + `TermTransport`/`PingableTermTransport`/`HttpTransport` verbatim (contracts unmodified). +- WS: `connect()` stamps `Origin = endpoint.originHeader` on the upgrade (CSWSH; asserted + byte-equal); `frames` = `channelFlow` draining an unlimited listener mailbox — clean close → + normal completion, `onFailure` → error (distinguishable); `send`→`webSocket.send`, + `close`→`close(1000)` (**detach, never kill**). REST: non-2xx RETURNED, transport-failure THROWN, + headers verbatim (no self-added Origin). Shared `OkHttpClient` `.cache(null)` (§8) + default + system trust; mTLS via a LOCAL `ClientIdentityProvider` fun-interface seam (module depends only + on `:wire-protocol`, no `:client-tls` edge). +- **Cross-review fixes (regression-tested):** (1) HIGH — handshake socket leak on mid-dial cancel: + `openConnection` now tears down the just-created WS on any throwable (incl. `CancellationException`) + via an idempotent `cancel()` (`webSocket.cancel()`+`finish(null)`) before rethrowing — proven + red→green by revert. (2) HIGH — the redundant transport-level 16 MiB frame cap + (`TransportFrameTooLargeException`) was **deleted**: it was unreachable from `:session-core` (which + may depend only on `:wire-protocol`), and `SessionEngine` already self-measures frames and emits + `REPLAY_TOO_LARGE` — the single authoritative classifier. (3) MEDIUM — pump rethrows + `CancellationException` (defensive; kept as a contract lock). + +### [x] A14 `SessionEngine` — pure lifecycle state machine (`:session-core`, runTest virtual time) +- Files: `session-core/src/main/.../session/SessionEngine.kt` + `SessionEngineTest.kt` (15 tests). + Composes ReconnectMachine/PingScheduler/GateTracker/AwayDigest over an injected `TermTransport` + + dispatcher/TimeSource; NO OkHttp/Android imports. +- Verified: attach-first ordering, adopt-server-id, connect-now-on-foreground, **close≠kill**, + oversized-replay→terminal `REPLAY_TOO_LARGE`, gate-decision epoch drop, backoff ladder + 1→2→4→8→16→30, ping 25s/2-miss — all under virtual time via the fakes. +- **Cross-review fixes (regression-tested):** (1) HIGH (found independently by BOTH reviewers) — + `close()` during the dial/attach window failed to detach the freshly-opened connection: engine now + re-checks the `closed` flag after dial and after attach, closing + returning terminal with no + further emits (2 new tests: close-during-dial, close-during-attach). (4) MEDIUM — gate double-send: + `decideGate()` now locally retires the held gate after a successful send so a second same-epoch tap + is dropped (new test). (2) MEDIUM — `started` flip moved onto the confined dispatcher (invariant + #4). (3) MEDIUM — suspend-call `runCatching` replaced with cancel-rethrowing `ignoringNonCancel`. +- **KNOWN follow-up routed to A15 (AW2):** the engine does not close its live WS when its *scope* is + cancelled (only on explicit `close()`). Intended teardown is explicit `engine.close()` (per §6.6), + so **the `RetainedSessionHolder` MUST call `engine.close()` before cancelling the engine scope**; + additionally consider a `finally`-close in the engine for defense-in-depth. Non-blocking for R1 + (server PTY survives either way; only affects prompt-vs-TCP-timeout detach). + +### [x] A33 server FCM — the ONE additive server change (§4.5) +- Files: NEW `src/push/fcm.ts` (`NotifyService` impl behind a seam) + `POST/DELETE /push/fcm-token` + route + `initFcm`/`normalizeFcmToken` wiring in `src/server.ts` (combined via + `combineNotifyServices` beside web-push/APNs — zero new event paths) + `FCM_*` env + (all-or-disabled) + `test/push-fcm.test.ts` (60 tests). Added `google-auth-library@^10` to + `package.json` deps (R7 decision). +- Data-only high-priority messages; payload minimized to `sessionId`/`cls`/`token` (no + notification block, no cwd/command); loose FCM-token validator; token/key never logged; disabled + cleanly when `FCM_*` incomplete. +- **Cross-review fix (regression-tested):** HIGH — removed `validateStatus:()=>true`, which had + silently defeated google-auth-library's built-in 401/403 refresh-and-retry (the whole reason R7 + chose the lib). Now wraps `client.request` in try/catch, reading `{status,body}` from + `GaxiosError.response`; a 401 goes through the lib's refresh before surfacing; a no-response + transport error is a send failure (token never falsely pruned). 3 new tests (401→refresh→200, + 404/UNREGISTERED→prune, no-response→keep). + +**Not run here (deferred per plan §7):** instrumented/device tests (no emulator installed) and the +S2 FCM real-handset delivery spike (needs ≥2 physical devices under Doze/OEM battery managers). + +--- + +## [~] Wave R2 — A13 (done) · A11 + A12 (building) · S1 → moved to head of AW3 + +### [x] A13 `:app` Compose baseline (DONE, orchestrator-verified 2026-07-08) +First real full Android app module — establishes the UI-stack version matrix every later Android +task reuses. Workflow: TDD builder → kotlin + design cross-review. +- Files: `app/build.gradle.kts`, `AndroidManifest.xml`, `WebTermApp.kt` (@HiltAndroidApp), + `MainActivity.kt` (@AndroidEntryPoint), `di/AppModule.kt` (minimal Hilt), `designsystem/{DesignSpec, + Tokens,Typography,Theme,Primitives}.kt`, `res/{values,xml}/*`, `DesignSpecTest.kt` (5 JVM tests). +- **Version matrix PROVEN** (`:app:assembleDebug` → 32.7 MB APK, `:app:testDebugUnitTest` green): + AGP 9.2.1 · Kotlin 2.3.21 · compose-compiler plugin 2.3.21 · Compose BOM 2025.11.01 (material3 + 1.4.0, ui 1.9.5, material3.adaptive 1.2.0) · Hilt 2.60.1 via KSP 2.3.9 · **compileSdk 36** + (installed `platforms;android-36`; SDK-37 unavailable — cmdline-tools too old), minSdk 29, + targetSdk 35. Design system mirrors iOS DS 1:1 (amber-gold #E3A64A/#C9892F, semantic status + colors, 8pt scale, tabular mono numerals, dark-first, fixed terminal-canvas #100F0D/#ECE9E3/gold). +- **Cross-review fix (orchestrator applied + re-verified assemble green):** both reviewers flagged + the primitives (StatusBadge/TelemetryChip/WebTermCard) hardcoding `WebTermColors.dark.*` → would + render wrong in light theme. Routed through `MaterialTheme.colorScheme.{onSurfaceVariant, + surfaceVariant,outline}` (Theme.kt already maps those). Also fixed preview `.dp` literals → + `Spacing.*`. Docs synced: `android/README.md` + `settings.gradle.kts` recipe + `[[android-build-env]]` + memory now say compileSdk 36 / android-36. +- Deferred (per §7): instrumented/Compose-UI tests (no emulator). Follow-up for AW3: add a + `Motion.gated()` helper (reduce-motion) to Tokens.kt when the first animation lands (LocalReduceMotion + + DesignSpec.MOTION_* already present); register a ContentObserver on ANIMATOR_DURATION_SCALE then. + +### [x] A11 `:client-tls-android` — ClientTLS framework half (DONE, orchestrator-verified) +Catalog pre-staged (tink 1.15.0, datastore-preferences 1.1.1, androidx-test); enabled in settings. +Build workflow → security+kotlin cross-review → fix workflow → adversarial security re-verify → +orchestrator applied 3 more fixes the re-verify caught + independent compile gate. +- Files: `client-tls-android/src/main/.../tlsandroid/{AndroidKeyStoreImporter, TinkCertStore(+CertStore + iface), ReReadingX509KeyManager, IdentityRepository(+ClientSslMaterial), StoredIdentityMetadata}.kt` + + androidTest `{Fixtures, AndroidKeyStoreImporterTest, IdentityRepositoryTest}.kt`. +- ONE key home = AndroidKeyStore (non-exportable, per-handshake re-reading `X509KeyManager`); Tink AEAD + encrypts ONLY the cert-chain+metadata blob; NO `.p12`/passphrase persisted; validate-before-persist; + `connectionPool.evictAll()` on rotate/remove. Exposes `IdentityRepository`/`ClientSslMaterial` as the + seam A15 bridges to `:transport-okhttp`'s `ClientIdentityProvider` (NO `:transport-okhttp` dep). +- **Security must-fix (rotation safety) — resolved via single-commit + adversarial follow-through:** + the re-verify caught the fixer's first attempt still lost the prior identity. Final design: + **ping-pong staging slot → one atomic durable `SharedPreferences.commit()` live-pointer flip** + (`StoredIdentityMetadata.keyStoreAlias`). Any failure before the flip → prior identity fully live; + after → new identity fully live; stores never diverge. Orchestrator then fixed 3 residual bugs the + security re-verify empirically proved: **(1 HIGH)** `currentLive()` used `liveOverride?.value ?: + initialIdentity` which collapsed `Box(null)` (explicitly removed) into the stale cached identity → + a *removed* cert stayed "live" and could be presented with a dangling key; now resolves via Box + presence. **(3)** `TinkCertStore.save()/clear()` switched `apply()`→`commit()` (durable + throws on + failure) so a crash-window can't leave the pointer naming a just-deleted key (both-identities-lost). + **(2)** widened staging-key cleanup to cover the key-readback/encode steps. Added instrumented + regression `remove_afterStartupTouch_reportsNoIdentity_notStaleCached`. +- Verified (orchestrator): `./gradlew :client-tls-android:assembleDebug :client-tls-android:compileDebugAndroidTestKotlin` + → BUILD SUCCESSFUL (main + instrumented compile). All AndroidKeyStore/Tink tests run on device (§7). + +### [x] A12 `:host-registry` — Host/HostStore + DataStore + LastSessionStore (DONE, both reviewers approved) +- Files: `host-registry/src/main/.../hostregistry/{Host, HostStore(+immutable transforms), + InMemoryHostStore, DataStoreHostStore, LastSessionStore, DataStoreLastSessionStore, HostCodec}.kt` + + tests (JVM unit: HostStoreTransforms/InMemoryHostStore/HostCodec/InMemoryLastSessionStore) + androidTest. +- Immutable HostStore (upsert/remove return new lists, position-preserved, unknown-id no-op); + DataStore read-modify-write; HostEndpoint re-validated on load; LastSessionStore set-on-adopted / + clear-on-exited. **Orchestrator fix:** `DataStoreLastSessionStore` now guards the persisted id with + the frozen v4 `Validation.isValidSessionId` (was `UUID.fromString` format-only). +- Verified (orchestrator): `./gradlew :host-registry:assembleDebug :host-registry:testDebugUnitTest` + → BUILD SUCCESSFUL, **17 JVM unit tests green**; DataStore-backed tests are androidTest (device QA). + +### S1 renderer spike — relocated to the head of AW3 (it gates A16, the XL renderer task). + +**Wave R2 complete.** Android modules now: 6 pure-JVM (250 tests) + `:app` + `:host-registry` + +`:client-tls-android` (framework, assemble+unit-verified here; instrumented deferred to device QA). + +--- + +## [x] Wave AW2 — A15 `:app` wiring + DI FREEZE (DONE, orchestrator-verified 2026-07-09) + +The convergence/contract task that gates all of AW4. Build → arch+coroutine cross-review (BOTH blocked) +→ fix (6 must-fixes) → arch+coroutine re-verify (caught 1 more real bug) → residual fix → orchestrator +added the final test lock + independent gate. `./gradlew :app:assembleDebug :app:testDebugUnitTest +:session-core:test` all green (EventBus 6 tests, RetainedSessionHolder 4, DesignSpec 5, SessionEngine 15). + +- Files: `app/src/main/.../wiring/{EventBus, TerminalSessionController, RetainedSessionHolder, + AppEnvironment, ColdStartPolicy, ApiClientFactory, SessionEngineFactory}.kt` + `di/{NetworkModule, + TlsModule, StorageModule, SessionModule}.kt` (replaced A13's placeholder AppModule) + tests. +- **Frozen contracts (AW4 depends on these):** per-session `EventBus` (NOT @Singleton) with two typed + sub-streams `outputBytes(): Flow` (Output-only, UTF-8 decode off-Main via flowOn) + + `controlEvents(): Flow` (non-Output); `TerminalSessionController` (start() decoupled + from bind(), eager mailbox registration); `RetainedSessionHolder` (@HiltViewModel, config-survival, + single-session-for-life BoundKey guard, close-join-before-cancel teardown); `AppEnvironment.warmUp()` + (off-Main first identity touch); `ColdStartPolicy`; factories for per-host ApiClient + per-session + SessionEngine. mTLS bridge: A11 `IdentityRepository` → A7 `ClientIdentityProvider` → ONE shared + `OkHttpClient` (.cache(null), WS+REST) — construction cycle broken via a shared `ConnectionPool`. +- **Coordination change (authorized):** `SessionEngine.close()` now returns its `Job` so teardown can + `join()` the clean detach BEFORE cancelling the confinement scope (structural close-before-cancel). +- **Cross-review caught + fixed:** (HIGH) EventBus was @Singleton → cross-session event bleed → now + per-session; (HIGH) bind()-started-engine-before-UI-subscribe → lost `attached`+replay → decoupled + start() + eager registration; (HIGH) eager DI did AndroidKeyStore/Tink I/O on Main → dagger.Lazy + + warmUp() on IO; (HIGH, re-verify) `register()`'s finally-close made the reused controller's output/ + events flows DEAD after one config-change (blank terminal on rotation) → mailbox now lives for the + session (receiveAsFlow consume=false), released only by `EventBus.close()` at teardown; + typed + sub-streams, structural close, bind mis-route guard, @Volatile started. R10 (per-consumer UNLIMITED + channels: slow consumer neither stalls nor drops) unit-tested; confinement invariant #4 intact. + +## [~] Wave AW3 — A16 `:terminal-view` DONE · A17 KeyBar + A18 ThumbnailPipeline building + +### [x] S1 + A16 `:terminal-view` (XL) — the renderer, DONE + orchestrator-verified 2026-07-09 +**S1 gate PASSED headless** (no device): a Termux `TerminalEmulator` (no JNI/subprocess) fed canned WS +bytes renders into a readable `TerminalBuffer` ("hello", cursorCol 5); DSR reply routes out via +`TerminalOutput.write`→engineSend; DECCKM `ESC[?1h` flips to `ESC OA`. **Termux resolved via JitPack** +`com.github.termux.termux-app:{terminal-view,terminal-emulator}:v0.118.0` (only transitive dep +androidx.annotation; no guava → R2 shim unneeded; Apache-2.0 scope held — never termux-shared/app). +JitPack repo + coordinate added to settings/catalog; `:terminal-view` enabled. +- Files: `terminal-view/src/main/.../terminalview/{RemoteTerminalSession, RemoteTerminalView, + TerminalGridMath, NoOpTerminalSessionClient, LinkPolicy}.kt` + 5 test suites (17 unit tests). +- `RemoteTerminalSession` = the FORK (no subprocess): a single confined-writer via an ordered + `Channel` (Feed/Resize/Bind/Unbind); append chunked 4 KiB + yield(); `onScreenUpdated` + posted to an injected main dispatcher. pendingOutput queued-then-flushed in order (ESC[0m preserved). + Resize math extracted as pure `TerminalGridMath` (R5), JVM-tested. Titles pass RAW (no :session-core + edge — :app wires TitleSanitizer); OSC-52 declined; http/https LinkPolicy. +- **Sound deviation (§6.1):** at v0.118.0 Termux `TerminalView`/`TerminalSession` are `final` → + `RemoteTerminalView` is a COMPOSITION wrapper binding the forked emulator via the public `mEmulator` + field (rendering stays 100% stock). Documented. +- **Cross-review (correctness approved; threading blocked→fixed):** HIGH — bind published `mEmulator` + to the renderer SYNC before the pendingOutput flush → bind-time UI-read-vs-confined-append race; + fixed by routing the publish through the Bind command (flush on confined thread, THEN post + publish+onScreenUpdated to Main together; test captures buffer state AT publish). MEDIUM — confined + command loop now try/catches (rethrows Cancellation) so a hostile escape byte can't freeze output. + LOW — added `kotlinx-coroutines-android` (else `Dispatchers.Main` crashes on-device). Verified: + `:terminal-view:assembleDebug` + `testDebugUnitTest` 17/17 green. +- **Accepted (not a defect):** steady-state append runs concurrently with the UI-thread `onDraw` — this + is the intended §6.2 single-WRITER model (matches upstream Termux; torn read self-corrects next frame). +- Deferred to device QA (§7): glyph/true-color rendering, IME/CJK, selection→clipboard, link-tap, + rotation-rebind, real font-metric→grid (TerminalRenderer metrics are package-private → measured + on-device, fed to the tested TerminalGridMath). + +### [x] A17 KeyBar (DONE, green) — `app/.../components/KeyBar.kt` + test (12 tests) +Compose mobile key-bar overlay porting `public/keybar.ts` (17 buttons, order/glyphs 1:1). Each tap → +`KeyByteMap.bytes(key)` sent VERBATIM via an injected `onSend` (A21 wires `controller::sendInput`), +bypassing IME/BasicTextField (soft keyboard never pops). Pinned above IME via +`windowInsetsPadding(WindowInsets.ime.union(navigationBars))`, horizontally scrollable, hidden when +`screenWidthDp>768` OR a hardware keyboard is present. **DECCKM split** = pure +`HardwareKeyRouter.resolve()`: ⇧Tab→ESC[Z, Esc→ESC, Ctrl+{C,R,O,L,T,B,D}→control byte; everything else +(arrows/Enter/Tab/unmapped) → `DeferToTerminal` so Termux `KeyHandler` emits DECCKM-correct `ESC OA` +(never hardcoded `ESC[A`). Verified `:app` 27 tests green (KeyBar 12). Cross-reviewed: reviewers +stalled (infra), but the byte contract is unit-locked. Device-QA: layout/IME-inset/real key delivery. +A21 install: `remote.onKeyCommand = { kc,ev -> HardwareKeyRouter.handle(kc,ev,controller::sendInput) }` ++ `KeyBar(onSend = controller::sendInput)`. + +### [x] A18 ThumbnailPipeline (DONE, green — review deferred) — `app/.../wiring/ThumbnailPipeline.kt` + test (5 tests) +Off-screen preview rasterisation (§6.7): `LruCache` keyed `(sessionId, lastOutputAt)` (unchanged ⇒ +cache hit, no re-render); fair `Semaphore(2)` FIFO fetch+render cap; in-flight dedup via +`Map>` under a `Mutex` (2nd same-key request awaits the 1st); failure caches a +PLACEHOLDER (no retry storm); preview fetched over the shared **mTLS** `ApiClient` (`GET +/live-sessions/:id/preview`, 256 KiB cap); off-screen `TerminalEmulator` (no view) → `Canvas` cell +painter behind a `Rasterizer` seam (bitmap draw device-QA'd; concurrency/cache logic JVM-tested). +**NOTE:** A18's build agent + all 4 AW3 reviewers stalled ~3.5h (infra degradation); the agent had +written the file first. Orchestrator fixed a 1-char test-name error (illegal `;` in a backtick name), +re-verified `:app:assembleDebug + testDebugUnitTest` → **32 tests green**. A18's formal cross-review was +NOT run — flag it for the AW6 acceptance pass (concurrency code warrants a second look). + +**Wave AW3 complete.** `:terminal-view` (17) + `:app` (32) + 6 pure-JVM (250) all green. The terminal +render path — the plan's dominant risk — is proven and hardened. + +--- + +## [~] Wave AW4 — 11 UI screens (A19–A29) + +### [x] A21 Terminal screen + reconnect/EXIT banner + new-session-in-cwd (built, green — integration pass in flight) +Files: `components/ReconnectBanner.kt` (pure `bannerModel` reducer: exited/failed OUTRANK +connecting/reconnecting; exit -1 spawn-failure; REPLAY_TOO_LARGE no-spinner+new-session), `wiring/ +TerminalSessionControllerImpl.kt` (wraps the holder controller `by base`, wires output→feedRemote +before start, OSC title→TitleSanitizer), `screens/TerminalScreen.kt` (generation-keyed AndroidView, +KeyBar/HardwareKeyRouter, FLAG_SECURE privacy shade, new-session-in-cwd → attach(null,cwd)). `:app` 64 +tests (ReconnectBanner 8 + NewSessionInCwd 3). + +### [x] A22 Gate/cockpit surfaces (built, green, reviewer approved) +Files: `viewmodels/GateViewModel.kt` (two-line epoch stale-guard, decide via controller.decideGate, +approve.mode top-level) + `components/{GateBanner (tool 2-btn), PlanGateSheet (plan 3-way sheet), +TelemetryChips, AwayDigestView}.kt`. Built but not yet composed into the terminal screen (→ integration). + +### [x] Terminal-path integration + A15 seam refinement (DONE, orchestrator-verified — `:app` 66 tests) +Both re-verifiers confirmed (arch 7/0 broken cosmetic; kotlin 7/0). Frozen-contract changes: +`TerminalSessionController.events` (single mailbox) → `controlEvents()` (per-consumer mailbox, no +split); `RetainedSessionHolder.generation` → `mutableIntStateOf`; holder now owns +`TerminalSessionControllerImpl` + `RemoteTerminalSession` (config-survival §6.6 — emulator+scrollback +content survive rotation, no replay round-trip); GateViewModel + gate surfaces composed into +TerminalScreen (haptic reduce-motion gated); warmUp error/retry pane. Confinement invariant #4 intact. +Residual (cosmetic, tracked for AW6 doc pass): dangling `events` KDoc refs; scroll *offset* (not +content) resets on rotation. Tests: TerminalSessionControllerTest (2, multi-consumer no-split), +RetainedSessionHolder (4), GateViewModel (10). + +### [x] AW4b — A19 Pairing + A20 Session list + A24 Diff + A25 Quick-reply (DONE, `:app` 111 tests green) +Pre-staged CameraX (1.4.1) + ML Kit barcode (17.3.0) for A19 QR. All 4 reviewers approve/warn, 0 must-fix. +- **A19 Pairing** — QR (CameraX+MLKit) / manual, both through the ONE `HostEndpoint.fromBaseUrl` + validator; confirm-before-network; §5.4 tiers (public explicit-ack; tunnel cert-gated choke point + retry can't bypass; fail-safe unknown→PUBLIC; tunnel-TLS→client-cert copy); no cert import; inert. +- **A20 Session list** — STARTED-scoped poll, status/telemetry/thumbnail/cols×rows/sanitized-title/ + unread-dot rows, swipe-kill (optimistic, 404=success), multi-host switch, host menu (配对新主机/设备证书). + SessionListViewModelTest 11. +- **A24 Diff** — staged flag as STRING "1"/"0", files→hunks→lines flatten, lossy decode, inert read-only. + A24's builder died mid-run (API drop) leaving a missing `LaunchedEffect` import (blocked :app compile) + + no test; orchestrator added the import + wrote DiffViewModelTest (8 tests: fromWire, flatten order, + lossy decode, VM phase transitions + staged re-fetch). +- **A25 Quick-reply** — DataStore CRUD palette (immutable), verbatim send, float-while-gate-held. + +### [x] AW4c — A23 Projects + A27 CertScreen + A28 Timeline + A29 Cold-start (DONE, `:app` 164 tests green) +Reviews: A27 approve; A23/A29 warn; all 0 must-fix except A28's "wired to away-digest expand" (a +composition/nav wiring → tracked for the app-assembly pass below, not a code defect). +- **A23 Projects** — ProjectGrouping BYTE-IDENTICAL group keys to web/iOS (" active"/" other" sentinels, + first-seen-cased namespace keys, MIN_GROUP_SIZE=2); favourites/collapse via `/prefs` unknown-key- + preserving (R11: never PUT if never loaded); detail page; open-Claude-here → attach(null,cwd,"claude\r"); + grid column seam (A26 owns full adaptive). ProjectsViewModelTest 12. +- **A27 ClientCertScreen** — import(SAF .p12)/rotate/remove over `:client-tls-android` IdentityRepository; + validate-before-persist keeps prior cert on bad passphrase; summary (issuer/subject-CN/expiry/EXPIRED); + passphrase scrubbed, never logged; confirm-gated remove; inert. ClientCertViewModelTest 14. +- **A28 Timeline** — TimelineSheet(ModalBottomSheet) + TimelineViewModel over `/events`, lossy decode, + tokenized event colors. TimelineViewModelTest 16. (away-digest→sheet wiring → app-assembly.) +- **A29 Cold-start** — SessionActivityBridge (set LastSessionStore on adopted / clear on exited), + ContinueLastBanner (stack+sidebar), ColdStartPolicy route (no host→pairing). Tests: bridge 6 + policy 2. + +### [x] A26 Adaptive large-screen + nav shell (DONE, review warn 0 must-fix) +Pure `LayoutPolicy.mode(WindowWidth)` (compact→STACK, medium/expanded→LIST_DETAIL) + `PointerMenuPolicy. +enabled(mode, sw>=600)` — both JVM-tested (LayoutPolicyTest 8). `AdaptiveHome` = NavigationSuiteScaffold + +ListDetailPaneScaffold keyed on `currentWindowAdaptiveInfo()`. `PointerContextMenu` = `pointerInput` +secondary-click → `DropdownMenu` (NOT ContextMenuArea), gated. Full NavGraph wiring → A32/app-assembly. + +**Wave AW4 COMPLETE — all 11 screens (A19–A29) built + verified; `:app` green.** + +--- + +## [~] Wave AW5 — push + deep-links (built; wiring → app-assembly) + +### [x] A30 FCM client (green — `:app` 222 tests, PushDecisionTest 14) +`push/{FcmService, NotificationBuilder, DenyBroadcastReceiver, AllowTrampolineActivity, PushPayload, +PushDecisionSubmitter, PushTokenSink}.kt`. Data-only payload → notification built LOCALLY; **Deny** = +BroadcastReceiver (goAsync + expedited POST, no UI, auth-free); **Allow** = translucent +excludeFromRecents FragmentActivity hosting `BiometricPrompt` → POST (R1). Token single-use, only in +FLAG_IMMUTABLE extras → ApiClient, never persisted/logged (test-asserted); payload minimized (no +cwd/command/bytes). Multi-host: tries each paired host until 204 (foreign host 403s harmlessly). +Biometric = BIOMETRIC_STRONG+negativeButton (minSdk-29 valid combo). Manifest (service/receiver/activity) ++ `Theme.WebTerm.Translucent` **applied by orchestrator; `:app` assembles**. + +### [x] A31 PushRegistrar (green — PushRegistrarTest 7) +`push/PushRegistrar.kt` — POST /push/fcm-token to each paired host, self-heal (token rotation / new host +/ removal→DELETE), token not logged. Review HIGH: not yet invoked in the app → **app-assembly** (DI: +@Binds PushTokenSink + on-start/host-change invocation). + +### [x] A32 DeepLinkRouter + NavGraph (green, review approve — DeepLinkRouterTest) +`nav/{DeepLinkRouter, NavGraph}.kt` — pure `webterminal://open?host=&join=` + verified App-Links parser, +v4-UUID whitelist on host+join (invalid ignored+counted, never partial), one router for cold/warm/push. +Manifest intent-filters (custom scheme + `autoVerify` https) **applied by orchestrator; `:app` assembles**. + +### [x] APP-ASSEMBLY — the app is a RUNNABLE whole (DONE, `:app` 227 tests, APK builds) +`MainActivity` (@AndroidEntryPoint) → `WebTermNavHost` composing all screens; start destination from +`ColdStartPolicy`; inter-screen callbacks wired (row/project/continue → terminal, host-menu → +pairing/cert, away-digest → timeline); `AdaptiveHome` for large screens; PushRegistrar DI (@Binds +PushTokenSink + on-start/host-change invocation); deep links via DeepLinkRouter (onCreate+onNewIntent); +warmUp off-Main; SessionActivityBridge drives LastSessionStore. Hilt graph valid+acyclic. Manifest +(push components + deep-link intent-filters) applied by orchestrator. +- **Review HIGH fixed by orchestrator:** cold-start deep links were dropped by a composition race + (navigate before the NavHost graph was set) → moved `DeepLinkEffect` inside the `route != null` + branch so it runs only after `WebTermNavHost` composed (graph set). Re-verified `:app` 227 green. +- Minor gaps tracked (see DEVICE_QA_CHECKLIST): push-tap→specific-gate, diff inbound link, host-remove + UI, the A21 Termux-view reflection shim. + +**Wave AW5 COMPLETE.** + +--- + +## [x] Wave AW6 — acceptance (DONE, orchestrator-verified 2026-07-09) +- **[x] A36 coverage gate:** Kover 0.9.1 applied to the 4 pure modules with an enforced `koverVerify` + `minBound(80)` rule. Measured line coverage: **wire-protocol 91.0%** (added a HostClassifier/TimelineEvent + test — the type lives here but was only tested cross-module, so its own report read 77% → 91%), + **session-core 95.2%, api-client 89.2%, client-tls 96.4%**. `./gradlew koverVerify` GREEN. +- **[x] Device-QA checklist:** `android/DEVICE_QA_CHECKLIST.md` — captures **A34** (instrumented E2E vs + real `npm start`: attach→attached→output, reconnect replay, kill, hook/decision, **bad-Origin reject + F9**) + **A35** (pair→attach→type→approve macrobenchmark) + the **S2** FCM real-handset spike + every + per-task device-deferred item + the deploy artifacts (google-services.json, assetlinks.json, FCM_* env). + A34/A35 are device/instrumented by definition (plan §7) — deferred to real hardware, not run here. +- **[x] FINAL UNIFIED GATE (orchestrator, measured):** `./gradlew test :app:assembleDebug + :app:testDebugUnitTest koverVerify` → **BUILD SUCCESSFUL**. ~**484 JVM tests** (257 pure/transport + + 227 `:app`) + Kover ≥80% + the full `:app` APK + `:terminal-view`/`:host-registry`/`:client-tls-android` + all assemble. Server side (A33 FCM) unchanged since R1 (1533 server tests green). + +--- + +## ✅ ANDROID CLIENT COMPLETE — all 36 plan tasks (A1–A36 + S1) landed + +Modules: `:wire-protocol :session-core :api-client :client-tls :test-support :transport-okhttp` (pure +JVM) + `:app :terminal-view :host-registry :client-tls-android` (framework). The Android app **builds to +an APK** with functional parity to the iOS P0+P1 scope; the Termux renderer seam (the plan's dominant +risk) is proven working headless. S2 (FCM real-device delivery) is the one spike inherently requiring +≥2 physical handsets — documented in the checklist. Everything device-observable is deferred to +`DEVICE_QA_CHECKLIST.md` per plan §7 (no emulator/Firebase/host in this env). + +**Method:** every wave ran a Workflow (TDD builders → 2-lens adversarial cross-review → fix workflow → +adversarial re-verify → orchestrator-independent gate). Cross-validation caught + fixed ~20 real defects +before they reached a screen (transport socket leaks, engine close-during-dial + gate double-send, an +FCM token-refresh regression, a security bug where a REMOVED client cert stayed live in TLS, cross-session +event bleed, blank-terminal-on-rotation, terminal-freeze-on-hostile-bytes, cold-start deep-link drop, …). + +**GIT / not committed:** all Android work is on-disk + test-verified but UNCOMMITTED — a concurrent +session was running the tunnel-automation workstream on this same `feat/tunnel-automation` working tree, +so the orchestrator held ALL git ops and never touched `docs/PROGRESS_LOG.md`. **TODO for the user:** +reconcile the two sessions, then commit the Android lane by explicit path and fold this file into +`docs/PROGRESS_LOG.md`. + +### Tracked APP-ASSEMBLY items (a final wiring pass composes screens into the NavGraph + connects +### callbacks): away-digest expand → TimelineSheet (A28); host menu → pairing/cert (A20→A19/A27); +### project open → terminal (A23→A21); continue-last → terminal (A29); session row → terminal (A20→A21); +### and the A21 reflection shim → `libs.termux.terminal.view` on `:app` (dep now available to add). +Cross-review of A21/A22 surfaced interlocking frozen-seam gaps being fixed together: (1 HIGH) +`controller.events` was a single mailbox but banner+gate both collect → event split → made +multi-consumer (`controlEvents()` per consumer); (2 HIGH) `holder.generation` made Compose-observable +(mutableIntStateOf) so a real-background bump re-keys the AndroidView; (3 HIGH) RemoteTerminalSession/ +controller moved into the config-surviving RetainedSessionHolder so §6.6 rotation keeps the emulator+ +scrollback; (4) A22 gate surfaces composed into TerminalScreen; (5) warmUp error handling. A21 also +flagged for later: the reflection shim to reach the impl-scoped Termux `TerminalView` from `:app` +(clean fix = add `libs.termux.terminal.view` to `:app`); adopted-session-id survival across +real-background → A29's LastSessionStore. + +### Remaining AW4 screens (independent of the terminal path, next batches): +A19 Pairing (QR/confirm/tiers) · A20 Session list+dashboard+host menu · A23 Projects+grouping+detail · +A24 Diff viewer · A25 Quick-reply chips+palette · A27 ClientCertScreen · A28 Timeline sheet (wires to +A22 away-digest expand) · A29 Cold-start UX (ColdStartPolicy + LastSessionStore lifecycle) · A26 +Adaptive large-screen (LAST — deps A20+A21). +- **[ ] AW2** A15 wiring/DI freeze (carry the A14 scope-close follow-up above). +- **[ ] AW3** A16/A17/A18 · **[ ] AW4** A19–A29 · **[ ] AW5** A30–A32 · **[ ] AW6** A34–A36. diff --git a/android/README.md b/android/README.md index 8f418ea..ba1859a 100644 --- a/android/README.md +++ b/android/README.md @@ -103,7 +103,10 @@ AGP library module compiled against SDK 35 and produced an AAR): - **SDK location:** `/usr/local/share/android-commandlinetools` (installed via `brew install --cask android-commandlinetools`). -- **Installed packages:** `platform-tools`, `platforms;android-35`, `build-tools;35.0.0`. +- **Installed packages:** `platform-tools`, `platforms;android-35`, `platforms;android-36`, + `build-tools;35.0.0`, `build-tools;36.0.0`. (`:app` compiles against SDK **36** — the + Kotlin-2.3.21-contemporaneous androidx/Compose line refuses SDK 35; `platforms;android-37` + is not fetchable here as the cmdline-tools are too old to parse the v4 repo XML.) - **`android/local.properties`** (gitignored) points Gradle at it: `sdk.dir=/usr/local/share/android-commandlinetools`. - **Shell env** (for `sdkmanager`/`adb`): `export ANDROID_HOME=/usr/local/share/android-commandlinetools`. @@ -116,7 +119,15 @@ AGP library module compiled against SDK 35 and produced an AAR): compatible with Gradle 9.6.1. - **Gotcha:** AGP 9 has **built-in Kotlin** — apply ONLY the android plugin. Adding `org.jetbrains.kotlin.android` errors with "no longer required since AGP 9.0". -- Module block: `android { namespace = "…"; compileSdk = 35; defaultConfig { minSdk = 29 } }`. +- Module block: `android { namespace = "…"; compileSdk = 36; defaultConfig { minSdk = 29 } }`. + (Framework modules target `compileSdk = 36`; `targetSdk` stays `35` per plan §2.) +- **`:app` UI-stack version matrix** (A13, proven `:app:assembleDebug` green): AGP 9.2.1 · + Kotlin 2.3.21 · Compose-compiler plugin `org.jetbrains.kotlin.plugin.compose` = 2.3.21 · + Compose BOM `2025.11.01` (→ material3 1.4.0, ui/foundation 1.9.5, material3.adaptive 1.2.0, + material3-adaptive-navigation-suite 1.4.0) · Hilt (dagger) 2.60.1 via KSP `2.3.9` · + androidx core-ktx 1.17.0 / activity-compose 1.12.4 / lifecycle 2.10.0. Apply plugins: + `android.application` + `kotlin.plugin.compose` + `ksp` + `dagger.hilt.android` (NEVER + `kotlin.android`). Bump these together with `compileSdk 37` once platform 37 is installable. To add more SDK pieces later (e.g. an emulator image for instrumented tests): `sdkmanager "system-images;android-35;google_apis;arm64-v8a" "emulator"`. diff --git a/android/api-client/build.gradle.kts b/android/api-client/build.gradle.kts index d0a3445..ba631c9 100644 --- a/android/api-client/build.gradle.kts +++ b/android/api-client/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.kover) } kotlin { @@ -25,3 +26,14 @@ dependencies { tasks.test { useJUnitPlatform() } + +// A36 acceptance gate: >=80% line coverage on this pure module (plan §7). +kover { + reports { + verify { + rule { + minBound(80) + } + } + } +} diff --git a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/LiveSessionInfo.kt b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/LiveSessionInfo.kt index adebdcd..f25e0e6 100644 --- a/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/LiveSessionInfo.kt +++ b/android/api-client/src/main/kotlin/wang/yaojia/webterm/api/models/LiveSessionInfo.kt @@ -30,6 +30,10 @@ public data class LiveSessionInfo( @Serializable(with = ClaudeStatusSerializer::class) val status: ClaudeStatus = ClaudeStatus.UNKNOWN, val exited: Boolean, val cwd: String? = null, + /** Tab title / derived label (e.g. 'claude', 'shell'), OSC-title-derived server-side (`src/types.ts` + * `title?`). HOST/ATTACKER-influenced — run through `TitleSanitizer` before any UI/list use (§8). + * Additive optional field; absent → `null`. */ + val title: String? = null, /** Current PTY size (latest-writer-wins on the server). */ val cols: Int, val rows: Int, diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 05e7fb8..8010a60 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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().configureEach { + useJUnitPlatform() } -*/ diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..45f9aa2 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -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. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..350ade0 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/wang/yaojia/webterm/MainActivity.kt b/android/app/src/main/java/wang/yaojia/webterm/MainActivity.kt new file mode 100644 index 0000000..e09fe06 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/MainActivity.kt @@ -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 ``s deliver, never validate; + * - renders [WebTermNavHost] with the cold-start start destination ([AppEnvironment.coldStartPolicy]). + */ +@AndroidEntryPoint +public class MainActivity : FragmentActivity() { + + @Inject + public lateinit var appEnvironment: AppEnvironment + + @Inject + public lateinit var pushCoordinator: PushCoordinator + + /** The latest deep-link URI to route (null = nothing pending). Fed by onCreate + onNewIntent. */ + private val pendingDeepLink = MutableStateFlow(null) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Build the shared OkHttp/mTLS stack OFF-Main before the first bind (warmUp hops to IO itself); + // a real failure is swallowed (the terminal screen surfaces its own retry, FIX 5) but cancellation + // is rethrown so a destroyed Activity tears the warm-up down cleanly. + lifecycleScope.launch { + try { + appEnvironment.warmUp() + } catch (cancel: CancellationException) { + throw cancel + } catch (_: Throwable) { + // best-effort pre-warm; TerminalScreen re-runs warmUp with an actionable retry. + } + } + // App start: register the current FCM token with every paired host (best-effort self-heal). + pushCoordinator.registerAllHosts() + + handleDeepLinkIntent(intent) + + setContent { + WebTermTheme { + Surface(modifier = Modifier.fillMaxSize()) { + val navController = rememberNavController() + NotificationPermissionGate() + ColdStartHost( + env = appEnvironment, + navController = navController, + pendingDeepLink = pendingDeepLink, + onHostPaired = { host -> pushCoordinator.registerHost(host) }, + ) + } + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + handleDeepLinkIntent(intent) + } + + /** Capture a VIEW deep link's data URI; a launcher/push-body intent has none → nothing to route. */ + private fun handleDeepLinkIntent(intent: Intent?) { + pendingDeepLink.value = intent?.data?.toString() + } +} + +/** + * Resolve the cold-start destination ([AppEnvironment.coldStartPolicy]) then render the graph. Shows a + * spinner until the (suspend) host-presence read completes. + */ +@Composable +private fun ColdStartHost( + env: AppEnvironment, + navController: NavHostController, + pendingDeepLink: MutableStateFlow, + onHostPaired: (Host) -> Unit, +) { + val startRoute by produceState(initialValue = null, key1 = env) { + value = NavRoutes.startRouteFor(env.coldStartPolicy.initialRoute()) + } + val route = startRoute + if (route == null) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else { + WebTermNavHost( + env = env, + startRoute = route, + navController = navController, + onHostPaired = onHostPaired, + ) + // Route pending deep links ONLY here — inside the `route != null` branch, so `WebTermNavHost` + // (which calls navController.setGraph) has composed and the graph is set before the effect body + // runs. On a cold launch the VIEW intent is captured before setContent, but its navigation is + // deferred to this point instead of racing an unset graph (which silently dropped the link). + DeepLinkEffect(pending = pendingDeepLink, navController = navController) + } +} + +/** + * Route a pending deep-link URI through [DeepLinkRouter] (the ONE whitelist parser) and navigate only a + * validated route; invalid/ambiguous links are ignored (never partially applied). Clears the pending + * value once handled. + */ +@Composable +private fun DeepLinkEffect( + pending: MutableStateFlow, + navController: NavHostController, +) { + val uri by pending.collectAsStateWithLifecycle() + LaunchedEffect(uri) { + val target = uri ?: return@LaunchedEffect + NavRoutes.forDeepLink(DeepLinkRouter.route(target))?.let { route -> + runCatching { navController.navigate(route) } + } + pending.value = null + } +} + +/** Request POST_NOTIFICATIONS once on first launch (API 33+); a denial only stops push from being SHOWN. */ +@Composable +private fun NotificationPermissionGate() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return + val context = LocalContext.current + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { } + LaunchedEffect(Unit) { + val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + if (!granted) launcher.launch(Manifest.permission.POST_NOTIFICATIONS) + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/WebTermApp.kt b/android/app/src/main/java/wang/yaojia/webterm/WebTermApp.kt new file mode 100644 index 0000000..e7a68f7 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/WebTermApp.kt @@ -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() diff --git a/android/app/src/main/java/wang/yaojia/webterm/components/AwayDigestView.kt b/android/app/src/main/java/wang/yaojia/webterm/components/AwayDigestView.kt new file mode 100644 index 0000000..7a52258 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/components/AwayDigestView.kt @@ -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() + 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 = {}, + ) + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/components/ContinueLastBanner.kt b/android/app/src/main/java/wang/yaojia/webterm/components/ContinueLastBanner.kt new file mode 100644 index 0000000..1242a49 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/components/ContinueLastBanner.kt @@ -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), + ) + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/components/GateBanner.kt b/android/app/src/main/java/wang/yaojia/webterm/components/GateBanner.kt new file mode 100644 index 0000000..b1f6a3f --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/components/GateBanner.kt @@ -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 = { _, _ -> }, + ) + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/components/KeyBar.kt b/android/app/src/main/java/wang/yaojia/webterm/components/KeyBar.kt new file mode 100644 index 0000000..0b84e2d --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/components/KeyBar.kt @@ -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- / ⇧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 = 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- chords the app claims — exactly the web key-bar's control keys. */ + private val CTRL_LETTER_KEYS: Map = 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- 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 = {}) + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/components/PlanGateSheet.kt b/android/app/src/main/java/wang/yaojia/webterm/components/PlanGateSheet.kt new file mode 100644 index 0000000..1350cbb --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/components/PlanGateSheet.kt @@ -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 = {}, + ) + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/components/PointerContextMenu.kt b/android/app/src/main/java/wang/yaojia/webterm/components/PointerContextMenu.kt new file mode 100644 index 0000000..0f19099 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/components/PointerContextMenu.kt @@ -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, + 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) diff --git a/android/app/src/main/java/wang/yaojia/webterm/components/QuickReply.kt b/android/app/src/main/java/wang/yaojia/webterm/components/QuickReply.kt new file mode 100644 index 0000000..7be9f9a --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/components/QuickReply.kt @@ -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, + 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 = {}, + ) + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/components/QuickReplyStore.kt b/android/app/src/main/java/wang/yaojia/webterm/components/QuickReplyStore.kt new file mode 100644 index 0000000..d4448bd --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/components/QuickReplyStore.kt @@ -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 = 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 + + /** + * 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 + + /** + * 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 + + /** Remove the chip with [id]. Unknown [id] is a no-op. Returns the new list. */ + public suspend fun remove(id: String): List + + /** + * 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 +} + +// ── 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.adding(text: String, id: String): List = + 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.editing(id: String, text: String): List = + 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.removingId(id: String): List = + filter { it.id != id } + +/** Immutable move [from] → [to]; out-of-range (either end) is a no-op. */ +internal fun List.moving(from: Int, to: Int): List { + 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): String = + buildJsonArray { + chips.forEach { chip -> + addJsonObject { + put("id", chip.id) + put("text", chip.text) + } + } + }.toString() + + fun decode(raw: String?): List { + 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` double. + */ +public class DataStoreQuickReplyStore( + private val dataStore: DataStore, + private val newId: () -> String = { UUID.randomUUID().toString() }, +) : QuickReplyStore { + + override suspend fun loadAll(): List { + val raw = dataStore.data.first()[PALETTE_KEY] + return if (raw == null) QuickReplyDefaults.chips else QuickReplyCodec.decode(raw) + } + + override suspend fun add(text: String): List = + writeTransform { it.adding(text, newId()) } + + override suspend fun edit(id: String, text: String): List = + writeTransform { it.editing(id, text) } + + override suspend fun remove(id: String): List = + writeTransform { it.removingId(id) } + + override suspend fun move(fromIndex: Int, toIndex: Int): List = + writeTransform { it.moving(fromIndex, toIndex) } + + private suspend fun writeTransform( + transform: (List) -> List, + ): List { + lateinit var updated: List + 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 = QuickReplyDefaults.chips, + private val newId: () -> String = { UUID.randomUUID().toString() }, +) : QuickReplyStore { + private val mutex = Mutex() + private var chips: List = initial.toList() + + override suspend fun loadAll(): List = mutex.withLock { chips } + + override suspend fun add(text: String): List = + write { it.adding(text, newId()) } + + override suspend fun edit(id: String, text: String): List = + write { it.editing(id, text) } + + override suspend fun remove(id: String): List = + write { it.removingId(id) } + + override suspend fun move(fromIndex: Int, toIndex: Int): List = + write { it.moving(fromIndex, toIndex) } + + private suspend fun write( + transform: (List) -> List, + ): List = mutex.withLock { + val updated = transform(chips) + chips = updated + updated + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/components/ReconnectBanner.kt b/android/app/src/main/java/wang/yaojia/webterm/components/ReconnectBanner.kt new file mode 100644 index 0000000..715dc92 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/components/ReconnectBanner.kt @@ -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 = {}) + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/components/SessionListRow.kt b/android/app/src/main/java/wang/yaojia/webterm/components/SessionListRow.kt new file mode 100644 index 0000000..61e44e9 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/components/SessionListRow.kt @@ -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(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, +) diff --git a/android/app/src/main/java/wang/yaojia/webterm/components/TelemetryChips.kt b/android/app/src/main/java/wang/yaojia/webterm/components/TelemetryChips.kt new file mode 100644 index 0000000..a246499 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/components/TelemetryChips.kt @@ -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 { + val chips = mutableListOf() + 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, + ) + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/designsystem/DesignSpec.kt b/android/app/src/main/java/wang/yaojia/webterm/designsystem/DesignSpec.kt new file mode 100644 index 0000000..acca97c --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/designsystem/DesignSpec.kt @@ -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 +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/designsystem/Primitives.kt b/android/app/src/main/java/wang/yaojia/webterm/designsystem/Primitives.kt new file mode 100644 index 0000000..33a6e62 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/designsystem/Primitives.kt @@ -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) + } + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/designsystem/Theme.kt b/android/app/src/main/java/wang/yaojia/webterm/designsystem/Theme.kt new file mode 100644 index 0000000..ad9f2a8 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/designsystem/Theme.kt @@ -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 = + 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, +) diff --git a/android/app/src/main/java/wang/yaojia/webterm/designsystem/Tokens.kt b/android/app/src/main/java/wang/yaojia/webterm/designsystem/Tokens.kt new file mode 100644 index 0000000..df036fe --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/designsystem/Tokens.kt @@ -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 +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/designsystem/Typography.kt b/android/app/src/main/java/wang/yaojia/webterm/designsystem/Typography.kt new file mode 100644 index 0000000..251c0ef --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/designsystem/Typography.kt @@ -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" +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/di/NetworkModule.kt b/android/app/src/main/java/wang/yaojia/webterm/di/NetworkModule.kt new file mode 100644 index 0000000..ab0165e --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/di/NetworkModule.kt @@ -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) +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/di/PushModule.kt b/android/app/src/main/java/wang/yaojia/webterm/di/PushModule.kt new file mode 100644 index 0000000..a5aed4d --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/di/PushModule.kt @@ -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` [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 +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/di/SessionModule.kt b/android/app/src/main/java/wang/yaojia/webterm/di/SessionModule.kt new file mode 100644 index 0000000..c0c38ae --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/di/SessionModule.kt @@ -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) +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/di/StorageModule.kt b/android/app/src/main/java/wang/yaojia/webterm/di/StorageModule.kt new file mode 100644 index 0000000..0cf3920 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/di/StorageModule.kt @@ -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."`) 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 = + PreferenceDataStoreFactory.create { context.preferencesDataStoreFile(DATASTORE_NAME) } + + @Provides + @Singleton + public fun provideHostStore(dataStore: DataStore): HostStore = + DataStoreHostStore(dataStore) + + @Provides + @Singleton + public fun provideLastSessionStore(dataStore: DataStore): LastSessionStore = + DataStoreLastSessionStore(dataStore) + + private const val DATASTORE_NAME: String = "webterm" +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/di/TlsModule.kt b/android/app/src/main/java/wang/yaojia/webterm/di/TlsModule.kt new file mode 100644 index 0000000..94c636f --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/di/TlsModule.kt @@ -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) + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/nav/CancellationSafe.kt b/android/app/src/main/java/wang/yaojia/webterm/nav/CancellationSafe.kt new file mode 100644 index 0000000..0727c67 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/nav/CancellationSafe.kt @@ -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 runCatchingCancellable(block: () -> T): Result = + try { + Result.success(block()) + } catch (cancel: CancellationException) { + throw cancel + } catch (error: Throwable) { + Result.failure(error) + } diff --git a/android/app/src/main/java/wang/yaojia/webterm/nav/DeepLinkRouter.kt b/android/app/src/main/java/wang/yaojia/webterm/nav/DeepLinkRouter.kt new file mode 100644 index 0000000..6499b87 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/nav/DeepLinkRouter.kt @@ -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=&join=` — the **custom scheme** (registered by the + * `` on the launcher Activity), and + * - `https:///open?host=&join=` — the **verified App Link** (an + * `autoVerify=true` ``, 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:///.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 = setOf("", "/") + + /** URI paths accepted for the App Link (`/open`, with or without a trailing slash). */ + private val APP_LINK_PATHS: Set = 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): 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>, 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> { + if (rawQuery.isNullOrEmpty()) return emptyMap() + val out = LinkedHashMap>() + 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 +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/nav/LayoutPolicy.kt b/android/app/src/main/java/wang/yaojia/webterm/nav/LayoutPolicy.kt new file mode 100644 index 0000000..8c40b0d --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/nav/LayoutPolicy.kt @@ -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 +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/nav/NavGraph.kt b/android/app/src/main/java/wang/yaojia/webterm/nav/NavGraph.kt new file mode 100644 index 0000000..0ba6072 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/nav/NavGraph.kt @@ -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 A19–A29). + * + * 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 ``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 + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/nav/Panes.kt b/android/app/src/main/java/wang/yaojia/webterm/nav/Panes.kt new file mode 100644 index 0000000..9305f74 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/nav/Panes.kt @@ -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(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(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), + ) + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/nav/ProjectsHome.kt b/android/app/src/main/java/wang/yaojia/webterm/nav/ProjectsHome.kt new file mode 100644 index 0000000..c0102ae --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/nav/ProjectsHome.kt @@ -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(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(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, + ) +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/nav/SessionsHome.kt b/android/app/src/main/java/wang/yaojia/webterm/nav/SessionsHome.kt new file mode 100644 index 0000000..4d6a300 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/nav/SessionsHome.kt @@ -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(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(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, + ) +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/nav/TerminalHost.kt b/android/app/src/main/java/wang/yaojia/webterm/nav/TerminalHost.kt new file mode 100644 index 0000000..5fb0c38 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/nav/TerminalHost.kt @@ -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(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 + } + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/push/AllowTrampolineActivity.kt b/android/app/src/main/java/wang/yaojia/webterm/push/AllowTrampolineActivity.kt new file mode 100644 index 0000000..4a2d12a --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/push/AllowTrampolineActivity.kt @@ -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" + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/push/DenyBroadcastReceiver.kt b/android/app/src/main/java/wang/yaojia/webterm/push/DenyBroadcastReceiver.kt new file mode 100644 index 0000000..fa20e61 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/push/DenyBroadcastReceiver.kt @@ -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 + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/push/FcmService.kt b/android/app/src/main/java/wang/yaojia/webterm/push/FcmService.kt new file mode 100644 index 0000000..a2f12bd --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/push/FcmService.kt @@ -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 + + 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" + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/push/NotificationBuilder.kt b/android/app/src/main/java/wang/yaojia/webterm/push/NotificationBuilder.kt new file mode 100644 index 0000000..c8afb06 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/push/NotificationBuilder.kt @@ -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, + ) + + // ── 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 +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/push/PushCoordinator.kt b/android/app/src/main/java/wang/yaojia/webterm/push/PushCoordinator.kt new file mode 100644 index 0000000..f9501d7 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/push/PushCoordinator.kt @@ -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) + } + } + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/push/PushDecisionSubmitter.kt b/android/app/src/main/java/wang/yaojia/webterm/push/PushDecisionSubmitter.kt new file mode 100644 index 0000000..fbbf350 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/push/PushDecisionSubmitter.kt @@ -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, + 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, + ) + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/push/PushPayload.kt b/android/app/src/main/java/wang/yaojia/webterm/push/PushPayload.kt new file mode 100644 index 0000000..f3161f2 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/push/PushPayload.kt @@ -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` 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): 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() } + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/push/PushRegistrar.kt b/android/app/src/main/java/wang/yaojia/webterm/push/PushRegistrar.kt new file mode 100644 index 0000000..5e78ccb --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/push/PushRegistrar.kt @@ -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) + } + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/push/PushRegistrarTokenSink.kt b/android/app/src/main/java/wang/yaojia/webterm/push/PushRegistrarTokenSink.kt new file mode 100644 index 0000000..bb0bb93 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/push/PushRegistrarTokenSink.kt @@ -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` [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) } + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/push/PushTokenSink.kt b/android/app/src/main/java/wang/yaojia/webterm/push/PushTokenSink.kt new file mode 100644 index 0000000..3b2c64b --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/push/PushTokenSink.kt @@ -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` 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 +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/AdaptiveHome.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/AdaptiveHome.kt new file mode 100644 index 0000000..788ca8f --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/AdaptiveHome.kt @@ -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 = 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 = 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 +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/ClientCertScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/ClientCertScreen.kt new file mode 100644 index 0000000..15f6871 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/ClientCertScreen.kt @@ -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 = {}, + ) + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/DiffScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/DiffScreen.kt new file mode 100644 index 0000000..761546f --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/DiffScreen.kt @@ -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) { + 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( + 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 = {}, + ) + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt new file mode 100644 index 0000000..7b9addb --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt @@ -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 = 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() } + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/ProjectDetailScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/ProjectDetailScreen.kt new file mode 100644 index 0000000..969da40 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/ProjectDetailScreen.kt @@ -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 = {}) + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/ProjectsScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/ProjectsScreen.kt new file mode 100644 index 0000000..c300888 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/ProjectsScreen.kt @@ -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, + 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 = {}, + ) + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/SessionListScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/SessionListScreen.kt new file mode 100644 index 0000000..fde7fce --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/SessionListScreen.kt @@ -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, + 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) } + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/TerminalScreen.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/TerminalScreen.kt new file mode 100644 index 0000000..cef28ca --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/TerminalScreen.kt @@ -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 diff --git a/android/app/src/main/java/wang/yaojia/webterm/screens/TimelineSheet.kt b/android/app/src/main/java/wang/yaojia/webterm/screens/TimelineSheet.kt new file mode 100644 index 0000000..2f1432a --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/screens/TimelineSheet.kt @@ -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) { + 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 = {}) } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ClientCertViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ClientCertViewModel.kt new file mode 100644 index 0000000..1f4a53f --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ClientCertViewModel.kt @@ -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 = _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), + ) diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/DiffViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/DiffViewModel.kt new file mode 100644 index 0000000..fa4475a --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/DiffViewModel.kt @@ -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 = _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 = 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 { + val rows = ArrayList() + 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) +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, +) + +public data class DiffResult(val files: List, 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 `://host[:port]/projects/diff?path=&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 = + "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() +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/GateViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/GateViewModel.kt new file mode 100644 index 0000000..9029880 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/GateViewModel.kt @@ -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 = controller.controlEvents() + + private val _uiState = MutableStateFlow(GateUiState()) + + /** The single snapshot the gate/cockpit surfaces render from. */ + public val uiState: StateFlow = _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( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + /** One `Unit` per NEW gate (rising edge). Composable → haptic, gated by `LocalReduceMotion`. */ + public val gateArrivals: SharedFlow = _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 + } + } diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt new file mode 100644 index 0000000..72871e6 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt @@ -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.Entry()) + + /** The single snapshot the pairing UI renders from. */ + public val uiState: StateFlow = _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 -> + "连接超时。主机可能不可达,或响应过慢。" + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectDetailViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectDetailViewModel.kt new file mode 100644 index 0000000..8ccc9d5 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectDetailViewModel.kt @@ -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.Loading) + + /** The single snapshot `ProjectDetailScreen` renders from. */ + public val phase: StateFlow = _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) } + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectsViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectsViewModel.kt new file mode 100644 index 0000000..be41fe9 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/ProjectsViewModel.kt @@ -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 = _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, query: String): List { + 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, favourites: Set): List = + projects.sortedWith( + compareBy { if (favourites.contains(it.path)) 0 else 1 } + .thenByDescending { it.lastActiveMs ?: 0L }, + ) + + /** + * The card label inside a namespace group: the tail after the `.` 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, favourites: Set): List { + 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 { maxLastActive(it.projects) }.thenBy { it.label }, + ) + + val groups = ArrayList() + 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, + favourites: Set, + ): Pair, List> { + val bucketOrder = ArrayList() + val buckets = HashMap() + val other = ArrayList() + 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() + 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): Long = + items.fold(0L) { acc, p -> maxOf(acc, p.lastActiveMs ?: 0L) } + + private fun makeGroup( + key: String, + label: String, + kind: ProjectGroupKind, + items: List, + favourites: Set, + ): 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) +} + +/** 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, + /** 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 = emptyList(), + /** Group key → collapsed (only `true` stored; expanded is the default). */ + val collapsedGroups: Map = emptyMap(), + val projects: List = emptyList(), + val openRequest: ProjectOpenRequest? = null, +) { + /** Search-filtered → web/iOS-identical grouping (favourites-first sort happens inside each group). */ + val groups: List + 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 + 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 = 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 活跃" +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/SessionListViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/SessionListViewModel.kt new file mode 100644 index 0000000..10f741f --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/SessionListViewModel.kt @@ -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 = _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 = 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? { + 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, ledger: UnreadLedger): List = + 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 = 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 = 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 + + /** `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 = 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 + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/viewmodels/TimelineViewModel.kt b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/TimelineViewModel.kt new file mode 100644 index 0000000..15baa80 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/viewmodels/TimelineViewModel.kt @@ -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, + private val zone: ZoneId = ZoneId.systemDefault(), +) { + private val _phase = MutableStateFlow(TimelinePhase.Loading) + + /** The single snapshot [TimelineSheet] renders from. */ + public val phase: StateFlow = _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, + ): 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, + 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) : 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 = "重试" +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/ApiClientFactory.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/ApiClientFactory.kt new file mode 100644 index 0000000..c50c856 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/ApiClientFactory.kt @@ -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` 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, +) { + /** + * 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()) +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt new file mode 100644 index 0000000..2ac98da --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt @@ -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, + private val httpTransportLazy: Lazy, + private val termTransportLazy: Lazy, +) { + /** + * 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 + } + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/ColdStartPolicy.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/ColdStartPolicy.kt new file mode 100644 index 0000000..89114d4 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/ColdStartPolicy.kt @@ -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 +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/EventBus.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/EventBus.kt new file mode 100644 index 0000000..b6421c3 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/EventBus.kt @@ -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, + 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() + + /** + * 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 { + 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 = + 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 = 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): 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() + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/RetainedSessionHolder.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/RetainedSessionHolder.kt new file mode 100644 index 0000000..56c54d1 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/RetainedSessionHolder.kt @@ -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 get() = titleState + + /** The retained reconnect-banner model, folded from its OWN control mailbox; survives a rotation. */ + private val bannerFlow = MutableStateFlow(BannerModel.Hidden) + public val bannerState: StateFlow = 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 + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/SessionActivityBridge.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/SessionActivityBridge.kt new file mode 100644 index 0000000..c1c416e --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/SessionActivityBridge.kt @@ -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) { + 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 + } + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/SessionEngineFactory.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/SessionEngineFactory.kt new file mode 100644 index 0000000..86fe8f7 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/SessionEngineFactory.kt @@ -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` 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, +) { + /** + * 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 = { emptyList() }, + ): SessionEngine = + SessionEngine( + transport = transport.get(), + endpoint = endpoint, + scope = scope, + initialSessionId = sessionId, + initialCwd = cwd, + awayTimeline = awayTimeline, + ) +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalSessionController.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalSessionController.kt new file mode 100644 index 0000000..99e11d9 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalSessionController.kt @@ -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 + + /** + * 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 + + /** + * 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 = 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 = 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() + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalSessionControllerImpl.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalSessionControllerImpl.kt new file mode 100644 index 0000000..a227af3 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalSessionControllerImpl.kt @@ -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 + } + } +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalThumbnailRasterizer.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalThumbnailRasterizer.kt new file mode 100644 index 0000000..56aa575 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalThumbnailRasterizer.kt @@ -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 +} diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/ThumbnailPipeline.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/ThumbnailPipeline.kt new file mode 100644 index 0000000..cd81fa5 --- /dev/null +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/ThumbnailPipeline.kt @@ -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>` 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>() + 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 = 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(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)) + } +} diff --git a/android/app/src/main/kotlin/wang/yaojia/webterm/app/.gitkeep b/android/app/src/main/kotlin/wang/yaojia/webterm/app/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..ee891fd --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + WebTerm + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..2df8fcc --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..a58ffec --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/android/app/src/test/java/wang/yaojia/webterm/components/AwayDigestViewTest.kt b/android/app/src/test/java/wang/yaojia/webterm/components/AwayDigestViewTest.kt new file mode 100644 index 0000000..f0b2d17 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/components/AwayDigestViewTest.kt @@ -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)) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/components/ContinueLastBannerTest.kt b/android/app/src/test/java/wang/yaojia/webterm/components/ContinueLastBannerTest.kt new file mode 100644 index 0000000..45adb04 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/components/ContinueLastBannerTest.kt @@ -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(" ")) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/components/KeyBarTest.kt b/android/app/src/test/java/wang/yaojia/webterm/components/KeyBarTest.kt new file mode 100644 index 0000000..8beb7e3 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/components/KeyBarTest.kt @@ -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 = 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() + + // 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() + 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)) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/components/QuickReplyTest.kt b/android/app/src/test/java/wang/yaojia/webterm/components/QuickReplyTest.kt new file mode 100644 index 0000000..525168b --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/components/QuickReplyTest.kt @@ -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`. + * 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 double (no Android Context needed) ───── + private class FakePreferencesDataStore( + initial: Preferences = emptyPreferences(), + ) : DataStore { + private val state = MutableStateFlow(initial) + override val data: Flow = 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() + 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" }) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/components/ReconnectBannerTest.kt b/android/app/src/test/java/wang/yaojia/webterm/components/ReconnectBannerTest.kt new file mode 100644 index 0000000..d108436 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/components/ReconnectBannerTest.kt @@ -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"))) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/components/TelemetryChipsTest.kt b/android/app/src/test/java/wang/yaojia/webterm/components/TelemetryChipsTest.kt new file mode 100644 index 0000000..86ee45c --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/components/TelemetryChipsTest.kt @@ -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(), 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(), telemetryChipModels(StatusTelemetry(model = " ", at = 0L))) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/designsystem/DesignSpecTest.kt b/android/app/src/test/java/wang/yaojia/webterm/designsystem/DesignSpecTest.kt new file mode 100644 index 0000000..aa7e3ab --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/designsystem/DesignSpecTest.kt @@ -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) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/nav/DeepLinkRouterTest.kt b/android/app/src/test/java/wang/yaojia/webterm/nav/DeepLinkRouterTest.kt new file mode 100644 index 0000000..8a9e3cf --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/nav/DeepLinkRouterTest.kt @@ -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) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/nav/LayoutPolicyTest.kt b/android/app/src/test/java/wang/yaojia/webterm/nav/LayoutPolicyTest.kt new file mode 100644 index 0000000..895574f --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/nav/LayoutPolicyTest.kt @@ -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) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/nav/NavRoutesTest.kt b/android/app/src/test/java/wang/yaojia/webterm/nav/NavRoutesTest.kt new file mode 100644 index 0000000..19fddb7 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/nav/NavRoutesTest.kt @@ -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)) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/push/PushDecisionTest.kt b/android/app/src/test/java/wang/yaojia/webterm/push/PushDecisionTest.kt new file mode 100644 index 0000000..3737763 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/push/PushDecisionTest.kt @@ -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() + 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) }, + ) +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/push/PushRegistrarTest.kt b/android/app/src/test/java/wang/yaojia/webterm/push/PushRegistrarTest.kt new file mode 100644 index 0000000..bf36cdd --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/push/PushRegistrarTest.kt @@ -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, + 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() + 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() + 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() + 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() + 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() + 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() + val errors = mutableListOf>() + 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() + val registrar = PushRegistrar(InMemoryHostStore(), apiFor = { RecordingApi(it.id, calls) }, onError = { _, _ -> }) + + registrar.registerAllHosts(token) + + assertTrue(calls.isEmpty()) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/screens/NewSessionInCwdTest.kt b/android/app/src/test/java/wang/yaojia/webterm/screens/NewSessionInCwdTest.kt new file mode 100644 index 0000000..0556e09 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/screens/NewSessionInCwdTest.kt @@ -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")) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/ClientCertViewModelTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/ClientCertViewModelTest.kt new file mode 100644 index 0000000..19df983 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/ClientCertViewModelTest.kt @@ -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() + 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"))) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/DiffViewModelTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/DiffViewModelTest.kt new file mode 100644 index 0000000..5e8ee28 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/DiffViewModelTest.kt @@ -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() // 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 + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GateViewModelTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GateViewModelTest.kt new file mode 100644 index 0000000..c5ed22b --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/GateViewModelTest.kt @@ -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(Channel.UNLIMITED) + val decisions = mutableListOf>() + + override fun controlEvents(): Flow = channel.receiveAsFlow() + override val output: Flow = 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>(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() + 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) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/PairingViewModelTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/PairingViewModelTest.kt new file mode 100644 index 0000000..cfc4ea1 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/PairingViewModelTest.kt @@ -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() + 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") + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/ProjectsViewModelTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/ProjectsViewModelTest.kt new file mode 100644 index 0000000..20ed3af --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/ProjectsViewModelTest.kt @@ -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 = emptyList(), + private val prefsValue: UiPrefs? = UiPrefs.create(), + private val prefsThrows: Boolean = false, + ) : ProjectsGateway { + val putCalls = mutableListOf() + override suspend fun projects(): List = 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() + }, + ) +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/SessionListViewModelTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/SessionListViewModelTest.kt new file mode 100644 index 0000000..93e1147 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/SessionListViewModelTest.kt @@ -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) : HostStore { + override suspend fun loadAll(): List = hosts + override suspend fun upsert(host: Host): List = hosts + override suspend fun remove(id: String): List = hosts + } + + /** Fake gateway: returns a canned list and records kills; [killError] scripts the DELETE outcome. */ + private class FakeGateway( + private val list: List, + private val killError: Throwable? = null, + ) : SessionListGateway { + val killed = mutableListOf() + override suspend fun liveSessions(): List = 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 { + 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()) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/viewmodels/TimelineViewModelTest.kt b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/TimelineViewModelTest.kt new file mode 100644 index 0000000..e5a8ec3 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/viewmodels/TimelineViewModelTest.kt @@ -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 = emptyList()) { + val ids = mutableListOf() + suspend fun fetch(id: UUID): List { + 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) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/ColdStartPolicyTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/ColdStartPolicyTest.kt new file mode 100644 index 0000000..c62b118 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/ColdStartPolicyTest.kt @@ -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()) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/EventBusTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/EventBusTest.kt new file mode 100644 index 0000000..aeb5b40 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/EventBusTest.kt @@ -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() + val gotControl = mutableListOf() + + // 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() + + 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() + val slowReceived = mutableListOf() + val release = CompletableDeferred() + + 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() + + // 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() + + 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() + 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 + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/RetainedSessionHolderTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/RetainedSessionHolderTest.kt new file mode 100644 index 0000000..1158563 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/RetainedSessionHolderTest.kt @@ -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 { + val transport = FakeTermTransport() + val factory = SessionEngineFactory(Lazy { 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) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/SessionActivityBridgeTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/SessionActivityBridgeTest.kt new file mode 100644 index 0000000..fed5a82 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/SessionActivityBridgeTest.kt @@ -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)) + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/TerminalSessionControllerTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/TerminalSessionControllerTest.kt new file mode 100644 index 0000000..a88e967 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/TerminalSessionControllerTest.kt @@ -0,0 +1,96 @@ +package wang.yaojia.webterm.wiring + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +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 +import wang.yaojia.webterm.session.Connection +import wang.yaojia.webterm.session.ConnectionState +import wang.yaojia.webterm.session.Output +import wang.yaojia.webterm.session.SessionEngine +import wang.yaojia.webterm.session.SessionEvent +import wang.yaojia.webterm.testsupport.FakeTermTransport +import wang.yaojia.webterm.wire.HostEndpoint + +/** + * FIX 1 — control events are MULTI-SUBSCRIBABLE at the controller surface. The reconnect banner AND the + * gate presenter (AND, later, the A28 timeline) each call [TerminalSessionController.controlEvents]; each + * MUST receive the FULL control-event stream. A single shared `Channel.receiveAsFlow()` mailbox would + * SPLIT events (each event reaches only one of two competing collectors) — this test proves the fix: + * every call mints its OWN [EventBus] mailbox, so N collectors all see every control event, in order. + * + * Driven through a REAL [SessionEngine] over a [FakeTermTransport] (so the whole + * controlEvents→EventBus→engine pump is exercised), on a virtual-time scope (mirrors the holder test). + */ +@OptIn(ExperimentalCoroutinesApi::class) +class TerminalSessionControllerTest { + + private fun endpoint(): HostEndpoint = HostEndpoint.fromBaseUrl("http://localhost:3000")!! + + @Test + fun `two controlEvents collectors both receive every control event (no split)`() = runTest { + val scope = CoroutineScope(StandardTestDispatcher(testScheduler) + SupervisorJob()) + val transport = FakeTermTransport() + val bus = EventBus(decodeDispatcher = StandardTestDispatcher(testScheduler)) + val engine = SessionEngine(transport = transport, endpoint = endpoint(), scope = scope) + val controller = DefaultTerminalSessionController(engine, bus, scope) + + // TWO independent control consumers — each its OWN mailbox, registered EAGERLY before start(). + val gotA = mutableListOf() + val gotB = mutableListOf() + val a = controller.controlEvents() + val b = controller.controlEvents() + scope.launch { a.collect { gotA += it } } + scope.launch { b.collect { gotB += it } } + + // start() wires the engine→bus pump, THEN the engine: Connecting → (connect) → Connected. + controller.start() + advanceUntilIdle() + // Detach: Closed (server PTY survives). Gives a 3-event control stream. + controller.close() + advanceUntilIdle() + + // NO SPLIT: BOTH collectors saw the SAME, ordered control stream — not one event each. + assertEquals(gotA, gotB) + assertTrue(gotA.size >= 2, "expected multiple control events, got $gotA") + assertTrue(gotA.contains(Connection(ConnectionState.Connected)), "missing Connected in $gotA") + assertTrue(gotA.contains(Connection(ConnectionState.Closed)), "missing Closed in $gotA") + // Control mailboxes must NOT carry Output (that is the single [output] stream's job, FIX 4). + assertTrue(gotA.none { it is Output }, "control stream leaked Output frames: $gotA") + + scope.cancel() + } + + @Test + fun `a third controlEvents subscriber also receives the full stream (A28 extensibility)`() = runTest { + // Adding a THIRD consumer needs no change to the pump — just another controlEvents() mailbox, + // still lossless and un-split. + val scope = CoroutineScope(StandardTestDispatcher(testScheduler) + SupervisorJob()) + val transport = FakeTermTransport() + val bus = EventBus(decodeDispatcher = StandardTestDispatcher(testScheduler)) + val engine = SessionEngine(transport = transport, endpoint = endpoint(), scope = scope) + val controller = DefaultTerminalSessionController(engine, bus, scope) + + val streams = List(3) { controller.controlEvents() } + val received = List(3) { mutableListOf() } + streams.forEachIndexed { i, flow -> scope.launch { flow.collect { received[i] += it } } } + + controller.start() + advanceUntilIdle() + controller.close() + advanceUntilIdle() + + assertEquals(received[0], received[1]) + assertEquals(received[1], received[2]) + assertTrue(received[0].contains(Connection(ConnectionState.Connected))) + + scope.cancel() + } +} diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/ThumbnailPipelineTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/ThumbnailPipelineTest.kt new file mode 100644 index 0000000..b885e71 --- /dev/null +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/ThumbnailPipelineTest.kt @@ -0,0 +1,214 @@ +package wang.yaojia.webterm.wiring + +import android.graphics.Bitmap +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +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.assertNotSame +import org.junit.jupiter.api.Assertions.assertSame +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.models.SessionPreview +import java.util.UUID + +/** + * The [ThumbnailPipeline] policy (plan §6.7), driven at JVM speed with fakes so the cache/concurrency/ + * dedup/failure logic is verified without android.graphics (the pixel raster lives behind the + * [ThumbnailRasterizer] seam — [CanvasThumbnailRasterizer] — and is device-QA'd). + * + * Proves: + * - an unchanged `(sessionId, lastOutputAt)` ⇒ cache hit, NO re-render; a changed `lastOutputAt` ⇒ render; + * - the `Semaphore(2)` caps concurrent fetch+render at 2; + * - two concurrent same-key requests SHARE one render (one fetch, one raster, one bitmap); + * - a fetch failure caches the placeholder and is NOT retried on the next request (no hot-loop storm); + * - [PreviewCap] caps the body at 256 KiB, keeping the tail. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ThumbnailPipelineTest { + + // ── Fakes ───────────────────────────────────────────────────────────────────────────────── + + /** Counts + optionally blocks fetches, tracking peak concurrency, so the semaphore cap is observable. */ + private class FakePreviewSource( + private val block: CompletableDeferred? = null, + private val failure: Throwable? = null, + ) : PreviewSource { + var fetchStarts = 0 + var active = 0 + var maxActive = 0 + + override suspend fun fetch(sessionId: UUID): SessionPreview { + fetchStarts++ + active++ + if (active > maxActive) maxActive = active + try { + block?.await() + failure?.let { throw it } + return SessionPreview(id = sessionId, cols = 80, rows = 24, data = "hi") + } finally { + active-- + } + } + } + + /** Hands back a fresh mock bitmap per raster (so identity distinguishes renders) + a fixed placeholder. */ + private class FakeRasterizer : ThumbnailRasterizer { + var rasterizeCount = 0 + val placeholderBitmap: Bitmap = mockk() + + override fun rasterize(preview: SessionPreview): Bitmap { + rasterizeCount++ + return mockk() + } + + override fun placeholder(): Bitmap = placeholderBitmap + } + + /** Map-backed cache seam — keeps LRU KEYING testable without touching the stubbed android.util.LruCache. */ + private class FakeBitmapCache : BitmapCache { + private val map = HashMap() + override fun get(key: ThumbnailKey): Bitmap? = map[key] + override fun put(key: ThumbnailKey, bitmap: Bitmap) { + map[key] = bitmap + } + } + + private fun session(id: UUID, lastOutputAt: Long?): LiveSessionInfo = LiveSessionInfo( + id = id, + createdAt = 0L, + clientCount = 0, + exited = false, + cols = 80, + rows = 24, + lastOutputAt = lastOutputAt, + ) + + // ── Tests ───────────────────────────────────────────────────────────────────────────────── + + @Test + fun `unchanged lastOutputAt hits cache with no re-render, changed lastOutputAt re-renders`() = runTest { + val raster = FakeRasterizer() + val pipeline = ThumbnailPipeline( + previewSource = FakePreviewSource(), + rasterizer = raster, + cache = FakeBitmapCache(), + dispatcher = StandardTestDispatcher(testScheduler), + ) + val id = UUID.randomUUID() + + val first = async { pipeline.thumbnail(session(id, lastOutputAt = 100L)) } + advanceUntilIdle() + val b1 = first.await() + assertEquals(1, raster.rasterizeCount) + + // Same key → cache hit, no second render. + val second = async { pipeline.thumbnail(session(id, lastOutputAt = 100L)) } + advanceUntilIdle() + val b2 = second.await() + assertEquals(1, raster.rasterizeCount) + assertSame(b1, b2) + + // Changed lastOutputAt → new key → re-render. + val third = async { pipeline.thumbnail(session(id, lastOutputAt = 200L)) } + advanceUntilIdle() + val b3 = third.await() + assertEquals(2, raster.rasterizeCount) + assertNotSame(b1, b3) + } + + @Test + fun `semaphore caps concurrent fetch and render at two`() = runTest { + val gate = CompletableDeferred() + val source = FakePreviewSource(block = gate) + val pipeline = ThumbnailPipeline( + previewSource = source, + rasterizer = FakeRasterizer(), + cache = FakeBitmapCache(), + dispatcher = StandardTestDispatcher(testScheduler), + ) + + val jobs = (0 until 5).map { launch { pipeline.thumbnail(session(UUID.randomUUID(), lastOutputAt = 1L)) } } + advanceUntilIdle() + + // Only 2 permits → only 2 fetches in flight; the other 3 are parked on the semaphore. + assertEquals(2, source.active) + assertEquals(2, source.maxActive) + assertEquals(2, source.fetchStarts) + + gate.complete(Unit) + advanceUntilIdle() + + assertEquals(2, source.maxActive) // never exceeded the cap across the whole run + assertEquals(5, source.fetchStarts) + jobs.forEach { assertTrue(it.isCompleted) } + } + + @Test + fun `two concurrent same-key requests share one render`() = runTest { + val gate = CompletableDeferred() + val source = FakePreviewSource(block = gate) + val raster = FakeRasterizer() + val pipeline = ThumbnailPipeline( + previewSource = source, + rasterizer = raster, + cache = FakeBitmapCache(), + dispatcher = StandardTestDispatcher(testScheduler), + ) + val same = session(UUID.randomUUID(), lastOutputAt = 7L) + + val a = async { pipeline.thumbnail(same) } + val b = async { pipeline.thumbnail(same) } + advanceUntilIdle() + assertEquals(1, source.fetchStarts) // deduped: a single in-flight fetch + + gate.complete(Unit) + advanceUntilIdle() + + assertEquals(1, source.fetchStarts) + assertEquals(1, raster.rasterizeCount) + assertSame(a.await(), b.await()) // both callers get the SAME bitmap + } + + @Test + fun `fetch failure caches placeholder and is not retried`() = runTest { + val source = FakePreviewSource(failure = RuntimeException("boom")) + val raster = FakeRasterizer() + val pipeline = ThumbnailPipeline( + previewSource = source, + rasterizer = raster, + cache = FakeBitmapCache(), + dispatcher = StandardTestDispatcher(testScheduler), + ) + val s = session(UUID.randomUUID(), lastOutputAt = 5L) + + val r1 = async { pipeline.thumbnail(s) } + advanceUntilIdle() + assertSame(raster.placeholderBitmap, r1.await()) + assertEquals(1, source.fetchStarts) + assertEquals(0, raster.rasterizeCount) + + // Same key again → cached placeholder → NO second fetch (no retry storm). + val r2 = async { pipeline.thumbnail(s) } + advanceUntilIdle() + assertSame(raster.placeholderBitmap, r2.await()) + assertEquals(1, source.fetchStarts) + } + + @Test + fun `preview cap keeps the tail when over 256 KiB and is a no-op under the cap`() { + val big = "A".repeat(PreviewCap.MAX_PREVIEW_BYTES + 100) + "TAIL" + val capped = PreviewCap.cap(SessionPreview(UUID.randomUUID(), 80, 24, big)) + assertTrue(capped.data.toByteArray(Charsets.UTF_8).size <= PreviewCap.MAX_PREVIEW_BYTES) + assertTrue(capped.data.endsWith("TAIL")) + + val small = SessionPreview(UUID.randomUUID(), 80, 24, "hello") + assertSame(small, PreviewCap.cap(small)) + } +} diff --git a/android/client-tls-android/build.gradle.kts b/android/client-tls-android/build.gradle.kts index 1db4556..809163d 100644 --- a/android/client-tls-android/build.gradle.kts +++ b/android/client-tls-android/build.gradle.kts @@ -1,30 +1,53 @@ // ───────────────────────────────────────────────────────────────────────────── -// :client-tls-android — the FRAMEWORK half of ClientTLS: AndroidKeyStore import -// (device-bound, non-exportable key), Tink AEAD cert-chain-at-rest storage, and -// connectionPool.evictAll() on rotation. Tested instrumented on a real device. -// Depends on the pure :client-tls module for the parse/selection logic. +// :client-tls-android — the FRAMEWORK half of ClientTLS (plan A11). // -// SCAFFOLD STUB ONLY — COMMENTED OUT in settings.gradle.kts (no Android SDK here). -// TODO(android-sdk): enable when an Android SDK is available. +// The ONE key home is AndroidKeyStore: a `.p12` is PARSED via the pure :client-tls +// half (KeyStore("PKCS12"), import-parse only), then the private key is imported +// NON-EXPORTABLE into AndroidKeyStore. Tink AEAD (AndroidKeystore master key) +// encrypts ONLY the cert-chain + metadata blob at rest — never the private key, +// never the .p12, never the passphrase. A re-reading X509KeyManager presents the +// AndroidKeyStore key/chain PER handshake so a mid-run import/rotation is used on +// the NEXT handshake with no relaunch (R4); rotation calls connectionPool.evictAll() +// on the shared OkHttpClient so pooled/resumed connections drop the old identity. +// +// Instrumented tests use the real AndroidKeyStore provider (NOT Robolectric — plan +// §7), so they only COMPILE here (no emulator) and RUN during device QA. +// +// AGP 9 has BUILT-IN Kotlin — apply ONLY com.android.library (adding kotlin.android +// errors). This module must NOT depend on :transport-okhttp; the :app wiring (A15) +// bridges IdentityRepository → :transport-okhttp's ClientIdentityProvider seam. // ───────────────────────────────────────────────────────────────────────────── -/* plugins { id("com.android.library") - alias(libs.plugins.kotlin.android) } android { namespace = "wang.yaojia.webterm.tlsandroid" - compileSdk = 35 - defaultConfig { minSdk = 29 } + compileSdk = 36 + defaultConfig { + minSdk = 29 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } } -kotlin { jvmToolchain(17) } +kotlin { + jvmToolchain(17) +} dependencies { + // Pure half: Pkcs12Parse (parse+validate), ClientKeyManagerLogic (alias truth table), + // CertificateSummary(Reader). `api` so :app sees the shared ParsedClientIdentity/summary types. + // (No :wire-protocol dep — nothing in src/main references wang.yaojia.webterm.wire*.) api(project(":client-tls")) - implementation(project(":wire-protocol")) - // implementation("com.google.crypto.tink:tink-android:1.15.0") + implementation(libs.tink.android) + implementation(libs.okhttp) + // Mutex serializes the two-store rotation commit (single-commit invariant, A11). + implementation(libs.kotlinx.coroutines.core) + + // Instrumented (androidTest) — compile here, run on a device (real AndroidKeyStore). + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.kotlinx.coroutines.core) // runBlocking for suspend mutators } -*/ diff --git a/android/client-tls-android/src/androidTest/kotlin/wang/yaojia/webterm/tlsandroid/AndroidKeyStoreImporterTest.kt b/android/client-tls-android/src/androidTest/kotlin/wang/yaojia/webterm/tlsandroid/AndroidKeyStoreImporterTest.kt new file mode 100644 index 0000000..28e010d --- /dev/null +++ b/android/client-tls-android/src/androidTest/kotlin/wang/yaojia/webterm/tlsandroid/AndroidKeyStoreImporterTest.kt @@ -0,0 +1,73 @@ +package wang.yaojia.webterm.tlsandroid + +import java.security.UnrecoverableKeyException +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import androidx.test.ext.junit.runners.AndroidJUnit4 +import wang.yaojia.webterm.clienttls.CertificateSummaryReader +import wang.yaojia.webterm.clienttls.NoClientIdentityException + +/** + * Instrumented (real AndroidKeyStore — NOT Robolectric, plan §7) tests for the framework import path. + * COMPILES in CI here; RUNS on a device/emulator during device QA. + */ +@RunWith(AndroidJUnit4::class) +class AndroidKeyStoreImporterTest { + + private val alias = "test-device-identity" + private val importer = AndroidKeyStoreImporter(alias = alias) + + @Before + fun clean() = importer.remove() + + @After + fun tearDown() = importer.remove() + + @Test + fun importHappyPath_importsRetrievableNonExportableKey() { + val parsed = importer.import(Fixtures.leafP12(), Fixtures.PASSPHRASE) + + assertEquals("RSA", parsed.keyAlgorithm) + assertEquals(Fixtures.LEAF_SUBJECT_CN, parsed.summary().subjectCommonName) + assertEquals(Fixtures.LEAF_ISSUER_CN, parsed.summary().issuerCommonName) + + assertTrue("key entry present after import", importer.hasInstalledKey()) + assertNotNull("AndroidKeyStore key handle readable", importer.loadPrivateKey()) + val chain = importer.loadCertificateChain() + assertNotNull("cert chain stored alongside key", chain) + assertTrue("chain has at least the leaf", chain!!.isNotEmpty()) + } + + @Test + fun certsOnlyP12_mapsToNoClientIdentity_andImportsNothing() { + assertThrows(NoClientIdentityException::class.java) { + importer.import(Fixtures.trustP12(), Fixtures.PASSPHRASE) + } + assertFalse("nothing persisted for a certs-only .p12", importer.hasInstalledKey()) + } + + @Test + fun importValidatesBeforePersist_badPassphraseCannotClobberPriorIdentity() { + // Install a good identity first. + importer.import(Fixtures.leafP12(), Fixtures.PASSPHRASE) + + // A wrong passphrase must throw during PARSE (before any AndroidKeyStore mutation). + assertThrows(UnrecoverableKeyException::class.java) { + importer.import(Fixtures.leafP12(), Fixtures.WRONG_PASSPHRASE) + } + + // The prior identity must still be present AND unchanged after the failed import. + assertTrue("prior key survives a failed import", importer.hasInstalledKey()) + val leafCnAfter = importer.loadCertificateChain()!!.first().let { + CertificateSummaryReader.summarize(it).subjectCommonName + } + assertEquals(Fixtures.LEAF_SUBJECT_CN, leafCnAfter) + } +} diff --git a/android/client-tls-android/src/androidTest/kotlin/wang/yaojia/webterm/tlsandroid/Fixtures.kt b/android/client-tls-android/src/androidTest/kotlin/wang/yaojia/webterm/tlsandroid/Fixtures.kt new file mode 100644 index 0000000..2441dfa --- /dev/null +++ b/android/client-tls-android/src/androidTest/kotlin/wang/yaojia/webterm/tlsandroid/Fixtures.kt @@ -0,0 +1,31 @@ +package wang.yaojia.webterm.tlsandroid + +import android.util.Base64 + +/** + * Real, deterministic PKCS#12 fixtures (base64-embedded so no external files are needed), reused + * verbatim from the pure `:client-tls` A10 tests so the framework half imports the SAME identity the + * pure parser validated. + * + * - [LEAF_P12]: a `device` PrivateKeyEntry (CN=`t1-android`, RSA, chain `device → ca`) plus a `ca` + * trustedCertEntry — the happy import path. + * - [TRUST_P12]: only the `ca` trustedCertEntry (no key) — the noIdentity case. + * + * Passphrase for both is [PASSPHRASE]; [WRONG_PASSPHRASE] is a mismatch. + */ +internal object Fixtures { + const val PASSPHRASE: String = "test-pass" + const val WRONG_PASSPHRASE: String = "wrong-pass" + + const val LEAF_SUBJECT_CN: String = "t1-android" + const val LEAF_ISSUER_CN: String = "webterm-device-ca" + + fun leafP12(): ByteArray = Base64.decode(LEAF_P12, Base64.DEFAULT) + fun trustP12(): ByteArray = Base64.decode(TRUST_P12, Base64.DEFAULT) + + private const val LEAF_P12: String = + "MIIQ1AIBAzCCEH4GCSqGSIb3DQEHAaCCEG8EghBrMIIQZzCCBa4GCSqGSIb3DQEHAaCCBZ8EggWbMIIFlzCCBZMGCyqGSIb3DQEMCgECoIIFQDCCBTwwZgYJKoZIhvcNAQUNMFkwOAYJKoZIhvcNAQUMMCsEFPpaTqIhQl8k+0dEeRcsfbjIn4dZAgInEAIBIDAMBggqhkiG9w0CCQUAMB0GCWCGSAFlAwQBKgQQaG2cWtSTczZKePLT0eg4RwSCBNDYTsTE5J2fGdHdBTcU/JDiIw9MY2THYSoHqrm/kb5uisEu8YFL8d+owv0oLzf88dSxp0vHGc9js/+Zx8e1POYNskINrOHhyh9tzRD8ecZHc2D/lUX5XG0eGSTFiCQ8LejczN8zRZNBn+RyZ7HmKCYxljF0UJhpOVE7fK0T3QAmTLG7TlknGBoEcQRSE1EaljYwTztBJTwCljQBCCKssL5C1j+28Z5mIJ+yALzTgKWbBoJak5mahF0Ti9FcwMBT7MKTlGlxvfgP2uRt/IRFXDHXJWH9xje/bx4hzegVrd29I/4lBQifN6p6gREHvrb0DvhigmrVtRIRXGjtxgYow5fGyrZsLV2LhzHsrTeo8V3wYjgFGtRa7/cpv/trOtQVz9zIVjk1ahTjNvEYuZKHwfVdSXz3P31BhYJzwWFh0AJNNltowB/rvECXKw8lxuTuy5xoBN9QrImqVV/IeW5sFVkm7NAO58Wy5naQwqbYLm+xV7Pur2xb/Ew4CnnjMzfvmvtl9BE60faK7gnBL/jceSa2HVgYtR7nxZFaYeUzUAq91yaqwZZeJCFMWklXdKxfdBEml1krRDKbbxI3u+JaTXAPdyAATKgg8paY+TiiB+gLAo0qfgaK8azAk6foOabZ9DS1a3KZi0aAmKssNUg9XwdG82I5udsJ4ExR3LPF5rJiU8w85CEMYjoNFFl/H0BCGRh4DLsjK87AaR/5bsm/9RCL5ULBDmXmD43VyP0/9LT+vNRGOzZo9DNvj2srXUX0J3SOer9D5hqYVYPs9kk3obAQfw5IVqnuiPrKf4aoHwZGpMhrBkRxg7Ut2PEeHbFbpVDC95yg1eApiAzxGI2051nWkBvE0JfRNh8jiv+RcezaRMePCNlhCB3yHU6BFwnvUC212S5W40xrpaV4BiMIqc59rvNl1ZeauKU4oWCH58vF/js6q96KavjQ5X+0OCEK6xKK/lT02N0iw5jwhXku8gQig8+hTE5s+zn2PHSW6sJTQiDJUTXMsPNCUP7zl6XQrJp+5j9RzAjqqlQ+Ttc4kJOcWC7bDqv1L0YGr3nsAMM9kLr5eXAESGD/JVVdvHTT9iuFxg7DxSkfZji15Sbji101xN8LjWuu2i1bxszqO46evBfI8f8HRaTYyhoSR02J3lsJ/DEJ398FJF/L2jm3Uf1eoPy0X9c5oP1OYXqb8FdwoL+Jch36gbmcIjl3JPdvelf7OasQeDXu5N/zn+Phi3RoFIb8yFQ4jrj/3bb6w7KOrYUOjVrph5bSHPPPcMbB3ZVXX6/MejXHtSMJOWHkuNLLi6VaZQU3n0rIgRdV+R0lOg8tieR63Jml3yF9udw+Vk9LebPWgko0lYrPVVRj3+XRkJD+1pvXUma6hxDDg2pAwucmkOP8XLU3Ax8uDhHDNxU2MKhSoehZZT8mm7ey6V7Z9FFaU2nozldT6KSJMolAYsIWh0U/38Rs6avnZE+KJHAbJOR/rxxdrZbF4XSQyBlEfEEGmHfxGwKYx9bq88ePM/i3FNN7rhTXj0GOiF/d9/8Rp7V2OR3FC+57eVnHD6FAa4XmszFKsECdq6meNzp7MUsq70/EBTklzJiVG+BSg+mVO2T48RjYty2j1Bev4cvnPzs02HM8WePQ8KlV2YMgsDFAMBsGCSqGSIb3DQEJFDEOHgwAZABlAHYAaQBjAGUwIQYJKoZIhvcNAQkVMRQEElRpbWUgMTc4MzUwNzk1MTAwNjCCCrEGCSqGSIb3DQEHBqCCCqIwggqeAgEAMIIKlwYJKoZIhvcNAQcBMGYGCSqGSIb3DQEFDTBZMDgGCSqGSIb3DQEFDDArBBSVIVu0GwH+Y2n0kk+lsYlHoWvQgAICJxACASAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEEObgqLbs3jUaA3uhbKvA4yAggog8fzgoVKaV7bvhWpbBxWjR5a2Wob6RZ4/itO440YsraKbCu676Ic+IDg4X388OX5d0dip/Y/O6imGiiy/KTWeKbeO82wIQcCnIbo/egCQ3xqECGcR/9OptLKSzrexSQ+CcJNNfs9bT6bkeNtcfUc0QxTuW6U7l9znMSx8qMYYxrK8dzeMqKJt8zNRYPc8CVrSiL8wA//BK5RBfN+fAh5GnJcsvU0LQRka4guGTovv3vwze2nvOPPIcYOdHlflMoKrC07+dFGCpJcWaMjRbnqo/GTcZX1CwTArbbmmv8j78kit01o4AaAyWAUP8NOYGEnC544Fw4ZZwtfG6ZlVqRVB6rWoMrwmzg72HqJuiZxw3u2TvUQTUjgcWnTtvclyPq8sqsVZLnJUWRrGHqHyi3ApNsWU29CjxlyLIgShGPU02AuWps6YAHaXdcV5k5rQPYMgKZOuYrOaXj5lLrmQgf3/B9jN2t95umAFsTK2wLNsbLmTACYsdMSVFiW26xYqRKIu4ZDzZX6OMHN9Ucjix6V8Tkobf0VbXFP4Yctzf6jd8bTUEFvGLWAWm5w/Ui9c6CyS0sch3AzitMUNUbYLPyW1ISsn2ZqlASE1+8vlALKwqvQdoieqbXktaEwSUNwmCD8hvTeABOKGPpWAnIsM8TZGVlVXc2BtkLZH6FFu1QV/8hJLvHv2pvld44ovUxYQ8d1kC9Wosqx+V8oGM3bGDYl0twBVRGoE/St1175AQ4XFFANP5WWutpjn/VjQ/Rf9bbSUm0FJ0WQv9NmwekVDP6XScyfSIV4lb86J7hxo0xq17tqvqTyjc0l/8nq3FaGp3jqu4efPJ14VO1sat1wZvjGSkQ2brg6v2NNYyQXKE54UlAg3e4Ur1exqs6xBrCGjL6t6IMxwGrodx6aeHNf820+NBzmya7Gs2tBunWKD2pnX49Y1ydCgkJs76o16zebD7As+gyzwWlk1zT+a3jblSCmzT/P2NhLhbfrHyNEvFnRoa0owzL8ahuDixaCunoq+YHci0m2JPDZBD9Xms8M9YtE2UHYjWc44ufhkG8QALrvBGS5mU7DMfjxWNzhenN75xvM6xIDl58UE/XYb0hr+yG/9Ho6foZDm251tX8u0lw7Ou/0Jfi0X58tKi52x+oCd3n+lwfhOakKoStPalhbZqwF3qqsf1W3xPRk9puEDo68QPrL7Cbk40oC5InXVml5m1HEUJ0gnblM+XYcplsBqKmAqt2Yk74VU+bmY5B3WJvwIpXS4hxhxm5Bjq27iOT+ooRbu3DvbBLLsjuFh5Fw2APWiLs9tw8fxwl9nrpGUsO7A2dGSAqu6fMb8Q/mKO641yJWkoOAdFf3/NeD7gURHMxTx4PRoFekXWEEFU65djWQ0nnCYfxbXIMgCoVQhQnPCzq7Z0UZni6ve0y160vmyl+CTc8qweDqt5IQoPp2ELrpbkBJa/2/13v2qWQWIScxdLIJ6NYjS7CbvMXpwyj/MDOZ4MyqYItv+XfmrX/h8SWspi5SsHAdm3/kxijAxzulXRkz6K1UUDEu6nzIybVvaOJSq0OuJ6ZAI9II4Spfq0U9aFACWE9Wh2VeIQwdZ8ynjGipIub6sOQiTm7+EWgWkUu/5lNlk2Y1mg/Qn7JxdILLBLLY2Af77RTLqTXp4BIFurhzqeFH7DOiwPfHZ176XIYFF080nyM7FG8xI92XK1gyMgmVlqYDB24jI58wj4QAUaGturyyrNdLDrBKos2oOcS06WDmUJzLJkcmgCKyFwMcwbIEANsngVlTEfBgSoiaszWgNuXeqazIHrpNEWUAY4RvYX+y3uLFFDG4MlxGdv5ftePpdvZZa2MA098o3mgmUnXTejQs3jLTILGLQ03sKIGOLVPvMIyplUyo+KfBVKx9etU2LwvB2bsvUDGwHE2hxbuGkU4Z6lCNqWpV+HHmmjw8uUzXbuaYDxVhtCPb5ZMaEtTBG8lECYP24JXYCQCKyGmqHOkJVDyx7zLKIE4sImM25OozS7ytsSW//fdV0W06Okdf4SqmAzfqUtTGWBq2OftX7PHgEdoGP3K2nVKwUl9jp4j2RPvl3J9MKOr52BdzBSXrc/8e3VG8IfnMNgPtAG8c8W0AYnUNBY1kNn2L3BR9ffh2vEw0PA0Ij7Rsy6WGpLUYM2rGhYypIRUbNJjwBjiW70gdf7NBwC9YCkVO98HBxs0S1P8ZZrv7w4/7VpGCegsspqfsjeXmtSOcFaJShhYN46BxDU4cvEriZY88B1d5lwwokXGEIPheal0PtSt+wBe7OkdJSEEqj1R2HSILyYuGeVgmBzzN+JBLh7lXr7y3GAHspZtW+enGGeUJ6LKxIS92PYAy23jNRUGAPPzCGWWuRu/LHMG7CLi4rdwDkKVI840v79gjwXTq/SlK3yqcDAVhL/Dk5oA7hUIXMviQ0185zPcPj8cZMDIoi/QVaG+l+iFgv15iiurZbB9q0KgQj0VZW4HHA5ESCJrX/8SItRrbiTnkY2JlWKURcDN/NusRVRaNDaXiNVgCaXlAcfmH8V6GSki6XaiSKVX3tZ/+wGMRUVacXyZljrroA0ozFyLoSEj0R/wo9sTc88Edut7UVEq92VViAzyB9Scrvelee895TqPBhCChWG0vWC9Twb7t1yMr4PDnqdcMjUNsCr+Euruwo0nteCmPBkVqhIBfbqjhNDg32zAi7AuZlFFL+H48dtRcVfvu+vg9EN9TgeupbsZW9vm95Y2hWBhyvNDE7/+tNx6NNEzf/fYZTloHaFmdWJiE51RbQn8hDYXyV8x1jvxXxEO2qTUTny1O1YWdIYMm1SGtflN831hoeBr57bt3O2QF0dUeU524nVBA13Rb94MdMdVMmvTjPdathvnErNhONadaq9BuYmp4+PcSPrnXKQz/sUfXrbRkBroPc61sWIIVFCSLPBnwdHqbu5tRsGsZYXofj/K0eUvvI5LKHnKNrUcmnckhGpDFC+N2cSVPKsrt0TND4bRD+PKGLxgb4Qshl/eHRdLd7qlr1Rgvm3hFuE2ajgrUWypgQwGRh0ZWoRqqIsUpRVEfwazBPfmeYupKK9UXlCsj/IYoLo3MohDikpgQJpAEVeAgQy/svYHRpSPt6ankfg0jOS+0s2qQy373xnlm9juraIpCUh8dJa1gkeWiM571c+0cl4gHF6zIfX5uLbBHwxw0K+GtAAYm9Wv53FW0qKyR6mIaJH6wx1M/WpHFd9asEDrSeR3WpSwGvEcah5qQ91LYIWURRDDwpcmyup7Ru5c+v7P4CrE7GUg6W84EUV9c3slgxQJW5SQInaDwSgnbuZhAC0c2kQ1aVoUepgf6BQhE7vQDhrh0kvsxNahMFeQVZa2614m+hL/FSWOU5aRsvMgUw50ldnGrR2dghqXKExTnjrCRVRVZNf9isZQe1+6QaSS2dDnFRq1afZO3knFTk3kZzjJuI7ir06J72ME0wMTANBglghkgBZQMEAgEFAAQgVqKg5coUVzlTAr5tT3DmW4kb3k9o6QdVs6cQQfK+jPQEFAWLju6rft4pPbB1rtQH8nOEVgBFAgInEA==" + + private const val TRUST_P12: String = + "MIIEYgIBAzCCBAwGCSqGSIb3DQEHAaCCA/0EggP5MIID9TCCA/EGCSqGSIb3DQEHBqCCA+IwggPeAgEAMIID1wYJKoZIhvcNAQcBMGYGCSqGSIb3DQEFDTBZMDgGCSqGSIb3DQEFDDArBBSrewHUfPbr+otdl4tmwoDvEJINhgICJxACASAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEENk23MvOQSnK5Pgy9odZiGGAggNg8I3/LcDIrF3uMG9grSSd1zKNVz+MVstaDZiREnwFHI0rUiKwiMUDpjGGSAUVglBCWglNOqWwbmNF8HiWgLcvJhH39mCzGI9n2nos3NceWVDVSh739ZdvY1wQS0jYyuyjfj3ECTuAIytkIMFn75Ar2gUURKjRRxgiZAPXUAm6WohBKYyHuqpSAcPSByvIOoP2IeBNl9GVCnVDGCQ4Q/9a+CjF0tN0lV/ajkn1jFJHbJPDojB5Wp3dyO5elqUEHk+d3zyb0uYGny/SrsH8bzCN7HjklxFVpQ1g3bfkA9RAuvXdORcLgQ0hy+sgLKjtqQ7ecJI/LRm8Kah04KYfdPTbgFYIfLWJbywm3m0BH8iDmxyDlceiWrrUvm1NAn2gHorU7JLAEe+EOvTicB/6ex4awhNnnaC5Jm6i4YZ5f+nmuIqx6KufaYY7m0rQf33+anTu4BRvRolIi08VdJdTQRV7z09ZCHVg0fF6TcX72Vg+QrPFb4J8OtEwonyIP7qaw028jmdLDphZ5xNfmjbviWYYtl8A/PXie7ll0fTV6hjRPEAVUUjuK2zI+536irJfePQFrPpI7OoyiajIuozib9Mkb0fXAG/zCFIV/C5p3IqI+JBZwvwQnXhuoqZcc7bQ+DsT1duwpqxAwT4qt+Cfde1AhLKlxPyx6nfmqJU2esJzY/ytllNTOw9Jh/BOFR4RhxBFKKx5OQSwg/QgDNISW9lXEb9PYH9eZ/OcVmm/3SijFFXYwF3V/vU+gsukX6oceiz3/L1qHRcPzToq494kZ4c9AeaeJqr+qvyX5QX4GC7DVGY6220gIV1mVQxZ9UGcScabZisHjIlr9NlUaZAkvD+yP9M0cQTiPL0F9QHkS9ZX50xiyBIKOS17KmP6uvkX/hpN6THNE5b54pSCe25NlejAn/rWj8CnGkuuqoqoqjSP6g9jGsywExrApsyV31+P6MYfPBv5CBRlV2/WC86bPi9OgPKlBvtoA2ASVHX/iFDjijnyiLBHOjFgHgm2zJBfxrH6m6gZbxUsEnv1QZm8dZ+3BmJZR3qPlv5+mX9wpBnbzCcmNRK+BSidkWzjBvgjN+1VJJba1TBmSMOdsISm9JzNxHnsfJTEQXCMb1VHZuvEZGV5jn8GOySd8yjqnsq8aqRJME0wMTANBglghkgBZQMEAgEFAAQgKozJ1u63f6k1oW1kqQIJZTPJvIjrnRgZjBDXMsa2bAQEFDb/2+4tiu9XeQhYImYtohonZpVwAgInEA==" +} diff --git a/android/client-tls-android/src/androidTest/kotlin/wang/yaojia/webterm/tlsandroid/IdentityRepositoryTest.kt b/android/client-tls-android/src/androidTest/kotlin/wang/yaojia/webterm/tlsandroid/IdentityRepositoryTest.kt new file mode 100644 index 0000000..6830306 --- /dev/null +++ b/android/client-tls-android/src/androidTest/kotlin/wang/yaojia/webterm/tlsandroid/IdentityRepositoryTest.kt @@ -0,0 +1,209 @@ +package wang.yaojia.webterm.tlsandroid + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import wang.yaojia.webterm.clienttls.Pkcs12Parse + +/** + * Instrumented (real AndroidKeyStore + Tink — plan §7) tests for the re-reading mTLS seam. + * COMPILES here; RUNS on a device/emulator during device QA. + */ +@RunWith(AndroidJUnit4::class) +class IdentityRepositoryTest { + + private val context: Context get() = ApplicationProvider.getApplicationContext() + private val alias = "test-repo-identity" + private val prefFile = "webterm_client_tls_test_prefs" + private val keysetName = "webterm_cert_test_keyset" + private val masterKeyUri = "android-keystore://webterm_cert_test_master_key" + + private lateinit var importer: AndroidKeyStoreImporter + private lateinit var certStore: TinkCertStore + private lateinit var client: OkHttpClient + + @Before + fun setUp() { + importer = AndroidKeyStoreImporter(alias = alias) + certStore = TinkCertStore(context, keysetName, prefFile, masterKeyUri) + client = OkHttpClient() + wipe() + } + + @After + fun tearDown() = wipe() + + /** Both ping-pong slots + the pointer, so no state leaks across tests. */ + private fun wipe() { + importer.remove(importer.primarySlot) + importer.remove(importer.secondarySlot) + certStore.clear() + } + + private fun newRepository(store: CertStore = certStore): AndroidIdentityRepository = + AndroidIdentityRepository(importer, store, client) + + @Test + fun reReadingKeyManager_presentsMidRunImportedCertOnNextHandshake() { + // A mutable holder stands in for the repository's live identity source. + var installed: InstalledIdentity? = null + val keyManager = ReReadingX509KeyManager { installed } + + // No identity → present nothing (a clean, classifiable handshake failure, never a wrong cert). + assertNull(keyManager.chooseClientAlias(arrayOf("RSA"), null, null)) + assertNull(keyManager.getPrivateKey(alias)) + + // Simulate a mid-run import (no relaunch, no factory rebuild). + val parsed = Pkcs12Parse.parse(Fixtures.leafP12(), Fixtures.PASSPHRASE) + installed = InstalledIdentity( + alias = parsed.alias, + keyAlgorithm = parsed.keyAlgorithm, + privateKey = parsed.privateKey, + leafCertificate = parsed.leafCertificate, + issuerCertificates = parsed.issuerCertificates, + ) + + // The very next lookup re-reads the source and presents the freshly-imported identity. + assertEquals(parsed.alias, keyManager.chooseClientAlias(arrayOf("RSA"), null, null)) + assertEquals(parsed.alias, keyManager.chooseEngineClientAlias(arrayOf("RSA"), null, null)) + assertNotNull(keyManager.getPrivateKey(parsed.alias)) + assertNotNull(keyManager.getCertificateChain(parsed.alias)) + // A foreign alias never leaks our key material. + assertNull(keyManager.getPrivateKey("someone-else")) + } + + @Test + fun importPublishesIdentityAndSummary() = runBlocking { + val repository = newRepository() + assertFalse(repository.hasInstalledIdentity()) + assertNotNull("SSL material installed even with no cert", repository.sslMaterial().sslSocketFactory) + + val summary = repository.importIdentity(Fixtures.leafP12(), Fixtures.PASSPHRASE) + + assertEquals(Fixtures.LEAF_SUBJECT_CN, summary.subjectCommonName) + assertEquals(Fixtures.LEAF_ISSUER_CN, summary.issuerCommonName) + assertTrue(repository.hasInstalledIdentity()) + assertEquals(Fixtures.LEAF_SUBJECT_CN, repository.currentSummary()?.subjectCommonName) + // First install lands on the primary slot; the pointer names it (single source of truth). + assertEquals(importer.primarySlot, certStore.load()?.keyStoreAlias) + } + + @Test + fun rotateThenRemove_evictsPooledConnections_andClearsIdentity() = runBlocking { + val repository = newRepository() + repository.importIdentity(Fixtures.leafP12(), Fixtures.PASSPHRASE) + assertTrue(repository.hasInstalledIdentity()) + + // Rotation stages into the OTHER slot and evicts pooled/resumed connections. + repository.rotate(Fixtures.leafP12(), Fixtures.PASSPHRASE) + assertTrue(repository.hasInstalledIdentity()) + assertEquals(importer.secondarySlot, certStore.load()?.keyStoreAlias) + assertEquals(0, client.connectionPool.connectionCount()) + + // Removal clears both stores and evicts again so nothing reuses the old identity. + // (A populated pool needs a live TLS server → the no-reuse proof is the A34 E2E; here we + // assert eviction ran and left the pool empty + the identity cleared.) + repository.remove() + assertFalse(repository.hasInstalledIdentity()) + assertNull(repository.currentSummary()) + assertNull(certStore.load()) + assertEquals(0, client.connectionPool.connectionCount()) + } + + /** + * The single-commit invariant (plan §8, A11): a rotation that fails AT the commit step must leave + * the prior identity fully live and the two stores consistent (no half-applied new identity), and + * a later successful install must fully replace it. + */ + @Test + fun failedCommit_keepsPriorIdentityLive_thenSuccessfulInstallReplacesIt() = runBlocking { + val failing = FailingOnceCertStore(certStore) + val repository = newRepository(failing) + + // Install identity A (lands on the primary slot; pointer names it). + repository.importIdentity(Fixtures.leafP12(), Fixtures.PASSPHRASE) + val slotA = certStore.load()!!.keyStoreAlias + assertEquals(importer.primarySlot, slotA) + assertNotNull("A's key present after install", importer.loadPrivateKey(slotA)) + + // Attempt to rotate to B, injecting a failure at the COMMIT (metadata save) step. + failing.failNextSave = true + assertThrows(InjectedCommitFailure::class.java) { + runBlocking { repository.rotate(Fixtures.leafP12(), Fixtures.PASSPHRASE) } + } + + // (a) prior intact → the re-reading KeyManager (reads the same live view) still presents A. + assertTrue("prior identity still live after a failed rotation", repository.hasInstalledIdentity()) + assertEquals(Fixtures.LEAF_SUBJECT_CN, repository.currentSummary()?.subjectCommonName) + // (b) stores consistent: the pointer still names A's slot; the staged slot holds NO half-B key. + assertEquals(slotA, certStore.load()!!.keyStoreAlias) + assertNotNull("A's key untouched by the failed rotation", importer.loadPrivateKey(slotA)) + assertNull("no half-written B key in the staging slot", importer.loadPrivateKey(importer.secondarySlot)) + + // (c) a subsequent successful install of B fully replaces A. + failing.failNextSave = false + repository.rotate(Fixtures.leafP12(), Fixtures.PASSPHRASE) + assertTrue(repository.hasInstalledIdentity()) + assertEquals("pointer flipped to the other slot", importer.secondarySlot, certStore.load()!!.keyStoreAlias) + assertNotNull("B's key now present", importer.loadPrivateKey(importer.secondarySlot)) + assertNull("A's superseded key GC'd", importer.loadPrivateKey(slotA)) + } + + /** + * Regression for the `currentLive()` single-source-of-truth bug: after [remove], the repository + * must report NO identity even when the lazily-loaded `initialIdentity` was already resolved to a + * real value at startup (the normal relaunch-then-remove flow). The old `liveOverride?.value ?: + * initialIdentity` collapsed the post-remove `Box(null)` back to the stale cached identity, so a + * removed cert kept being reported live and would be presented in a handshake with a dangling key. + */ + @Test + fun remove_afterStartupTouch_reportsNoIdentity_notStaleCached() = runBlocking { + // Persist identity A, then simulate an app relaunch: a FRESH repository over the same stores. + newRepository().importIdentity(Fixtures.leafP12(), Fixtures.PASSPHRASE) + val relaunched = newRepository() + + // Startup touch: resolve the lazy `initialIdentity` to the real installed identity A. + assertTrue("identity restored from storage on relaunch", relaunched.hasInstalledIdentity()) + assertNotNull(relaunched.currentSummary()) + + // Remove — must win over the already-cached initialIdentity. + relaunched.remove() + + assertFalse("removed identity must NOT still report as installed", relaunched.hasInstalledIdentity()) + assertNull("removed identity must have no summary", relaunched.currentSummary()) + assertNull("pointer cleared", certStore.load()) + // The KeyManager reads the same live view → presents nothing after removal. + assertNull(ReReadingX509KeyManager { null }.chooseClientAlias(arrayOf("RSA"), null, null)) + } + + /** Wraps a real [CertStore], failing the next [save] exactly once so the commit step can be forced to throw. */ + private class FailingOnceCertStore(private val delegate: CertStore) : CertStore { + @Volatile + var failNextSave: Boolean = false + + override fun save(metadata: StoredIdentityMetadata) { + if (failNextSave) { + failNextSave = false + throw InjectedCommitFailure() + } + delegate.save(metadata) + } + + override fun load(): StoredIdentityMetadata? = delegate.load() + override fun clear(): Unit = delegate.clear() + } + + private class InjectedCommitFailure : RuntimeException("injected commit failure") +} diff --git a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/AndroidKeyStoreImporter.kt b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/AndroidKeyStoreImporter.kt new file mode 100644 index 0000000..eed816c --- /dev/null +++ b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/AndroidKeyStoreImporter.kt @@ -0,0 +1,130 @@ +package wang.yaojia.webterm.tlsandroid + +import android.security.keystore.KeyProperties +import android.security.keystore.KeyProtection +import java.security.KeyStore +import java.security.PrivateKey +import java.security.cert.X509Certificate +import wang.yaojia.webterm.clienttls.ParsedClientIdentity +import wang.yaojia.webterm.clienttls.Pkcs12Parse + +/** + * Imports a device `.p12` into AndroidKeyStore — the ONE runtime key home (plan §2/§8, R4). + * + * Two-step, validate-before-persist: + * 1. PARSE + VALIDATE with the pure [Pkcs12Parse] (a transient `KeyStore("PKCS12")` used ONLY to read + * the import file). This throws on a wrong passphrase / corrupt blob / certs-only file BEFORE any + * AndroidKeyStore mutation — so a bad import can NEVER clobber a previously installed identity. + * 2. IMPORT the private key NON-EXPORTABLE into AndroidKeyStore via `setEntry(..., KeyProtection)`. + * AndroidKeyStore keys are non-exportable by construction (there is no key-material getter); the + * private key never leaves secure hardware/OS custody once imported. + * + * No `.p12` bytes and no passphrase are ever persisted here (the cert chain + metadata is persisted + * separately, encrypted, by [TinkCertStore]). + */ +public class AndroidKeyStoreImporter( + public val alias: String = DEFAULT_ALIAS, +) { + /** + * The two physical AndroidKeyStore slots this importer ping-pongs between so a rotation can import + * the NEW key WITHOUT overwriting the still-live OLD key (single-commit rotation, see + * [AndroidIdentityRepository]). Neither is inherently "live" — the Tink-encrypted live pointer + * ([StoredIdentityMetadata.keyStoreAlias]) names whichever slot currently holds the live key. Each + * `setEntry` is per-alias atomic, so importing into one slot never disturbs the other. + */ + public val primarySlot: String get() = alias + public val secondarySlot: String get() = alias + STAGING_SUFFIX + + /** + * Parse+validate then import into [slot]. Returns the [ParsedClientIdentity] so the caller can + * persist the chain/metadata and derive a display summary. Throws (leaving [slot] — and every other + * slot — untouched, validate-before-persist) on: + * - wrong passphrase → `UnrecoverableKeyException`, + * - corrupt / not-a-p12 → `Pkcs12DecodeException`, + * - certs-only (no key) → `NoClientIdentityException`. + */ + public fun import(p12Bytes: ByteArray, passphrase: String, slot: String = alias): ParsedClientIdentity { + // Step 1 — validate. Throws before we touch AndroidKeyStore (validate-before-persist). + val parsed = Pkcs12Parse.parse(p12Bytes, passphrase) + // Step 2 — persist the key non-exportably into `slot` (per-alias atomic; other slots untouched). + persist(parsed, slot) + return parsed + } + + /** The AndroidKeyStore-backed (opaque, non-exportable) private key handle in [slot], or null. */ + public fun loadPrivateKey(slot: String = alias): PrivateKey? = + androidKeyStore().getKey(slot, null) as? PrivateKey + + /** The cert chain AndroidKeyStore stored alongside the key entry in [slot]; null if none. */ + public fun loadCertificateChain(slot: String = alias): List? = + androidKeyStore().getCertificateChain(slot) + ?.filterIsInstance() + ?.takeIf { it.isNotEmpty() } + + /** Cheap existence check for [slot] (does NOT read key material). */ + public fun hasInstalledKey(slot: String = alias): Boolean = androidKeyStore().isKeyEntry(slot) + + /** Delete the imported key entry in [slot]. Idempotent (a missing alias is a no-op). */ + public fun remove(slot: String = alias) { + val keyStore = androidKeyStore() + if (keyStore.containsAlias(slot)) { + keyStore.deleteEntry(slot) + } + } + + private fun persist(parsed: ParsedClientIdentity, slot: String) { + val keyStore = androidKeyStore() + keyStore.setEntry( + slot, + KeyStore.PrivateKeyEntry(parsed.privateKey, parsed.fullChain.toTypedArray()), + keyProtection(parsed.keyAlgorithm), + ) + } + + private fun androidKeyStore(): KeyStore = + KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + + /** + * Protection for the imported client-auth key. TLS client authentication signs the + * `CertificateVerify`, so SIGN is always required; RSA additionally needs DECRYPT (RSA key + * transport in TLS ≤1.2) and its padding set. EC keys reject paddings, so only SIGN is granted. + * A broad digest set keeps TLS 1.2/1.3 signature negotiation working. The key stays + * non-exportable (AndroidKeyStore has no export path). + */ + private fun keyProtection(keyAlgorithm: String): KeyProtection { + val isRsa = keyAlgorithm.equals(KeyProperties.KEY_ALGORITHM_RSA, ignoreCase = true) + val purposes = if (isRsa) { + KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_DECRYPT + } else { + KeyProperties.PURPOSE_SIGN + } + val builder = KeyProtection.Builder(purposes) + .setDigests( + KeyProperties.DIGEST_NONE, + KeyProperties.DIGEST_SHA256, + KeyProperties.DIGEST_SHA384, + KeyProperties.DIGEST_SHA512, + ) + if (isRsa) { + builder + .setSignaturePaddings( + KeyProperties.SIGNATURE_PADDING_RSA_PKCS1, + KeyProperties.SIGNATURE_PADDING_RSA_PSS, + ) + .setEncryptionPaddings( + KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1, + KeyProperties.ENCRYPTION_PADDING_RSA_OAEP, + ) + } + return builder.build() + } + + public companion object { + /** The single device-identity alias this app presents (one pinned identity, plan §8). */ + public const val DEFAULT_ALIAS: String = "webterm-device-identity" + + /** Suffix of the second ping-pong slot ([secondarySlot]) used for single-commit rotation. */ + public const val STAGING_SUFFIX: String = ".staging" + private const val ANDROID_KEYSTORE = "AndroidKeyStore" + } +} diff --git a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/IdentityRepository.kt b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/IdentityRepository.kt new file mode 100644 index 0000000..c9c90ed --- /dev/null +++ b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/IdentityRepository.kt @@ -0,0 +1,252 @@ +package wang.yaojia.webterm.tlsandroid + +import android.util.Log +import java.security.KeyStore +import javax.net.ssl.KeyManager +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.TrustManager +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.OkHttpClient +import wang.yaojia.webterm.clienttls.CertificateSummary +import wang.yaojia.webterm.clienttls.CertificateSummaryReader + +/** + * The `(SSLSocketFactory, X509TrustManager)` pair the shared `OkHttpClient` is built with. The + * factory wraps the re-reading KeyManager (so it is STABLE across cert rotations — rotation is + * handled inside the KeyManager, not by swapping the factory), and the trust manager is the default + * system trust (tunnel/Tailscale present real LE certs — plan §6.9). This is the clean seam the `:app` + * wiring (A15) bridges to `:transport-okhttp`'s `ClientIdentityProvider`; A11 never depends on that + * module. + */ +public class ClientSslMaterial( + public val sslSocketFactory: SSLSocketFactory, + public val trustManager: X509TrustManager, +) + +/** + * The current device client identity, exposed as a re-reading mTLS seam (plan §2 mTLS row, §8, R4). + * + * The [sslMaterial] pair is built ONCE and installed on the shared client for its whole lifetime; a + * mid-run [importIdentity]/[rotate] is presented on the NEXT handshake (re-reading KeyManager) and + * [rotate]/[remove] additionally evict pooled/resumed connections so they can't reuse the old + * identity. No relaunch, no client rebuild. + */ +public interface IdentityRepository { + /** The stable SSL material to install on the shared `OkHttpClient` (always present — presents no + * client cert when no identity is installed). */ + public fun sslMaterial(): ClientSslMaterial + + /** Summary of the currently installed identity for the cert screen, or null if none installed. */ + public fun currentSummary(): CertificateSummary? + + /** Cheap gating check — is a device identity installed? */ + public fun hasInstalledIdentity(): Boolean + + /** Import a `.p12` (validates before persisting), then evict pooled connections. Returns the summary. */ + public suspend fun importIdentity(p12Bytes: ByteArray, passphrase: String): CertificateSummary + + /** Replace the installed identity with a new `.p12` (rotation — single-commit, prior stays live on failure). */ + public suspend fun rotate(p12Bytes: ByteArray, passphrase: String): CertificateSummary + + /** Remove the installed identity, then evict pooled connections so none reuse it. Idempotent. */ + public suspend fun remove() +} + +/** + * Default [IdentityRepository] over [AndroidKeyStoreImporter] (key home) + [CertStore] (encrypted + * live-pointer at rest) + the shared [OkHttpClient] (for `connectionPool.evictAll()`). + * + * ### Single-commit rotation (the security invariant, plan §8, A11) + * An import/rotation that fails at ANY point leaves the PRIOR working identity fully live and NEVER + * leaves the two stores diverged. This is achieved by staging, then committing with one atomic write: + * 1. Import the new key into the ping-pong slot that is NOT currently live + * ([AndroidKeyStoreImporter] `primary`/`secondary`) — the live key is never overwritten. + * 2. COMMIT by persisting one blob to the [CertStore] whose [StoredIdentityMetadata.keyStoreAlias] + * names the staged slot. That single write is the source-of-truth "live pointer" flip. + * - Any failure BEFORE the flip → the prior pointer + key are untouched → prior identity fully live + * (the half-written staging key is best-effort deleted and is never read as live). + * - Any failure AFTER the flip → the NEW identity is fully live (the superseded key is best-effort + * GC'd; a stray unreferenced key is harmless and gets overwritten on the next rotation). + * Startup and the re-reading KeyManager resolve the live identity SOLELY via that pointer. + * Validate-before-persist still holds (a bad passphrase throws before any keystore/store mutation). + * + * ### Concurrency & lazy init + * The mutating methods are `suspend` and serialized by a [Mutex] so overlapping calls (double-tap) + * can never interleave the two-store commit. The initial on-disk identity is loaded LAZILY on first + * access (not in the constructor), so this repository can be constructed on any thread with no I/O. + * + * A15 wiring note: the FIRST touch of the identity (e.g. [currentSummary]/[hasInstalledIdentity], a + * mutator, or the first TLS handshake) performs synchronous AndroidKeyStore + Tink I/O — trigger that + * first access off `Dispatchers.Main` (a background warm-up), never on the UI thread. + * + * The cached runtime identity always holds the AndroidKeyStore-backed (non-exportable) private-key + * handle, never the transient parsed software key. + */ +public class AndroidIdentityRepository( + private val importer: AndroidKeyStoreImporter, + private val certStore: CertStore, + private val sharedClient: OkHttpClient, +) : IdentityRepository { + + /** The live identity as tracked by the repo: the KeyManager view + which physical slot holds the key. */ + private class LiveIdentity(val installed: InstalledIdentity, val keyStoreAlias: String) + + /** Distinguishes "not yet mutated" (null) from "explicitly set, possibly to null" ([Box]). */ + private class Box(val value: T) + + // Serializes the two-store commit so a double-tap can't interleave two rotations (FIX 2). + private val commitMutex = Mutex() + + // Lazily loaded on FIRST access (never in the constructor) — no slow I/O to construct (FIX 3). + private val initialIdentity: LiveIdentity? by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { + loadInstalledOrNull() + } + + // Post-mutation state shadows the lazily-loaded initial value; volatile read on the handshake path. + @Volatile + private var liveOverride: Box? = null + + // Resolve via the Box PRESENCE, not the unwrapped value's null-ness: after remove() the override + // is Box(null) (explicitly "no identity"), which MUST win over a still-cached non-null + // initialIdentity — otherwise a removed cert (whose key/pointer are already deleted) would keep + // being reported live and presented in a handshake with a dangling key handle. `?: initialIdentity` + // would collapse Box(null) back to the stale identity, so this cannot use the Elvis operator. + private fun currentLive(): LiveIdentity? { + val override = liveOverride + return if (override != null) override.value else initialIdentity + } + + // Default system trust: tunnel/Tailscale present real LE certs (plan §6.9). Built once. + private val systemTrustManager: X509TrustManager = systemDefaultTrustManager() + + // Stable factory wrapping the re-reading KeyManager over `currentLive()`. Built once. + private val socketFactory: SSLSocketFactory = buildSocketFactory() + + override fun sslMaterial(): ClientSslMaterial = + ClientSslMaterial(sslSocketFactory = socketFactory, trustManager = systemTrustManager) + + override fun currentSummary(): CertificateSummary? = + currentLive()?.let { CertificateSummaryReader.summarize(it.installed.leafCertificate) } + + override fun hasInstalledIdentity(): Boolean = currentLive() != null + + override suspend fun importIdentity(p12Bytes: ByteArray, passphrase: String): CertificateSummary = + install(p12Bytes, passphrase) + + override suspend fun rotate(p12Bytes: ByteArray, passphrase: String): CertificateSummary = + install(p12Bytes, passphrase) + + override suspend fun remove(): Unit = commitMutex.withLock { + // Clear the pointer FIRST (no live identity even if a key delete lags), then both key slots. + certStore.clear() + importer.remove(importer.primarySlot) + importer.remove(importer.secondarySlot) + liveOverride = Box(null) + // Drop pooled/resumed connections so nothing reuses the removed identity (R4/§8). + sharedClient.connectionPool.evictAll() + } + + /** + * Single-commit install/rotation (see the class KDoc). Validation throws before any mutation; + * the new key is imported into the non-live slot; the COMMIT is one atomic [CertStore.save] that + * flips the live pointer. Only after the flip is the new identity published and the superseded + * key GC'd. Any earlier failure leaves the prior identity fully live and the stores consistent. + */ + private suspend fun install(p12Bytes: ByteArray, passphrase: String): CertificateSummary = + commitMutex.withLock { + val liveAlias = currentLive()?.keyStoreAlias + val stagingAlias = stagingSlotFor(liveAlias) + + // Stage into the NON-live slot then COMMIT. Everything up to and including the atomic + // certStore.save() flip is wrapped so ANY pre-flip failure (import, key readback, encode, + // or the commit write) best-effort drops the half-written staging key and rethrows — + // leaving the prior pointer + live key untouched (validate-before-persist still applies: + // a bad passphrase throws inside importer.import before any keystore write). + val (parsed, keyHandle) = try { + val p = importer.import(p12Bytes, passphrase, stagingAlias) + val k = importer.loadPrivateKey(stagingAlias) + ?: error("Imported key not found in AndroidKeyStore slot '$stagingAlias'") + val metadata = StoredIdentityMetadata( + alias = p.alias, + keyAlgorithm = p.keyAlgorithm, + keyStoreAlias = stagingAlias, + certificateChain = p.fullChain, + ) + certStore.save(metadata) // COMMIT — the single atomic durable live-pointer flip. + p to k + } catch (e: Exception) { + runCatching { importer.remove(stagingAlias) } // best-effort: drop any half-written key + throw e + } + + // Committed: publish the new identity (AndroidKeyStore handle, non-exportable) and GC the old. + liveOverride = Box( + LiveIdentity( + InstalledIdentity( + alias = parsed.alias, + keyAlgorithm = parsed.keyAlgorithm, + privateKey = keyHandle, + leafCertificate = parsed.leafCertificate, + issuerCertificates = parsed.issuerCertificates, + ), + stagingAlias, + ), + ) + liveAlias?.let { old -> runCatching { importer.remove(old) } } // best-effort GC of old slot + // Next handshake uses the new identity; drop pooled/resumed connections holding the old one. + sharedClient.connectionPool.evictAll() + parsed.summary() + } + + /** The ping-pong slot to stage into: whichever physical slot is NOT the current live one. */ + private fun stagingSlotFor(liveAlias: String?): String = + if (liveAlias == importer.primarySlot) importer.secondarySlot else importer.primarySlot + + /** + * Reconstruct the installed identity from storage at startup, resolving the live key SOLELY via + * the persisted pointer ([StoredIdentityMetadata.keyStoreAlias]). A storage/decrypt fault must not + * crash launch — it degrades to "no identity" and is logged (never silently dropped), mirroring + * iOS `loadedIdentityOrNil`. + */ + private fun loadInstalledOrNull(): LiveIdentity? { + return try { + val metadata = certStore.load() ?: return null + val keyHandle = importer.loadPrivateKey(metadata.keyStoreAlias) ?: return null + LiveIdentity( + InstalledIdentity( + alias = metadata.alias, + keyAlgorithm = metadata.keyAlgorithm, + privateKey = keyHandle, + leafCertificate = metadata.leafCertificate, + issuerCertificates = metadata.issuerCertificates, + ), + metadata.keyStoreAlias, + ) + } catch (e: Exception) { + Log.e(TAG, "Failed to load stored device identity; continuing without a client cert", e) + null + } + } + + private fun buildSocketFactory(): SSLSocketFactory { + val keyManager = ReReadingX509KeyManager { currentLive()?.installed } + val context = SSLContext.getInstance("TLS") + context.init(arrayOf(keyManager), arrayOf(systemTrustManager), null) + return context.socketFactory + } + + private fun systemDefaultTrustManager(): X509TrustManager { + val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + factory.init(null as KeyStore?) + return factory.trustManagers.filterIsInstance().firstOrNull() + ?: error("No system X509TrustManager available") + } + + private companion object { + private const val TAG = "IdentityRepository" + } +} diff --git a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/ReReadingX509KeyManager.kt b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/ReReadingX509KeyManager.kt new file mode 100644 index 0000000..111b990 --- /dev/null +++ b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/ReReadingX509KeyManager.kt @@ -0,0 +1,93 @@ +package wang.yaojia.webterm.tlsandroid + +import java.net.Socket +import java.security.Principal +import java.security.PrivateKey +import java.security.cert.X509Certificate +import javax.net.ssl.SSLEngine +import javax.net.ssl.X509ExtendedKeyManager +import wang.yaojia.webterm.clienttls.ClientKeyManagerLogic +import wang.yaojia.webterm.clienttls.KeyManagerIdentity + +/** + * The live, non-exportable device identity as the KeyManager sees it: the AndroidKeyStore-backed + * [privateKey] handle (opaque — signing happens inside the keystore) plus the cert chain to present. + */ +public class InstalledIdentity( + public val alias: String, + public val keyAlgorithm: String, + public val privateKey: PrivateKey, + public val leafCertificate: X509Certificate, + public val issuerCertificates: List, +) { + /** Full chain, leaf at index 0, as presented to the server. */ + public val chain: List get() = listOf(leafCertificate) + issuerCertificates + + internal val keyManagerIdentity: KeyManagerIdentity + get() = KeyManagerIdentity(alias = alias, keyAlgorithm = keyAlgorithm) +} + +/** + * A **re-reading** `X509KeyManager` (R4): every alias/key/chain lookup consults [identitySource] + * afresh, so a cert imported or rotated mid-run is presented on the NEXT handshake with no app + * relaunch and no rebuild of the shared `OkHttpClient`. The alias truth table is delegated to the + * pure [ClientKeyManagerLogic] (present iff an identity is installed AND its key type is acceptable), + * keeping the security-load-bearing decision unit-tested at JVM speed in `:client-tls`. + * + * Client-only: the server-side methods return null. Key material is gated by `ownsAlias` so a foreign + * alias never leaks the device identity. + */ +internal class ReReadingX509KeyManager( + private val identitySource: () -> InstalledIdentity?, +) : X509ExtendedKeyManager() { + + override fun chooseClientAlias( + keyType: Array?, + issuers: Array?, + socket: Socket?, + ): String? = logicOrNull()?.chooseClientAlias(keyType) + + override fun chooseEngineClientAlias( + keyType: Array?, + issuers: Array?, + engine: SSLEngine?, + ): String? = logicOrNull()?.chooseClientAlias(keyType) + + override fun getClientAliases( + keyType: String?, + issuers: Array?, + ): Array? = logicOrNull()?.clientAliases(keyType) + + override fun getPrivateKey(alias: String?): PrivateKey? { + val identity = identitySource() ?: return null + return if (ClientKeyManagerLogic(identity.keyManagerIdentity).ownsAlias(alias)) { + identity.privateKey + } else { + null + } + } + + override fun getCertificateChain(alias: String?): Array? { + val identity = identitySource() ?: return null + return if (ClientKeyManagerLogic(identity.keyManagerIdentity).ownsAlias(alias)) { + identity.chain.toTypedArray() + } else { + null + } + } + + // Client-only: never act as a TLS server. + override fun chooseServerAlias( + keyType: String?, + issuers: Array?, + socket: Socket?, + ): String? = null + + override fun getServerAliases( + keyType: String?, + issuers: Array?, + ): Array? = null + + private fun logicOrNull(): ClientKeyManagerLogic? = + identitySource()?.let { ClientKeyManagerLogic(it.keyManagerIdentity) } +} diff --git a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/StoredIdentityMetadata.kt b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/StoredIdentityMetadata.kt new file mode 100644 index 0000000..b7d6fa4 --- /dev/null +++ b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/StoredIdentityMetadata.kt @@ -0,0 +1,108 @@ +package wang.yaojia.webterm.tlsandroid + +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.IOException +import java.security.GeneralSecurityException +import java.security.cert.CertificateException +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate + +/** + * The at-rest description of the installed device identity — and the single source-of-truth "live + * pointer" for the whole repository. It carries the [alias] + [keyAlgorithm] the pure + * `ClientKeyManagerLogic` needs for alias selection, the DER cert [certificateChain] (leaf at index 0) + * presented in the TLS handshake, AND the [keyStoreAlias] naming which physical AndroidKeyStore slot + * holds the live private key. Persisting a new blob (one atomic [TinkCertStore.save]) is the COMMIT + * that flips which identity is live; startup and the re-reading KeyManager resolve the live identity + * SOLELY through this pointer (see [AndroidIdentityRepository]). + * + * The PRIVATE KEY is deliberately NOT here — it lives non-exportable in AndroidKeyStore (the one key + * home, plan §2/§8). This blob is exactly what [TinkCertStore] AEAD-encrypts at rest; no `.p12` bytes + * and no passphrase are ever persisted. + */ +public data class StoredIdentityMetadata( + val alias: String, + val keyAlgorithm: String, + val keyStoreAlias: String, + val certificateChain: List, +) { + init { + require(keyStoreAlias.isNotBlank()) { "keyStoreAlias (live-key slot) must not be blank" } + require(certificateChain.isNotEmpty()) { "certificateChain must contain at least the leaf" } + } + + /** The leaf (device) certificate — chain[0]. */ + public val leafCertificate: X509Certificate get() = certificateChain.first() + + /** Issuer certificates accompanying the leaf (chain minus leaf); may be empty. */ + public val issuerCertificates: List get() = certificateChain.drop(1) +} + +/** The stored identity blob was present but could not be decoded (tamper / version skew / corruption). */ +public class CorruptStoredIdentityException(message: String, cause: Throwable? = null) : + GeneralSecurityException(message, cause) + +/** + * Length-prefixed binary codec for [StoredIdentityMetadata] (KISS: no serialization framework — the + * cert chain is `X509Certificate`, not a plain data type). Certificates are stored as their DER + * encoding and re-parsed with the JVM `CertificateFactory`. + * + * Layout: `UTF(alias) · UTF(keyAlgorithm) · UTF(keyStoreAlias) · Int(chainCount) · [Int(derLen) · derBytes]*`. + */ +public object IdentityMetadataCodec { + private const val X509 = "X.509" + private const val MAX_CHAIN = 32 + private const val MAX_DER_BYTES = 1 shl 20 // 1 MiB per cert — a defensive upper bound. + + public fun encode(metadata: StoredIdentityMetadata): ByteArray { + val out = ByteArrayOutputStream() + DataOutputStream(out).use { data -> + data.writeUTF(metadata.alias) + data.writeUTF(metadata.keyAlgorithm) + data.writeUTF(metadata.keyStoreAlias) + data.writeInt(metadata.certificateChain.size) + metadata.certificateChain.forEach { cert -> + val der = cert.encoded + data.writeInt(der.size) + data.write(der) + } + } + return out.toByteArray() + } + + /** Decode [bytes]; any structural or certificate-parse failure → [CorruptStoredIdentityException]. */ + public fun decode(bytes: ByteArray): StoredIdentityMetadata = + try { + DataInputStream(ByteArrayInputStream(bytes)).use { data -> + val alias = data.readUTF() + val keyAlgorithm = data.readUTF() + val keyStoreAlias = data.readUTF() + val count = data.readInt() + if (count !in 1..MAX_CHAIN) { + throw CorruptStoredIdentityException("Implausible chain length: $count") + } + val factory = CertificateFactory.getInstance(X509) + val chain = (0 until count).map { readCertificate(data, factory) } + StoredIdentityMetadata(alias, keyAlgorithm, keyStoreAlias, chain) + } + } catch (e: CorruptStoredIdentityException) { + throw e + } catch (e: IOException) { + throw CorruptStoredIdentityException("Stored identity blob was truncated/malformed", e) + } catch (e: CertificateException) { + throw CorruptStoredIdentityException("Stored identity certificate could not be parsed", e) + } + + private fun readCertificate(data: DataInputStream, factory: CertificateFactory): X509Certificate { + val len = data.readInt() + if (len !in 1..MAX_DER_BYTES) { + throw CorruptStoredIdentityException("Implausible certificate length: $len") + } + val der = ByteArray(len) + data.readFully(der) + return factory.generateCertificate(ByteArrayInputStream(der)) as X509Certificate + } +} diff --git a/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/TinkCertStore.kt b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/TinkCertStore.kt new file mode 100644 index 0000000..5e8e333 --- /dev/null +++ b/android/client-tls-android/src/main/kotlin/wang/yaojia/webterm/tlsandroid/TinkCertStore.kt @@ -0,0 +1,135 @@ +package wang.yaojia.webterm.tlsandroid + +import android.content.Context +import android.content.SharedPreferences +import android.util.Base64 +import com.google.crypto.tink.Aead +import com.google.crypto.tink.KeyTemplates +import com.google.crypto.tink.RegistryConfiguration +import com.google.crypto.tink.aead.AeadConfig +import com.google.crypto.tink.integration.android.AndroidKeysetManager + +/** + * At-rest storage for the encrypted device-identity "live pointer" ([StoredIdentityMetadata]). + * Abstracted behind an interface so [AndroidIdentityRepository] depends on the operation contract + * (repository pattern) — the storage backend is swappable and a fault can be injected in tests. The + * single [save] is the atomic COMMIT that flips which identity is live (plan §8, A11). + */ +public interface CertStore { + /** Encrypt and persist [metadata], atomically replacing any previously stored blob (the commit). */ + public fun save(metadata: StoredIdentityMetadata) + + /** Load + decrypt the stored pointer, or null if none is stored. */ + public fun load(): StoredIdentityMetadata? + + /** Remove the stored pointer. Idempotent. */ + public fun clear() +} + +/** + * At-rest storage for the device identity's cert-chain + metadata blob ([StoredIdentityMetadata]), + * AEAD-encrypted with Tink and an AndroidKeystore-wrapped master key (plan §2 persistence-secret row, + * §8 cert storage). + * + * What is and is NOT stored here: + * - STORED (encrypted): the cert chain + alias + key algorithm — the material the re-reading + * KeyManager and the summary UI need. + * - NOT stored: the private key (non-exportable in AndroidKeyStore — the one key home), the raw + * `.p12` bytes, and the passphrase. + * + * The keyset + ciphertext live in an app-private `SharedPreferences` file (uninstall-wiped, excluded + * from cloud backup by the app manifest); the Tink keyset itself is encrypted by the AndroidKeystore + * master key so the on-disk keyset is useless off this device. + */ +public class TinkCertStore( + context: Context, + private val keysetName: String = DEFAULT_KEYSET_NAME, + private val prefFileName: String = DEFAULT_PREF_FILE, + private val masterKeyUri: String = DEFAULT_MASTER_KEY_URI, +) : CertStore { + private val appContext: Context = context.applicationContext + + // Built on first use: registers AEAD, loads-or-creates the AndroidKeystore-wrapped keyset. + private val aead: Aead by lazy { buildAead() } + + /** + * Encrypt and persist [metadata], replacing any previously stored blob. Uses synchronous + * [SharedPreferences.Editor.commit] (NOT `apply()`): the pointer flip is the atomic durable + * commit of a rotation, and `install()` deletes the superseded key immediately after this + * returns — so the write MUST be on disk (and MUST surface a failure) before that delete. A + * non-durable/silently-failed write could leave the on-disk pointer naming an already-deleted + * key after a crash, losing BOTH identities. + */ + override fun save(metadata: StoredIdentityMetadata) { + val plaintext = IdentityMetadataCodec.encode(metadata) + val ciphertext = aead.encrypt(plaintext, ASSOCIATED_DATA) + val committed = prefs().edit() + .putString(BLOB_KEY, Base64.encodeToString(ciphertext, Base64.NO_WRAP)) + .commit() + if (!committed) { + throw java.io.IOException("Failed to durably persist the device-identity live pointer") + } + } + + /** + * Load + decrypt the stored blob, or null if none is stored. Throws + * [CorruptStoredIdentityException] if a blob is present but fails to decrypt/decode (tamper or + * version skew) — callers treat that as "no usable identity" (see [IdentityRepository]). + */ + override fun load(): StoredIdentityMetadata? { + val encoded = prefs().getString(BLOB_KEY, null) ?: return null + val ciphertext = try { + Base64.decode(encoded, Base64.NO_WRAP) + } catch (e: IllegalArgumentException) { + throw CorruptStoredIdentityException("Stored identity blob was not valid base64", e) + } + val plaintext = try { + aead.decrypt(ciphertext, ASSOCIATED_DATA) + } catch (e: java.security.GeneralSecurityException) { + throw CorruptStoredIdentityException("Stored identity blob failed AEAD decryption", e) + } + return IdentityMetadataCodec.decode(plaintext) + } + + /** + * Remove the stored blob. Idempotent. Uses synchronous [SharedPreferences.Editor.commit] and + * throws on failure: `remove()` clears this pointer BEFORE deleting the AndroidKeyStore keys, so + * a silently-failed clear (pointer left naming a soon-to-be-deleted key) would diverge the two + * stores — surfacing the failure lets `remove()` abort with the prior identity still live. The + * Tink keyset (master key) is intentionally left intact. + */ + override fun clear() { + val committed = prefs().edit().remove(BLOB_KEY).commit() + if (!committed) { + throw java.io.IOException("Failed to durably clear the device-identity live pointer") + } + } + + private fun buildAead(): Aead { + AeadConfig.register() + val keysetHandle = AndroidKeysetManager.Builder() + .withSharedPref(appContext, keysetName, prefFileName) + .withKeyTemplate(KeyTemplates.get(AEAD_KEY_TEMPLATE)) + .withMasterKeyUri(masterKeyUri) + .build() + .keysetHandle + return keysetHandle.getPrimitive(RegistryConfiguration.get(), Aead::class.java) + } + + private fun prefs(): SharedPreferences = + appContext.getSharedPreferences(prefFileName, Context.MODE_PRIVATE) + + public companion object { + private const val DEFAULT_KEYSET_NAME = "webterm_cert_keyset" + private const val DEFAULT_PREF_FILE = "webterm_client_tls_prefs" + private const val DEFAULT_MASTER_KEY_URI = "android-keystore://webterm_cert_master_key" + private const val AEAD_KEY_TEMPLATE = "AES256_GCM" + private const val BLOB_KEY = "cert_chain_blob" + + /** + * AEAD associated data — binds the ciphertext to this app's identity-blob context so a blob + * lifted into another Tink ciphertext slot fails to decrypt. Not secret. + */ + private val ASSOCIATED_DATA: ByteArray = "webterm.client-tls.identity".toByteArray() + } +} diff --git a/android/client-tls/build.gradle.kts b/android/client-tls/build.gradle.kts index 9d2e452..91026bc 100644 --- a/android/client-tls/build.gradle.kts +++ b/android/client-tls/build.gradle.kts @@ -7,6 +7,7 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.kover) } kotlin { @@ -24,3 +25,14 @@ dependencies { tasks.test { useJUnitPlatform() } + +// A36 acceptance gate: >=80% line coverage on this pure module (plan §7). +kover { + reports { + verify { + rule { + minBound(80) + } + } + } +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 72a74cd..a179dfa 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -12,6 +12,58 @@ junit = "5.13.4" junitPlatform = "1.13.4" turbine = "1.2.1" mockk = "1.14.2" +# Kover — coverage gate on the pure JVM modules (plan A36 / section 7: >=80%). +kover = "0.9.1" +# OkHttp 4.x — plain JVM/Kotlin (NO Android SDK); used by :transport-okhttp (A7). +# MockWebServer (same version) drives A7's JVM tests. Okio arrives transitively. +okhttp = "4.12.0" + +# ── Android UI stack (A13 — the :app-stack baseline; resolved against Gradle) ──── +# The Compose BOM pins ALL Compose + Material3 + Material3-Adaptive artifacts, so +# those libraries below carry NO explicit version (BOM-managed → guaranteed mutual +# compatibility). BOM 2025.11.01 → material3 1.4.0 · ui/foundation 1.9.5 · +# material3.adaptive 1.2.0 · material3-adaptive-navigation-suite 1.4.0. +# +# CEILING = compileSdk 36. The env's cmdline-tools cannot fetch platform 37, and +# the 2026 androidx line (BOM 2026.x, core 1.19, activity 1.13, lifecycle 2.11) +# requires compiling against SDK 37. So the stack is capped at the newest SDK-36 +# releases. Bump these together with compileSdk 37 once the platform is installable. +composeBom = "2025.11.01" +androidxCoreKtx = "1.17.0" +androidxActivityCompose = "1.12.4" +androidxLifecycle = "2.10.0" +# Hilt 2.60.1 — its Gradle plugin references kotlin-bom 2.3.21 (matches Kotlin here). +hilt = "2.60.1" +hiltNavigationCompose = "1.2.0" +# KSP2 — the post-2.3.0 unified scheme tracks Kotlin 2.3.x (Hilt's annotation processor). +ksp = "2.3.9" + +# ── Android storage + crypto (A11 :client-tls-android · A12 :host-registry) ───── +# Tink AEAD encrypts the cert-chain+metadata blob at rest (private key stays +# non-exportable in AndroidKeyStore). DataStore = host list / last-session / prefs. +tink = "1.15.0" +datastore = "1.1.1" +# ── Terminal renderer (A16 :terminal-view) ────────────────────────────────────── +# Termux terminal-emulator + terminal-view — Apache-2.0 (plan §9 R2). Consumed from +# JitPack, which builds the two Apache-2.0 submodules of github.com/termux/termux-app +# on demand. Version is the git TAG (v-prefixed). Pinned to v0.118.0 (the plan's +# default). NEVER pull termux-shared / the app module (GPLv3). terminal-view's only +# transitive deps are terminal-emulator + androidx.annotation (no guava → the R2 +# listenablefuture conflict dep is unnecessary here). +termux = "v0.118.0" +# androidx-test — lets androidTest (instrumented) sources COMPILE here; they only +# RUN on a device (no emulator installed → deferred to device QA, plan §7). +androidxTestExtJunit = "1.2.1" +androidxTestCore = "1.6.1" +androidxTestRunner = "1.6.2" +# QR pairing (A19): CameraX preview/analysis + on-device ML Kit barcode scanning. +camerax = "1.4.1" +mlkitBarcode = "17.3.0" +# Push (A30) + nav/deep-links (A32). FCM via the Firebase BOM (no google-services plugin here — +# runtime init needs google-services.json, which is device-QA; classes compile without it). +firebaseBom = "33.7.0" +biometric = "1.1.0" +navigationCompose = "2.8.5" [libraries] # Serialization @@ -20,6 +72,9 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa # Coroutines kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } +# The Android Main-dispatcher provider — REQUIRED at runtime by any Android module that +# references Dispatchers.Main(.immediate) (else "Module with the Main dispatcher is missing"). +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } # Test — JUnit5 (Jupiter) junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } @@ -29,14 +84,97 @@ junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +# OkHttp (WS + REST) — :transport-okhttp (A7). Pure JVM. +okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } +okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } + +# ── Android UI stack (A13) — SDK-gated modules (:app etc.) only ────────────────── +# AndroidX foundation +androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCoreKtx" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivityCompose" } +androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidxLifecycle" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidxLifecycle" } +androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" } + +# Compose — the BOM governs every version below (do NOT pin these individually). +androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } +androidx-compose-ui = { module = "androidx.compose.ui:ui" } +androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } +androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } +androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" } +androidx-compose-material3 = { module = "androidx.compose.material3:material3" } +# Material 3 Adaptive (size-class split — SwiftUI-adaptive analogue, plan §"iPad-equivalent") +androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive" } +androidx-compose-material3-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout" } +androidx-compose-material3-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation" } +androidx-compose-material3-adaptive-navigation-suite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite" } + +# Hilt (Dagger) — one shared OkHttpClient singleton + testable seams (plan §2 DI row). +hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } +hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } +androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" } + +# Storage + crypto (A11/A12) +tink-android = { module = "com.google.crypto.tink:tink-android", version.ref = "tink" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } + +# Terminal renderer (A16) — Apache-2.0 Termux libs via JitPack (see [versions].termux). +termux-terminal-view = { module = "com.github.termux.termux-app:terminal-view", version.ref = "termux" } +termux-terminal-emulator = { module = "com.github.termux.termux-app:terminal-emulator", version.ref = "termux" } + +# Instrumented-test deps (androidTest source sets; compile here, run on device) +androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidxTestExtJunit" } +androidx-test-core = { module = "androidx.test:core", version.ref = "androidxTestCore" } +androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidxTestRunner" } + +# QR pairing camera (A19) +androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "camerax" } +androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camerax" } +androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax" } +androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "camerax" } +mlkit-barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "mlkitBarcode" } + +# Push (A30) + nav (A32) +firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" } +firebase-messaging = { module = "com.google.firebase:firebase-messaging" } +androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } + [bundles] # Convenience bundle wired into every pure module's testImplementation. unit-test = ["junit-jupiter", "kotlinx-coroutines-test", "turbine", "mockk"] +# CameraX (A19 QR pairing) — preview + analysis + PreviewView. +camerax = ["androidx-camera-core", "androidx-camera-camera2", "androidx-camera-lifecycle", "androidx-camera-view"] + +# Compose UI + Material 3 (+ Adaptive) — wired into :app implementation. All +# BOM-managed; add ui-tooling separately as a debugImplementation. +compose = [ + "androidx-compose-ui", + "androidx-compose-ui-graphics", + "androidx-compose-ui-tooling-preview", + "androidx-compose-foundation", + "androidx-compose-material3", + "androidx-compose-material3-adaptive", + "androidx-compose-material3-adaptive-layout", + "androidx-compose-material3-adaptive-navigation", + "androidx-compose-material3-adaptive-navigation-suite", +] + [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } android-library = { id = "com.android.library", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" } +kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } # NOTE: AGP 9+ has BUILT-IN Kotlin support — Android modules apply only the # android plugin, NOT a separate org.jetbrains.kotlin.android (it errors out). +# +# Compose compiler plugin — REQUIRED for Kotlin 2.x Compose; version MUST equal the +# Kotlin version (the compiler ships inside the Kotlin release since 2.0). +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +# KSP — Hilt's annotation processor runs through it. +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +# Hilt Gradle plugin (component-tree codegen); version tracks the hilt libraries. +hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } diff --git a/android/host-registry/build.gradle.kts b/android/host-registry/build.gradle.kts index 83a9351..2235b72 100644 --- a/android/host-registry/build.gradle.kts +++ b/android/host-registry/build.gradle.kts @@ -1,33 +1,61 @@ // ───────────────────────────────────────────────────────────────────────────── -// :host-registry — Host / HostStore + DataStore-backed storage, LastSessionStore. -// Mirrors the iOS HostRegistry package (storage behind an interface). +// :host-registry (A12) — Host list + LastSessionStore behind interfaces, backed by +// Jetpack DataStore (Preferences). Mirrors the iOS HostRegistry package: storage +// lives behind an interface (HostStore / LastSessionStore), the immutable list +// transforms (upserting/removing) are shared helpers that never mutate, and the +// in-memory doubles double as JVM-testable fakes for the AW4 ViewModel tests. // -// NOTE: the plan (§3) treats the storage half as Android-framework-bound (DataStore), -// so it is scaffolded here as an SDK-gated module. Its pure logic could later be -// split into a JVM module if the Kover gate needs it; for now it is COMMENTED OUT -// in settings.gradle.kts (no Android SDK here). +// Android-framework-bound (DataStore needs a Context/File), so DataStore-backed +// stores are exercised in androidTest (compile here, run on device — no emulator). +// The pure logic (Host list transforms, in-memory doubles, HostCodec at-rest +// reconstruct/drop-invalid) is covered by plain JVM JUnit5 unit tests. // -// SCAFFOLD STUB ONLY. TODO(android-sdk): enable when an Android SDK is available. +// AGP 9 has built-in Kotlin → apply ONLY com.android.library (adding +// org.jetbrains.kotlin.android errors). The serialization compiler plugin is added +// via its alias and composes with the built-in Kotlin compilation (PersistedHost +// is @Serializable — the at-rest DTO). // ───────────────────────────────────────────────────────────────────────────── -/* plugins { id("com.android.library") - alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.serialization) } android { namespace = "wang.yaojia.webterm.hostregistry" - compileSdk = 35 - defaultConfig { minSdk = 29 } + compileSdk = 36 + + defaultConfig { + minSdk = 29 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } } -kotlin { jvmToolchain(17) } +kotlin { + jvmToolchain(17) +} dependencies { - implementation(project(":wire-protocol")) - // implementation("androidx.datastore:datastore-preferences:1.1.1") - // implementation("androidx.datastore:datastore:1.1.1") + api(project(":wire-protocol")) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.androidx.datastore.preferences) + + testImplementation(libs.bundles.unit.test) + testRuntimeOnly(libs.junit.platform.launcher) + + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.runner) +} + +// Local (JVM) unit tests run on the JUnit 5 platform, matching the pure modules +// and the :app design-token tests. +tasks.withType().configureEach { + useJUnitPlatform() } -*/ diff --git a/android/host-registry/src/androidTest/kotlin/wang/yaojia/webterm/hostregistry/DataStoreHostStoreTest.kt b/android/host-registry/src/androidTest/kotlin/wang/yaojia/webterm/hostregistry/DataStoreHostStoreTest.kt new file mode 100644 index 0000000..eb02af1 --- /dev/null +++ b/android/host-registry/src/androidTest/kotlin/wang/yaojia/webterm/hostregistry/DataStoreHostStoreTest.kt @@ -0,0 +1,66 @@ +package wang.yaojia.webterm.hostregistry + +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 androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import wang.yaojia.webterm.wire.HostEndpoint +import java.util.UUID + +/** + * Instrumented (device) contract tests for [DataStoreHostStore] — the real + * Preferences-DataStore-backed store needs a Context, so these run on a device + * (no emulator in the build env → compiled here, executed in device QA, plan §7). + * They assert the same read-modify-write / order-preserving behaviour the JVM + * [InMemoryHostStoreTest] pins for the in-memory double. + */ +@RunWith(AndroidJUnit4::class) +class DataStoreHostStoreTest { + + private fun newStore(): DataStoreHostStore { + val ctx = ApplicationProvider.getApplicationContext() + val dataStore: DataStore = PreferenceDataStoreFactory.create( + produceFile = { ctx.preferencesDataStoreFile("host-registry-test-${UUID.randomUUID()}") }, + ) + return DataStoreHostStore(dataStore) + } + + private fun host(id: String, name: String = "host-$id", baseUrl: String = "http://10.0.0.1:3000") = + Host(id = id, name = name, endpoint = requireNotNull(HostEndpoint.fromBaseUrl(baseUrl))) + + @Test + fun upsert_persists_and_loadAll_reads_back() = runBlocking { + val store = newStore() + val a = host("1") + + assertEquals(listOf(a), store.upsert(a)) + assertEquals(listOf(a), store.loadAll()) + } + + @Test + fun upsert_same_id_replaces_in_place() = runBlocking { + val store = newStore() + store.upsert(host("1")) + val renamed = host("1", name = "renamed") + + assertEquals(listOf(renamed), store.upsert(renamed)) + assertEquals("renamed", store.loadAll().single().name) + } + + @Test + fun remove_unknown_is_noop() = runBlocking { + val store = newStore() + store.upsert(host("1")) + + assertEquals(1, store.remove("nope").size) + assertEquals(0, store.remove("1").size) + assertEquals(0, store.loadAll().size) + } +} diff --git a/android/host-registry/src/androidTest/kotlin/wang/yaojia/webterm/hostregistry/DataStoreLastSessionStoreTest.kt b/android/host-registry/src/androidTest/kotlin/wang/yaojia/webterm/hostregistry/DataStoreLastSessionStoreTest.kt new file mode 100644 index 0000000..8941622 --- /dev/null +++ b/android/host-registry/src/androidTest/kotlin/wang/yaojia/webterm/hostregistry/DataStoreLastSessionStoreTest.kt @@ -0,0 +1,71 @@ +package wang.yaojia.webterm.hostregistry + +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 androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import java.util.UUID + +/** + * Instrumented (device) contract tests for [DataStoreLastSessionStore]. Mirrors the + * JVM in-memory tests plus the iOS `garbageStoredValueReadsBackAsNil` defence: a + * value that is not a UUID reads back as null (untrusted at rest). Runs on a device + * (no emulator here → compiled, deferred to device QA, plan §7). + */ +@RunWith(AndroidJUnit4::class) +class DataStoreLastSessionStoreTest { + + private fun newStore(): DataStoreLastSessionStore { + val ctx = ApplicationProvider.getApplicationContext() + val dataStore: DataStore = PreferenceDataStoreFactory.create( + produceFile = { ctx.preferencesDataStoreFile("last-session-test-${UUID.randomUUID()}") }, + ) + return DataStoreLastSessionStore(dataStore) + } + + private val validSession = UUID.randomUUID().toString() + + @Test + fun set_then_get_returns_same_sessionId() = runBlocking { + val store = newStore() + store.setLastSessionId(validSession, hostId = "host-1") + + assertEquals(validSession, store.lastSessionId("host-1")) + } + + @Test + fun unset_host_returns_null() = runBlocking { + assertNull(newStore().lastSessionId("host-1")) + } + + @Test + fun setting_null_clears() = runBlocking { + val store = newStore() + store.setLastSessionId(validSession, hostId = "host-1") + + store.setLastSessionId(null, hostId = "host-1") + + assertNull(store.lastSessionId("host-1")) + } + + @Test + fun distinct_hosts_are_isolated() = runBlocking { + val store = newStore() + val a = UUID.randomUUID().toString() + val b = UUID.randomUUID().toString() + + store.setLastSessionId(a, hostId = "host-a") + store.setLastSessionId(b, hostId = "host-b") + + assertEquals(a, store.lastSessionId("host-a")) + assertEquals(b, store.lastSessionId("host-b")) + } +} diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/DataStoreHostStore.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/DataStoreHostStore.kt new file mode 100644 index 0000000..a6c1de6 --- /dev/null +++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/DataStoreHostStore.kt @@ -0,0 +1,44 @@ +package wang.yaojia.webterm.hostregistry + +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 + +/** + * Preferences-DataStore-backed [HostStore]. The whole host list is ONE JSON string + * under [HOSTS_KEY], serialized by [HostCodec]. Reads decode + reconstruct (dropping + * records whose baseUrl no longer validates); writes are an atomic read-modify-write + * via [DataStore.edit], reusing the shared immutable list transforms. + * + * The [DataStore] is injected (constructed from a Context in :app DI) so this class + * has no Android-framework surface of its own; its behaviour is verified instrumented + * (androidTest) on a device. + */ +public class DataStoreHostStore( + private val dataStore: DataStore, +) : HostStore { + + override suspend fun loadAll(): List = + HostCodec.decode(dataStore.data.first()[HOSTS_KEY]) + + override suspend fun upsert(host: Host): List = + writeTransform { it.upserting(host) } + + override suspend fun remove(id: String): List = + writeTransform { it.removing(id) } + + private suspend fun writeTransform(transform: (List) -> List): List { + lateinit var updated: List + dataStore.edit { prefs -> + updated = transform(HostCodec.decode(prefs[HOSTS_KEY])) + prefs[HOSTS_KEY] = HostCodec.encode(updated) + } + return updated + } + + private companion object { + val HOSTS_KEY = stringPreferencesKey("hosts") + } +} diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/DataStoreLastSessionStore.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/DataStoreLastSessionStore.kt new file mode 100644 index 0000000..ef9264f --- /dev/null +++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/DataStoreLastSessionStore.kt @@ -0,0 +1,42 @@ +package wang.yaojia.webterm.hostregistry + +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 wang.yaojia.webterm.wire.Validation + +/** + * Preferences-DataStore-backed [LastSessionStore]. Each host's last sessionId is + * ONE string entry under `"lastSessionId."` (mirrors the iOS UserDefaults + * key prefix), so distinct hosts never collide. + * + * The stored value crosses a storage boundary → validated on read with the frozen + * v4-specific [Validation.isValidSessionId] (the SAME guard the wire codec applies + * to server-issued ids), so a non-v4/garbage value reads back as null rather than + * being handed to the attach path (untrusted at rest). The [DataStore] is injected; + * behaviour is verified instrumented (androidTest) on a device. + */ +public class DataStoreLastSessionStore( + private val dataStore: DataStore, +) : LastSessionStore { + + override suspend fun lastSessionId(hostId: String): String? { + val raw = dataStore.data.first()[keyFor(hostId)] + return raw?.takeIf(Validation::isValidSessionId) + } + + override suspend fun setLastSessionId(sessionId: String?, hostId: String) { + val key = keyFor(hostId) + dataStore.edit { prefs -> + if (sessionId == null) prefs.remove(key) else prefs[key] = sessionId + } + } + + private fun keyFor(hostId: String) = stringPreferencesKey(KEY_PREFIX + hostId) + + private companion object { + const val KEY_PREFIX = "lastSessionId." + } +} diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/Host.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/Host.kt new file mode 100644 index 0000000..44b5363 --- /dev/null +++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/Host.kt @@ -0,0 +1,42 @@ +package wang.yaojia.webterm.hostregistry + +import wang.yaojia.webterm.wire.HostEndpoint + +/** + * A paired web-terminal host (frozen contract, plan §3 :host-registry). Immutable + * snapshot mirroring iOS `HostRegistry.Host`: identity ([id]) and dial info + * ([endpoint]) are fixed at creation; "renaming" a host means upserting a NEW value + * with the same [id] (see the `upserting`/`removing` transforms in HostStore.kt). + * + * - [id] is a stable UUID string, allocated once when the host is first paired. + * - [endpoint] is the SINGLE point of Origin/WS derivation ([HostEndpoint], frozen + * in :wire-protocol) — never redeclared here. Persistence stores only the + * [HostEndpoint.baseUrl] string and reconstructs via [HostEndpoint.fromBaseUrl], + * re-validating on load (host records are untrusted at rest — see HostCodec). + * - [hasDeviceCert] is a UI flag (does this host have an imported mTLS client + * identity?) surfaced by the session-list host menu / cert screen. + */ +public data class Host( + val id: String, + val name: String, + val endpoint: HostEndpoint, + val hasDeviceCert: Boolean = false, +) { + public companion object { + /** + * Validating factory: reconstructs [endpoint] from an untrusted [baseUrl] + * (QR/manual entry, or a value read back from storage). Returns null when + * [baseUrl] is not a valid http(s) URL — the caller drops the record rather + * than trusting a malformed endpoint at rest. + */ + public fun create( + id: String, + name: String, + baseUrl: String, + hasDeviceCert: Boolean = false, + ): Host? { + val endpoint = HostEndpoint.fromBaseUrl(baseUrl) ?: return null + return Host(id = id, name = name, endpoint = endpoint, hasDeviceCert = hasDeviceCert) + } + } +} diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/HostCodec.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/HostCodec.kt new file mode 100644 index 0000000..f7f5f8b --- /dev/null +++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/HostCodec.kt @@ -0,0 +1,62 @@ +package wang.yaojia.webterm.hostregistry + +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * At-rest DTO for a [Host]. Only the raw dial string [baseUrl] is persisted — NOT + * the derived Origin/WS values — so a stored record can never smuggle a mismatched + * Origin past the CSWSH defence: [HostEndpoint][wang.yaojia.webterm.wire.HostEndpoint] + * is re-derived from [baseUrl] on load. `@Serializable` (the serialization compiler + * plugin generates the serializer; composes with AGP 9's built-in Kotlin). + */ +@Serializable +internal data class PersistedHost( + val id: String, + val name: String, + val baseUrl: String, + val hasDeviceCert: Boolean = false, +) + +/** + * Pure JSON codec for the persisted host list (shared by [DataStoreHostStore], + * JVM-testable without a Context). Encode is total; decode is defensive — host + * records are untrusted at rest: + * - a corrupt/undecodable blob → empty list (start clean, never crash); + * - a record whose [PersistedHost.baseUrl] no longer validates → dropped + * (reconstructed via [Host.create]). + */ +internal object HostCodec { + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = true + } + + fun encode(hosts: List): String = + json.encodeToString(hosts.map { it.toPersisted() }) + + fun decode(raw: String?): List { + if (raw.isNullOrBlank()) return emptyList() + val persisted = try { + json.decodeFromString>(raw) + } catch (_: Exception) { + return emptyList() // corrupted blob at rest → start clean, never crash + } + // mapNotNull drops records whose baseUrl fails re-validation (untrusted at rest). + return persisted.mapNotNull { it.toHost() } + } + + private fun Host.toPersisted(): PersistedHost = + PersistedHost( + id = id, + name = name, + baseUrl = endpoint.baseUrl, + hasDeviceCert = hasDeviceCert, + ) + + private fun PersistedHost.toHost(): Host? = + Host.create(id = id, name = name, baseUrl = baseUrl, hasDeviceCert = hasDeviceCert) +} diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/HostStore.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/HostStore.kt new file mode 100644 index 0000000..2019211 --- /dev/null +++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/HostStore.kt @@ -0,0 +1,49 @@ +package wang.yaojia.webterm.hostregistry + +/** + * Frozen contract (plan §3 :host-registry). Implementations: [DataStoreHostStore] + * (real, Preferences-DataStore-backed) and [InMemoryHostStore] (in-Sources double + * for this module's contract tests AND the AW4 ViewModel tests). + * + * Immutable style throughout: mutations return the NEW collection instead of + * mutating shared state in place (mirrors the iOS `HostStore` protocol). + */ +public interface HostStore { + /** The full paired-host list, in stored (insertion) order. */ + public suspend fun loadAll(): List + + /** + * Insert, or replace the host with the same [Host.id] (position preserved). + * Returns the new collection. + */ + public suspend fun upsert(host: Host): List + + /** + * Remove the host with [id]. Removing an unknown [id] is an explicit no-op: + * returns the unchanged collection, never throws for "not found". + */ + public suspend fun remove(id: String): List +} + +// ── Pure collection transforms shared by all HostStore implementations (DRY) ───── +// Never mutate the receiver — always return a fresh list. Mirrors the iOS +// `extension [Host] { upserting / removing }`. `internal` so both stores and the +// same-module unit tests can use them without widening the public surface. + +/** + * Insert [host], or replace the existing entry with the same [Host.id] IN PLACE + * (position preserved). Returns a new list; the receiver is untouched. + */ +internal fun List.upserting(host: Host): List = + if (any { it.id == host.id }) { + map { if (it.id == host.id) host else it } + } else { + this + host + } + +/** + * Return a new list without the host whose id equals [id]. Unknown id → a copy + * with the same contents (no-op). The receiver is untouched. + */ +internal fun List.removing(id: String): List = + filter { it.id != id } diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/InMemoryHostStore.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/InMemoryHostStore.kt new file mode 100644 index 0000000..5ec9469 --- /dev/null +++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/InMemoryHostStore.kt @@ -0,0 +1,37 @@ +package wang.yaojia.webterm.hostregistry + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * In-memory [HostStore]. Lives in `main` (not `test`) on purpose — it doubles for + * the DataStore store in this module's contract tests AND in the AW4 ViewModel + * tests (mirrors the iOS `InMemoryHostStore` actor). + * + * A [Mutex] serializes the read-modify-write in [upsert]/[remove] so concurrent + * callers can't interleave; the state is a value snapshot replaced wholesale on + * every change (no in-place mutation of a shared reference). + */ +public class InMemoryHostStore( + initial: List = emptyList(), +) : HostStore { + private val mutex = Mutex() + + // Defensive copy so an external mutable list handed to the ctor can't alias + // our state; replaced wholesale on every write. + private var hosts: List = initial.toList() + + override suspend fun loadAll(): List = mutex.withLock { hosts } + + override suspend fun upsert(host: Host): List = mutex.withLock { + val updated = hosts.upserting(host) + hosts = updated + updated + } + + override suspend fun remove(id: String): List = mutex.withLock { + val updated = hosts.removing(id) + hosts = updated + updated + } +} diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/InMemoryLastSessionStore.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/InMemoryLastSessionStore.kt new file mode 100644 index 0000000..93c5e98 --- /dev/null +++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/InMemoryLastSessionStore.kt @@ -0,0 +1,23 @@ +package wang.yaojia.webterm.hostregistry + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * In-memory [LastSessionStore] double (in `main` so the AW4 ViewModel/cold-start + * tests can inject it). A [Mutex] serializes writes; the per-host map is an + * immutable snapshot replaced wholesale on every change (never mutated in place). + */ +public class InMemoryLastSessionStore : LastSessionStore { + private val mutex = Mutex() + private var byHost: Map = emptyMap() + + override suspend fun lastSessionId(hostId: String): String? = + mutex.withLock { byHost[hostId] } + + override suspend fun setLastSessionId(sessionId: String?, hostId: String) { + mutex.withLock { + byHost = if (sessionId == null) byHost - hostId else byHost + (hostId to sessionId) + } + } +} diff --git a/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/LastSessionStore.kt b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/LastSessionStore.kt new file mode 100644 index 0000000..195c414 --- /dev/null +++ b/android/host-registry/src/main/kotlin/wang/yaojia/webterm/hostregistry/LastSessionStore.kt @@ -0,0 +1,28 @@ +package wang.yaojia.webterm.hostregistry + +/** + * Frozen contract (plan §3 / A29). Persists the last server-adopted sessionId PER + * HOST — NON-SECRET UI state only (anything sensitive belongs in the device-cert + * store). Mirrors the iOS `LastSessionStore` protocol, made `suspend` for DataStore. + * + * Lifecycle (driven by `SessionActivityBridge`, A29): + * - **set** on `.adopted` — [setLastSessionId] with the server-issued id; + * - **clear** on `.exited` — [setLastSessionId] with `null` (a dead session must + * not be offered as "continue last", else cold-start silently spawns a NEW one); + * - **get** for cold-start — [lastSessionId] feeds the "继续上次会话" banner + * (`ColdStartPolicy`). + */ +public interface LastSessionStore { + /** + * The last adopted sessionId for [hostId], or null (unknown host, cleared, or — + * for a persisted store — a value that no longer parses; untrusted at rest). + */ + public suspend fun lastSessionId(hostId: String): String? + + /** + * Persist [sessionId] as the last adopted session for [hostId], or clear it when + * [sessionId] is null. Distinct hosts are keyed independently (no cross-host + * clobbering). + */ + public suspend fun setLastSessionId(sessionId: String?, hostId: String) +} diff --git a/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/HostCodecTest.kt b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/HostCodecTest.kt new file mode 100644 index 0000000..628bf7d --- /dev/null +++ b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/HostCodecTest.kt @@ -0,0 +1,70 @@ +package wang.yaojia.webterm.hostregistry + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.wire.HostEndpoint + +/** + * [HostCodec] is the pure at-rest (de)serializer the DataStore store delegates to. + * Host records are UNTRUSTED at rest: the codec must round-trip cleanly, re-derive + * the endpoint via [HostEndpoint.fromBaseUrl], drop records whose baseUrl no longer + * validates, and never crash on a corrupt blob. + */ +class HostCodecTest { + + private fun host(id: String, name: String, baseUrl: String, hasCert: Boolean = false): Host = + Host(id = id, name = name, endpoint = requireNotNull(HostEndpoint.fromBaseUrl(baseUrl)), hasDeviceCert = hasCert) + + @Test + fun `encode then decode round-trips id, name, endpoint and cert flag`() { + val hosts = listOf( + host("1", "mac", "http://192.168.1.5:3000", hasCert = true), + host("2", "tailnet", "https://mac.tailnet.ts.net"), + ) + + val restored = HostCodec.decode(HostCodec.encode(hosts)) + + assertEquals(hosts, restored) + // The endpoint is RE-DERIVED, not stored — verify the derivations survived. + assertEquals("wss://mac.tailnet.ts.net/term", restored[1].endpoint.wsUrl) + assertEquals(true, restored[0].hasDeviceCert) + } + + @Test + fun `decode drops a record whose stored baseUrl no longer validates`() { + // A hand-written blob with one good and one malformed baseUrl (untrusted at rest). + val raw = """ + [ + {"id":"1","name":"good","baseUrl":"http://10.0.0.1:3000","hasDeviceCert":false}, + {"id":"2","name":"bad","baseUrl":"not a url","hasDeviceCert":false} + ] + """.trimIndent() + + val restored = HostCodec.decode(raw) + + assertEquals(1, restored.size) + assertEquals("1", restored.single().id) + } + + @Test + fun `decode of a corrupt or blank blob yields an empty list, never throws`() { + assertTrue(HostCodec.decode(null).isEmpty()) + assertTrue(HostCodec.decode("").isEmpty()) + assertTrue(HostCodec.decode(" ").isEmpty()) + assertTrue(HostCodec.decode("{not json").isEmpty()) + } + + @Test + fun `decode preserves stored order`() { + val hosts = listOf( + host("a", "first", "http://10.0.0.1:3000"), + host("b", "second", "http://10.0.0.2:3000"), + host("c", "third", "http://10.0.0.3:3000"), + ) + + val restored = HostCodec.decode(HostCodec.encode(hosts)) + + assertEquals(listOf("a", "b", "c"), restored.map { it.id }) + } +} diff --git a/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/HostStoreTransformsTest.kt b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/HostStoreTransformsTest.kt new file mode 100644 index 0000000..92fa11d --- /dev/null +++ b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/HostStoreTransformsTest.kt @@ -0,0 +1,86 @@ +package wang.yaojia.webterm.hostregistry + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotSame +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.wire.HostEndpoint + +/** + * The pure list transforms shared by every [HostStore] impl (DRY). They must be + * immutable: return a NEW list, never mutate the receiver — the invariant the whole + * store contract rests on. Mirrors the iOS `extension [Host]` behaviour. + */ +class HostStoreTransformsTest { + + private fun host(id: String, name: String = "host-$id", baseUrl: String = "http://10.0.0.$id:3000"): Host = + Host(id = id, name = name, endpoint = requireNotNull(HostEndpoint.fromBaseUrl(baseUrl))) + + @Test + fun `upserting a new id appends to the end`() { + // Arrange + val a = host("1") + val b = host("2") + + // Act + val result = listOf(a).upserting(b) + + // Assert + assertEquals(listOf(a, b), result) + } + + @Test + fun `upserting an existing id replaces in place, preserving position`() { + // Arrange + val a = host("1") + val b = host("2") + val c = host("3") + val renamedB = host("2", name = "renamed") + + // Act + val result = listOf(a, b, c).upserting(renamedB) + + // Assert — same length, same order, middle element replaced + assertEquals(listOf(a, renamedB, c), result) + assertEquals("renamed", result[1].name) + } + + @Test + fun `upserting does not mutate the original list`() { + // Arrange + val original = listOf(host("1")) + + // Act + val result = original.upserting(host("2")) + + // Assert — original untouched; a new instance was returned + assertEquals(1, original.size) + assertEquals(2, result.size) + assertNotSame(original, result) + } + + @Test + fun `removing an existing id drops exactly that entry`() { + // Arrange + val a = host("1") + val b = host("2") + + // Act + val result = listOf(a, b).removing("1") + + // Assert + assertEquals(listOf(b), result) + } + + @Test + fun `removing an unknown id is a no-op returning the same contents`() { + // Arrange + val original = listOf(host("1"), host("2")) + + // Act + val result = original.removing("nope") + + // Assert — contents unchanged, original list not mutated + assertEquals(original, result) + assertEquals(2, original.size) + } +} diff --git a/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/InMemoryHostStoreTest.kt b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/InMemoryHostStoreTest.kt new file mode 100644 index 0000000..00c0e6d --- /dev/null +++ b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/InMemoryHostStoreTest.kt @@ -0,0 +1,59 @@ +package wang.yaojia.webterm.hostregistry + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.wire.HostEndpoint + +/** + * Contract tests for the in-Sources [InMemoryHostStore] double (which the AW4 VM + * tests reuse). Same behaviour the DataStore store must exhibit; verified here at + * JVM speed under virtual time. + */ +class InMemoryHostStoreTest { + + private fun host(id: String, name: String = "host-$id"): Host = + Host(id = id, name = name, endpoint = requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.1:3000"))) + + @Test + fun `loadAll returns the seeded hosts`() = runTest { + val a = host("1") + val store = InMemoryHostStore(listOf(a)) + + assertEquals(listOf(a), store.loadAll()) + } + + @Test + fun `upsert inserts then replaces same-id, and loadAll reflects it`() = runTest { + val store = InMemoryHostStore() + val a = host("1") + + assertEquals(listOf(a), store.upsert(a)) + // replace with same id + val renamed = host("1", name = "renamed") + assertEquals(listOf(renamed), store.upsert(renamed)) + assertEquals(listOf(renamed), store.loadAll()) + } + + @Test + fun `remove drops the host, unknown id is a no-op`() = runTest { + val a = host("1") + val b = host("2") + val store = InMemoryHostStore(listOf(a, b)) + + assertEquals(listOf(a, b), store.remove("nope")) + assertEquals(listOf(a), store.remove("2")) + assertEquals(listOf(a), store.loadAll()) + } + + @Test + fun `a list passed to the ctor is defensively copied`() = runTest { + val seed = mutableListOf(host("1")) + val store = InMemoryHostStore(seed) + + // Mutating the caller's list must not leak into the store. + seed.add(host("2")) + + assertEquals(1, store.loadAll().size) + } +} diff --git a/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/InMemoryLastSessionStoreTest.kt b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/InMemoryLastSessionStoreTest.kt new file mode 100644 index 0000000..34ca96d --- /dev/null +++ b/android/host-registry/src/test/kotlin/wang/yaojia/webterm/hostregistry/InMemoryLastSessionStoreTest.kt @@ -0,0 +1,50 @@ +package wang.yaojia.webterm.hostregistry + +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 + +/** + * Contract tests for the in-Sources [InMemoryLastSessionStore] double (reused by the + * A29 cold-start / SessionActivityBridge tests). Mirrors the iOS + * `LastSessionStoreTests`: set→get, unknown→null, set(null)→clear, host isolation. + */ +class InMemoryLastSessionStoreTest { + + @Test + fun `set then get returns the same sessionId`() = runTest { + val store = InMemoryLastSessionStore() + store.setLastSessionId("session-1", hostId = "host-1") + + assertEquals("session-1", store.lastSessionId("host-1")) + } + + @Test + fun `an unset host returns null`() = runTest { + val store = InMemoryLastSessionStore() + + assertNull(store.lastSessionId("host-1")) + } + + @Test + fun `setting null clears an existing sessionId`() = runTest { + val store = InMemoryLastSessionStore() + store.setLastSessionId("session-1", hostId = "host-1") + + store.setLastSessionId(null, hostId = "host-1") + + assertNull(store.lastSessionId("host-1")) + } + + @Test + fun `distinct hosts are keyed independently`() = runTest { + val store = InMemoryLastSessionStore() + + store.setLastSessionId("session-a", hostId = "host-a") + store.setLastSessionId("session-b", hostId = "host-b") + + assertEquals("session-a", store.lastSessionId("host-a")) + assertEquals("session-b", store.lastSessionId("host-b")) + } +} diff --git a/android/session-core/build.gradle.kts b/android/session-core/build.gradle.kts index 05fd26e..ca3ea5f 100644 --- a/android/session-core/build.gradle.kts +++ b/android/session-core/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.kover) } kotlin { @@ -26,3 +27,14 @@ dependencies { tasks.test { useJUnitPlatform() } + +// A36 acceptance gate: >=80% line coverage on this pure module (plan §7). +kover { + reports { + verify { + rule { + minBound(80) + } + } + } +} diff --git a/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEngine.kt b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEngine.kt new file mode 100644 index 0000000..4857a73 --- /dev/null +++ b/android/session-core/src/main/kotlin/wang/yaojia/webterm/session/SessionEngine.kt @@ -0,0 +1,415 @@ +package wang.yaojia.webterm.session + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import wang.yaojia.webterm.wire.ClientMessage +import wang.yaojia.webterm.wire.ConnectionPinger +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.MessageCodec +import wang.yaojia.webterm.wire.PingableTermTransport +import wang.yaojia.webterm.wire.ServerMessage +import wang.yaojia.webterm.wire.TermTransport +import wang.yaojia.webterm.wire.TimelineEvent +import wang.yaojia.webterm.wire.TransportConnection +import wang.yaojia.webterm.wire.Tunables + +/** + * The session lifecycle state machine (A14, plan §5 / §6.6). The Android analogue of the iOS + * `SessionCore.SessionEngine`, and the composition root that drives the pure reducers + * ([ReconnectMachine], [PingScheduler], [GateTracker], [AwayDigest]) against an injected + * [TermTransport]. + * + * PURE / TESTABLE (invariant #4 — the confinement contract): the engine imports NO OkHttp/Android + * types. ALL engine state ([reconnect], [gateTracker], [currentConnection], [generation], …) is + * touched ONLY on the injected [scope]'s dispatcher — a `Dispatchers.Default.limitedParallelism(1)` + * in production, a `StandardTestDispatcher` under `runTest`. The only cross-boundary calls are the + * event fan-out ([events], engine→UI) and [send]/[decideGate]/[notifyForegrounded]/[close] + * (UI→engine), each of which hops onto [scope] before touching state. Because the dispatcher is + * single-threaded the fields need no locks. + * + * The load-bearing invariants (plan §1): + * - **attach-first (#2):** [ClientMessage.Attach] is the FIRST frame sent on EVERY (re)connect, + * with the `sessionId` key always present (JSON `null` for a new session), sent BEFORE the + * connection is published so a queued UI send can never overtake it. + * - **adopt-server-id:** every `attached` frame adopts the server-issued id ([ReconnectMachine.withSessionId]), + * so the next reconnect re-attaches to the same PTY and the ring buffer replays. + * - **close == detach, NEVER kill (#2):** [close] closes the WS (server PTY survives) and stops + * reconnecting; it never sends a kill. A [close] that lands while a fresh connection is still + * inside the dial/attach window (before it is published to [currentConnection]) still detaches + * that connection — it is never leaked (see [runOneConnection]). + * - **generation-safe cancellation (defensive-only):** [generation] bumps on each (re)connect. + * Under the strictly-sequential supervisor no two connections ever run at once, so the + * `gen != generation` drop in [onFrame] is belt-and-braces — a superseded connection's late + * frame would be dropped rather than emitted onto a newer generation. + * - **oversized replay is terminal:** a frame past [maxFrameBytes] is the non-retryable + * [FailureReason.REPLAY_TOO_LARGE] — distinct from a retryable disconnect (never fed to backoff). + */ +public class SessionEngine( + private val transport: TermTransport, + private val endpoint: HostEndpoint, + private val scope: CoroutineScope, + initialSessionId: String? = null, + private val initialCwd: String? = null, + private val awayTimeline: suspend (sinceMs: Long) -> List = { emptyList() }, + private val nowMs: () -> Long = { System.currentTimeMillis() }, + private val pingScheduler: PingScheduler = PingScheduler(), + private val maxFrameBytes: Long = Tunables.MAX_WS_MESSAGE_BYTES, + private val digestLimit: Int = DEFAULT_DIGEST_LIMIT, +) { + /** A retryable end (drop/clean-server-close/ping-loss) vs a non-retryable [Terminal] one. */ + private sealed interface EndCause { + /** The WS dropped while we were connected — reconnect with back-off. */ + data object Disconnected : EndCause + + /** `exit` or oversized-replay: the terminal event was already emitted; do NOT reconnect. */ + data object Terminal : EndCause + } + + /** One live connection plus its optional ping capability (null = plain [TermTransport]). */ + private class OpenConn(val connection: TransportConnection, val pinger: ConnectionPinger?) + + private val eventsChannel = Channel(Channel.UNLIMITED) + + /** Engine→UI event stream (single consumer; the App's EventBus fans out per R10). */ + public val events: Flow = eventsChannel.receiveAsFlow() + + // ── engine-confined state (touched only on [scope]) ─────────────────────────────────── + private var reconnect: ReconnectMachine = ReconnectMachine.initial.withSessionId(initialSessionId) + private var gateTracker: GateTracker = GateTracker.INITIAL + private var currentConnection: TransportConnection? = null + private var lastDims: Pair? = null + private var generation: Int = 0 + private var reconnectAttempt: Int = 0 + private var pendingDigest: Boolean = false + private var disconnectedAtMs: Long? = null + private var retryWakeup: CompletableDeferred? = null + private var started: Boolean = false + private var closed: Boolean = false + + /** Begin connecting. Idempotent — a second call is a no-op. */ + public fun start() { + scope.launch { + // Idempotency flip happens ON the confined dispatcher (invariant #4): [started] is engine + // state and must never be touched on the caller's thread. + if (started) return@launch + started = true + try { + supervisor() + } finally { + eventsChannel.close() + } + } + } + + /** + * Send a UI-originated frame (input / resize / approve / reject) on the live connection. + * Dropped when disconnected (a `resize` is re-sent from [lastDims] on the next attach; input + * while detached is meaningless). Encoding is byte-exact via [MessageCodec.encode]. + */ + public fun send(message: ClientMessage) { + scope.launch { doSend(message) } + } + + /** + * Resolve a held gate ONLY if [epoch] still matches the current gate ([GateTracker.canDecide]); + * a stale epoch is DROPPED so a slow tap can never approve the NEXT gate (two-line stale-guard). + */ + public fun decideGate(epoch: Int, message: ClientMessage) { + scope.launch { handleDecideGate(epoch, message) } + } + + /** + * The app returned to the foreground (plan §6.4/§6.6). If connected, re-send [lastDims] to + * reclaim full-screen (latest-writer-wins). If in back-off, connect NOW without resetting the + * ladder (no-reset-on-foreground, [ReconnectMachine]). [cols]/[rows] update the remembered dims. + */ + public fun notifyForegrounded(cols: Int? = null, rows: Int? = null) { + scope.launch { handleForegrounded(cols, rows) } + } + + /** + * Detach: close the WS (the server-side PTY keeps running) and stop reconnecting. NEVER a kill. + * + * Returns the launched [Job] so a caller can AWAIT the clean detach (the RFC-6455 `1000` close + * frame handed to the transport writer) BEFORE it cancels the confinement [scope] (FIX 5 — + * `RetainedSessionHolder.teardown()`). Fire-and-forget callers simply ignore the result. + */ + public fun close(): Job = scope.launch { handleClose() } + + // ── supervisor: the connect / reconnect loop ────────────────────────────────────────── + + private suspend fun supervisor() { + emit(Connection(ConnectionState.Connecting)) + while (!closed) { + val gen = ++generation + val cause = runOneConnection(gen) + if (closed || cause == EndCause.Terminal) break + scheduleAndWaitRetry() + } + } + + private suspend fun runOneConnection(gen: Int): EndCause { + val open = dialOrNull() ?: return EndCause.Disconnected + if (closed) return abandon(open) // close() landed during dial → detach, never publish + if (!attachFirst(open)) return EndCause.Disconnected + if (closed) return abandon(open) // close() landed during attach → detach, never publish + currentConnection = open.connection + onConnected() + val cause = collectConnection(gen, open) + currentConnection = null + if (cause == EndCause.Disconnected) { + // A live connection dropped → the next reattach owes an away-digest. + pendingDigest = true + disconnectedAtMs = nowMs() + } + return cause + } + + /** + * A [close] arrived while this connection was still opening (currentConnection was null, so + * [handleClose] could not detach it). Detach it here and end terminally — emit NOTHING further + * (no Connected/Adopted, no reconnect): [handleClose] already emitted [ConnectionState.Closed] + * and [closed] short-circuits the supervisor. Upholds close == detach, NEVER kill (#2 / §6.6). + */ + private suspend fun abandon(open: OpenConn): EndCause { + ignoringNonCancel { open.connection.close() } + return EndCause.Terminal + } + + private suspend fun dialOrNull(): OpenConn? = + try { + if (transport is PingableTermTransport) { + val pingable = transport.connectPingable(endpoint) + OpenConn(pingable.connection, pingable.pinger) + } else { + OpenConn(transport.connect(endpoint), null) + } + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + null // connect-time failure → retryable + } + + /** attach-first (+ replay [lastDims]) BEFORE publishing the connection. */ + private suspend fun attachFirst(open: OpenConn): Boolean = + try { + open.connection.send(MessageCodec.encode(ClientMessage.Attach(reconnect.sessionId, cwdForAttach()))) + lastDims?.let { (cols, rows) -> + open.connection.send(MessageCodec.encode(ClientMessage.Resize(cols, rows))) + } + true + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + ignoringNonCancel { open.connection.close() } + false + } + + /** `cwd` rides the attach only for a brand-new session (server appends it iff sessionId is null). */ + private fun cwdForAttach(): String? = if (reconnect.sessionId == null) initialCwd else null + + private fun onConnected() { + reconnect = reconnect.reduce(ReconnectMachine.Input.Connected).first // reset ladder, keep id + reconnectAttempt = 0 + emit(Connection(ConnectionState.Connected)) + } + + private suspend fun scheduleAndWaitRetry() { + val (next, effect) = reconnect.reduce(ReconnectMachine.Input.Disconnected) + reconnect = next + reconnectAttempt += 1 + val after = (effect as ReconnectMachine.Effect.ScheduleRetry).after + emit(Connection(ConnectionState.Reconnecting(reconnectAttempt, after))) + val wake = CompletableDeferred() + retryWakeup = wake + // Woken early by foreground/close → connect now; else the timer elapses → connect now. + withTimeoutOrNull(after) { wake.await() } + retryWakeup = null + } + + // ── one connection's lifetime: frames + keep-alive ping ──────────────────────────────── + + private suspend fun collectConnection(gen: Int, open: OpenConn): EndCause { + var terminal: EndCause? = null + return try { + coroutineScope { + val pingJob = open.pinger?.let { launchPing(open.connection, it) } + try { + open.connection.frames.collect { frame -> + onFrame(gen, frame)?.let { cause -> + terminal = cause + ignoringNonCancel { open.connection.close() } // ends the frames flow → collect returns + } + } + } finally { + pingJob?.cancel() + } + terminal ?: EndCause.Disconnected // clean finish with no exit frame = server-side close + } + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + EndCause.Disconnected // transport error → retryable + } + } + + private fun CoroutineScope.launchPing(conn: TransportConnection, pinger: ConnectionPinger): Job = + launch { + if (pingScheduler.run { pinger.ping() } == PingScheduler.Outcome.CONNECTION_LOST) { + ignoringNonCancel { conn.close() } // declare dead → ends frames → collectConnection = Disconnected + } + } + + /** Handle one server frame; returns a terminal [EndCause] to stop the connection, else null. */ + private fun onFrame(gen: Int, frame: String): EndCause? { + if (gen != generation) return null // defensive-only: the sequential supervisor never overlaps generations + if (utf8Size(frame) > maxFrameBytes) { + emit(Connection(ConnectionState.Failed(FailureReason.REPLAY_TOO_LARGE))) + return EndCause.Terminal + } + return when (val msg = MessageCodec.decodeServer(frame)) { + null -> null // undecodable → drop + is ServerMessage.Attached -> { onAttached(msg.sessionId); null } + is ServerMessage.Output -> { emit(Output(msg.data)); null } + is ServerMessage.Status -> { onStatus(msg); null } + is ServerMessage.Telemetry -> { emit(Telemetry(msg.telemetry)); null } + is ServerMessage.Exit -> { + emit(Exited(msg.code, msg.reason)) + EndCause.Terminal + } + } + } + + private fun onAttached(sessionId: String) { + reconnect = reconnect.withSessionId(sessionId) // always adopt the server-issued id + emit(Adopted(sessionId)) + if (pendingDigest) { + pendingDigest = false + disconnectedAtMs?.let { since -> scope.launch { emitDigest(since) } } + } + } + + private fun onStatus(status: ServerMessage.Status) { + val previous = gateTracker.current + gateTracker = gateTracker.reduce(status.pending, status.gate, status.detail) + val next = gateTracker.current + if (next != previous) emit(Gate(next)) + } + + private suspend fun emitDigest(sinceMs: Long) { + val timeline = try { + awayTimeline(sinceMs) + } catch (e: CancellationException) { + throw e // never swallow cancellation (FIX 3) + } catch (_: Throwable) { + emptyList() + } + emit(Digest(AwayDigest.reduce(timeline, sinceMs, digestLimit))) // EMPTY still delivered; UI suppresses + } + + // ── UI→engine command handlers (all on [scope]) ──────────────────────────────────────── + + private suspend fun doSend(message: ClientMessage) { + if (message is ClientMessage.Resize) lastDims = message.cols to message.rows + currentConnection?.let { conn -> ignoringNonCancel { conn.send(MessageCodec.encode(message)) } } + } + + private suspend fun handleDecideGate(epoch: Int, message: ClientMessage) { + if (!gateTracker.canDecide(epoch)) return // stale epoch or no gate → drop (防误批新 gate) + doSend(message) + retireHeldGate() // a decision resolves the gate → a second same-epoch tap must NOT double-send + } + + /** + * Optimistically clear the held gate after a decision was sent (mirrors the web client setting + * `pendingApprovalValue=false` inside `approve()`/`reject()`, public/terminal-session.ts). Folds a + * synthetic falling edge through [GateTracker] so `canDecide(sameEpoch)` returns false afterwards; + * [lastEpoch] is retained so the server's next rising edge still mints a fresh epoch. The server's + * own next `pending=false` status is then a no-op (state already null → no duplicate [Gate] event). + */ + private fun retireHeldGate() { + if (gateTracker.current == null) return + gateTracker = gateTracker.reduce(pending = false, gate = null, detail = null) + emit(Gate(gateTracker.current)) // null → the UI hides the approve/reject affordances immediately + } + + private suspend fun handleForegrounded(cols: Int?, rows: Int?) { + if (closed) return + if (cols != null && rows != null) lastDims = cols to rows + val conn = currentConnection + if (conn != null) { + // Connected: reclaim full-screen by re-sending the remembered dims (latest-writer-wins). + lastDims?.let { (c, r) -> ignoringNonCancel { conn.send(MessageCodec.encode(ClientMessage.Resize(c, r))) } } + } else { + // In back-off / connecting: connect NOW without touching the ladder (no-reset-on-foreground). + retryWakeup?.complete(Unit) + } + } + + private suspend fun handleClose() { + if (closed) return + closed = true + retryWakeup?.complete(Unit) // break any back-off wait so the supervisor exits + val conn = currentConnection + currentConnection = null + conn?.let { ignoringNonCancel { it.close() } } // detach — the server PTY keeps running + emit(Connection(ConnectionState.Closed)) + } + + private fun emit(event: SessionEvent) { + eventsChannel.trySend(event) // UNLIMITED → never fails while open; no-op after close + } + + /** + * Run a best-effort suspend [block] (transport teardown / send), swallowing ordinary failures + * but RETHROWING [CancellationException] so structured-concurrency cancellation is never + * silently absorbed (FIX 3). Inline so the suspend calls run in the caller's coroutine. + */ + private suspend inline fun ignoringNonCancel(block: () -> Unit) { + try { + block() + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + // best-effort: a failed close/send is non-fatal to the engine's lifecycle + } + } + + /** + * UTF-8 byte length of [text], short-circuiting the moment it passes [maxFrameBytes] so a + * multi-MB oversized replay is not fully scanned. Cheaper than allocating a `ByteArray` for + * every normal frame. + */ + private fun utf8Size(text: String): Long { + var bytes = 0L + var i = 0 + while (i < text.length) { + val code = text[i].code + bytes += when { + code < 0x80 -> 1 + code < 0x800 -> 2 + code in 0xD800..0xDBFF && i + 1 < text.length && text[i + 1].code in 0xDC00..0xDFFF -> { + i++ // consume the low surrogate: a full pair encodes to 4 bytes + 4 + } + else -> 3 + } + if (bytes > maxFrameBytes) return bytes // over the cap — exact size no longer matters + i++ + } + return bytes + } + + public companion object { + /** Max [AwayDigest.recent] entries carried in the once-per-reconnect digest. */ + public const val DEFAULT_DIGEST_LIMIT: Int = 20 + } +} diff --git a/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/SessionEngineTest.kt b/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/SessionEngineTest.kt new file mode 100644 index 0000000..44066c5 --- /dev/null +++ b/android/session-core/src/test/kotlin/wang/yaojia/webterm/session/SessionEngineTest.kt @@ -0,0 +1,468 @@ +package wang.yaojia.webterm.session + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +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.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.testsupport.FakePingableTermTransport +import wang.yaojia.webterm.testsupport.FakeTermTransport +import wang.yaojia.webterm.testsupport.FakeTimeSource +import wang.yaojia.webterm.wire.ClientMessage +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.MessageCodec +import wang.yaojia.webterm.wire.TermTransport +import wang.yaojia.webterm.wire.TimelineEvent +import wang.yaojia.webterm.wire.TransportConnection +import wang.yaojia.webterm.wire.Tunables +import kotlin.time.ExperimentalTime +import kotlin.time.Duration.Companion.seconds + +/** + * A14 verification (plan §5 "A14 Verify"): the [SessionEngine] state machine driven purely through + * the [FakeTermTransport]/[FakePingableTermTransport] doubles and the coroutine virtual clock under + * `runTest`. No OkHttp, no Android, no wall-clock waits. + */ +class SessionEngineTest { + + private companion object { + /** A valid lower-case UUID-v4 the server can issue on `attached`. */ + const val SERVER_ID = "11111111-1111-4111-8111-111111111111" + const val ATTACH_NEW = """{"type":"attach","sessionId":null}""" + const val APPROVE = """{"type":"approve"}""" + } + + private fun endpoint(): HostEndpoint = HostEndpoint.fromBaseUrl("http://localhost:3000")!! + + private fun attachWith(id: String): String = MessageCodec.encode(ClientMessage.Attach(id)) + + private fun attachedFrame(id: String): String = """{"type":"attached","sessionId":"$id"}""" + + private fun statusFrame(pending: Boolean, gate: String? = null): String { + val gatePart = if (gate != null) ""","gate":"$gate"""" else "" + return """{"type":"status","status":"working","pending":$pending$gatePart}""" + } + + private fun TestScope.newEngine( + transport: TermTransport, + initialSessionId: String? = null, + initialCwd: String? = null, + maxFrameBytes: Long = Tunables.MAX_WS_MESSAGE_BYTES, + awayTimeline: suspend (Long) -> List = { emptyList() }, + nowMs: () -> Long = { 0L }, + ): SessionEngine = SessionEngine( + transport = transport, + endpoint = endpoint(), + scope = backgroundScope, + initialSessionId = initialSessionId, + initialCwd = initialCwd, + awayTimeline = awayTimeline, + nowMs = nowMs, + maxFrameBytes = maxFrameBytes, + ) + + /** Live-updating list of everything the engine emits (single consumer, buffered channel). */ + private fun TestScope.collectEvents(engine: SessionEngine): List { + val out = mutableListOf() + backgroundScope.launch { engine.events.collect { out += it } } + return out + } + + private fun List.connectionStates(): List = + filterIsInstance().map { it.state } + + // ── attach-first ordering ────────────────────────────────────────────────────────────── + + @Test + fun `attach is the first frame on connect with an explicit null sessionId for a new session`() = runTest { + val transport = FakeTermTransport() + val engine = newEngine(transport) + collectEvents(engine) + + engine.start() + testScheduler.runCurrent() + + assertEquals(listOf(ATTACH_NEW), transport.sentFramesByConnection[0]) + } + + @Test + fun `on reconnect attach is re-sent first carrying the adopted server id`() = runTest { + val transport = FakeTermTransport() + val engine = newEngine(transport) + collectEvents(engine) + + engine.start() + testScheduler.runCurrent() + transport.emit(attachedFrame(SERVER_ID)) // server confirms + issues the id + testScheduler.runCurrent() + + transport.emitError(RuntimeException("ws dropped")) // conn 0 drops → back-off 1s + testScheduler.runCurrent() + testScheduler.advanceTimeBy(1.seconds) + testScheduler.runCurrent() + + assertEquals(attachWith(SERVER_ID), transport.sentFramesByConnection[1].first()) + } + + // ── adopt-server-id ───────────────────────────────────────────────────────────────────── + + @Test + fun `every attached frame is adopted and surfaced as an Adopted event`() = runTest { + val transport = FakeTermTransport() + val engine = newEngine(transport) + val events = collectEvents(engine) + + engine.start() + testScheduler.runCurrent() + transport.emit(attachedFrame(SERVER_ID)) + testScheduler.runCurrent() + + assertTrue(events.contains(Adopted(SERVER_ID)), "expected an Adopted($SERVER_ID) event, got $events") + } + + // ── connect-now-on-foreground (and no-reset-on-foreground) ────────────────────────────── + + @Test + fun `foregrounding connects immediately without resetting the back-off ladder`() = runTest { + val transport = FakeTermTransport() + val engine = newEngine(transport) + val events = collectEvents(engine) + + engine.start() + testScheduler.runCurrent() // conn 0 connected + transport.emitError(RuntimeException("drop")) // → Reconnecting(1, 1s), waiting + testScheduler.runCurrent() + + transport.scriptConnectFailure() // the foreground connect will fail so we can read the ladder + engine.notifyForegrounded() + testScheduler.runCurrent() // wake → connect NOW (no 1s advance) + + assertEquals(2, transport.connectAttempts.size) // connected immediately, not after 1s + val reconnecting = events.connectionStates().filterIsInstance() + assertEquals(2.seconds, reconnecting.last().next) // ladder advanced to 2s, NOT reset to 1s + } + + // ── close == detach, NEVER kill ───────────────────────────────────────────────────────── + + @Test + fun `close detaches the connection without a kill and never reconnects`() = runTest { + val transport = FakeTermTransport() + val engine = newEngine(transport) + val events = collectEvents(engine) + + engine.start() + testScheduler.runCurrent() + engine.close() + testScheduler.runCurrent() + + assertEquals(1, transport.closeCallCount) // exactly one detach + assertEquals(listOf(ATTACH_NEW), transport.sentFrames) // only attach was ever sent — no kill frame + assertTrue(events.contains(Connection(ConnectionState.Closed))) + + testScheduler.advanceTimeBy(60.seconds) // no back-off reconnect after a client close + testScheduler.runCurrent() + assertEquals(1, transport.connectAttempts.size) + } + + // ── oversized-replay is a terminal, non-retryable failure ─────────────────────────────── + + @Test + fun `an oversized frame yields replayTooLarge and never reconnects`() = runTest { + val transport = FakeTermTransport() + val engine = newEngine(transport, maxFrameBytes = 32) + val events = collectEvents(engine) + + engine.start() + testScheduler.runCurrent() + transport.emit("x".repeat(100)) // 100 bytes > 32-byte cap + testScheduler.runCurrent() + + assertTrue( + events.contains(Connection(ConnectionState.Failed(FailureReason.REPLAY_TOO_LARGE))), + "expected a REPLAY_TOO_LARGE failure, got $events", + ) + // Distinct from a retryable disconnect: no Reconnecting, no second connect. + assertFalse(events.connectionStates().any { it is ConnectionState.Reconnecting }) + testScheduler.advanceTimeBy(60.seconds) + testScheduler.runCurrent() + assertEquals(1, transport.connectAttempts.size) + } + + // ── gate-decision epoch drop (two-line stale-guard) ───────────────────────────────────── + + @Test + fun `a decision against a stale gate epoch is dropped while a live epoch sends`() = runTest { + val transport = FakeTermTransport() + val engine = newEngine(transport) + val events = collectEvents(engine) + + engine.start() + testScheduler.runCurrent() + + transport.emit(statusFrame(pending = true, gate = "tool")) // rising edge → epoch 1 + testScheduler.runCurrent() + val epoch1 = events.filterIsInstance().last().gate!!.epoch + assertEquals(1, epoch1) + + engine.decideGate(epoch1, ClientMessage.Approve(null)) // live epoch → sends + testScheduler.runCurrent() + + transport.emit(statusFrame(pending = false)) // gate lifted + testScheduler.runCurrent() + transport.emit(statusFrame(pending = true, gate = "tool")) // rising edge → epoch 2 + testScheduler.runCurrent() + + engine.decideGate(epoch1, ClientMessage.Approve(null)) // stale epoch → dropped + testScheduler.runCurrent() + + assertEquals(1, transport.sentFrames.count { it == APPROVE }) // exactly one approve reached the wire + } + + @Test + fun `a second decision against the same live gate epoch does not double-send`() = runTest { + val transport = FakeTermTransport() + val engine = newEngine(transport) + val events = collectEvents(engine) + + engine.start() + testScheduler.runCurrent() + transport.emit(statusFrame(pending = true, gate = "tool")) // rising edge → epoch 1, gate held + testScheduler.runCurrent() + val epoch1 = events.filterIsInstance().last().gate!!.epoch + + engine.decideGate(epoch1, ClientMessage.Approve(null)) // first tap → sends, retires the gate + testScheduler.runCurrent() + engine.decideGate(epoch1, ClientMessage.Approve(null)) // second same-epoch tap BEFORE any status update + testScheduler.runCurrent() + + assertEquals(1, transport.sentFrames.count { it == APPROVE }) // exactly ONE approve reached the wire + } + + // ── close during the dial/attach window still detaches (close == detach, #2 / §6.6) ───── + + @Test + fun `close during the dial window detaches the freshly-opened connection and emits nothing further`() = runTest { + val gate = CompletableDeferred() + val transport = GatedConnectTransport(connectGate = gate) + val engine = newEngine(transport) + val events = collectEvents(engine) + + engine.start() + testScheduler.runCurrent() // supervisor runs; connect() suspends on the gate (currentConnection is null) + + engine.close() // close lands DURING the dial window + testScheduler.runCurrent() + + gate.complete(Unit) // the connection now opens, after close() already fired + testScheduler.runCurrent() + + assertTrue(transport.connection.isClosed, "the connection opened during close() must be detached") + val states = events.connectionStates() + assertFalse(states.contains(ConnectionState.Connected), "must not emit Connected after close, got $states") + assertFalse(events.any { it is Adopted }, "must not adopt a server id after close, got $events") + assertEquals(1, transport.connectCount) // no reconnect scheduled after a client close + } + + @Test + fun `close during the attach window detaches the freshly-opened connection and emits nothing further`() = runTest { + val sendGate = CompletableDeferred() + val transport = GatedConnectTransport(sendGate = sendGate) // connect succeeds; the first send suspends + val engine = newEngine(transport) + val events = collectEvents(engine) + + engine.start() + testScheduler.runCurrent() // connect returns; attachFirst's send() suspends on the gate + + engine.close() // close lands DURING the attach window + testScheduler.runCurrent() + + sendGate.complete(Unit) // the attach send now completes, after close() already fired + testScheduler.runCurrent() + + assertTrue(transport.connection.isClosed, "the connection opened during close() must be detached") + val states = events.connectionStates() + assertFalse(states.contains(ConnectionState.Connected), "must not emit Connected after close, got $states") + assertEquals(1, transport.connectCount) // no reconnect scheduled after a client close + } + + // ── back-off ladder 1 → 2 → 4 → 8 → 16 → 30 ───────────────────────────────────────────── + + @Test + fun `the reconnect back-off ladder climbs 1 2 4 8 16 30`() = runTest { + val transport = FakeTermTransport() + val engine = newEngine(transport) + val events = collectEvents(engine) + + engine.start() + testScheduler.runCurrent() // conn 0 connected + repeat(6) { transport.scriptConnectFailure() } // every reconnect attempt fails → ladder climbs + transport.emitError(RuntimeException("drop")) // → Reconnecting(1, 1s) + testScheduler.runCurrent() + + for (rung in listOf(1, 2, 4, 8, 16, 30)) { + testScheduler.advanceTimeBy(rung.seconds) + testScheduler.runCurrent() + } + + val delays = events.connectionStates() + .filterIsInstance() + .map { it.next } + assertEquals(listOf(1, 2, 4, 8, 16, 30).map { it.seconds }, delays.take(6)) + } + + // ── ping 25s / 2-miss (only through a PingableTermTransport) ───────────────────────────── + + @Test + fun `two consecutive missed pongs on a pingable transport trigger a reconnect`() = runTest { + val transport = FakePingableTermTransport() + transport.pinger.scriptPongs(false, false) // both pings miss + val engine = newEngine(transport) + val events = collectEvents(engine) + + engine.start() + testScheduler.runCurrent() + assertEquals(1, transport.connectPingableCount) // took the pingable path + assertEquals(0, transport.pinger.pingCallCount) + + testScheduler.advanceTimeBy(Tunables.PING_INTERVAL) // ping #1 (miss) + testScheduler.runCurrent() + assertEquals(1, transport.pinger.pingCallCount) + + testScheduler.advanceTimeBy(Tunables.PING_INTERVAL) // ping #2 (miss) → CONNECTION_LOST + testScheduler.runCurrent() + assertEquals(2, transport.pinger.pingCallCount) + assertTrue(events.connectionStates().any { it is ConnectionState.Reconnecting }) + + engine.close() + testScheduler.runCurrent() + } + + @Test + fun `a plain non-pingable transport is never pinged`() = runTest { + val transport = FakeTermTransport() // does NOT implement PingableTermTransport + val engine = newEngine(transport) + collectEvents(engine) + + engine.start() + testScheduler.runCurrent() + // Advance well past several ping intervals — a plain transport must not disconnect on its own. + testScheduler.advanceTimeBy(Tunables.PING_INTERVAL * 5) + testScheduler.runCurrent() + + assertEquals(1, transport.connectAttempts.size) // still the one live connection, no ping-driven drop + } + + // ── away-digest once-per-reconnect (all-zero suppressed by the UI, still delivered here) ── + + @OptIn(ExperimentalTime::class) + @Test + fun `a completed reconnect emits exactly one away digest reduced since the disconnect`() = runTest { + val transport = FakeTermTransport() + val clock = FakeTimeSource() // epochMillis starts at 0 + val timeline = listOf( + TimelineEvent(at = 500, eventClass = "tool", label = "ran Bash"), + TimelineEvent(at = 600, eventClass = "waiting", label = "needs approval"), + ) + val engine = newEngine(transport, awayTimeline = { timeline }, nowMs = clock::epochMillis) + val events = collectEvents(engine) + + engine.start() + testScheduler.runCurrent() + transport.emit(attachedFrame(SERVER_ID)) // first attach → NO digest + testScheduler.runCurrent() + + transport.emitError(RuntimeException("drop")) // disconnect at clock=0 → digest owed + testScheduler.runCurrent() + testScheduler.advanceTimeBy(1.seconds) + testScheduler.runCurrent() + transport.emit(attachedFrame(SERVER_ID)) // reconnect adopt → digest fires once + testScheduler.runCurrent() // deliver the frame → onAttached launches + runs emitDigest + + val digests = events.filterIsInstance() + assertEquals(1, digests.size) + val digest = digests.single().digest + assertEquals(1, digest.toolRuns) + assertEquals(1, digest.waitingCount) + assertNotNull(digest) + } + + // ── output + exit pass-through ────────────────────────────────────────────────────────── + + @Test + fun `output frames pass through verbatim and an exit is terminal`() = runTest { + val transport = FakeTermTransport() + val engine = newEngine(transport) + val events = collectEvents(engine) + + engine.start() + testScheduler.runCurrent() + transport.emit("""{"type":"output","data":"hello"}""") + testScheduler.runCurrent() + transport.emit("""{"type":"exit","code":-1,"reason":"spawn failed"}""") + testScheduler.runCurrent() + + assertTrue(events.contains(Output("hello"))) + assertTrue(events.contains(Exited(-1, "spawn failed"))) + // Terminal: no reconnect after an exit. + testScheduler.advanceTimeBy(60.seconds) + testScheduler.runCurrent() + assertEquals(1, transport.connectAttempts.size) + } +} + +/** + * A [TransportConnection] that records its [close]/[send] and can suspend the FIRST send on a gate, + * so a test can wedge `close()` into the attach window (before the connection is published). + */ +private class RecordingConnection(private val sendGate: CompletableDeferred?) : TransportConnection { + private val framesChannel = Channel(Channel.UNLIMITED) + override val frames: Flow = framesChannel.receiveAsFlow() + val sent: MutableList = mutableListOf() + + @Volatile + var isClosed: Boolean = false + private set + + private var firstSend = true + + override suspend fun send(frame: String) { + if (firstSend) { + firstSend = false + sendGate?.await() // suspend the attach send → the dispatcher is free to run close() + } + sent += frame + } + + override suspend fun close() { + isClosed = true + framesChannel.close() + } +} + +/** + * A [TermTransport] whose [connect] (and/or the connection's first [send]) suspends on a gate, so a + * test can fire `close()` while the engine is still inside the dial/attach window (FIX 1 regression). + */ +private class GatedConnectTransport( + private val connectGate: CompletableDeferred? = null, + sendGate: CompletableDeferred? = null, +) : TermTransport { + val connection: RecordingConnection = RecordingConnection(sendGate) + + @Volatile + var connectCount: Int = 0 + private set + + override suspend fun connect(endpoint: HostEndpoint): TransportConnection { + connectCount += 1 + connectGate?.await() // suspend the dial → the dispatcher is free to run close() + return connection + } +} diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 0fb6c5a..46ba5ca 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -15,6 +15,10 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + // JitPack — builds the Apache-2.0 Termux terminal-emulator + terminal-view + // submodules on demand for :terminal-view (A16, plan §9 R2). Scoped to those + // two libs only; never termux-shared/app (GPLv3). + maven { url = uri("https://jitpack.io") } } // The version catalog at gradle/libs.versions.toml is auto-registered as `libs`. } @@ -28,16 +32,22 @@ include(":session-core") include(":api-client") include(":client-tls") include(":test-support") +// :transport-okhttp (A7) — OkHttp is a plain JVM lib, so this pure-JVM module +// builds and tests here (MockWebServer) with NO Android SDK. +include(":transport-okhttp") // ── Android-framework modules ─────────────────────────────────────────────────── // The Android SDK IS now installed (see android/README.md → "Android SDK setup"), -// and AGP 9.2.1 is proven to build against SDK 35 with Gradle 9.6.1. These stay -// commented out only because their build.gradle.kts are still empty SCAFFOLD STUBS -// — enable each as its real module is implemented (plan AW2+). +// and AGP 9.2.1 is proven to build against SDK 35 with Gradle 9.6.1. Enable each as +// its real module is implemented (plan AW2+). // Recipe: apply `alias(libs.plugins.android.library)` ONLY (AGP 9 has built-in -// Kotlin — do NOT add kotlin.android); android { namespace; compileSdk = 35; +// Kotlin — do NOT add kotlin.android); android { namespace; compileSdk = 36; // defaultConfig { minSdk = 29 } }. -// include(":app") // AW4 — uses libs.plugins.android.application -// include(":terminal-view") // AW3 -// include(":host-registry") // AW1/AW2 storage half -// include(":client-tls-android") // AW1 framework half +// +// :app (A13) — the Android application: Compose Material 3 (+ Adaptive) design +// system, Hilt DI skeleton, launcher MainActivity. This module establishes the +// Android UI-stack version matrix for every later Android task. +include(":app") // A13 — libs.plugins.android.application + compose + ksp + hilt +include(":terminal-view") // A16 — android-library + Termux terminal-emulator/-view (JitPack, Apache-2.0) +include(":host-registry") // A12 — android-library + DataStore (Host list / last-session) +include(":client-tls-android") // A11 — android-library + AndroidKeyStore import + Tink AEAD diff --git a/android/terminal-view/build.gradle.kts b/android/terminal-view/build.gradle.kts index c976bb2..1764aa6 100644 --- a/android/terminal-view/build.gradle.kts +++ b/android/terminal-view/build.gradle.kts @@ -1,31 +1,80 @@ // ───────────────────────────────────────────────────────────────────────────── -// :terminal-view — Android-framework-bound Termux `terminal-emulator` + -// `terminal-view` wrap (RemoteTerminalSession: no local process, WS-driven). -// Mirrors the iOS SwiftTerm host view. Depends ONLY on :wire-protocol. +// :terminal-view (A16) — the XL terminal-render module. // -// SCAFFOLD STUB ONLY — COMMENTED OUT in settings.gradle.kts (no Android SDK here). -// TODO(android-sdk): enable when an Android SDK is available. +// Wraps Termux `terminal-emulator` (VT100/xterm parser + `TerminalBuffer` scrollback +// ring) + `terminal-view` (`TerminalView`/`TerminalRenderer` glyph painter). These two +// libraries are Apache-2.0 (plan §9 R2 — they descend from jackpal's Apache-2.0 emulator; +// only `termux-shared`/app are GPLv3, and we depend on NEITHER). Consumed from JitPack. +// +// `RemoteTerminalSession` is the FORK (plan §6.1): it holds a `TerminalEmulator` with NO +// local subprocess / NO JNI, feeds remote WS bytes into it off the main thread, and routes +// emulator-originated bytes back out through `engineSend`. Depends ONLY on :wire-protocol +// (boundary note §3 — the SessionEvent→ByteArray decode is A21's, not here). +// +// AGP 9 has built-in Kotlin → apply ONLY com.android.library (adding +// org.jetbrains.kotlin.android errors). No serialization/compose plugins needed. +// +// The pure logic (RemoteTerminalSession seam, TerminalGridMath resize formula, +// pendingOutput ordering, DECCKM key form) is a LOCAL JVM unit test: `TerminalEmulator` +// is pure Java whose exercised path touches no runtime android API, so it drives headless +// on `testDebugUnitTest` (android.jar stubs on the classpath, isReturnDefaultValues=true). +// Rendering / IME / selection / rotation-rebind are device QA (plan §7). // ───────────────────────────────────────────────────────────────────────────── -/* plugins { id("com.android.library") - alias(libs.plugins.kotlin.android) } android { namespace = "wang.yaojia.webterm.terminalview" - compileSdk = 35 - defaultConfig { minSdk = 29 } + compileSdk = 36 + + defaultConfig { + minSdk = 29 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + testOptions { + unitTests { + // The Termux emulator references a couple of android.* APIs (e.g. Base64 in the + // OSC-52 path we never exercise); default-return keeps any incidental android + // call from throwing "not mocked" in the headless JVM seam tests. + isReturnDefaultValues = true + } + } } -kotlin { jvmToolchain(17) } +kotlin { + jvmToolchain(17) +} dependencies { - implementation(project(":wire-protocol")) - // Termux VT100/xterm emulator (Apache-2.0) — scope to these two libs ONLY, - // never `termux-shared`/app module (GPLv3). Consumable via JitPack. - // implementation("com.termux:terminal-view:0.118.0") - // implementation("com.termux:terminal-emulator:0.118.0") + // Boundary note (plan §3): :terminal-view depends ONLY on :wire-protocol. + api(project(":wire-protocol")) + implementation(libs.kotlinx.coroutines.core) + // Provides the `Main` dispatcher on Android — without it `Dispatchers.Main.immediate` + // throws "Module with the Main dispatcher is missing" at runtime on-device. + implementation(libs.kotlinx.coroutines.android) + + // Termux VT100/xterm emulator + view (Apache-2.0). Scope is these TWO libs ONLY — + // never `termux-shared`/app (GPLv3). JitPack builds them from the termux-app repo. + implementation(libs.termux.terminal.view) + implementation(libs.termux.terminal.emulator) + + testImplementation(libs.bundles.unit.test) + testRuntimeOnly(libs.junit.platform.launcher) + + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.runner) +} + +// Local (JVM) unit tests run on the JUnit 5 platform, matching the pure modules. +tasks.withType().configureEach { + useJUnitPlatform() } -*/ diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/.gitkeep b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/LinkPolicy.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/LinkPolicy.kt new file mode 100644 index 0000000..7b44b95 --- /dev/null +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/LinkPolicy.kt @@ -0,0 +1,29 @@ +package wang.yaojia.webterm.terminalview + +import java.util.Locale + +/** + * The http/https-only allowlist for terminal link taps (plan §6.5: "URL detection → `Intent(ACTION_VIEW)` + * with an http/https-only allowlist"). Terminal output is attacker-influenced (a session can print any + * escape/URL), so before the view launches an `ACTION_VIEW` intent it must reject every other scheme — + * `file:`, `content:`, `intent:`, `javascript:`, custom app schemes — which could otherwise exfiltrate + * local files or trigger a deep link. + * + * Pure + JVM-testable; the view layer calls [isAllowedUrl] before building the intent. + */ +public object LinkPolicy { + + private val ALLOWED_SCHEMES = setOf("http", "https") + + /** + * True iff [url] carries an explicit `http`/`https` scheme (case-insensitive). A missing or foreign + * scheme is rejected — no scheme-relative or bare-host guessing (that is how a `file:`/`intent:` + * payload slips through). + */ + public fun isAllowedUrl(url: String): Boolean { + val separator = url.indexOf(':') + if (separator <= 0) return false + val scheme = url.substring(0, separator).lowercase(Locale.ROOT) + return scheme in ALLOWED_SCHEMES + } +} diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/NoOpTerminalSessionClient.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/NoOpTerminalSessionClient.kt new file mode 100644 index 0000000..5451f84 --- /dev/null +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/NoOpTerminalSessionClient.kt @@ -0,0 +1,42 @@ +package wang.yaojia.webterm.terminalview + +import com.termux.terminal.TerminalEmulator +import com.termux.terminal.TerminalSession +import com.termux.terminal.TerminalSessionClient + +/** + * A no-op [TerminalSessionClient] handed to the [TerminalEmulator] constructor. + * + * Termux's `TerminalEmulator` calls `mClient` on a handful of rare escape-sequence paths (DECRQM, + * termcap/terminfo queries, some SGR/OSC diagnostics) WITHOUT a null-check, so the emulator must be + * built with a non-null client or those sequences would crash the parser. We therefore supply a client + * that swallows every callback: this class is NOT a subprocess bridge (there is no [TerminalSession] — + * the callback parameters are always ignored) and it logs NOTHING (terminal bytes are attacker- + * influenced; the parser's diagnostic strings must never reach logcat). + * + * Title changes do NOT arrive here — the emulator routes OSC 0/2 titles through + * `TerminalOutput.titleChanged`, which [RemoteTerminalSession] handles. This client's [onTitleChanged] + * is intentionally inert. + */ +internal class NoOpTerminalSessionClient : 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 + + /** Default block cursor; the on-screen view owns real cursor styling. */ + 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 +} diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalSession.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalSession.kt new file mode 100644 index 0000000..981c19a --- /dev/null +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalSession.kt @@ -0,0 +1,308 @@ +package wang.yaojia.webterm.terminalview + +import android.util.Log +import com.termux.terminal.KeyHandler +import com.termux.terminal.TerminalEmulator +import com.termux.terminal.TerminalOutput +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield +import java.util.ArrayDeque +import java.util.concurrent.Executors +import wang.yaojia.webterm.wire.ClientMessage + +/** + * The FORK of Termux's `TerminalSession` (plan §6.1) — a terminal session with **no local subprocess + * and no JNI**. It owns a [TerminalEmulator] (the VT100/xterm parser + `TerminalBuffer` scrollback ring) + * that is driven entirely by bytes arriving over the WebSocket, and routes emulator-originated bytes + * (DA/DSR replies, mouse reports, bracketed-paste wrappers) back out through [engineSend]. + * + * This is the single seam A21 wires the engine to: `output: Flow` → [feedRemote]; + * `sendInput`/`resize` ← [writeInput]/[updateSize] via [engineSend]. It depends ONLY on + * `:wire-protocol` ([ClientMessage]) — the `SessionEvent → ByteArray` decode is A21's, not here + * (boundary note §3). + * + * ### Single-writer confinement (plan §6.2 mustFix) + * `TerminalEmulator`/`TerminalBuffer` is single-writer, read by the renderer on the UI thread. Every + * mutation (append, resize, pending flush) is serialized onto ONE confined dispatcher via an ordered + * command [Channel] — mirroring Termux's background reader thread — so a multi-MB ring replay never + * blocks the UI thread (ANR) and never races the on-screen draw. Only [onScreenUpdated] is posted to + * [mainDispatcher]. Ordering is exactly submission order (FIFO channel + one consumer). + * + * The emulator is **not** exposed to the stock renderer until the initial [pendingOutput] flush has + * COMPLETED: the Bind command flushes the queue on the confined thread first, THEN posts a single Main + * action that publishes `mEmulator` to the view AND fires the first screen update — so the renderer + * never reads [TerminalBuffer] while the confined thread is still appending at the bind moment (§6.2). + * One malformed/hostile escape byte cannot freeze the terminal either: [consumeCommands] survives a + * per-command throw and keeps draining (only cancellation propagates). + * + * @param engineSend the outbound sink — every [ClientMessage] produced here (Input/Resize) is handed to + * it. A21 bridges it to the engine's ordered send pump (§6.3). Must be safe to call from any thread. + * @param onTitleChanged raw OSC 0/2 title delegate. Passed through UNsanitized — :terminal-view has no + * `:session-core` edge, so :app wires this to `TitleSanitizer` (plan §6.5 / boundary note §3). + * @param onBell OSC/ctrl-G bell delegate (the view may buzz/flash). + * @param appendDispatcher the confined single-writer dispatcher (default: a private single-thread + * executor). Injected as a `StandardTestDispatcher` in unit tests so the seam drives under virtual time. + * @param mainDispatcher where [onScreenUpdated] is posted (default `Main.immediate`; a test dispatcher + * in unit tests so no real main looper is needed). + */ +public class RemoteTerminalSession( + private val engineSend: (ClientMessage) -> Unit, + initialCols: Int = DEFAULT_COLS, + initialRows: Int = DEFAULT_ROWS, + transcriptRows: Int = TRANSCRIPT_ROWS, + private val onTitleChanged: (String) -> Unit = {}, + private val onBell: () -> Unit = {}, + appendDispatcher: CoroutineDispatcher? = null, + private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main.immediate, +) { + /** Emulator-originated output sink + title/clipboard/bell delegate. Runs on the confined thread. */ + private val termOutput: TerminalOutput = object : TerminalOutput() { + override fun write(data: ByteArray?, offset: Int, count: Int) { + if (data == null || count <= 0) return + // Emulator reply bytes (DA/DSR/mouse) are ASCII; the engine re-encodes verbatim. Mirrors + // the web/iOS clients treating input as a string (invariant #9 — no content filtering). + engineSend(ClientMessage.Input(String(data, offset, count, Charsets.UTF_8))) + } + + override fun titleChanged(oldTitle: String?, newTitle: String?) { + onTitleChanged(newTitle ?: "") + } + + // OSC 52 host-clipboard writes are DECLINED (plan §6.5) — never leak session output to the + // device clipboard without an explicit user copy. + override fun onCopyTextToClipboard(text: String?) = Unit + override fun onPasteTextFromClipboard() = Unit + override fun onBell(): Unit = this@RemoteTerminalSession.onBell() + override fun onColorsChanged() = Unit + } + + /** The VT parser + scrollback buffer. Public so the on-screen view binds it and tests read it. */ + public val emulator: TerminalEmulator = + TerminalEmulator(termOutput, initialCols, initialRows, transcriptRows, NoOpTerminalSessionClient()) + + // ── Confinement plumbing (all mutable state below is touched ONLY on the confined thread) ──────── + private val ownedExecutor = if (appendDispatcher == null) { + Executors.newSingleThreadExecutor { r -> Thread(r, "webterm-term-append").apply { isDaemon = true } } + } else { + null + } + private val confinedDispatcher: CoroutineDispatcher = + appendDispatcher ?: ownedExecutor!!.asCoroutineDispatcher() + private val scope = CoroutineScope(SupervisorJob() + confinedDispatcher) + private val commands = Channel(Channel.UNLIMITED) + + /** Queued output that arrived before the view bound; flushed in submission order on bind (§6.2). */ + private val pendingOutput = ArrayDeque() + private var bound = false + private var screenUpdateSink: (() -> Unit)? = null + private var lastSentDims: TerminalGridSize? = null + + init { + scope.launch { consumeCommands() } + } + + // ── Inbound: remote output → emulator (off-main, chunked) ──────────────────────────────────────── + + /** + * Feed remote WS output into the emulator. Never blocks the caller (the command channel is + * UNLIMITED) and never mutates the emulator on the caller's thread — the bytes are appended on the + * confined dispatcher. Output arriving before [attachView]/[bind] is queued and replayed in order + * (§6.2); the `ESC[0m` replay prefix is passed through, never stripped. + */ + public fun feedRemote(bytes: ByteArray) { + if (bytes.isEmpty()) return + commands.trySend(TerminalCommand.Feed(bytes)) + } + + /** + * Latest-writer-wins resize (§6.4). Resolves the emulator's local buffer reflow AND emits a + * `Resize` to the server (→ SIGWINCH) — but only when [dims] actually change from [lastSentDims] + * and are positive. The dedup + `emulator.resize` are serialized on the confined thread so a resize + * never races an append. + */ + public fun updateSize(cols: Int, rows: Int) { + if (cols < TerminalGridMath.MIN_DIMENSION || rows < TerminalGridMath.MIN_DIMENSION) return + commands.trySend(TerminalCommand.Resize(cols, rows)) + } + + // ── Outbound: typed / key-bar bytes → wire ─────────────────────────────────────────────────────── + + /** Typed / key-bar bytes → `engineSend(Input(data))`, verbatim (invariant #9). Any thread. */ + public fun writeInput(data: String) { + if (data.isEmpty()) return + engineSend(ClientMessage.Input(data)) + } + + /** + * The byte sequence for a hardware key, deferring to Termux's [KeyHandler] so DECCKM + * (`cursorKeysApplication`) still emits `ESC O A` (not `ESC [ A`) for vim/htop (plan §6.3). The + * caller (view) sends the result via [writeInput]. Returns null when the key maps to nothing. + */ + public fun keyBytes(keyCode: Int, keyMode: Int): String? = + KeyHandler.getCode( + keyCode, + keyMode, + emulator.isCursorKeysApplicationMode, + emulator.isKeypadApplicationMode, + ) + + // ── View binding (pendingOutput flush) ─────────────────────────────────────────────────────────── + + /** + * Bind the on-screen view. The emulator is NOT published to the stock renderer synchronously here + * (on Main): the confined thread may still be flushing [pendingOutput] into the buffer, and the + * renderer reads it during draw — publishing now would race that flush (§6.2). Instead the publish + * is routed through the command channel so it lands on [mainDispatcher] only AFTER the initial flush + * completes, together with the first screen update. The sink then invalidates the view on every feed. + */ + public fun attachView(view: RemoteTerminalView) { + view.session = this + bind(publishEmulator = { view.bindEmulator(emulator) }) { view.requestScreenUpdate() } + } + + /** Detach the view (rotation / real background). The emulator + scrollback survive in this holder. */ + public fun detachView() { + commands.trySend(TerminalCommand.Unbind) + } + + /** + * Testable bind seam: register the [onScreenUpdated] sink, flush pending output on the confined + * thread, THEN publish the emulator ([publishEmulator]) + fire the first screen update on the main + * dispatcher. Production goes through [attachView] (which supplies [publishEmulator]); unit tests + * call this directly (no android View needed) and default [publishEmulator] to a no-op. + */ + internal fun bind(publishEmulator: () -> Unit = {}, onScreenUpdated: () -> Unit) { + commands.trySend(TerminalCommand.Bind(publishEmulator, onScreenUpdated)) + } + + /** Await-free shutdown: stop the consumer, cancel the scope, release the owned thread. */ + public fun close() { + commands.close() + scope.cancel() + ownedExecutor?.shutdownNow() + } + + // ── The single confined consumer (one writer to the emulator) ──────────────────────────────────── + + private suspend fun consumeCommands() { + for (command in commands) { + try { + when (command) { + is TerminalCommand.Feed -> onFeed(command.bytes) + is TerminalCommand.Resize -> onResize(command.cols, command.rows) + is TerminalCommand.Bind -> onBind(command.publishEmulator, command.onScreenUpdated) + TerminalCommand.Unbind -> { + bound = false + screenUpdateSink = null + } + } + } catch (e: CancellationException) { + throw e // never swallow cooperative cancellation — let the scope tear down cleanly + } catch (t: Throwable) { + // A malformed / hostile escape byte must NOT kill the single consumer and permanently + // freeze all further output. Survive the one bad command and keep draining. Log the + // throwable TYPE only — never its message or the command bytes, which may carry + // terminal/session content (no secrets leaked). + Log.w(LOG_TAG, "dropping a terminal command that threw: ${t.javaClass.simpleName}") + } + } + } + + private suspend fun onFeed(bytes: ByteArray) { + if (!bound) { + pendingOutput.addLast(bytes) + return + } + appendChunked(bytes) + postScreenUpdate() + } + + private suspend fun onBind(publishEmulator: () -> Unit, onScreenUpdated: () -> Unit) { + screenUpdateSink = onScreenUpdated + bound = true + while (pendingOutput.isNotEmpty()) { + appendChunked(pendingOutput.removeFirst()) + } + // The flush is now COMPLETE on this confined thread. Only now do we hand the emulator to the + // stock renderer AND fire the first screen update — both on the main dispatcher, in one action + // ordered strictly after the flush. The renderer therefore never reads TerminalBuffer while the + // confined thread is still appending at the bind moment (§6.2 single-writer-vs-UI-read). + withContext(mainDispatcher) { + publishEmulator() + onScreenUpdated() + } + } + + private fun onResize(cols: Int, rows: Int) { + val next = TerminalGridSize(cols, rows) + if (next == lastSentDims) return + emulator.resize(cols, rows) // local buffer reflow (confined-thread single writer) + engineSend(ClientMessage.Resize(cols, rows)) // → server SIGWINCH + lastSentDims = next + } + + /** + * Append [bytes] to the emulator in [APPEND_CHUNK_BYTES] slices, yielding between chunks so a + * multi-MB replay stays cooperative (cancellable, and never monopolizes the confined thread). + * UTF-8 continuation state is buffered inside the emulator across `append` calls, so slicing at an + * arbitrary byte boundary never corrupts a multi-byte character. + */ + private suspend fun appendChunked(bytes: ByteArray) { + var offset = 0 + while (offset < bytes.size) { + val end = minOf(offset + APPEND_CHUNK_BYTES, bytes.size) + val chunk = if (offset == 0 && end == bytes.size) bytes else bytes.copyOfRange(offset, end) + emulator.append(chunk, chunk.size) + offset = end + if (offset < bytes.size) yield() + } + } + + private suspend fun postScreenUpdate() { + val sink = screenUpdateSink ?: return + withContext(mainDispatcher) { sink() } + } + + // ── Test-only inspectors (confined state read after the scheduler is idle) ─────────────────────── + + /** The rendered screen + scrollback text, for the S1 seam assertion. */ + internal fun screenTextForTest(): String = emulator.screen.transcriptText + + /** Count of not-yet-flushed pending chunks, for the pendingOutput-ordering assertion. */ + internal fun pendingChunkCountForTest(): Int = pendingOutput.size + + /** The last dims actually sent to the server, for the resize-dedup assertion. */ + internal fun lastSentDimsForTest(): TerminalGridSize? = lastSentDims + + public companion object { + /** Server hardcodes 80×24 on attach (`src/server.ts`); the real grid follows via `resize`. */ + public const val DEFAULT_COLS: Int = 80 + public const val DEFAULT_ROWS: Int = 24 + + /** Local scroll-up history while connected; authoritative history is the server's ring replay. */ + public const val TRANSCRIPT_ROWS: Int = 10_000 + + /** Append slice size — bounds one `append` call so a multi-MB replay never blocks. */ + public const val APPEND_CHUNK_BYTES: Int = 4096 + + /** Logcat tag for the survive-a-bad-command path (logs the throwable type only, never content). */ + private const val LOG_TAG: String = "RemoteTerminalSession" + } +} + +/** Ordered commands to the single confined consumer — FIFO submission order == emulator write order. */ +private sealed interface TerminalCommand { + class Feed(val bytes: ByteArray) : TerminalCommand + class Resize(val cols: Int, val rows: Int) : TerminalCommand + class Bind(val publishEmulator: () -> Unit, val onScreenUpdated: () -> Unit) : TerminalCommand + data object Unbind : TerminalCommand +} diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalView.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalView.kt new file mode 100644 index 0000000..cdba279 --- /dev/null +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalView.kt @@ -0,0 +1,56 @@ +package wang.yaojia.webterm.terminalview + +import android.content.Context +import android.view.KeyEvent +import com.termux.terminal.TerminalEmulator +import com.termux.view.TerminalView + +/** + * The on-screen terminal seam — a COMPOSITION wrapper around Termux's stock [TerminalView]. + * + * ### Deviation from plan §6.1 (recorded): composition, not subclass + * §6.1 says "the Termux `TerminalView` is subclassed only to expose an onKeyCommand outlet + install + * the key-bar." But at the pinned v0.118.0, `com.termux.view.TerminalView` is declared + * `public final class` — it CANNOT be subclassed (verified: the Kotlin compiler rejects + * `: TerminalView(...)` with "This type is final"). We therefore WRAP a stock instance and bind our + * forked emulator through its PUBLIC `mEmulator` field instead of via `attachSession(TerminalSession)` + * (there is no `TerminalSession` to fork — it too is `final`, and forking a process is exactly what we + * must not do). Glyph rendering, cursor, selection, IME and scroll stay 100% stock — the wrapped view + * renders unchanged; we only redirect where the emulator comes from and where input goes. + * + * The [terminalView] is what A21 puts in the Compose tree (`AndroidView { remote.terminalView }`); the + * key-bar / hardware-chord layer (A17) installs its handler through [onKeyCommand] (delivered via a + * `TerminalViewClient` A17 sets on [terminalView]) and reads [onViewSizeChanged] for the resize path. + * + * DEVICE-QA (plan §7): rendering, IME, selection, link taps, and the real font-metric→grid resize + * (`TerminalRenderer.mFontWidth` is package-private → measured on-device and fed to [TerminalGridMath]) + * are verified on a device, not here. This class only has to compile and offer the binding seam. + */ +public class RemoteTerminalView(context: Context) { + + /** The stock, final Termux view. Added to the Compose tree by A21; rendering is entirely its own. */ + public val terminalView: TerminalView = TerminalView(context, null) + + /** The forked session backing this view. Set by [RemoteTerminalSession.attachView]. */ + public var session: RemoteTerminalSession? = null + + /** + * Hardware-key outlet (A17 install point). A17 wires this into a `TerminalViewClient` set on + * [terminalView]; returning true consumes the event, otherwise arrows/Enter/Tab should be resolved + * via [RemoteTerminalSession.keyBytes] so DECCKM still emits `ESC O A` (plan §6.3). + */ + public var onKeyCommand: ((keyCode: Int, event: KeyEvent) -> Boolean)? = null + + /** Layout-change outlet (A17/A21 install point) — wires real font metrics into the resize path. */ + public var onViewSizeChanged: ((widthPx: Int, heightPx: Int) -> Unit)? = null + + /** Point the stock renderer at the forked emulator (bypasses the process-forking `attachSession`). */ + public fun bindEmulator(emulator: TerminalEmulator) { + terminalView.mEmulator = emulator + } + + /** Invalidate/redraw the stock view for the current emulator state (posted on the main thread). */ + public fun requestScreenUpdate() { + terminalView.onScreenUpdated() + } +} diff --git a/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalGridMath.kt b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalGridMath.kt new file mode 100644 index 0000000..551428b --- /dev/null +++ b/android/terminal-view/src/main/kotlin/wang/yaojia/webterm/terminalview/TerminalGridMath.kt @@ -0,0 +1,57 @@ +package wang.yaojia.webterm.terminalview + +import kotlin.math.floor + +/** + * The cols×rows grid a terminal viewport resolves to for a given pixel size and font metric — the + * pure, JVM-unit-testable half of the resize path (plan §6.4, risk R5: "cols×rows drift — Android + * font metrics ≠ SwiftTerm"). Extracted as a free function so the arithmetic is verified against known + * metrics WITHOUT a device: the real `mFontWidth` / `mFontLineSpacing` come from Termux's + * `TerminalRenderer` at runtime (they need a real `android.graphics.Paint`), but the formula that turns + * them into a grid must never drift from the server/web/iOS contract. + * + * ``` + * cols = max(1, floor((viewW - 2*hPad) / mFontWidth)) + * rows = max(1, floor((viewH - 2*vPad) / mFontLineSpacing)) + * ``` + * + * Non-positive pre-layout dimensions (a view measured at 0×0 before its first layout pass) yield + * `null` — the caller drops them and never sends a bogus `resize` (plan §6.4: "drop non-positive + * pre-layout dims"). + */ +public data class TerminalGridSize(val cols: Int, val rows: Int) + +public object TerminalGridMath { + + /** The smallest usable grid — the server hardcodes 80×24 on attach; we never send below 1×1. */ + public const val MIN_DIMENSION: Int = 1 + + /** + * Resolve [viewWidthPx]×[viewHeightPx] (minus symmetric padding) into a terminal grid using the + * cell metrics [fontWidthPx] (`TerminalRenderer.mFontWidth`, a float) and [fontLineSpacingPx] + * (`TerminalRenderer.mFontLineSpacing`, an int). + * + * Returns `null` when any input is non-positive OR the usable area after padding is non-positive — + * i.e. the view has not been laid out yet, so there is no valid grid to send. + */ + public fun computeGridSize( + viewWidthPx: Int, + viewHeightPx: Int, + horizontalPaddingPx: Int, + verticalPaddingPx: Int, + fontWidthPx: Float, + fontLineSpacingPx: Int, + ): TerminalGridSize? { + // Fail fast on any pre-layout / degenerate metric — never emit a bogus resize. + if (viewWidthPx <= 0 || viewHeightPx <= 0) return null + if (fontWidthPx <= 0f || fontLineSpacingPx <= 0) return null + + val usableWidth = viewWidthPx - 2 * horizontalPaddingPx + val usableHeight = viewHeightPx - 2 * verticalPaddingPx + if (usableWidth <= 0 || usableHeight <= 0) return null + + val cols = floor(usableWidth / fontWidthPx).toInt().coerceAtLeast(MIN_DIMENSION) + val rows = (usableHeight / fontLineSpacingPx).coerceAtLeast(MIN_DIMENSION) + return TerminalGridSize(cols, rows) + } +} diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/DecckmKeyTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/DecckmKeyTest.kt new file mode 100644 index 0000000..5eb7648 --- /dev/null +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/DecckmKeyTest.kt @@ -0,0 +1,64 @@ +package wang.yaojia.webterm.terminalview + +import android.view.KeyEvent +import com.termux.terminal.KeyHandler +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.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * DECCKM (plan §6.3): with `cursorKeysApplication` set (`ESC[?1h`), arrow keys must emit `ESC O A` + * (SS3) not `ESC [ A` (CSI) — hardcoding CSI would break vim/htop. We defer to Termux's [KeyHandler] + * and drive it from the emulator's live mode bit. `KeyHandler.getCode` runs headless because its + * `KEYCODE_*` cases are inlined compile-time constants (no runtime `android.view.KeyEvent` link). + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DecckmKeyTest { + + private val noModifiers = 0 + + @Test + fun `KeyHandler emits SS3 for arrows under cursor-keys-application, CSI otherwise`() { + // Normal (DECCKM off) → CSI. + assertEquals("\u001b[A", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_UP, noModifiers, false, false)) + assertEquals("\u001b[B", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_DOWN, noModifiers, false, false)) + assertEquals("\u001b[C", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_RIGHT, noModifiers, false, false)) + assertEquals("\u001b[D", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_LEFT, noModifiers, false, false)) + + // Application-cursor-keys (DECCKM on) → SS3. + assertEquals("\u001bOA", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_UP, noModifiers, true, false)) + assertEquals("\u001bOB", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_DOWN, noModifiers, true, false)) + assertEquals("\u001bOC", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_RIGHT, noModifiers, true, false)) + assertEquals("\u001bOD", KeyHandler.getCode(KeyEvent.KEYCODE_DPAD_LEFT, noModifiers, true, false)) + } + + @Test + fun `session keyBytes tracks the emulator's live DECCKM mode`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val session = RemoteTerminalSession( + engineSend = {}, + appendDispatcher = dispatcher, + mainDispatcher = dispatcher, + ) + session.bind {} + + // Before any mode change: CSI. + assertFalse(session.emulator.isCursorKeysApplicationMode) + assertEquals("\u001b[A", session.keyBytes(KeyEvent.KEYCODE_DPAD_UP, noModifiers)) + + // ESC[?1h sets DECCKM (application cursor keys) — vim/htop turn this on. + session.feedRemote("\u001b[?1h".toByteArray(Charsets.UTF_8)) + advanceUntilIdle() + + assertTrue(session.emulator.isCursorKeysApplicationMode, "ESC[?1h must set DECCKM") + assertEquals("\u001bOA", session.keyBytes(KeyEvent.KEYCODE_DPAD_UP, noModifiers)) + + session.close() + advanceUntilIdle() + } +} diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/LinkPolicyTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/LinkPolicyTest.kt new file mode 100644 index 0000000..e5233e4 --- /dev/null +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/LinkPolicyTest.kt @@ -0,0 +1,33 @@ +package wang.yaojia.webterm.terminalview + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * The link allowlist (plan §6.5): only http/https URLs from attacker-influenced terminal output may + * become an `ACTION_VIEW` intent. Everything else — `file:`, `content:`, `intent:`, `javascript:`, + * custom app schemes, scheme-less text — is rejected so a printed payload can't exfiltrate a local file + * or fire a deep link. + */ +class LinkPolicyTest { + + @Test + fun `http and https are allowed, case-insensitively`() { + assertTrue(LinkPolicy.isAllowedUrl("http://example.com")) + assertTrue(LinkPolicy.isAllowedUrl("https://example.com/path?q=1")) + assertTrue(LinkPolicy.isAllowedUrl("HTTPS://EXAMPLE.COM")) + } + + @Test + fun `non-web and scheme-less URLs are rejected`() { + assertFalse(LinkPolicy.isAllowedUrl("file:///etc/passwd")) + assertFalse(LinkPolicy.isAllowedUrl("content://media/external")) + assertFalse(LinkPolicy.isAllowedUrl("intent://scan#Intent;scheme=x;end")) + assertFalse(LinkPolicy.isAllowedUrl("javascript:alert(1)")) + assertFalse(LinkPolicy.isAllowedUrl("myapp://open")) + assertFalse(LinkPolicy.isAllowedUrl("example.com")) + assertFalse(LinkPolicy.isAllowedUrl("")) + assertFalse(LinkPolicy.isAllowedUrl(":nohost")) + } +} diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/PendingOutputTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/PendingOutputTest.kt new file mode 100644 index 0000000..a36d06d --- /dev/null +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/PendingOutputTest.kt @@ -0,0 +1,108 @@ +package wang.yaojia.webterm.terminalview + +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 + +/** + * The pendingOutput invariant (plan §6.2): output that arrives BEFORE the view binds (a ring replay can + * land first) is queued in submission order and flushed the instant the view binds — nothing dropped, + * nothing reordered, and the `ESC[0m` soft-reset replay prefix is passed through, never stripped. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class PendingOutputTest { + + @Test + fun `output before bind is queued then flushed in submission order`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val session = RemoteTerminalSession( + engineSend = {}, + appendDispatcher = dispatcher, + mainDispatcher = dispatcher, + ) + + // Two chunks arrive BEFORE any view binds. The first carries the ESC[0m replay prefix. + session.feedRemote("\u001b[0mAAA".toByteArray(Charsets.UTF_8)) + session.feedRemote("BBB".toByteArray(Charsets.UTF_8)) + advanceUntilIdle() + + // Nothing appended yet — both chunks are held. + assertEquals(2, session.pendingChunkCountForTest(), "both chunks should be pending pre-bind") + assertEquals("", session.screenTextForTest().trim(), "emulator must be untouched pre-bind") + + // Binding flushes in submission order: AAA then BBB, ESC[0m preserved (produces no glyphs). + session.bind {} + advanceUntilIdle() + + assertEquals(0, session.pendingChunkCountForTest(), "queue drained on bind") + assertEquals("AAABBB", session.screenTextForTest().trim(), "flush order must be AAA then BBB") + + session.close() + advanceUntilIdle() + } + + @Test + fun `emulator is published to the renderer only after pending output is in the buffer`() = runTest { + // FIX 1 (bind-time data race): the emulator must NOT be handed to the stock renderer until the + // initial pendingOutput flush has COMPLETED — otherwise the UI-thread draw can read + // TerminalBuffer while the confined thread is still appending the ring replay. + val dispatcher = StandardTestDispatcher(testScheduler) + val session = RemoteTerminalSession( + engineSend = {}, + appendDispatcher = dispatcher, + mainDispatcher = dispatcher, + ) + + // A ring-replay chunk lands BEFORE any view binds — it is queued, buffer untouched. + session.feedRemote("\u001b[0mHELLO".toByteArray(Charsets.UTF_8)) + advanceUntilIdle() + assertEquals(1, session.pendingChunkCountForTest(), "chunk should be pending pre-bind") + assertEquals("", session.screenTextForTest().trim(), "buffer must be untouched pre-bind") + + // publishEmulator stands in for `view.bindEmulator(emulator)` (exposing the buffer to the + // renderer). Record what the buffer holds AT THE MOMENT it is published. + var textAtPublish: String? = null + var pendingAtPublish = -1 + session.bind( + publishEmulator = { + textAtPublish = session.screenTextForTest().trim() + pendingAtPublish = session.pendingChunkCountForTest() + }, + ) { } + advanceUntilIdle() + + // The publish happened strictly AFTER the flush: the replay bytes are already in the buffer and + // the queue is drained. Under the old synchronous publish this read "" on an empty buffer while + // the confined thread was still flushing — the bind-moment single-writer-vs-UI-read race. + assertEquals("HELLO", textAtPublish, "emulator must be published only after the flush completed") + assertEquals(0, pendingAtPublish, "queue must be drained before the emulator is published") + + session.close() + advanceUntilIdle() + } + + @Test + fun `screen-update sink fires on bind flush`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val session = RemoteTerminalSession( + engineSend = {}, + appendDispatcher = dispatcher, + mainDispatcher = dispatcher, + ) + var updates = 0 + + session.feedRemote("hi".toByteArray(Charsets.UTF_8)) + advanceUntilIdle() + session.bind { updates++ } + advanceUntilIdle() + + assertTrue(updates >= 1, "binding a pending session must post at least one screen update") + + session.close() + advanceUntilIdle() + } +} diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalSessionSeamTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalSessionSeamTest.kt new file mode 100644 index 0000000..80ff183 --- /dev/null +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/RemoteTerminalSessionSeamTest.kt @@ -0,0 +1,122 @@ +package wang.yaojia.webterm.terminalview + +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 +import wang.yaojia.webterm.wire.ClientMessage + +/** + * S1 GATE (plan §5 S1 / §6.1) — proves the Termux terminal-emulator seam is drivable HEADLESS: a + * [RemoteTerminalSession] instantiates a `TerminalEmulator` with NO subprocess / NO JNI, we append + * canned WS-shaped ANSI bytes, and read the expected cells back out of `TerminalBuffer`. It also proves + * the OUTBOUND seam: emulator-originated bytes (a DSR reply) flow back through `engineSend`, and typed + * input maps to `Input(...)`. If this fails, A16 is BLOCKED (→ §6.8 fallback). + */ +@OptIn(ExperimentalCoroutinesApi::class) +class RemoteTerminalSessionSeamTest { + + // Canned WS-shaped bytes: ESC[0m (soft reset) · ESC[32m (green) · "hello" · ESC[0m — the shape a + // ring replay begins with. \u001b is the ESC byte. + private val cannedAnsi = "\u001b[0m\u001b[32mhello\u001b[0m" + + @Test + fun `canned ANSI bytes render into the TerminalBuffer`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val session = RemoteTerminalSession( + engineSend = {}, + appendDispatcher = dispatcher, + mainDispatcher = dispatcher, + ) + session.bind {} // bind so output appends live rather than queueing + + session.feedRemote(cannedAnsi.toByteArray(Charsets.UTF_8)) + advanceUntilIdle() + + val text = session.screenTextForTest().trim() + assertTrue(text.contains("hello"), "emulator did not render 'hello'; got: '$text'") + assertEquals(5, session.emulator.cursorCol, "cursor should advance past 'hello'") + + session.close() + advanceUntilIdle() + } + + @Test + fun `emulator-originated reply (DSR) flows out through engineSend`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val sent = mutableListOf() + val session = RemoteTerminalSession( + engineSend = { sent += it }, + appendDispatcher = dispatcher, + mainDispatcher = dispatcher, + ) + session.bind {} + + // ESC[6n = Device Status Report (cursor position). The emulator answers ESC[;R via + // TerminalOutput.write → engineSend(Input) — the DA/DSR path §6.1 calls out. + session.feedRemote("\u001b[6n".toByteArray(Charsets.UTF_8)) + advanceUntilIdle() + + val reply = sent.filterIsInstance().firstOrNull { it.data.contains('R') } + assertTrue(reply != null, "expected a DSR reply Input containing 'R'; sent: $sent") + + session.close() + advanceUntilIdle() + } + + @Test + fun `a command that throws is survived and later output still renders`() = runTest { + // FIX 2: a malformed / hostile escape byte that makes the emulator throw must NOT kill the + // single confined consumer and permanently freeze all further output. Here the injected onBell + // throws on the first invocation (standing in for the emulator choking on a hostile byte): the + // BEL byte 0x07 makes the emulator call onBell synchronously mid-append on the confined thread. + val dispatcher = StandardTestDispatcher(testScheduler) + var bellCount = 0 + val session = RemoteTerminalSession( + engineSend = {}, + onBell = { + bellCount++ + if (bellCount == 1) throw RuntimeException("hostile-byte boom") + }, + appendDispatcher = dispatcher, + mainDispatcher = dispatcher, + ) + session.bind {} + + // This feed throws while being processed on the confined thread. + session.feedRemote("\u0007".toByteArray(Charsets.UTF_8)) + advanceUntilIdle() + + // The consumer survived — a subsequent feed still appends and renders. + session.feedRemote("OK".toByteArray(Charsets.UTF_8)) + advanceUntilIdle() + + assertTrue( + session.screenTextForTest().trim().contains("OK"), + "consumer must survive a throwing command and keep rendering; got: '${session.screenTextForTest().trim()}'", + ) + + session.close() + advanceUntilIdle() + } + + @Test + fun `writeInput maps verbatim to Input`() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val sent = mutableListOf() + val session = RemoteTerminalSession( + engineSend = { sent += it }, + appendDispatcher = dispatcher, + mainDispatcher = dispatcher, + ) + + session.writeInput("ls -la\r") + + assertEquals(listOf(ClientMessage.Input("ls -la\r")), sent) + session.close() + advanceUntilIdle() + } +} diff --git a/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalGridMathTest.kt b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalGridMathTest.kt new file mode 100644 index 0000000..e476c87 --- /dev/null +++ b/android/terminal-view/src/test/kotlin/wang/yaojia/webterm/terminalview/TerminalGridMathTest.kt @@ -0,0 +1,71 @@ +package wang.yaojia.webterm.terminalview + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +/** + * The R5 gate (plan §6.4) — the pure cols×rows resize arithmetic, verified against KNOWN font metrics + * so it can never drift from the server/web/iOS contract without a device. The real metrics come from + * Termux's `TerminalRenderer` at runtime; this test pins the formula that turns them into a grid. + */ +class TerminalGridMathTest { + + // 10px-wide cells, 20px line spacing — an easy exact-division case. + private val fontWidth = 10f + private val lineSpacing = 20 + + @Test + fun `exact division yields expected grid minus symmetric padding`() { + // usableW = 820 - 2*10 = 800 → 80 cols; usableH = 500 - 2*10 = 480 → 24 rows. + val grid = TerminalGridMath.computeGridSize( + viewWidthPx = 820, viewHeightPx = 500, + horizontalPaddingPx = 10, verticalPaddingPx = 10, + fontWidthPx = fontWidth, fontLineSpacingPx = lineSpacing, + ) + assertEquals(TerminalGridSize(cols = 80, rows = 24), grid) + } + + @Test + fun `partial cells floor down`() { + // usableW = 795 → floor(795/10)=79 cols; usableH = 495 → floor(495/20)=24 rows. + val grid = TerminalGridMath.computeGridSize( + viewWidthPx = 795, viewHeightPx = 495, + horizontalPaddingPx = 0, verticalPaddingPx = 0, + fontWidthPx = fontWidth, fontLineSpacingPx = lineSpacing, + ) + assertEquals(TerminalGridSize(cols = 79, rows = 24), grid) + } + + @Test + fun `non-positive view dimensions drop to null (pre-layout)`() { + assertNull( + TerminalGridMath.computeGridSize(0, 500, 0, 0, fontWidth, lineSpacing), + "zero width is pre-layout — no grid", + ) + assertNull( + TerminalGridMath.computeGridSize(820, 0, 0, 0, fontWidth, lineSpacing), + "zero height is pre-layout — no grid", + ) + } + + @Test + fun `non-positive metrics drop to null`() { + assertNull(TerminalGridMath.computeGridSize(820, 500, 0, 0, 0f, lineSpacing)) + assertNull(TerminalGridMath.computeGridSize(820, 500, 0, 0, fontWidth, 0)) + } + + @Test + fun `padding larger than the view drops to null`() { + assertNull( + TerminalGridMath.computeGridSize(20, 500, 20, 0, fontWidth, lineSpacing), + "usable width <= 0 after padding — no grid", + ) + } + + @Test + fun `a tiny viewport still clamps to at least 1x1`() { + val grid = TerminalGridMath.computeGridSize(5, 5, 0, 0, fontWidth, lineSpacing) + assertEquals(TerminalGridSize(cols = 1, rows = 1), grid) + } +} diff --git a/android/transport-okhttp/build.gradle.kts b/android/transport-okhttp/build.gradle.kts new file mode 100644 index 0000000..73c6440 --- /dev/null +++ b/android/transport-okhttp/build.gradle.kts @@ -0,0 +1,30 @@ +// :transport-okhttp (A7) — the ONLY concrete WS + REST transport. Implements the +// frozen TermTransport / PingableTermTransport / HttpTransport interfaces over +// OkHttp (a plain JVM lib, so this module builds + tests here with NO Android SDK). +// One shared OkHttpClient → mTLS SSLSocketFactory applies to both WS and REST; +// custom Origin header stamped on the WS upgrade. Depends only on :wire-protocol +// (+ an injected ClientIdentityProvider so mTLS is wired without :session-core +// knowing about it). MockWebServer drives the JVM tests. + +plugins { + alias(libs.plugins.kotlin.jvm) +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + api(project(":wire-protocol")) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.okhttp) + + testImplementation(project(":test-support")) + testImplementation(libs.bundles.unit.test) + testImplementation(libs.okhttp.mockwebserver) + testRuntimeOnly(libs.junit.platform.launcher) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt new file mode 100644 index 0000000..ca64aae --- /dev/null +++ b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt @@ -0,0 +1,98 @@ +package wang.yaojia.webterm.transport + +import okhttp3.OkHttpClient +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.X509TrustManager + +/** + * The optional mTLS material a [ClientIdentityProvider] hands to the shared client (plan §2 mTLS + * row, §8). Both fields are required together — OkHttp binds an `(SSLSocketFactory, X509TrustManager)` + * pair, never one without the other. + * + * The [sslSocketFactory] SHOULD be backed by a **re-reading `X509KeyManager`** (built in + * `:client-tls-android`, wired in `:app`) so a mid-run cert rotation is presented on the next + * handshake without rebuilding the client (plan §8 / R4). This module treats the pair as opaque — + * it never parses PKCS#12, never touches `AndroidKeyStore`, and keeps depending ONLY on + * `:wire-protocol`. + */ +public class ClientIdentity( + public val sslSocketFactory: SSLSocketFactory, + public val trustManager: X509TrustManager, +) + +/** + * The mTLS injection seam, defined LOCALLY in `:transport-okhttp` on purpose (plan §5 A7): it lets + * `:app` supply a `:client-tls-android`-backed client identity WITHOUT this module gaining a + * `:client-tls` dependency edge — the module stays pure `:wire-protocol` + OkHttp. + * + * A SAM ([fun interface]) so the common "no device cert installed" case is just + * `ClientIdentityProvider.NONE`, and a test/`:app` can supply `ClientIdentityProvider { currentId }`. + * + * [currentIdentity] is read ONCE, when the shared [OkHttpClient] is built ([OkHttpClientFactory]). + * Live cert rotation is the injected `SSLSocketFactory`'s concern (its `X509KeyManager` re-reads the + * `AndroidKeyStore` per handshake), not this seam's — so returning a fresh value later has no effect, + * by design. + */ +public fun interface ClientIdentityProvider { + /** The current client identity, or null for no mTLS (default system trust only). */ + public fun currentIdentity(): ClientIdentity? + + public companion object { + /** No client certificate — the default for unpaired / bare-LAN hosts. */ + public val NONE: ClientIdentityProvider = ClientIdentityProvider { null } + } +} + +/** + * Builds the SINGLE shared [OkHttpClient] both transports use (plan §2 "one shared OkHttpClient"): + * one client → one `SSLSocketFactory` → mTLS applies uniformly to WS and REST, and the connection + * pool is shared. + * + * Two invariants baked in here: + * - **`.cache(null)`** (plan §8): no ephemeral/disk HTTP cache — preview/diff bodies can hold + * terminal secrets, so nothing is persisted. + * - **Server-cert trust = default system trust.** Tunnel (`*.terminal.yaojia.wang`) and Tailscale + * MagicDNS present real LE certs, so NO custom `X509TrustManager` is installed for the common + * case (plan §6.9). Bare-LAN `ws://` cleartext is an `:app` `network_security_config` allowlist + * concern, NOT this module's — a plain `ws://` upgrade needs no TLS here at all. + */ +public object OkHttpClientFactory { + /** + * @param identityProvider optional mTLS material; [ClientIdentityProvider.NONE] → default trust, + * no client cert. + */ + public fun create( + identityProvider: ClientIdentityProvider = ClientIdentityProvider.NONE, + ): OkHttpClient { + val builder = OkHttpClient.Builder().cache(null) + identityProvider.currentIdentity()?.let { identity -> + builder.sslSocketFactory(identity.sslSocketFactory, identity.trustManager) + } + return builder.build() + } +} + +/** + * Convenience composition root proving the "one client for BOTH transports" contract: builds the + * shared [OkHttpClient] once via [OkHttpClientFactory] and hands the SAME instance to the WS and + * REST transports (the WS one derives a streaming-tuned variant via `newBuilder()`, which shares the + * connection pool + dispatcher + mTLS). `:app`'s Hilt module will typically call this. + */ +public class OkHttpTransports private constructor( + public val term: OkHttpTermTransport, + public val http: OkHttpHttpTransport, + public val client: OkHttpClient, +) { + public companion object { + public fun create( + identityProvider: ClientIdentityProvider = ClientIdentityProvider.NONE, + ): OkHttpTransports { + val client = OkHttpClientFactory.create(identityProvider) + return OkHttpTransports( + term = OkHttpTermTransport(client), + http = OkHttpHttpTransport(client), + client = client, + ) + } + } +} diff --git a/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpHttpTransport.kt b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpHttpTransport.kt new file mode 100644 index 0000000..882ce64 --- /dev/null +++ b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpHttpTransport.kt @@ -0,0 +1,88 @@ +package wang.yaojia.webterm.transport + +import kotlinx.coroutines.suspendCancellableCoroutine +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Headers +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import wang.yaojia.webterm.wire.HttpMethod +import wang.yaojia.webterm.wire.HttpRequest +import wang.yaojia.webterm.wire.HttpResponse +import wang.yaojia.webterm.wire.HttpTransport +import java.io.IOException +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * The only concrete REST transport (A7): implements [HttpTransport] over the SAME shared + * [OkHttpClient] as the WS transport (one client → mTLS + `cache(null)` apply uniformly). + * + * Contract (frozen [HttpTransport] doc + plan §4.3): + * - Headers are copied VERBATIM. This module NEVER adds `Origin` itself — `:api-client` stamps it + * only on the guarded routes; the read-only GETs must stay Origin-free. Getting this wrong breaks + * the CSWSH split. + * - A **non-2xx** status is RETURNED, not thrown (classification is the caller's job). Only a + * **transport-level** failure (connection refused, TLS, reset) throws — as OkHttp's [IOException]. + * + * Uses the async `enqueue` path wrapped in [suspendCancellableCoroutine] so coroutine cancellation + * cancels the in-flight [Call] (no thread parked on a blocking `execute`). + */ +public class OkHttpHttpTransport( + private val client: OkHttpClient, +) : HttpTransport { + + override suspend fun send(request: HttpRequest): HttpResponse = + suspendCancellableCoroutine { continuation -> + val call = client.newCall(request.toOkHttpRequest()) + continuation.invokeOnCancellation { runCatching { call.cancel() } } + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + continuation.resumeWithException(e) // transport-level failure → thrown + } + + override fun onResponse(call: Call, response: Response) { + val mapped = try { + response.use { it.toHttpResponse() } + } catch (e: IOException) { + continuation.resumeWithException(e) + return + } + continuation.resume(mapped) // any status (incl. non-2xx) is returned + } + }) + } +} + +private val EMPTY_REQUEST_BODY: RequestBody = ByteArray(0).toRequestBody(null) + +private fun HttpRequest.toOkHttpRequest(): Request { + val builder = Request.Builder().url(url) + // Verbatim header copy — single-valued map, so replace (not append). NO Origin injected here. + headers.forEach { (name, value) -> builder.header(name, value) } + // Null media type → OkHttp adds no Content-Type, so a caller-supplied one is preserved verbatim. + val requestBody = body?.toRequestBody(null) + builder.method(method.name, bodyFor(method, requestBody)) + return builder.build() +} + +/** OkHttp requires GET to have no body and POST/PUT to have one; DELETE accepts either. */ +private fun bodyFor(method: HttpMethod, body: RequestBody?): RequestBody? = when (method) { + HttpMethod.GET -> null + HttpMethod.DELETE -> body + HttpMethod.POST, HttpMethod.PUT -> body ?: EMPTY_REQUEST_BODY +} + +private fun Response.toHttpResponse(): HttpResponse = + HttpResponse( + status = code, + body = body?.bytes() ?: ByteArray(0), + headers = headers.toSingleValueMap(), + ) + +/** Flatten OkHttp's multimap headers to last-value-wins (matches the pure `Map` DTO). */ +private fun Headers.toSingleValueMap(): Map = + buildMap { for (i in 0 until size) put(name(i), value(i)) } diff --git a/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpTermTransport.kt b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpTermTransport.kt new file mode 100644 index 0000000..2c6a537 --- /dev/null +++ b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpTermTransport.kt @@ -0,0 +1,69 @@ +package wang.yaojia.webterm.transport + +import okhttp3.OkHttpClient +import okhttp3.Request +import wang.yaojia.webterm.wire.HostEndpoint +import wang.yaojia.webterm.wire.PingableConnection +import wang.yaojia.webterm.wire.PingableTermTransport +import wang.yaojia.webterm.wire.TransportConnection +import wang.yaojia.webterm.wire.Tunables +import java.util.concurrent.TimeUnit + +/** The `Origin` header name — THE CSWSH defence; stamped byte-equal from [HostEndpoint.originHeader]. */ +internal const val HEADER_ORIGIN: String = "Origin" + +/** + * The only concrete WS transport (A7): implements [PingableTermTransport] (and thus `TermTransport`) + * over OkHttp. `SessionEngine` (A14) cannot tell it apart from the `FakeTermTransport` double. + * + * The shared [OkHttpClient] (built by [OkHttpClientFactory], mTLS + `cache(null)` applied) is reused, + * but WS needs streaming tuning, so a variant is derived via `newBuilder()` — which SHARES the + * connection pool, dispatcher and `SSLSocketFactory`: + * - **`readTimeout(0)`** — a long-lived stream may sit idle between frames; a read timeout would + * wrongly kill it. Keep-alive is the ping's job, not the read timeout's. + * - **`pingInterval(PING_INTERVAL)`** — OkHttp sends real WS control pings and fails a + * pong-less (half-dead) connection, surfacing it as an `onFailure` → flow error → reconnect. + * + * `connect` stamps `Origin: endpoint.originHeader` on the upgrade [Request] and suspends until the + * handshake opens (or rethrows the connect-time failure verbatim, per the frozen contract). The dial + * is cancellation-safe: if the caller's coroutine is cancelled (or the handshake fails) while it is + * in flight, the just-created WebSocket is torn down before rethrowing, so no socket is leaked. + * + * Inbound frames flow up UNMODIFIED — there is deliberately NO transport-level frame-size cap here. + * `SessionEngine` (A14) self-measures each inbound frame's UTF-8 size and is the single authoritative + * classifier of an oversized ring-buffer replay (the non-retryable `REPLAY_TOO_LARGE` failure). + */ +public class OkHttpTermTransport( + sharedClient: OkHttpClient, +) : PingableTermTransport { + + private val wsClient: OkHttpClient = sharedClient.newBuilder() + .readTimeout(0, TimeUnit.MILLISECONDS) + .pingInterval(Tunables.PING_INTERVAL.inWholeMilliseconds, TimeUnit.MILLISECONDS) + .build() + + override suspend fun connect(endpoint: HostEndpoint): TransportConnection = + openConnection(endpoint) + + override suspend fun connectPingable(endpoint: HostEndpoint): PingableConnection { + val connection = openConnection(endpoint) + return PingableConnection(connection = connection, pinger = connection.pinger) + } + + private suspend fun openConnection(endpoint: HostEndpoint): OkHttpWebSocketConnection { + val request = Request.Builder() + .url(endpoint.wsUrl) // OkHttp maps ws(s):// → http(s):// internally + .header(HEADER_ORIGIN, endpoint.originHeader) + .build() + val connection = OkHttpWebSocketConnection(wsClient, request) + try { + connection.awaitOpen() + } catch (e: Throwable) { + // Cancelled or failed mid-handshake → abort the socket so it is never leaked, then + // rethrow verbatim (a connect-time onFailure stays byte-identical for the A9 probe). + connection.cancel() + throw e + } + return connection + } +} diff --git a/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpWebSocketConnection.kt b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpWebSocketConnection.kt new file mode 100644 index 0000000..fcb0a5f --- /dev/null +++ b/android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpWebSocketConnection.kt @@ -0,0 +1,153 @@ +package wang.yaojia.webterm.transport + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okio.ByteString +import wang.yaojia.webterm.wire.ConnectionPinger +import wang.yaojia.webterm.wire.TransportConnection +import java.io.IOException + +/** RFC 6455 normal-closure status code, used for a client-initiated detach. */ +internal const val NORMAL_CLOSURE_CODE: Int = 1000 + +/** + * One live OkHttp WebSocket, adapted to the frozen [TransportConnection] contract (A7). + * + * The connection is opened eagerly in the constructor (`newWebSocket`), so [send] and [close] act on + * a real socket and [awaitOpen] can rethrow a connect-time failure verbatim (the pairing probe, A9, + * classifies POSIX/TLS codes off it). Inbound frames land in an UNLIMITED [inbox] mailbox from the + * listener (`onMessage → trySend`, never dropped even before a collector attaches); [frames] is a + * `channelFlow` that drains the mailbox with backpressure and turns the mailbox's terminal state into + * the two distinguishable flow outcomes the engine relies on: + * - a clean `onClosing`/`onClosed` → mailbox closed normally → the flow **completes normally**; + * - an `onFailure` → mailbox closed with the cause → the flow **throws** it. + * + * Inbound frames are forwarded up UNMODIFIED — there is NO transport-level size cap. `SessionEngine` + * (A14) self-measures each frame's UTF-8 byte size and is the single authoritative classifier of an + * oversized ring-buffer replay (the non-retryable `REPLAY_TOO_LARGE` failure); the transport just + * shuttles bytes. + * + * `close()` is a client DETACH — it sends a 1000 close; the server-side PTY keeps running (invariant + * #2). It is NEVER a kill. + * + * Single-collection by contract (one live WS, consumed once), mirroring the `FakeTermTransport` + * double. + */ +internal class OkHttpWebSocketConnection( + client: OkHttpClient, + request: Request, +) : TransportConnection { + + private val inbox = Channel(Channel.UNLIMITED) + private val opened = CompletableDeferred() + + @Volatile + private var terminated = false + + private val webSocket: WebSocket = client.newWebSocket(request, WsListener()) + + /** + * Liveness-based pinger. OkHttp (unlike iOS `URLSessionWebSocketTask.sendPing`) exposes NO manual + * ping with an observable pong via [WebSocketListener], so a true per-ping round-trip is not + * available. Real keep-alive is delegated to OkHttp's `pingInterval` (set on the streaming WS + * client in [OkHttpTermTransport]); this hook reports whether the connection is still live so the + * pure `PingScheduler` (A5) can count a dead connection as a miss. + */ + val pinger: ConnectionPinger = object : ConnectionPinger { + override suspend fun ping(): Boolean = !terminated + } + + /** Suspends until the WS handshake completes; rethrows the connect-time failure verbatim. */ + suspend fun awaitOpen() { + opened.await() + } + + override val frames: Flow = channelFlow { + val scope = this + val pump = launch { + try { + // Suspending send → lossless, in-order forwarding (the mailbox holds the backlog). + for (frame in inbox) { + scope.send(frame) + } + scope.close() // mailbox drained after a clean close → complete normally + } catch (e: CancellationException) { + throw e // cooperative cancellation must propagate, never be swallowed into a close + } catch (cause: Throwable) { + scope.close(cause) // mailbox closed with a cause → propagate the error + } + } + awaitClose { + pump.cancel() + // Downstream cancelled its collection → tear the socket down (harmless if already closed). + webSocket.cancel() + } + } + + override suspend fun send(frame: String) { + if (!webSocket.send(frame)) { + throw IOException("WS send failed: the connection is closing or closed") + } + } + + override suspend fun close() { + webSocket.close(NORMAL_CLOSURE_CODE, null) + finish(null) + } + + /** + * Abort the underlying socket, tearing down an IN-FLIGHT handshake so a dial cancelled (or + * failed) before the connection opens never leaks the just-created WebSocket. Idempotent and + * harmless once the socket is already opened, closing or closed. + */ + fun cancel() { + webSocket.cancel() + finish(null) + } + + /** Idempotently terminate: mark dead and close the mailbox (null = clean, non-null = error). */ + private fun finish(cause: Throwable?) { + terminated = true + inbox.close(cause) + } + + private inner class WsListener : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + opened.complete(Unit) + } + + // Forwarded verbatim — the engine (A14) is the authoritative oversized-replay classifier. + override fun onMessage(webSocket: WebSocket, text: String) { + inbox.trySend(text) + } + + override fun onMessage(webSocket: WebSocket, bytes: ByteString) { + inbox.trySend(bytes.utf8()) + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + webSocket.close(NORMAL_CLOSURE_CODE, null) // complete the closing handshake + finish(null) + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + finish(null) + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + finish(t) + // If we never opened, unblock connect() with the verbatim cause; no-op once opened. + opened.completeExceptionally(t) + } + } +} diff --git a/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactoryTest.kt b/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactoryTest.kt new file mode 100644 index 0000000..a9ba58c --- /dev/null +++ b/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactoryTest.kt @@ -0,0 +1,58 @@ +package wang.yaojia.webterm.transport + +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Test +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager +import java.security.KeyStore + +/** + * The shared-client invariants (plan §8): NO HTTP cache (preview/diff bodies can hold terminal + * secrets), and injected mTLS material is actually wired onto the client. + */ +class OkHttpClientFactoryTest { + + @Test + fun sharedClientHasNoCache() { + val client = OkHttpClientFactory.create() + assertNull(client.cache, "OkHttpClient.cache must be null — no terminal secrets on disk") + } + + @Test + fun defaultProviderInstallsNoClientIdentityButStillBuilds() { + val client = OkHttpClientFactory.create(ClientIdentityProvider.NONE) + assertNotNull(client.sslSocketFactory) // default system factory, not a caller-supplied one + } + + @Test + fun injectedIdentityIsAppliedToTheClient() { + // Arrange: a system-default trust manager + matching socket factory as stand-in mTLS material. + val trustManager = systemDefaultTrustManager() + val sslSocketFactory = SSLContext.getInstance("TLS") + .apply { init(null, arrayOf(trustManager), null) } + .socketFactory + + // Act + val client = OkHttpClientFactory.create { ClientIdentity(sslSocketFactory, trustManager) } + + // Assert: the shared client uses exactly the injected factory (mTLS applies to WS + REST). + assertSame(sslSocketFactory, client.sslSocketFactory) + } + + @Test + fun composesBothTransportsFromOneClientWithNoCache() { + val transports = OkHttpTransports.create() + assertNotNull(transports.term) + assertNotNull(transports.http) + assertNull(transports.client.cache) + } + + private fun systemDefaultTrustManager(): X509TrustManager { + val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + factory.init(null as KeyStore?) + return factory.trustManagers.filterIsInstance().first() + } +} diff --git a/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/OkHttpHttpTransportTest.kt b/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/OkHttpHttpTransportTest.kt new file mode 100644 index 0000000..e42eada --- /dev/null +++ b/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/OkHttpHttpTransportTest.kt @@ -0,0 +1,126 @@ +package wang.yaojia.webterm.transport + +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import wang.yaojia.webterm.wire.HttpMethod +import wang.yaojia.webterm.wire.HttpRequest +import java.io.IOException + +/** + * A7 REST contract over MockWebServer: 2xx returns status+body; non-2xx is returned (not thrown); + * a transport failure throws; the Origin split (guarded POST carries the caller's Origin verbatim, + * a read-only GET carries none) is preserved byte-for-byte. + */ +class OkHttpHttpTransportTest { + + private lateinit var server: MockWebServer + private lateinit var transport: OkHttpHttpTransport + private val client = OkHttpClientFactory.create() + + @BeforeEach + fun setUp() { + server = MockWebServer() + server.start() + transport = OkHttpHttpTransport(client) + } + + @AfterEach + fun tearDown() { + server.shutdown() + client.dispatcher.executorService.shutdown() + client.connectionPool.evictAll() + } + + private fun url(path: String): String = server.url(path).toString() + + @Test + fun returnsStatusAndBodyForA2xxResponse() = runBlocking { + // Arrange + server.enqueue(MockResponse().setResponseCode(200).setBody("[]")) + + // Act + val response = transport.send(HttpRequest(HttpMethod.GET, url("/live-sessions"))) + + // Assert + assertEquals(200, response.status) + assertEquals("[]", String(response.body, Charsets.UTF_8)) + } + + @Test + fun returnsNon2xxStatusWithoutThrowing() = runBlocking { + // Arrange: a 403 is meaningful to the caller (Origin guard), never an exception. + server.enqueue(MockResponse().setResponseCode(403).setBody("forbidden")) + + // Act + val response = transport.send( + HttpRequest( + method = HttpMethod.POST, + url = url("/hook/decision"), + headers = mapOf("Origin" to "http://host:3000", "Content-Type" to "application/json"), + body = """{"x":1}""".toByteArray(Charsets.UTF_8), + ), + ) + + // Assert + assertEquals(403, response.status) + assertEquals("forbidden", String(response.body, Charsets.UTF_8)) + } + + @Test + fun guardedPostCarriesCallerOriginAndBodyVerbatim() = runBlocking { + // Arrange + server.enqueue(MockResponse().setResponseCode(204)) + val origin = "http://192.168.1.5:3000" + val payload = """{"sessionId":"s","decision":"allow","token":"t"}""" + + // Act + transport.send( + HttpRequest( + method = HttpMethod.POST, + url = url("/hook/decision"), + headers = mapOf("Origin" to origin, "Content-Type" to "application/json"), + body = payload.toByteArray(Charsets.UTF_8), + ), + ) + + // Assert: the server saw the exact Origin, Content-Type, method and body. + val recorded = server.takeRequest() + assertEquals("POST", recorded.method) + assertEquals(origin, recorded.getHeader("Origin")) + assertEquals("application/json", recorded.getHeader("Content-Type")) + assertEquals(payload, recorded.body.readUtf8()) + } + + @Test + fun readOnlyGetCarriesNoOriginHeader() = runBlocking { + // Arrange + server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + + // Act + transport.send(HttpRequest(HttpMethod.GET, url("/config/ui"))) + + // Assert: the CSWSH split — read-only GET must NOT carry Origin. + val recorded = server.takeRequest() + assertEquals("GET", recorded.method) + assertNull(recorded.getHeader("Origin")) + } + + @Test + fun throwsOnTransportLevelFailure() { + // Arrange: the server drops the socket at connect → an IOException, not a returned status. + server.enqueue(MockResponse().apply { socketPolicy = SocketPolicy.DISCONNECT_AT_START }) + + // Act + Assert + assertThrows { + runBlocking { transport.send(HttpRequest(HttpMethod.GET, url("/live-sessions"))) } + } + } +} diff --git a/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/OkHttpTermTransportTest.kt b/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/OkHttpTermTransportTest.kt new file mode 100644 index 0000000..9c11c97 --- /dev/null +++ b/android/transport-okhttp/src/test/kotlin/wang/yaojia/webterm/transport/OkHttpTermTransportTest.kt @@ -0,0 +1,227 @@ +package wang.yaojia.webterm.transport + +import app.cash.turbine.test +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import okhttp3.OkHttpClient +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy +import org.junit.jupiter.api.AfterEach +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.BeforeEach +import org.junit.jupiter.api.Test +import wang.yaojia.webterm.wire.HostEndpoint +import java.io.IOException + +/** + * A7 WS contract over MockWebServer's `withWebSocketUpgrade`: + * - the `Origin` header on the upgrade byte-equals `endpoint.originHeader` (THE CSWSH defence); + * - frames arrive in order; a clean server close COMPLETES the flow normally; a server abort + * SURFACES as a flow error (the two must stay distinguishable for A14); + * - `send` writes the exact frame; `close` detaches (server sees a 1000 close). + */ +class OkHttpTermTransportTest { + + private lateinit var server: MockWebServer + private val client: OkHttpClient = OkHttpClientFactory.create() + + @BeforeEach + fun setUp() { + server = MockWebServer() + server.start() + } + + @AfterEach + fun tearDown() { + // Force-close any live WS connection so MockWebServer isn't left waiting on an open socket. + client.dispatcher.cancelAll() + client.connectionPool.evictAll() + // MockWebServer's WS shutdown occasionally times out draining its own task queue even after + // the socket is closed — a teardown artifact, not a product concern (assertions already ran). + runCatching { server.shutdown() } + client.dispatcher.executorService.shutdown() + } + + private fun endpoint(): HostEndpoint = + requireNotNull(HostEndpoint.fromBaseUrl("http://${server.hostName}:${server.port}")) + + private fun transport(): OkHttpTermTransport = OkHttpTermTransport(client) + + @Test + fun stampsOriginHeaderOnTheWsUpgrade() = runBlocking { + // Arrange + val serverWs = RecordingServerWebSocket() + server.enqueue(MockResponse().withWebSocketUpgrade(serverWs)) + val endpoint = endpoint() + + // Act + val connection = withTimeout(TIMEOUT_MS) { transport().connect(endpoint) } + + // Assert: the upgrade request carried the byte-equal Origin. + val upgrade = server.takeRequest() + assertEquals(endpoint.originHeader, upgrade.getHeader("Origin")) + connection.close() + } + + @Test + fun deliversFramesInArrivalOrderThenCompletesOnCleanClose() = runBlocking { + // Arrange + val serverWs = RecordingServerWebSocket() + server.enqueue(MockResponse().withWebSocketUpgrade(serverWs)) + val connection = withTimeout(TIMEOUT_MS) { transport().connect(endpoint()) } + val socket = withTimeout(TIMEOUT_MS) { serverWs.opened.await() } + + // Act + Assert + connection.frames.test { + socket.send("frame-1") + socket.send("frame-2") + assertEquals("frame-1", awaitItem()) + assertEquals("frame-2", awaitItem()) + socket.close(NORMAL_CLOSURE_CODE, "done") + awaitComplete() // clean close → NORMAL flow completion + } + } + + @Test + fun serverAbortSurfacesAsAFlowError() = runBlocking { + // Arrange + val serverWs = RecordingServerWebSocket() + server.enqueue(MockResponse().withWebSocketUpgrade(serverWs)) + val connection = withTimeout(TIMEOUT_MS) { transport().connect(endpoint()) } + withTimeout(TIMEOUT_MS) { serverWs.opened.await() } + + // Act + Assert + connection.frames.test { + server.shutdown() // abrupt TCP teardown (no WS close frame), not a clean close + val error = awaitError() + assertTrue(error is IOException, "expected a transport IOException, got $error") + } + } + + @Test + fun sendWritesTheExactFrameToTheServer() = runBlocking { + // Arrange + val serverWs = RecordingServerWebSocket() + server.enqueue(MockResponse().withWebSocketUpgrade(serverWs)) + val connection = withTimeout(TIMEOUT_MS) { transport().connect(endpoint()) } + withTimeout(TIMEOUT_MS) { serverWs.opened.await() } + + // Act + connection.send("""{"type":"attach","sessionId":null}""") + + // Assert + val received = withTimeout(TIMEOUT_MS) { serverWs.messages.receive() } + assertEquals("""{"type":"attach","sessionId":null}""", received) + connection.close() + } + + @Test + fun closeDetachesWithANormalClosure() = runBlocking { + // Arrange + val serverWs = RecordingServerWebSocket() + server.enqueue(MockResponse().withWebSocketUpgrade(serverWs)) + val connection = withTimeout(TIMEOUT_MS) { transport().connect(endpoint()) } + withTimeout(TIMEOUT_MS) { serverWs.opened.await() } + + // Act: client detach (server PTY keeps running — this is never a kill). + connection.close() + + // Assert: the server observed a graceful 1000 close. + val code = withTimeout(TIMEOUT_MS) { serverWs.closingCode.await() } + assertEquals(NORMAL_CLOSURE_CODE, code) + } + + @Test + fun connectPingableReportsLivenessAcrossClose() = runBlocking { + // Arrange + val serverWs = RecordingServerWebSocket() + server.enqueue(MockResponse().withWebSocketUpgrade(serverWs)) + + // Act + val pingable = withTimeout(TIMEOUT_MS) { transport().connectPingable(endpoint()) } + withTimeout(TIMEOUT_MS) { serverWs.opened.await() } + + // Assert: alive before close, dead after a client detach. + assertTrue(pingable.pinger.ping()) + pingable.connection.close() + assertFalse(pingable.pinger.ping()) + } + + @Test + fun cancellingAnInFlightDialTearsDownTheSocketWithNoLeak() = runBlocking { + // Arrange: the server accepts the TCP connection but never completes the WS upgrade, so onOpen + // never fires — connect() stays suspended in the handshake (a genuine in-flight dial). + server.enqueue(MockResponse().apply { socketPolicy = SocketPolicy.NO_RESPONSE }) + + // Act: dial in a child job, wait until OkHttp has the call in flight, then cancel mid-handshake. + val dialing = launch(start = CoroutineStart.UNDISPATCHED) { + transport().connect(endpoint()) + } + withTimeout(TIMEOUT_MS) { + while (client.dispatcher.runningCallsCount() == 0) delay(POLL_MS) + } + dialing.cancelAndJoin() + + // Assert: the just-created WebSocket was torn down — no leaked running call survives the cancel. + // (Without the fix the call sits blocked on readTimeout(0) forever, so this poll never settles.) + withTimeout(TIMEOUT_MS) { + while (client.dispatcher.runningCallsCount() != 0) delay(POLL_MS) + } + assertEquals(0, client.dispatcher.runningCallsCount()) + } + + @Test + fun cancellingFrameCollectionPropagatesCancellationCleanly() = runBlocking { + // Arrange + val serverWs = RecordingServerWebSocket() + server.enqueue(MockResponse().withWebSocketUpgrade(serverWs)) + val connection = withTimeout(TIMEOUT_MS) { transport().connect(endpoint()) } + withTimeout(TIMEOUT_MS) { serverWs.opened.await() } + + // Act: park a collector inside the flow (no frames sent → the pump suspends draining the mailbox). + val collector = launch(start = CoroutineStart.UNDISPATCHED) { + connection.frames.collect { } + } + + // Assert: cancelling the collection lands as a cancellation (the pump must NOT swallow the + // CancellationException into a normal close) and join returns promptly — no deadlock. + withTimeout(TIMEOUT_MS) { collector.cancelAndJoin() } + assertTrue(collector.isCancelled, "collector must end cancelled, not completed normally") + } + + private companion object { + const val TIMEOUT_MS = 5_000L + const val POLL_MS = 10L + } +} + +/** Server side of the wire: records the upgrade socket, inbound frames, and the client's close code. */ +private class RecordingServerWebSocket : WebSocketListener() { + val opened = CompletableDeferred() + val messages = Channel(Channel.UNLIMITED) + val closingCode = CompletableDeferred() + + override fun onOpen(webSocket: WebSocket, response: Response) { + opened.complete(webSocket) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + messages.trySend(text) + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + closingCode.complete(code) + } +} diff --git a/android/wire-protocol/build.gradle.kts b/android/wire-protocol/build.gradle.kts index f0c28ae..2572fff 100644 --- a/android/wire-protocol/build.gradle.kts +++ b/android/wire-protocol/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.kover) } kotlin { @@ -23,3 +24,14 @@ dependencies { tasks.test { useJUnitPlatform() } + +// A36 acceptance gate: >=80% line coverage on this pure module (plan §7). +kover { + reports { + verify { + rule { + minBound(80) + } + } + } +} diff --git a/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/HostClassifierTest.kt b/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/HostClassifierTest.kt new file mode 100644 index 0000000..be8d851 --- /dev/null +++ b/android/wire-protocol/src/test/kotlin/wang/yaojia/webterm/wire/HostClassifierTest.kt @@ -0,0 +1,87 @@ +package wang.yaojia.webterm.wire + +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 + +/** + * The canonical `:wire-protocol` [HostClassifier] (plan §5.4 tiers). This type lives here (shared by + * :api-client + :client-tls), so its coverage is asserted in the module that OWNS it. + */ +class HostClassifierTest { + + @Test + fun `loopback hosts classify as LOOPBACK`() { + assertEquals(HostNetworkTier.LOOPBACK, HostClassifier.classify("localhost")) + assertEquals(HostNetworkTier.LOOPBACK, HostClassifier.classify("LOCALHOST")) + assertEquals(HostNetworkTier.LOOPBACK, HostClassifier.classify("::1")) + assertEquals(HostNetworkTier.LOOPBACK, HostClassifier.classify("[::1]")) + assertEquals(HostNetworkTier.LOOPBACK, HostClassifier.classify("127.0.0.1")) + assertEquals(HostNetworkTier.LOOPBACK, HostClassifier.classify("127.5.9.1")) // whole 127/8 + } + + @Test + fun `RFC1918 + link-local + mDNS classify as PRIVATE_LAN`() { + assertEquals(HostNetworkTier.PRIVATE_LAN, HostClassifier.classify("10.0.0.5")) + assertEquals(HostNetworkTier.PRIVATE_LAN, HostClassifier.classify("192.168.1.7")) + assertEquals(HostNetworkTier.PRIVATE_LAN, HostClassifier.classify("172.16.0.1")) + assertEquals(HostNetworkTier.PRIVATE_LAN, HostClassifier.classify("172.31.255.254")) + assertEquals(HostNetworkTier.PRIVATE_LAN, HostClassifier.classify("169.254.10.10")) + assertEquals(HostNetworkTier.PRIVATE_LAN, HostClassifier.classify("mymac.local")) + // 172.15 / 172.32 are OUTSIDE 172.16/12 → public (boundary). + assertEquals(HostNetworkTier.PUBLIC, HostClassifier.classify("172.15.0.1")) + assertEquals(HostNetworkTier.PUBLIC, HostClassifier.classify("172.32.0.1")) + } + + @Test + fun `Tailscale CGNAT + MagicDNS classify as TAILSCALE`() { + assertEquals(HostNetworkTier.TAILSCALE, HostClassifier.classify("100.64.0.1")) + assertEquals(HostNetworkTier.TAILSCALE, HostClassifier.classify("100.127.255.254")) + assertEquals(HostNetworkTier.TAILSCALE, HostClassifier.classify("mybox.tail1234.ts.net")) + // 100.63 / 100.128 are OUTSIDE 100.64/10 → public. + assertEquals(HostNetworkTier.PUBLIC, HostClassifier.classify("100.63.0.1")) + assertEquals(HostNetworkTier.PUBLIC, HostClassifier.classify("100.128.0.1")) + } + + @Test + fun `unknown, malformed, out-of-range and IPv6 fall back to PUBLIC (fail-safe)`() { + assertEquals(HostNetworkTier.PUBLIC, HostClassifier.classify("example.com")) + assertEquals(HostNetworkTier.PUBLIC, HostClassifier.classify("8.8.8.8")) + assertEquals(HostNetworkTier.PUBLIC, HostClassifier.classify("999.1.1.1")) // out-of-range octet + assertEquals(HostNetworkTier.PUBLIC, HostClassifier.classify("10.0.0")) // wrong arity + assertEquals(HostNetworkTier.PUBLIC, HostClassifier.classify("10.0.0.x")) // non-numeric + assertEquals(HostNetworkTier.PUBLIC, HostClassifier.classify("fd00::1")) // ULA IPv6 → PUBLIC + assertEquals(HostNetworkTier.PUBLIC, HostClassifier.classify("")) + } + + @Test + fun `ipv4Octets is a strict dotted-quad parser`() { + assertEquals(listOf(192, 168, 0, 1), HostClassifier.ipv4Octets("192.168.0.1")) + assertNull(HostClassifier.ipv4Octets("192.168.0")) // too few + assertNull(HostClassifier.ipv4Octets("192.168.0.1.2")) // too many + assertNull(HostClassifier.ipv4Octets("192.168.0.256")) // out of range + assertNull(HostClassifier.ipv4Octets("a.b.c.d")) // non-numeric + } + + @Test + fun `classify(endpoint) derives the host, and a hostless baseUrl is PUBLIC (fail-safe)`() { + val lan = HostEndpoint.fromBaseUrl("http://192.168.1.50:3000")!! + assertEquals(HostNetworkTier.PRIVATE_LAN, HostClassifier.classify(lan)) + assertEquals("192.168.1.50", HostClassifier.hostOf(lan)) + + val loop = HostEndpoint.fromBaseUrl("http://localhost:3000")!! + assertEquals(HostNetworkTier.LOOPBACK, HostClassifier.classify(loop)) + } + + @Test + fun `TimelineEvent flags known vs unknown event classes`() { + val tool = TimelineEvent(at = 1L, eventClass = "tool", toolName = "bash", label = "ran bash") + assertTrue(tool.hasKnownClass) + val unknown = TimelineEvent(at = 2L, eventClass = "future-kind", label = "x") + assertFalse(unknown.hasKnownClass) + assertTrue("user" in TimelineEvent.KNOWN_CLASSES) + assertEquals(5, TimelineEvent.KNOWN_CLASSES.size) + } +}