feat(android): native Android client — full app parity build (A1–A36 + S1)
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
Mirror the iOS P0+P1 client as a Gradle multi-module app. Pure-JVM (Kover ≥80% gated): :wire-protocol (frozen contract + HostEndpoint CSWSH origin + byte-exact codec), :session-core (SessionEngine + reconnect/ping/gate/digest reducers), :api-client, :client-tls (pure PKCS12/keymanager), :transport-okhttp (OkHttp WS+REST). Framework: :app (Compose M3 Adaptive, Hilt, FCM, 11 screens + NavGraph), :terminal-view (Termux terminal-emulator/-view via JitPack, Apache-2.0 — the renderer seam proven headless), :host-registry (DataStore), :client-tls-android (AndroidKeyStore + Tink). Highlights: single-key-home mTLS (non-exportable AndroidKeyStore key + re-reading X509KeyManager + connectionPool.evictAll on rotation, ping-pong single-commit so a failed rotation never clobbers the prior identity); FCM Allow/Deny trust split (Deny=BroadcastReceiver, Allow=trampoline Activity hosting BiometricPrompt); per-consumer-Channel EventBus (R10); config-surviving RetainedSessionHolder; byte-exact KeyByteMap; §5.4 pairing warning tiers. Verified: ~484 JVM tests + Kover ≥80% on the pure modules + :app assembles to an APK; all framework modules assemble. Device behaviors (rendering/IME/FCM/biometric/camera, E2E A34/A35, S2 real-handset FCM spike) deferred to android/DEVICE_QA_CHECKLIST.md per plan §7 (no emulator/Firebase here). Built via multi-agent orchestration (TDD builders → adversarial cross-review → fix → re-verify → independent gate); progress in android/PROGRESS_ANDROID.md.
This commit is contained in:
@@ -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<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
*/
|
||||
|
||||
@@ -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<Context>()
|
||||
val dataStore: DataStore<Preferences> = 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)
|
||||
}
|
||||
}
|
||||
@@ -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<Context>()
|
||||
val dataStore: DataStore<Preferences> = 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"))
|
||||
}
|
||||
}
|
||||
@@ -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<Preferences>,
|
||||
) : HostStore {
|
||||
|
||||
override suspend fun loadAll(): List<Host> =
|
||||
HostCodec.decode(dataStore.data.first()[HOSTS_KEY])
|
||||
|
||||
override suspend fun upsert(host: Host): List<Host> =
|
||||
writeTransform { it.upserting(host) }
|
||||
|
||||
override suspend fun remove(id: String): List<Host> =
|
||||
writeTransform { it.removing(id) }
|
||||
|
||||
private suspend fun writeTransform(transform: (List<Host>) -> List<Host>): List<Host> {
|
||||
lateinit var updated: List<Host>
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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.<hostId>"` (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<Preferences>,
|
||||
) : 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."
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Host>): String =
|
||||
json.encodeToString(hosts.map { it.toPersisted() })
|
||||
|
||||
fun decode(raw: String?): List<Host> {
|
||||
if (raw.isNullOrBlank()) return emptyList()
|
||||
val persisted = try {
|
||||
json.decodeFromString<List<PersistedHost>>(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)
|
||||
}
|
||||
@@ -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<Host>
|
||||
|
||||
/**
|
||||
* Insert, or replace the host with the same [Host.id] (position preserved).
|
||||
* Returns the new collection.
|
||||
*/
|
||||
public suspend fun upsert(host: Host): List<Host>
|
||||
|
||||
/**
|
||||
* 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<Host>
|
||||
}
|
||||
|
||||
// ── 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<Host>.upserting(host: Host): List<Host> =
|
||||
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<Host>.removing(id: String): List<Host> =
|
||||
filter { it.id != id }
|
||||
@@ -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<Host> = 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<Host> = initial.toList()
|
||||
|
||||
override suspend fun loadAll(): List<Host> = mutex.withLock { hosts }
|
||||
|
||||
override suspend fun upsert(host: Host): List<Host> = mutex.withLock {
|
||||
val updated = hosts.upserting(host)
|
||||
hosts = updated
|
||||
updated
|
||||
}
|
||||
|
||||
override suspend fun remove(id: String): List<Host> = mutex.withLock {
|
||||
val updated = hosts.removing(id)
|
||||
hosts = updated
|
||||
updated
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user