Compare commits

...
Author SHA1 Message Date
Bailey Dixon de8f5558c2 Merge origin/dev into fix/android-emulator-testing 2026-08-31 16:37:42 -04:00
Bailey Dixon adcf4ded79 Merge pull request #517 from Codename-11/fix/android-supervised-parent-secret
fix(android): require app-specific supervised parent access
2026-08-31 16:36:44 -04:00
Bailey Dixon 05d6ee4d7c Merge origin/dev into fix/android-emulator-testing 2026-08-31 16:25:48 -04:00
Bailey Dixon ae18bbee24 Merge origin/dev into fix/android-emulator-testing
# Conflicts:
#	AGENTS.md
#	app/src/main/kotlin/com/hermesandroid/relay/network/upstream/DashboardApiClient.kt
#	app/src/main/kotlin/com/hermesandroid/relay/runtime/HermesRuntimeBinder.kt
#	app/src/main/kotlin/com/hermesandroid/relay/viewmodel/ChatViewModel.kt
#	app/src/test/kotlin/com/hermesandroid/relay/network/upstream/DashboardApiClientTest.kt
2026-08-31 16:13:34 -04:00
Bailey Dixon 105da550e7 docs: note Android history auth recovery 2026-08-31 15:32:40 -04:00
Bailey Dixon febc26fe35 Merge origin/dev into fix/android-emulator-testing 2026-08-31 15:12:55 -04:00
Bailey Dixon 28a906215d test(android): add managed emulator coverage 2026-08-31 15:12:44 -04:00
9 changed files with 468 additions and 15 deletions
+1
View File
@@ -15,6 +15,7 @@ contract here and in `RELEASE.md`.
- Contributor setup → **[CONTRIBUTING.md](CONTRIBUTING.md)**
- Gateway/session/reconnect testing → **[docs/gateway-contract-testing.md](docs/gateway-contract-testing.md)**
- Android local/cloud verification → **[docs/android-build-lane.md](docs/android-build-lane.md)**
- Android emulator lanes → **[docs/android-emulator-testing.md](docs/android-emulator-testing.md)** — suggest the smallest relevant API 36 lanes; never run the full matrix automatically
- `android_*` toolset + MCP → **[docs/mcp-tooling.md](docs/mcp-tooling.md)**
- Follow-ups / deferred work / known gaps → **[TODO.md](TODO.md)** (the single home for "what's next" — never DEVLOG, never scattered code comments)
+1
View File
@@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Fixed
- **Android keeps completed chat text visible when Dashboard sign-in expires.** Generic and reason-coded history `401` responses settle the local turn, preserve its transcript, and surface the existing sign-in recovery without reading another profile's API history.
- **Android Bot Chats render loaded history immediately.** Route-owned chat screens observe their own handler state from first composition, including fast history loads that settle before another frame. (Supersedes #453.)
- **Supervised Gateway setup stays parent-owned.** Add Gateway is single-flight and checks live parent authority before allocating a draft, relock/back cancels the exact pending setup, and the locked Chat footer no longer attempts protected navigation.
- **Generated images stay visible and use their intended Chat animation.** Completed image media survives a marker-lagging history refresh, and both the built-in `image_generate` tool and profile tools ending in `_create_image` use the image-generation presentation.
+13
View File
@@ -6,6 +6,19 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
---
## Consider hosted Android emulator execution
The local API 36 Gradle Managed Device lanes are intentionally on demand and
individually selected. The current Android On-Demand workflow covers hosted
source, unit, lint, and build verification only; it does not run emulators. If
local emulator capacity becomes a recurring constraint, evaluate a separately
approved hosted-emulator design with explicit cost, concurrency, artifact
retention, and trigger policy. Do not schedule the full form-factor matrix or
add a device farm until that policy is approved; keep live-server mutation tests
outside any automatic matrix.
---
## Upstream a public Dashboard plugin WebSocket admission seam
The same-origin Relay ingress follows current upstream's bundled Dashboard
+58
View File
@@ -249,6 +249,58 @@ android {
it.systemProperty("roborazzi.test.record", "true")
it.maxHeapSize = "2g"
}
// On-demand only. Keep each form factor as an individually selected
// Gradle-managed device; there is deliberately no aggregate matrix
// task or scheduled emulator job. See docs/android-emulator-testing.md.
managedDevices {
localDevices {
create("compactPhoneApi36") {
device = "Pixel 2"
apiLevel = 36
systemImageSource = "aosp"
require64Bit = true
testedAbi = "x86_64"
}
create("standardPhoneApi36") {
device = "Pixel 6"
apiLevel = 36
systemImageSource = "aosp"
require64Bit = true
testedAbi = "x86_64"
}
create("largePhoneApi36") {
device = "Pixel 7 Pro"
apiLevel = 36
systemImageSource = "aosp"
require64Bit = true
testedAbi = "x86_64"
}
create("foldableApi36") {
device = "Pixel Fold"
apiLevel = 36
systemImageSource = "aosp"
require64Bit = true
testedAbi = "x86_64"
}
create("tabletApi36") {
device = "Pixel Tablet"
apiLevel = 36
systemImageSource = "aosp"
require64Bit = true
testedAbi = "x86_64"
}
create("futureApi37Ps16k") {
device = "Pixel 7 Pro"
apiLevel = 37
systemImageSource = "google_apis_playstore"
require64Bit = true
testedAbi = "x86_64"
pageAlignment =
com.android.build.api.dsl.ManagedVirtualDevice.PageAlignment.FORCE_16KB_PAGES
}
}
}
}
}
@@ -390,6 +442,12 @@ dependencies {
// Konsist — enforces the ADR 34 upstream/relay/shared package fence as a JUnit test
testImplementation(libs.konsist)
androidTestImplementation(libs.compose.ui.test.junit4)
// Compose UI Test still declares Espresso 3.5.0 transitively. API 37
// removed the reflected InputManager.getInstance() seam; Espresso 3.7.0
// uses Context.getSystemService and is the current stable AndroidX line.
androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
androidTestImplementation("androidx.test:runner:1.7.0")
androidTestImplementation("androidx.test.ext:junit:1.3.0")
// On-device vanilla-Gateway contract tests exercise the production
// Dashboard ticket + WebSocket stack over real loopback sockets.
androidTestImplementation(libs.okhttp.mockwebserver)
@@ -6,7 +6,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.test.platform.app.InstrumentationRegistry
@@ -4,6 +4,7 @@ import android.os.Handler
import android.os.Looper
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Button
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.Modifier
@@ -17,6 +18,11 @@ import androidx.compose.ui.test.onNodeWithTag
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.ChatTurnAssistantCheckpoint
import com.hermesandroid.relay.data.ChatTurnCheckpoint
import com.hermesandroid.relay.data.ChatTurnCheckpointStore
import com.hermesandroid.relay.data.ChatTurnUserCheckpoint
import com.hermesandroid.relay.network.upstream.ChatHandler
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.GatewayChatClient
@@ -27,6 +33,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
@@ -44,6 +51,7 @@ import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
@@ -74,10 +82,11 @@ class GatewayForegroundRecoveryInstrumentedTest {
@Volatile
private var persistedHistory: List<MessageItem> = emptyList()
private val historySignInRequired = MutableStateFlow(false)
@Before
fun setUp() {
fixture = AndroidGatewayContractFixture()
fixture = AndroidGatewayContractFixture().also { it.profileName = PROFILE_NAME }
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val okHttp = OkHttpClient()
gatewayClient = GatewayChatClient(
@@ -97,6 +106,7 @@ class GatewayForegroundRecoveryInstrumentedTest {
handler,
)
it.streamingEndpoint = "gateway"
it.setSessionProfileNameProvider { PROFILE_NAME }
it.setProfileMessageLoader { Result.success(persistedHistory) }
it.updateGatewayClient(gatewayClient)
it.setChatVisible(true)
@@ -105,6 +115,7 @@ class GatewayForegroundRecoveryInstrumentedTest {
compose.setContent {
val messages by viewModel.messages.collectAsStateWithLifecycle()
val streaming by viewModel.isStreaming.collectAsStateWithLifecycle()
val signInRequired by historySignInRequired.collectAsStateWithLifecycle()
MaterialTheme {
Column(Modifier.testTag("contract-transcript")) {
Text(
@@ -117,6 +128,14 @@ class GatewayForegroundRecoveryInstrumentedTest {
modifier = Modifier.testTag("message-${message.id}"),
)
}
if (signInRequired) {
Button(
onClick = {},
modifier = Modifier.testTag("dashboard-sign-in-recovery"),
) {
Text("SIGN IN")
}
}
}
}
}
@@ -252,9 +271,6 @@ class GatewayForegroundRecoveryInstrumentedTest {
"session.interrupt",
"prompt.submit",
)
val baseline = controlMethods.associateWith(fixture::rpcCount)
val baselineActiveList = fixture.rpcCount("session.active_list")
fixture.activeSessionStatus = "working"
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val okHttp = OkHttpClient()
gatewayClient = GatewayChatClient(
@@ -269,6 +285,17 @@ class GatewayForegroundRecoveryInstrumentedTest {
)
viewModel.setChatTurnCheckpointStore(null)
viewModel.updateGatewayClient(gatewayClient)
assertTrue(runBlocking { gatewayClient.observeAwait() })
serverSocket = fixture.awaitServerSocket()
viewModel.switchProfileContext(
AgentDisplay.profileContextKey("fixture-connection", PROFILE_NAME),
STORED_SESSION_ID,
)
viewModel.updateSessionActivityDirectory(listOf(PROFILE_NAME to STORED_SESSION_ID))
val baseline = controlMethods.associateWith(fixture::rpcCount)
val baselineActiveList = fixture.rpcCount("session.active_list")
fixture.activeSessionStatus = "working"
viewModel.setChatVisible(true)
compose.activityRule.scenario.moveToState(Lifecycle.State.STARTED)
@@ -293,6 +320,141 @@ class GatewayForegroundRecoveryInstrumentedTest {
)
}
@Test
fun normalCompletion_genericHistory401RetainsTranscriptAndRequiresProfileSignIn() {
bindDashboardHistoryFailure(
body = "Unauthorized",
profileName = PROFILE_NAME,
)
viewModel.sendMessage("Keep this local transcript")
fixture.awaitRpc("prompt.submit")
serverSocket.send(fixture.event("message.start", null, LIVE_SESSION_ID))
serverSocket.send(
fixture.event(
"message.delta",
buildJsonObject { put("text", LOCAL_COMPLETION) },
LIVE_SESSION_ID,
),
)
serverSocket.send(
fixture.event(
"message.complete",
buildJsonObject { put("text", LOCAL_COMPLETION) },
LIVE_SESSION_ID,
),
)
compose.waitUntil(15_000) {
historySignInRequired.value &&
!handler.isStreaming.value &&
handler.messages.value.any { it.content == LOCAL_COMPLETION }
}
compose.activityRule.scenario.moveToState(Lifecycle.State.STARTED)
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
compose.onNodeWithTag("contract-transcript").assertIsDisplayed()
compose.onNodeWithTag("stream-state").assertTextEquals("IDLE")
compose.onNodeWithTag("dashboard-sign-in-recovery").assertIsDisplayed()
assertFalse(viewModel.isLoadingHistory.value)
assertTrue(handler.messages.value.any { it.content == "Keep this local transcript" })
assertTrue(handler.messages.value.any { it.content == LOCAL_COMPLETION })
assertNull(viewModel.chatFailure.value)
assertExactProfileHistoryOnly(PROFILE_NAME)
}
@Test
fun recoveredCompletion_sessionExpiredHistoryRetainsSettledTranscript() {
bindDashboardHistoryFailure(
body = """{"reason":"session_expired"}""",
profileName = PROFILE_NAME,
)
val now = System.currentTimeMillis()
val contextKey = AgentDisplay.profileContextKey("fixture-connection", PROFILE_NAME)
viewModel.setChatTurnCheckpointStore(
MemoryCheckpointStore(
ChatTurnCheckpoint(
contextKey = contextKey,
profileKey = PROFILE_NAME,
sessionId = STORED_SESSION_ID,
liveSessionId = LIVE_SESSION_ID,
transport = "gateway",
user = ChatTurnUserCheckpoint("recovered-user", "Resume this turn", now - 2_000L),
assistant = ChatTurnAssistantCheckpoint(
id = "recovered-assistant",
content = "Recovered partial",
timestamp = now - 1_900L,
),
priorUserMessageCount = 0,
baselineAssistantCount = 0,
startedAt = now - 2_000L,
updatedAt = now,
),
),
)
fixture.recoveryRunning = true
handler.setSessionId(null)
viewModel.switchProfileContext(contextKey, STORED_SESSION_ID)
fixture.awaitRpc("session.activate")
serverSocket.send(
fixture.event(
"message.delta",
buildJsonObject { put("text", RECOVERED_COMPLETION) },
LIVE_SESSION_ID,
),
)
serverSocket.send(
fixture.event(
"message.complete",
buildJsonObject { put("text", RECOVERED_COMPLETION) },
LIVE_SESSION_ID,
),
)
compose.waitUntil(15_000) {
historySignInRequired.value && !handler.isStreaming.value
}
compose.activityRule.scenario.moveToState(Lifecycle.State.STARTED)
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
compose.onNodeWithTag("contract-transcript").assertIsDisplayed()
compose.onNodeWithTag("stream-state").assertTextEquals("IDLE")
compose.onNodeWithTag("dashboard-sign-in-recovery").assertIsDisplayed()
assertFalse(viewModel.isLoadingHistory.value)
assertTrue(
"recovered completion was not retained: ${handler.messages.value}",
handler.messages.value.any { it.content.contains(RECOVERED_COMPLETION.trim()) },
)
assertFalse(handler.messages.value.any { it.isStreaming || it.isThinkingStreaming })
assertNull(viewModel.chatFailure.value)
assertExactProfileHistoryOnly(PROFILE_NAME)
}
private fun bindDashboardHistoryFailure(body: String, profileName: String) {
fixture.profileName = profileName
fixture.historyFailureBody = body
val dashboard = DashboardApiClient(
baseUrl = fixture.server.url("/").toString().trimEnd('/'),
okHttpClient = OkHttpClient(),
)
viewModel.setProfileMessageLoaderWithMode { profile, sessionId, mode ->
dashboard.getSessionMessages(sessionId, profile, mode)
}
viewModel.setDashboardSignInRequiredHandler {
historySignInRequired.value = true
}
}
private fun assertExactProfileHistoryOnly(profileName: String) {
val historyRequests = fixture.historyRequestPaths()
assertTrue("no Dashboard history request was observed", historyRequests.isNotEmpty())
assertTrue(
"history escaped the exact profile: $historyRequests",
historyRequests.all { it.contains("profile=$profileName") },
)
}
private companion object {
const val STORED_SESSION_ID = "20260821_120000_fixture"
const val LIVE_SESSION_ID = "fixture-live-1"
@@ -301,6 +463,23 @@ class GatewayForegroundRecoveryInstrumentedTest {
const val PARTIAL_ANSWER = "Partial foreground answer"
const val AUTHORITATIVE_ANSWER = "Foreground task finished."
const val FOREIGN_ANSWER = "Wrong session content"
const val PROFILE_NAME = "research"
const val LOCAL_COMPLETION = "Completed before Dashboard auth expired."
const val RECOVERED_COMPLETION = " and then recovered to completion."
}
}
private class MemoryCheckpointStore(
private var checkpoint: ChatTurnCheckpoint?,
) : ChatTurnCheckpointStore {
override suspend fun read(): ChatTurnCheckpoint? = checkpoint
override suspend fun write(checkpoint: ChatTurnCheckpoint) {
this.checkpoint = checkpoint
}
override suspend fun clear() {
checkpoint = null
}
}
@@ -320,6 +499,12 @@ internal class AndroidGatewayContractFixture {
@Volatile
var activeSessionStatus: String? = null
@Volatile
var historyFailureBody: String? = null
@Volatile
var profileName: String = "default"
private val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
sockets.add(webSocket)
@@ -377,6 +562,11 @@ internal class AndroidGatewayContractFixture {
"""{"ticket":"device-${ticketCount.incrementAndGet()}","ttl_seconds":30}""",
)
path.startsWith("/api/ws") -> MockResponse().withWebSocketUpgrade(listener)
path.startsWith("/api/sessions/") && path.contains("/messages") &&
historyFailureBody != null -> MockResponse()
.setResponseCode(401)
.setHeader("Content-Type", "application/json")
.setBody(historyFailureBody.orEmpty())
else -> MockResponse().setResponseCode(404)
}
}
@@ -388,7 +578,7 @@ internal class AndroidGatewayContractFixture {
put("session_id", sessionId)
put("running", recoveryRunning)
put("status", if (recoveryRunning) "streaming" else "idle")
put("info", buildJsonObject { put("profile_name", "default") })
put("info", buildJsonObject { put("profile_name", profileName) })
}
fun event(type: String, payload: JsonObject?, sessionId: String?): String =
@@ -406,7 +596,7 @@ internal class AndroidGatewayContractFixture {
sockets.poll(5, TimeUnit.SECONDS) ?: error("Gateway WebSocket did not open")
fun awaitRpc(method: String): JsonObject {
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15)
while (System.nanoTime() < deadline) {
rpcLog.firstOrNull { it.first == method }?.let { return it.second }
Thread.sleep(20)
@@ -415,7 +605,7 @@ internal class AndroidGatewayContractFixture {
}
fun awaitRpcCount(method: String, count: Int) {
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15)
while (System.nanoTime() < deadline) {
if (rpcCount(method) >= count) return
Thread.sleep(20)
@@ -425,6 +615,10 @@ internal class AndroidGatewayContractFixture {
fun requestsTo(path: String): Int = requestPaths.count { it.startsWith(path) }
fun historyRequestPaths(): List<String> = requestPaths.filter {
it.startsWith("/api/sessions/") && it.contains("/messages")
}
fun rpcCount(method: String): Int = rpcLog.count { it.first == method }
fun shutdown() {
@@ -2436,6 +2436,10 @@ internal fun Throwable.isDashboardSignInRequiredFailure(): Boolean {
java.util.IdentityHashMap<Throwable, Boolean>(),
)
while (current != null && seen.add(current)) {
// Every 401 from an authenticated Dashboard route means the saved
// browser/native session can no longer authorize this request. Older
// gateways used `no_cookie`/`unauthenticated`; current builds may return
// reason codes such as `session_expired`, or no structured body at all.
if (current is DashboardHttpException && current.statusCode == 401) {
return true
}
@@ -3184,6 +3184,7 @@ class ChatViewModel : ViewModel() {
) {
val handler = chatHandler ?: return
if (chatHandler !== handler || handler.currentSessionId.value != storedSessionId) return
val contextKey = activeProfileContextKey
gatewayHistoryReconcileJob?.cancel()
gatewayHistoryReconcileJob = viewModelScope.launch {
val expected = expectedAssistantText?.trim()?.takeIf { it.isNotEmpty() }
@@ -3216,7 +3217,28 @@ class ChatViewModel : ViewModel() {
}
val transcriptSnapshot = handler.messages.value
val serverMessages = loadGatewaySessionHistory(storedSessionId)
val serverMessages = try {
loadGatewaySessionHistory(
sessionId = storedSessionId,
requireProfileScope = true,
)
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
// A live completion is already visible and settled locally.
// History auth loss must retain that transcript and promote
// the existing sign-in recovery instead of escaping this
// Main-scope coroutine and crashing the app.
if (
chatHandler === handler &&
activeProfileContextKey == contextKey &&
handler.currentSessionId.value == storedSessionId
) {
publishHistoryLoadFailure(storedSessionId, e)
}
gatewayHistoryReconcileJob = null
return@launch
}
if (chatHandler !== handler || handler.currentSessionId.value != storedSessionId) {
return@launch
}
@@ -5401,6 +5423,7 @@ class ChatViewModel : ViewModel() {
// recovery state. Preserve cached history and
// mark the directory unavailable without also
// emitting a generic turn/error toast.
dashboardSignInRequiredHandler?.invoke()
} else if (scoped != null) {
// The shared API list belongs to the launch/default
// database. Preserve the current profile's rows and
@@ -5423,7 +5446,9 @@ class ChatViewModel : ViewModel() {
)
retryUnavailable = true
retryReadiness = retryReadiness || !e.isSessionReadTimeout()
if (!e.isDashboardSignInRequiredFailure()) {
if (e.isDashboardSignInRequiredFailure()) {
dashboardSignInRequiredHandler?.invoke()
} else {
emitError(
e,
context = if (profileSessionLister != null) {
@@ -7854,11 +7879,22 @@ class ChatViewModel : ViewModel() {
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
if (handler.currentSessionId.value == expectedSessionId) {
// Recovery completion has already settled the
// local turn. Keep it visible and route an
// expired Dashboard session to sign-in.
if (
chatHandler === handler &&
activeProfileContextKey == checkpoint.contextKey &&
handler.currentSessionId.value == expectedSessionId
) {
publishHistoryLoadFailure(expectedSessionId, e)
}
} finally {
if (handler.currentSessionId.value == expectedSessionId) {
if (
chatHandler === handler &&
activeProfileContextKey == checkpoint.contextKey &&
handler.currentSessionId.value == expectedSessionId
) {
refreshSessions()
scheduleTitleReconcile(expectedSessionId)
}
@@ -9727,6 +9763,7 @@ class ChatViewModel : ViewModel() {
// tool.complete. The structured reload recovers those calls without ever
// parsing assistant prose and retains the profile-aware history boundary.
val sid = handler.currentSessionId.value
val historyContextKey = activeProfileContextKey
// A turn that ended in an error (gateway ❌ lifecycle → "Error" badge)
// has NO assistant message persisted server-side, so reconciling the
// server transcript would WIPE the just-shown error bubble (the user
@@ -9769,7 +9806,11 @@ class ChatViewModel : ViewModel() {
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
if (handler.currentSessionId.value == sid) {
if (
chatHandler === handler &&
activeProfileContextKey == historyContextKey &&
handler.currentSessionId.value == sid
) {
publishHistoryLoadFailure(sid, e)
}
} finally {
@@ -9779,8 +9820,14 @@ class ChatViewModel : ViewModel() {
// is persisted, so a brand-new chat would otherwise stay missing
// from the drawer (carried only by the optimistic row) until a
// manual reload. By message.complete the dashboard list includes it.
refreshSessions()
scheduleTitleReconcile(sid)
if (
chatHandler === handler &&
activeProfileContextKey == historyContextKey &&
handler.currentSessionId.value == sid
) {
refreshSessions()
scheduleTitleReconcile(sid)
}
drainQueue()
}
}
+135
View File
@@ -0,0 +1,135 @@
# Android emulator testing
Hermes-Relay Android uses individually selected Gradle Managed Devices for
repeatable, on-demand instrumentation. The routine virtual baseline is API 36.
There is deliberately no aggregate matrix task and no scheduled emulator job:
choose the smallest lane that can prove the behavior under review.
## Lanes
| Evidence lane | Gradle device | Hardware profile | Use it for |
|---|---|---|---|
| Real Device | None | Explicitly selected physical hardware | Firmware, radio, audio, camera, biometrics, background limits, accessibility, and release-candidate claims |
| Compact Phone | `compactPhoneApi36` | Pixel 2 | Narrow phone layouts, compact height, keyboard pressure |
| Standard Phone | `standardPhoneApi36` | Pixel 6 | Default functional and regression instrumentation |
| Large Phone | `largePhoneApi36` | Pixel 7 Pro | Large handset layout and reachability |
| Foldable | `foldableApi36` | Pixel Fold | Fold/unfold, posture, continuity, and width-class changes |
| Tablet | `tabletApi36` | Pixel Tablet | Expanded layout, panes, and large-window behavior |
| Future platform / native canary | `futureApi37Ps16k` | Pixel 7 Pro, API 37, forced 16 KB pages | On-demand platform and native-library compatibility only |
Routine API 36 lanes use the AOSP x86_64 image so deterministic app tests do not
spend host capacity on unrelated Google-service startup. The API 37/16 KB device
is not a screen-size lane and is not part of routine testing. Gradle Managed
Devices may download a missing image on first use; that setup can be large and
slow.
## Commands
List the registered tasks:
```powershell
.\scripts\android-lane.ps1 gradle :app:tasks --all |
Select-String 'Api36|Ps16k'
```
Compile the app and instrumentation APK without starting an emulator:
```powershell
.\scripts\android-lane.ps1 gradle `
:app:assembleSideloadDebug `
:app:assembleSideloadDebugAndroidTest
```
Run one complete lane, normally Standard Phone first:
```powershell
.\scripts\android-lane.ps1 gradle `
:app:standardPhoneApi36SideloadDebugAndroidTest
```
Run one test class on one lane:
```powershell
.\scripts\android-lane.ps1 gradle `
:app:standardPhoneApi36SideloadDebugAndroidTest `
'-Pandroid.testInstrumentationRunnerArguments.class=com.hermesandroid.relay.viewmodel.GatewayForegroundRecoveryInstrumentedTest'
```
Run the other virtual lanes only when their form factor is relevant:
```powershell
.\scripts\android-lane.ps1 gradle :app:compactPhoneApi36SideloadDebugAndroidTest
.\scripts\android-lane.ps1 gradle :app:largePhoneApi36SideloadDebugAndroidTest
.\scripts\android-lane.ps1 gradle :app:foldableApi36SideloadDebugAndroidTest
.\scripts\android-lane.ps1 gradle :app:tabletApi36SideloadDebugAndroidTest
```
Run the future-platform/native canary explicitly:
```powershell
.\scripts\android-lane.ps1 gradle `
:app:futureApi37Ps16kSideloadDebugAndroidTest
```
All Windows commands use the repository's machine-wide build lane; see
[`docs/android-build-lane.md`](android-build-lane.md). Check the lane without
starting work with:
```powershell
.\scripts\android-lane.ps1 status
```
Do not invoke every device task as one command. Run lanes serially, record each
result, and stop when the relevant evidence is complete or the host reaches a
capacity limit.
## Configuration coverage
Form factor is only one axis. Select additional states according to the change:
- Test dark mode first; also cover light mode when colors, contrast, system bars,
or theme persistence changed.
- Cover portrait and landscape when layout, keyboard, media, drawers, or panes
changed. Foldable work must include a posture or width-class transition.
- Check default font scale and at least one enlarged scale for text-heavy or
accessibility-sensitive UI.
- Use the default locale for functional regressions; add a long-string locale
and an RTL locale when copy, formatting, or layout direction changed.
- Record gesture versus three-button navigation when bottom insets, edge-to-edge,
back handling, sheets, or overlays changed.
These dimensions are selected test conditions, not permanent duplicated device
definitions. Record any non-default setting in the evidence.
## Deterministic fixtures and live servers
Embedded MockWebServer tests own deterministic transport regressions. They use
production clients and view models against loopback HTTP/WebSocket boundaries,
require no credentials, mutate no real sessions, and are the correct lane for
authentication loss, reconnect gaps, malformed frames, profile isolation, and
repeatable lifecycle assertions.
Live-server testing is separate and on demand. Use a disposable test or staging
Hermes server with disposable profiles and sessions. Normally run only the
Standard Phone emulator plus one explicitly selected real device when physical
evidence is required. Never multiply live mutation testing across the full size
matrix, use a production server, or use personal conversation data. Sanitize
logs and exports before attaching them to a pull request.
## Evidence
For each executed lane, record:
```text
Commit: <exact SHA>
Artifact/variant: sideloadDebug app + androidTest
Lane: Standard Phone (standardPhoneApi36), API 36
Test selection: <class or package>
Configuration: dark/light, orientation/posture, font scale, locale, navigation
Result: pass/fail/blocked, test count, report path
Notes: retries, emulator/image limitation, relevant sanitized observation
```
Keep claims lane-specific. Emulator proof is not physical-device proof. A
passing API 37/16 KB canary proves only that selected platform/native lane; it
does not replace API 36 form-factor coverage or physical firmware evidence.