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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user