Closes the six remediation items from the 2026-07-29 iOS completion audit plus the
whole P2 wave and Android access-token parity.
The audit's headline was that the client was code-complete but stuck at the device
door: no DEVELOPMENT_TEAM, no entitlements, so it had never run on real hardware
once, and it had fallen two months behind the server (Android had the git panel,
iOS had none) while neither native client could connect at all once WEBTERM_TOKEN
was set.
Package tests 310 -> 452, app bundle 296 -> 550 (iPhone and iPad, zero known
issues), integration 10 -> 32, Android 687 -> 691. ClientTLS went 55.76% -> 89.49%
and is now actually in the coverage gate, which it never was. Device build now
succeeds on the free personal team.
src/ and public/ are untouched — the git-panel endpoints already existed
server-side; iOS simply never consumed them.
# Conflicts:
# android/.gitignore
# android/README.md
# android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt
# android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt
# android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt
# android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt
# android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt
# docs/PROGRESS_LOG.md
App layer, four sequential slices (a shared .xcodeproj means adding files
regenerates it, so these could not run in parallel):
- token UX end to end: pairing prompts for a token when a host 401s, POST /auth
validates it, and 204-without-Set-Cookie is correctly read as "this server has
auth disabled" rather than "authenticated". A host paired before the token was
turned on recovers by re-pairing in place. Remove-host now exists and finally
gives PushRegistrar.handleHostRemoved a caller.
- project git panel + worktree lifecycle (T-iOS-32) + claude --resume history —
the parity gap with Android and the web front end.
- terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a
session switch between dictation and confirm cannot inject into the wrong
session.
- theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no
longer hard-locks .preferredColorScheme(.dark).
Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that
collided with the upstream one, verified green from a fresh derivedDataPath.
Includes the two HIGH fixes the security review found:
- iOS resolved the WS token host-independently, so a token-gated host sitting
next to an open one could never open a terminal and no on-screen remedy could
fix it. Now one transport per host; cross-host leakage is structurally
impossible since both read paths return only that host's own value.
- Android reported the host's own git-credential 401 (git-ops.ts:108, "Push
authentication required on the host.") as "your access token is wrong", because
a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now
ROUTE_DEFINED and keep the server's message.
And the doc sync: README/ios README no longer claim the client is unmerged on
feat/ios-client, the Clients section finally lists Android, and the plan
checkboxes reflect what is actually built.
iOS 534 app tests + 452 package tests; Android 687 tests.
Android could not connect at all once the server set WEBTERM_TOKEN. Hand-written
Cookie header on every request and on the WS upgrade (no CookieJar, matching the
frozen decision), POST /auth pairing probe, Keystore-backed storage, and a 401
upgrade as a terminal state with no reconnect loop.
Four gaps that made "ship it" and "test it on hardware" impossible.
RELEASE SIGNING. assembleRelease was silently producing
app-release-unsigned.apk because there was no signingConfigs block at all —
the failure mode where you only notice at install time. Credentials now
resolve from keystore.properties, then local.properties, then
WEBTERM_RELEASE_* env; nothing is created or committed, and
keystore.properties.example is the template.
The degradation is ENFORCED, not documented. The signingConfig is only
created when credentials actually resolve — an empty config is precisely what
makes AGP emit an unsigned artifact quietly — and a guard on packageRelease
throws with an actionable message, distinguishing "not configured" from
"credentials found but the keystore file is missing: <path>". It hooks the
PACKAGE task on purpose: R8 has already run and reported by then, so a
developer without a keystore still gets the full shrink result rather than an
early abort. Verified: minifyReleaseWithR8 completes, packageRelease fails
loudly, and no unsigned APK is written.
VERSIONING. Was still the scaffold 1 / "0.1.0". Now 1 / "0.1.0-alpha01",
where the suffix is a factual claim about device verification rather than
decoration: alpha means device QA is essentially unstarted, beta means the
A34/A35 checklist blocks pass on hardware, and plain 0.1.0 means the whole
checklist is ticked. versionCode stays 1 because no artifact has ever left
the build machine.
MINIFICATION. release had isMinifyEnabled = false and an empty rules file —
so nothing had ever exercised R8 on this app, and the first release build
would have been the experiment. Now minify + resource shrinking, with a
deliberately short rules file: the actual artifacts were checked, and
kotlinx.serialization, Tink, Hilt, FCM, OkHttp, Compose and CameraX all ship
their own consumer rules, so none are re-declared. The termux packages are
kept whole because the JitPack artifacts ship no rules and :terminal-view
binds to non-API internals — it writes the public TerminalView.mEmulator
field and calls TerminalRenderer.getFontWidth()/getFontLineSpacing(). Getting
that wrong yields a build that crashes only in production.
lintVitalRelease also passes now, the two pre-existing lint ERRORS having
been fixed earlier this session.
ANDROID TEST ENABLEMENT. :app had no androidTest source set and no
instrumentation runner, which is the mechanical reason A34/A35 have no code
at all. The runner (Hilt-aware) and dependencies are wired, and a
:macrobenchmark module exists. The tests themselves are a separate change.
Verified: ./gradlew test :app:assembleDebug :app:assembleDebugAndroidTest
:app:assembleBenchmark :macrobenchmark:assembleBenchmark
:app:lintVitalRelease koverVerify -> BUILD SUCCESSFUL; 901 JVM tests, 0
failures on a forced re-execution with test-results deleted and
--no-build-cache.
Android SDK installed on this machine (cmdline-tools via brew;
platform-tools + platforms;android-35 + build-tools;35.0.0), local.properties
points Gradle at it (gitignored). Wired AGP 9.2.1 into the version catalog +
google() repos; proved the full chain by building a throwaway android-library
module against SDK 35 (AAR produced), then removed the probe.
Groundwork for AW2+: AGP plugins in the catalog, google() in settings, and the
working recipe in README (incl. the AGP-9-has-built-in-Kotlin gotcha — Android
modules apply ONLY the android plugin, never kotlin.android). Pure JVM modules
still green (218 tests). Framework module stubs stay gated until implemented.