feat(android): native Android client — full app parity build (A1–A36 + S1)
Some checks failed
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled

Mirror the iOS P0+P1 client as a Gradle multi-module app. Pure-JVM (Kover ≥80% gated):
:wire-protocol (frozen contract + HostEndpoint CSWSH origin + byte-exact codec),
:session-core (SessionEngine + reconnect/ping/gate/digest reducers), :api-client,
:client-tls (pure PKCS12/keymanager), :transport-okhttp (OkHttp WS+REST). Framework:
:app (Compose M3 Adaptive, Hilt, FCM, 11 screens + NavGraph), :terminal-view (Termux
terminal-emulator/-view via JitPack, Apache-2.0 — the renderer seam proven headless),
:host-registry (DataStore), :client-tls-android (AndroidKeyStore + Tink).

Highlights: single-key-home mTLS (non-exportable AndroidKeyStore key + re-reading
X509KeyManager + connectionPool.evictAll on rotation, ping-pong single-commit so a failed
rotation never clobbers the prior identity); FCM Allow/Deny trust split (Deny=BroadcastReceiver,
Allow=trampoline Activity hosting BiometricPrompt); per-consumer-Channel EventBus (R10);
config-surviving RetainedSessionHolder; byte-exact KeyByteMap; §5.4 pairing warning tiers.

Verified: ~484 JVM tests + Kover ≥80% on the pure modules + :app assembles to an APK; all
framework modules assemble. Device behaviors (rendering/IME/FCM/biometric/camera, E2E A34/A35,
S2 real-handset FCM spike) deferred to android/DEVICE_QA_CHECKLIST.md per plan §7 (no
emulator/Firebase here). Built via multi-agent orchestration (TDD builders → adversarial
cross-review → fix → re-verify → independent gate); progress in android/PROGRESS_ANDROID.md.
This commit is contained in:
Yaojia Wang
2026-07-10 16:41:21 +02:00
parent 542fde9580
commit e254918b1c
159 changed files with 20114 additions and 73 deletions

View File

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

File diff suppressed because one or more lines are too long

View File

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