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:
@@ -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