Compare commits

..
Author SHA1 Message Date
Bailey Dixon 258e6f5390 fix(android): keep clarify custom answers reachable 2026-08-28 22:21:53 -04:00
21 changed files with 325 additions and 528 deletions
+1 -3
View File
@@ -16,9 +16,7 @@ function classifyCiPaths(paths) {
android: forceAll || under(['app/', 'relay-core/', 'relay-ui/', 'ui-preview/', 'quest/', 'gradle/']) || exact([
'build.gradle.kts', 'settings.gradle.kts', 'gradle.properties', 'gradlew', 'gradlew.bat',
'scripts/check-android-locales.py', 'scripts/android-locale-harness.py',
'scripts/check-android-collection-apis.py', 'scripts/check-android-native-compat.py',
'scripts/check-android-release-notes.py',
'scripts/tests/check_android_native_compat_test.py',
'scripts/check-android-collection-apis.py', 'scripts/check-android-release-notes.py',
'scripts/tests/check_android_release_notes_test.py', '.github/workflows/ci-android.yml',
'.github/workflows/play-preflight-android.yml',
'.github/workflows/approve-release-android.yml',
@@ -16,8 +16,6 @@ assert.deepEqual(classifyCiPaths(['README.md']), none);
assert.deepEqual(classifyCiPaths(['desktop/src/cli.ts']), { ...none, desktop: true });
assert.deepEqual(classifyCiPaths(['relay-core/src/main/kotlin/Wire.kt']), { ...none, android: true });
assert.deepEqual(classifyCiPaths(['scripts/check-android-release-notes.py']), { ...none, android: true });
assert.deepEqual(classifyCiPaths(['scripts/check-android-native-compat.py']), { ...none, android: true });
assert.deepEqual(classifyCiPaths(['scripts/tests/check_android_native_compat_test.py']), { ...none, android: true });
assert.deepEqual(classifyCiPaths(['plugin/relay/server.py']), { ...none, plugin: true });
assert.deepEqual(classifyCiPaths(['plugin/dashboard/src/App.tsx']), { ...none, dashboard: true });
assert.deepEqual(classifyCiPaths(['user-docs/index.md']), { ...none, docs: true });
-17
View File
@@ -33,9 +33,7 @@ on:
- "scripts/check-android-locales.py"
- "scripts/android-locale-harness.py"
- "scripts/check-android-collection-apis.py"
- "scripts/check-android-native-compat.py"
- "scripts/check-android-release-notes.py"
- "scripts/tests/check_android_native_compat_test.py"
- "scripts/tests/check_android_release_notes_test.py"
- ".github/workflows/ci-android.yml"
- ".github/workflows/play-preflight-android.yml"
@@ -82,9 +80,6 @@ jobs:
python3 scripts/check-android-release-notes.py
python3 -m unittest scripts.tests.check_android_release_notes_test
- name: Test Android native compatibility checker
run: python3 -m unittest scripts.tests.check_android_native_compat_test
- name: Run Android lint
run: ./gradlew lint --console=plain
@@ -114,12 +109,6 @@ jobs:
- name: Build debug APK
run: ./gradlew assembleDebug --console=plain
- name: Verify packaged ONNX Runtime compatibility
run: |
python3 scripts/check-android-native-compat.py \
app/build/outputs/apk/googlePlay/debug/*.apk \
app/build/outputs/apk/sideload/debug/*.apk
- name: Upload debug APK
uses: actions/upload-artifact@v7
if: ${{ github.ref == 'refs/heads/main' }}
@@ -236,9 +225,3 @@ jobs:
python3 scripts/check-android-collection-apis.py \
--apk app/build/outputs/apk/googlePlay/release/*.apk \
--apk app/build/outputs/apk/sideload/release/*.apk
- name: Verify packaged ONNX Runtime compatibility
run: |
python3 scripts/check-android-native-compat.py \
app/build/outputs/apk/googlePlay/release/*.apk \
app/build/outputs/apk/sideload/release/*.apk
-13
View File
@@ -263,19 +263,6 @@ jobs:
python3 scripts/check-android-collection-apis.py \
--apk app/build/outputs/apk/sideload/candidate/*.apk
- name: Verify stable packaged ONNX Runtime compatibility
if: ${{ needs.validate.outputs.prerelease != 'true' }}
run: |
python3 scripts/check-android-native-compat.py \
app/build/outputs/apk/googlePlay/release/*.apk \
app/build/outputs/apk/sideload/release/*.apk
- name: Verify candidate packaged ONNX Runtime compatibility
if: ${{ needs.validate.outputs.prerelease == 'true' }}
run: |
python3 scripts/check-android-native-compat.py \
app/build/outputs/apk/sideload/candidate/*.apk
- name: List produced artifacts (debug aid)
run: |
echo "=== APK outputs ==="
+1 -1
View File
@@ -17,7 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Fixed
- **Android wake-word detection now loads a compatible native ONNX Runtime.** Packaged sherpa and Java JNI consumers are checked against the shared runtime for every supported ABI before release.
- **Android Clarify cards make custom answers explicit and keyboard-friendly.** Choice prompts label their Other answer field, submit trimmed text from the keyboard, and do not restore an authoritatively expired prompt after session navigation.
- **The visible Android Sphere keeps its smooth procedural motion across startup and chat.** Backgrounded and motion-disabled surfaces remain still without reducing foreground animation to a stepped ambient pulse.
### Removed
@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
@@ -25,6 +24,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
@@ -71,6 +71,7 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
@@ -172,26 +173,26 @@ fun HermesCardBubble(
),
shape = appearanceRoundedCornerShape(12.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(IntrinsicSize.Min),
) {
Box(modifier = Modifier.fillMaxWidth()) {
// Accent stripe — runs full card height so tall cards keep the
// color tie. Using the SAME tertiary accent strategy as the
// voice/phone-action bubble marker in MessageBubble.kt so the
// visual language stays consistent.
Box(
modifier = Modifier
.width(3.dp)
.fillMaxHeight()
.background(accentColor),
)
modifier = Modifier.matchParentSize(),
) {
Box(
modifier = Modifier
.width(3.dp)
.fillMaxHeight()
.background(accentColor),
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
.padding(start = 15.dp, top = 12.dp, end = 12.dp, bottom = 12.dp),
) {
// Header
Row(
@@ -426,6 +427,19 @@ private fun CardInputSlot(
input.kind != HermesCardInput.Kinds.CONFIRM)
)
val submitFreeText = {
val customAnswer = answerText.trim()
if (customAnswer.isNotEmpty()) {
onSubmit(
if (isMultiSelect) {
encodeClarifyMultiSelectAnswer(selectedChoices + customAnswer)
} else {
customAnswer
},
)
}
}
Column(modifier = Modifier.fillMaxWidth()) {
// Choice chips
if (input.choices.isNotEmpty()) {
@@ -515,11 +529,16 @@ private fun CardInputSlot(
InlineAnswerField(
value = answerText,
onValueChange = { answerText = it },
placeholder = stringResource(
if (input.choices.isNotEmpty()) R.string.card_other_answer_placeholder
else R.string.card_answer_placeholder,
),
onSubmit = submitFreeText,
modifier = Modifier.weight(1f),
)
if (!isMultiSelect) {
IconButton(
onClick = { onSubmit(answerText.trim()) },
onClick = submitFreeText,
enabled = answerText.isNotBlank(),
) {
Icon(
@@ -593,6 +612,8 @@ private fun CardInputSlot(
private fun InlineAnswerField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
onSubmit: () -> Unit,
modifier: Modifier = Modifier,
) {
val shape = appearanceRoundedCornerShape(16.dp)
@@ -605,7 +626,7 @@ private fun InlineAnswerField(
) {
if (value.isEmpty()) {
Text(
text = stringResource(R.string.card_answer_placeholder),
text = placeholder,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
)
@@ -617,8 +638,12 @@ private fun InlineAnswerField(
color = MaterialTheme.colorScheme.onSurface,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { onSubmit() }),
maxLines = 3,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.semantics { contentDescription = placeholder },
)
}
}
@@ -2509,6 +2509,30 @@ class ChatViewModel : ViewModel() {
}
is GatewayBackgroundInteractionEvent.Expired -> {
if (key != null) {
val checkpoint = backgroundTurnCheckpoints[key]
val checkpointAsk = checkpoint?.pendingAsk
if (checkpoint != null && checkpointAsk != null &&
checkpointAsk.kind == event.ask.kind.name &&
(event.ask.kind == GatewayAsk.Kind.APPROVAL ||
checkpointAsk.requestId == event.ask.requestId)
) {
val updated = checkpoint.copy(
pendingAsk = null,
updatedAt = System.currentTimeMillis(),
)
// Recovery consults memory before disk, so retire the
// stale card synchronously before a navigation can
// reclaim this turn. Persist the same exact owner so
// process restart cannot resurrect it either.
backgroundTurnCheckpoints[key] = updated
chatTurnCheckpointStore?.let { store ->
viewModelScope.launch {
checkpointMutex.withLock {
runCatching { store.write(updated) }
}
}
}
}
backgroundNeedsInputKeys -= key
backgroundPendingInteractions.computeIfPresent(key) { _, current ->
if (current.ask.kind == event.ask.kind &&
@@ -3263,6 +3263,7 @@
<string name="card_send_answer_a11y">Enviar resposta</string>
<string name="card_submit">Enviar</string>
<string name="card_answer_placeholder">Digite uma resposta…</string>
<string name="card_other_answer_placeholder">Outra (digite sua resposta)…</string>
<string name="session_ttl_title">Manter este pareamento por…</string>
<string name="session_ttl_intro">Seu celular se reconectará automaticamente durante este período. Você pode revogar o pareamento a qualquer momento.</string>
<string name="session_ttl_stays_paired">Este dispositivo permanecerá pareado até você revogá-lo.</string>
@@ -3363,6 +3363,7 @@
<string name="card_send_answer_a11y">发送回答</string>
<string name="card_submit">提交</string>
<string name="card_answer_placeholder">输入回答…</string>
<string name="card_other_answer_placeholder">其他(输入自定义回答)…</string>
<string name="session_ttl_title">保持配对的时长…</string>
<string name="session_ttl_intro">在此时间内,手机会自动重新连接。您可以随时撤销配对。</string>
<string name="session_ttl_stays_paired">此设备会保持配对,直到您主动撤销。</string>
+1
View File
@@ -3431,6 +3431,7 @@
<string name="card_send_answer_a11y">Antwort senden</string>
<string name="card_submit">Absenden</string>
<string name="card_answer_placeholder">Antwort eingeben…</string>
<string name="card_other_answer_placeholder">Andere (eigene Antwort eingeben)…</string>
<string name="session_ttl_title">Diese Kopplung behalten für…</string>
<string name="session_ttl_intro">Dein Smartphone verbindet sich in diesem Zeitraum automatisch erneut. Du kannst die Kopplung jederzeit widerrufen.</string>
<string name="session_ttl_stays_paired">Dieses Gerät bleibt gekoppelt, bis du es widerrufst.</string>
+1
View File
@@ -3094,6 +3094,7 @@
<string name="card_send_answer_a11y">Enviar respuesta</string>
<string name="card_submit">Entregar</string>
<string name="card_answer_placeholder">Escribe una respuesta...</string>
<string name="card_other_answer_placeholder">Otra (escribe tu respuesta)…</string>
<string name="session_ttl_title">Mantenga este emparejamiento para...</string>
<string name="session_ttl_intro">Su teléfono se volverá a conectar automáticamente durante esta ventana. Puedes revocar el emparejamiento en cualquier momento.</string>
<string name="session_ttl_stays_paired">Este dispositivo permanecerá emparejado hasta que lo revoques.</string>
+1
View File
@@ -3439,6 +3439,7 @@
<string name="card_send_answer_a11y">回答を送信する</string>
<string name="card_submit">提出する</string>
<string name="card_answer_placeholder">答えを入力してください…</string>
<string name="card_other_answer_placeholder">その他(自由回答を入力)…</string>
<string name="session_ttl_title">このペアリングをしばらく保持してください…</string>
<string name="session_ttl_intro">この期間中に電話は自動的に再接続します。ペアリングはいつでも取り消すことができます。</string>
<string name="session_ttl_stays_paired">このデバイスは、取り消すまでペアリングされたままになります。</string>
+1
View File
@@ -3298,6 +3298,7 @@
<string name="card_send_answer_a11y">Отправить ответ</string>
<string name="card_submit">Отправить</string>
<string name="card_answer_placeholder">Введите ответ…</string>
<string name="card_other_answer_placeholder">Другое (введите свой ответ)…</string>
<string name="session_ttl_title">Сохранить это сопряжение на…</string>
<string name="session_ttl_intro">Ваш телефон будет автоматически переподключаться в течение этого периода. Вы можете отозвать сопряжение в любой момент.</string>
<string name="session_ttl_stays_paired">Это устройство останется сопряженным, пока вы не отзовёте его.</string>
+1
View File
@@ -3754,6 +3754,7 @@
<string name="card_send_answer_a11y">Send answer</string>
<string name="card_submit">Submit</string>
<string name="card_answer_placeholder">Type an answer…</string>
<string name="card_other_answer_placeholder">Other (type your answer)…</string>
<string name="session_ttl_title">Keep this pairing for…</string>
<string name="session_ttl_intro">Your phone will reconnect automatically during this window. You can revoke the pairing at any time.</string>
<string name="session_ttl_stays_paired">This device will stay paired until you revoke it.</string>
@@ -0,0 +1,103 @@
package com.hermesandroid.relay.screenshots
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.test.hasScrollAction
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeUp
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
import com.hermesandroid.relay.data.HermesCard
import com.hermesandroid.relay.data.HermesCardInput
import com.hermesandroid.relay.ui.components.HermesCardBubble
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import java.io.File
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class ClarifyCardScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Test
@Config(qualifiers = "w320dp-h568dp-xhdpi")
fun compactPhoneLongOptions() = capture("01-compact-phone-long-options.png", 1f)
@Test
@Config(qualifiers = "w320dp-h568dp-xhdpi")
fun compactPhoneLargeText() = capture("02-compact-phone-font-1_5.png", 1.5f)
@Test
@Config(qualifiers = "w720dp-h360dp-xhdpi")
fun landscape() = capture("03-landscape-720x360.png", 1f)
@Test
@Config(qualifiers = "w330dp-h720dp-xhdpi")
fun foldablePaneWidth() = capture("04-foldable-narrow-pane.png", 1.3f)
private fun capture(fileName: String, fontScale: Float) {
compose.setContent {
val density = LocalDensity.current
CompositionLocalProvider(
LocalDensity provides Density(density.density, fontScale),
) {
HermesRelayTheme(themePreference = "dark") {
LazyColumn(
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(12.dp),
) {
item {
HermesCardBubble(
card = clarifyCard(),
cardKey = "clarify-render",
dispatches = emptyList(),
onActionTap = { _, _ -> },
onInputSubmit = { _, _ -> },
)
}
}
}
}
}
compose.onNodeWithText("Other (type your answer)…").assertExists()
repeat(4) { compose.onNode(hasScrollAction()).performTouchInput { swipeUp() } }
val evidenceDir = File("build/ui-evidence/clarify-card")
evidenceDir.mkdirs()
compose.onRoot().captureRoboImage(File(evidenceDir, fileName).path)
}
private fun clarifyCard() = HermesCard(
type = HermesCard.BuiltInTypes.ASK_CLARIFY,
title = "Hermes needs clarification",
body = "Which deployment approach should I use for the migration?",
input = HermesCardInput(
kind = HermesCardInput.Kinds.CHOICE,
choices = listOf(
"Migrate everything immediately and accept a short maintenance window",
"Keep both systems running while traffic moves in measured stages",
"Pause until every downstream consumer has been verified",
"Use a reversible canary rollout with automatic rollback thresholds",
),
allowFreeText = true,
),
)
}
@@ -13,7 +13,10 @@ import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.click
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performImeAction
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeDown
import androidx.compose.ui.test.swipeUp
@@ -147,6 +150,74 @@ class HermesApprovalCardInteractionTest {
compose.runOnIdle { assertEquals(listOf("[\"prod\",\"dev\"]"), answers) }
}
@Test
fun `custom clarify answer uses IME send and trims exactly once`() {
val answers = mutableListOf<String>()
compose.setContent {
MaterialTheme {
HermesCardBubble(
card = clarifyCard(),
cardKey = "clarify-custom",
dispatches = emptyList(),
onActionTap = { _, _ -> },
onInputSubmit = { _, value -> answers += value },
)
}
}
val field = compose.onNodeWithContentDescription("Other (type your answer)…")
field.performTextInput(" use a hybrid rollout ")
field.performImeAction()
compose.runOnIdle { assertEquals(listOf("use a hybrid rollout"), answers) }
}
@Test
fun `blank custom clarify answer cannot submit from icon or IME`() {
val answers = mutableListOf<String>()
compose.setContent {
MaterialTheme {
HermesCardBubble(
card = clarifyCard(),
cardKey = "clarify-blank",
dispatches = emptyList(),
onActionTap = { _, _ -> },
onInputSubmit = { _, value -> answers += value },
)
}
}
val field = compose.onNodeWithContentDescription("Other (type your answer)…")
field.performTextInput(" ")
compose.onNodeWithContentDescription("Send answer").assertIsNotEnabled()
field.performImeAction()
compose.runOnIdle { assertEquals(emptyList<String>(), answers) }
}
@Test
fun `multi select IME submission combines choices and custom answer`() {
val answers = mutableListOf<String>()
compose.setContent {
MaterialTheme {
HermesCardBubble(
card = clarifyCard(multiSelect = true),
cardKey = "clarify-multi-custom",
dispatches = emptyList(),
onActionTap = { _, _ -> },
onInputSubmit = { _, value -> answers += value },
)
}
}
compose.onNodeWithText("stage").performTouchInput { click() }
val field = compose.onNodeWithContentDescription("Other (type your answer)…")
field.performTextInput("canary first")
field.performImeAction()
compose.runOnIdle { assertEquals(listOf("[\"stage\",\"canary first\"]"), answers) }
}
private fun approvalCard() = HermesCard(
type = HermesCard.BuiltInTypes.ASK_APPROVAL,
title = "Approval requested",
@@ -173,6 +244,17 @@ class HermesApprovalCardInteractionTest {
id = CARD_KEY,
)
private fun clarifyCard(multiSelect: Boolean = false) = HermesCard(
type = HermesCard.BuiltInTypes.ASK_CLARIFY,
title = "Choose environments",
input = HermesCardInput(
kind = HermesCardInput.Kinds.CHOICE,
choices = listOf("dev", "stage", "prod"),
multiSelect = multiSelect,
allowFreeText = true,
),
)
private companion object {
const val CARD_KEY = "approval-session-a"
}
@@ -11,6 +11,7 @@ import com.hermesandroid.relay.data.ChatTurnCheckpoint
import com.hermesandroid.relay.data.ChatTurnCheckpointStore
import com.hermesandroid.relay.data.ChatTurnToolCheckpoint
import com.hermesandroid.relay.data.ChatTurnUserCheckpoint
import com.hermesandroid.relay.data.HermesCard
import com.hermesandroid.relay.data.HermesCardDispatch
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.Profile
@@ -1049,6 +1050,65 @@ class ChatViewModelGatewayInboundTurnTest {
assertTrue(gatewayHarness.rpcLog.none { it.first == "session.interrupt" })
}
@Test
fun detachedClarifyExpiryCannotRestoreAStaleCardAfterNavigation() {
val secondSession = "stored-session-b"
val contextKey = AgentDisplay.profileContextKey("connection-a", null)
val checkpointStore = MemoryCheckpointStore()
gatewayHarness.resumeLiveSessionIds[secondSession] = "live-b"
viewModel.setChatTurnCheckpointStore(checkpointStore)
viewModel.switchProfileContext(contextKey, STORED_SESSION_ID)
viewModel.sendMessage("Ask before continuing")
gatewayHarness.awaitRpc("prompt.submit")
serverWs.send(
gatewayHarness.eventFrame(
"clarify.request",
buildJsonObject {
put("request_id", "clarify-detached")
put("question", "Which rollout?")
put("choices", buildJsonArray {
add(JsonPrimitive("canary"))
add(JsonPrimitive("all at once"))
})
},
"live-resumed",
),
)
awaitCondition { viewModel.pendingAsk.value?.ask?.requestId == "clarify-detached" }
viewModel.switchSession(secondSession)
gatewayHarness.awaitRpcCount("session.resume", 2)
awaitCondition { handler.currentSessionId.value == secondSession }
awaitCondition { checkpointStore.checkpoint?.pendingAsk?.requestId == "clarify-detached" }
serverWs.send(
gatewayHarness.eventFrame(
"clarify.expire",
buildJsonObject { put("request_id", "clarify-detached") },
"live-resumed",
),
)
awaitCondition {
checkpointStore.checkpoint != null && checkpointStore.checkpoint?.pendingAsk == null
}
gatewayHarness.recoveryRunning = true
gatewayHarness.recoveryAssistant = "Waiting for rollout choice"
viewModel.switchSession(STORED_SESSION_ID)
gatewayHarness.awaitRpc("session.activate")
awaitCondition {
handler.currentSessionId.value == STORED_SESSION_ID && handler.isStreaming.value
}
assertNull(viewModel.pendingAsk.value)
assertTrue(
handler.messages.value.flatMap { it.cards }
.none { it.type == HermesCard.BuiltInTypes.ASK_CLARIFY },
)
assertTrue(gatewayHarness.rpcLog.none { it.first == "clarify.respond" })
}
@Test
fun stopOnUnsolicitedTurnInterruptsTheGatewaySession() {
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
+6 -6
View File
@@ -13,7 +13,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
"main": "f300cbaf71e3600130e51120b76272fbcdd5ea1d073c95c684d054ced457747d",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -48,7 +48,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
"main": "f300cbaf71e3600130e51120b76272fbcdd5ea1d073c95c684d054ced457747d",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -72,7 +72,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
"main": "f300cbaf71e3600130e51120b76272fbcdd5ea1d073c95c684d054ced457747d",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -96,7 +96,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
"main": "f300cbaf71e3600130e51120b76272fbcdd5ea1d073c95c684d054ced457747d",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -120,7 +120,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
"main": "f300cbaf71e3600130e51120b76272fbcdd5ea1d073c95c684d054ced457747d",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -135,7 +135,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
"main": "f300cbaf71e3600130e51120b76272fbcdd5ea1d073c95c684d054ced457747d",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
+1 -1
View File
@@ -31,7 +31,7 @@ play-publisher = "4.1.1"
media3 = "1.11.0"
androidVad = "2.0.10"
sherpaOnnx = "v1.13.4"
onnxRuntime = "1.27.0"
onnxRuntime = "1.29.0"
spatialsdk = "0.13.2"
play-app-update = "2.1.0"
-306
View File
@@ -1,306 +0,0 @@
#!/usr/bin/env python3
"""Verify that packaged Android JNI consumers match their shared ONNX Runtime.
The APK is the authority for this check. Gradle can resolve multiple AARs that
contain the same native filename, and ``pickFirst`` alone does not prove that
the selected runtime exports the symbol version required by sherpa's JNI.
"""
from __future__ import annotations
import argparse
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
import re
import struct
import sys
import zipfile
ORT_ENTRY_POINT = "OrtGetApiBase"
RUNTIME_LIBRARY = "libonnxruntime.so"
REQUIRED_CONSUMER = "libsherpa-onnx-jni.so"
ORT_JAVA_CONSUMER = "libonnxruntime4j_jni.so"
SUPPORTED_ABIS = {"arm64-v8a", "armeabi-v7a", "x86", "x86_64"}
LIBRARY_PATH = re.compile(
r"^(?:base/)?lib/(?P<abi>[^/]+)/(?P<library>[^/]+\.so)$"
)
@dataclass(frozen=True)
class Section:
section_type: int
offset: int
size: int
link: int
entry_size: int
@dataclass(frozen=True)
class VersionedSymbol:
defined: bool
version: str | None
def _unpack(fmt: str, data: bytes, offset: int) -> tuple[int, ...]:
size = struct.calcsize(fmt)
if offset < 0 or offset + size > len(data):
raise ValueError("ELF structure extends beyond the file")
return struct.unpack_from(fmt, data, offset)
def _cstring(data: bytes, offset: int) -> str:
if offset < 0 or offset >= len(data):
raise ValueError("ELF string offset is outside its string table")
end = data.find(b"\0", offset)
if end < 0:
raise ValueError("ELF string is not NUL-terminated")
return data[offset:end].decode("utf-8", errors="replace")
def _elf_layout(data: bytes) -> tuple[str, bool, list[Section]]:
if len(data) < 16 or data[:4] != b"\x7fELF":
raise ValueError("not an ELF file")
elf_class = data[4]
byte_order = data[5]
if elf_class not in (1, 2) or byte_order not in (1, 2):
raise ValueError("unsupported ELF class or byte order")
endian = "<" if byte_order == 1 else ">"
is_64_bit = elf_class == 2
if is_64_bit:
header = _unpack(endian + "HHIQQQIHHHHHH", data, 16)
section_offset, section_entry_size, section_count = header[5], header[10], header[11]
section_format = endian + "IIQQQQIIQQ"
else:
header = _unpack(endian + "HHIIIIIHHHHHH", data, 16)
section_offset, section_entry_size, section_count = header[5], header[10], header[11]
section_format = endian + "IIIIIIIIII"
minimum_entry_size = struct.calcsize(section_format)
if section_entry_size < minimum_entry_size:
raise ValueError("ELF section-header entry is too small")
sections: list[Section] = []
for index in range(section_count):
fields = _unpack(section_format, data, section_offset + index * section_entry_size)
sections.append(
Section(
section_type=fields[1],
offset=fields[4],
size=fields[5],
link=fields[6],
entry_size=fields[9],
)
)
return endian, is_64_bit, sections
def _section_data(data: bytes, section: Section) -> bytes:
end = section.offset + section.size
if section.offset < 0 or end > len(data):
raise ValueError("ELF section extends beyond the file")
return data[section.offset:end]
def _version_names(
data: bytes,
endian: str,
sections: list[Section],
) -> dict[int, str]:
names: dict[int, str] = {}
for section in sections:
if section.section_type not in (0x6FFFFFFD, 0x6FFFFFFE):
continue
if section.link >= len(sections):
raise ValueError("ELF version section has an invalid string-table link")
strings = _section_data(data, sections[section.link])
cursor = 0
while cursor < section.size:
base = section.offset + cursor
if section.section_type == 0x6FFFFFFD: # SHT_GNU_verdef
fields = _unpack(endian + "HHHHIII", data, base)
version_index, aux_offset, next_offset = fields[2], fields[5], fields[6]
name_offset, _ = _unpack(endian + "II", data, base + aux_offset)
names[version_index] = _cstring(strings, name_offset)
else: # SHT_GNU_verneed
fields = _unpack(endian + "HHIII", data, base)
count, aux_offset, next_offset = fields[1], fields[3], fields[4]
aux_cursor = base + aux_offset
for _ in range(count):
aux = _unpack(endian + "IHHII", data, aux_cursor)
names[aux[2] & 0x7FFF] = _cstring(strings, aux[3])
if aux[4] == 0:
break
aux_cursor += aux[4]
if next_offset == 0:
break
cursor += next_offset
return names
def read_versioned_symbols(data: bytes) -> dict[str, list[VersionedSymbol]]:
endian, is_64_bit, sections = _elf_layout(data)
version_names = _version_names(data, endian, sections)
symbols: dict[str, list[VersionedSymbol]] = defaultdict(list)
for dynsym_index, dynsym in enumerate(sections):
if dynsym.section_type != 11: # SHT_DYNSYM
continue
if dynsym.link >= len(sections):
raise ValueError("ELF dynamic-symbol table has an invalid string-table link")
strings = _section_data(data, sections[dynsym.link])
symbol_format = endian + ("IBBHQQ" if is_64_bit else "IIIBBH")
symbol_size = dynsym.entry_size or struct.calcsize(symbol_format)
symbol_count = dynsym.size // symbol_size
versions: tuple[int, ...] = ()
for section in sections:
if section.section_type == 0x6FFFFFFF and section.link == dynsym_index:
raw_versions = _section_data(data, section)
versions = struct.unpack(endian + f"{len(raw_versions) // 2}H", raw_versions)
break
for index in range(symbol_count):
fields = _unpack(symbol_format, data, dynsym.offset + index * symbol_size)
name_offset = fields[0]
section_index = fields[3] if is_64_bit else fields[5]
name = _cstring(strings, name_offset)
if not name:
continue
version_index = (versions[index] & 0x7FFF) if index < len(versions) else 0
symbols[name].append(
VersionedSymbol(
defined=section_index != 0,
version=version_names.get(version_index),
)
)
return symbols
def _single_symbol_version(
blob: bytes,
*,
defined: bool,
context: str,
) -> str:
matches = [
symbol.version
for symbol in read_versioned_symbols(blob).get(ORT_ENTRY_POINT, [])
if symbol.defined == defined
]
if not matches:
role = "export" if defined else "requirement"
raise ValueError(f"{context} has no {ORT_ENTRY_POINT} {role}")
versions = set(matches)
if None in versions:
raise ValueError(f"{context} uses an unversioned {ORT_ENTRY_POINT} symbol")
if len(versions) != 1:
raise ValueError(f"{context} has ambiguous {ORT_ENTRY_POINT} versions: {sorted(versions)}")
return next(iter(versions)) # type: ignore[return-value]
def check_artifact(path: Path, expected_abis: set[str] | None = None) -> list[str]:
failures: list[str] = []
with zipfile.ZipFile(path) as archive:
libraries: dict[str, dict[str, list[zipfile.ZipInfo]]] = defaultdict(
lambda: defaultdict(list)
)
for info in archive.infolist():
match = LIBRARY_PATH.fullmatch(info.filename)
if match:
libraries[match.group("abi")][match.group("library")].append(info)
if not libraries:
return [f"{path}: no packaged native libraries found"]
expected = SUPPORTED_ABIS if expected_abis is None else expected_abis
actual = set(libraries)
if actual != expected:
failures.append(
f"{path.name}: packaged ABI set is {sorted(actual)}, expected {sorted(expected)}"
)
for abi, by_name in sorted(libraries.items()):
for required in (RUNTIME_LIBRARY, REQUIRED_CONSUMER, ORT_JAVA_CONSUMER):
count = len(by_name.get(required, []))
if count != 1:
failures.append(
f"{path.name} [{abi}]: expected exactly one {required}, found {count}"
)
if failures and (
len(by_name.get(RUNTIME_LIBRARY, [])) != 1
or len(by_name.get(REQUIRED_CONSUMER, [])) != 1
or len(by_name.get(ORT_JAVA_CONSUMER, [])) != 1
):
continue
try:
runtime_version = _single_symbol_version(
archive.read(by_name[RUNTIME_LIBRARY][0]),
defined=True,
context=f"{path.name} [{abi}] {RUNTIME_LIBRARY}",
)
for consumer in (REQUIRED_CONSUMER, ORT_JAVA_CONSUMER):
consumer_version = _single_symbol_version(
archive.read(by_name[consumer][0]),
defined=False,
context=f"{path.name} [{abi}] {consumer}",
)
if runtime_version != consumer_version:
failures.append(
f"{path.name} [{abi}]: {consumer} requires "
f"{ORT_ENTRY_POINT}@{consumer_version}, but {RUNTIME_LIBRARY} exports "
f"{ORT_ENTRY_POINT}@{runtime_version}"
)
except ValueError as error:
failures.append(str(error))
return failures
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"artifact",
nargs="+",
type=Path,
help="APK or AAB artifact to inspect.",
)
parser.add_argument(
"--expected-abi",
action="append",
default=[],
help=(
"Expected packaged ABI; repeat to override the standard four-ABI set "
"for a deliberate -Phermes.devAbi build."
),
)
args = parser.parse_args()
failures: list[str] = []
for artifact in args.artifact:
if not artifact.is_file():
failures.append(f"artifact does not exist: {artifact}")
continue
try:
expected_abis = set(args.expected_abi) or SUPPORTED_ABIS
failures.extend(check_artifact(artifact, expected_abis))
except (OSError, ValueError, zipfile.BadZipFile) as error:
failures.append(f"{artifact}: {error}")
if failures:
print("Android native compatibility check failed:", file=sys.stderr)
for failure in failures:
print(f" {failure}", file=sys.stderr)
return 1
print(
"Android native compatibility check passed "
f"({len(args.artifact)} artifact(s), {ORT_ENTRY_POINT} symbol versions aligned)"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,164 +0,0 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
import struct
import sys
import tempfile
import unittest
import zipfile
SCRIPT = Path(__file__).resolve().parents[1] / "check-android-native-compat.py"
SPEC = importlib.util.spec_from_file_location("check_android_native_compat", SCRIPT)
assert SPEC is not None and SPEC.loader is not None
native_compat = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = native_compat
SPEC.loader.exec_module(native_compat)
def elf_with_ort_symbol(*, defined: bool, version: str) -> bytes:
"""Build a minimal ELF64 containing a versioned OrtGetApiBase dynsym."""
symbol_name = b"OrtGetApiBase"
version_name = version.encode("ascii")
dependency_name = b"libonnxruntime.so"
strings = b"\0" + symbol_name + b"\0" + version_name + b"\0" + dependency_name + b"\0"
symbol_offset = 1
version_offset = symbol_offset + len(symbol_name) + 1
dependency_offset = version_offset + len(version_name) + 1
dynsym = b"\0" * 24 + struct.pack(
"<IBBHQQ",
symbol_offset,
0x12,
0,
1 if defined else 0,
0,
0,
)
versym = struct.pack("<HH", 0, 2)
if defined:
version_section_type = 0x6FFFFFFD
version_data = struct.pack("<HHHHIII", 1, 0, 2, 1, 0, 20, 0)
version_data += struct.pack("<II", version_offset, 0)
else:
version_section_type = 0x6FFFFFFE
version_data = struct.pack("<HHIII", 1, 1, dependency_offset, 16, 0)
version_data += struct.pack("<IHHII", 0, 0, 2, version_offset, 0)
section_blobs = [b"", strings, dynsym, versym, version_data]
offsets: list[int] = []
image = bytearray(b"\0" * 64)
for blob in section_blobs:
while len(image) % 8:
image.append(0)
offsets.append(len(image))
image.extend(blob)
while len(image) % 8:
image.append(0)
section_header_offset = len(image)
section_headers = [
(0, 0, 0, 0, offsets[0], 0, 0, 0, 0, 0),
(0, 3, 0, 0, offsets[1], len(strings), 0, 0, 1, 0),
(0, 11, 0, 0, offsets[2], len(dynsym), 1, 0, 8, 24),
(0, 0x6FFFFFFF, 0, 0, offsets[3], len(versym), 2, 0, 2, 2),
(0, version_section_type, 0, 0, offsets[4], len(version_data), 1, 0, 4, 0),
]
for header in section_headers:
image.extend(struct.pack("<IIQQQQIIQQ", *header))
ident = b"\x7fELF" + bytes((2, 1, 1, 0)) + b"\0" * 8
header = struct.pack(
"<16sHHIQQQIHHHHHH",
ident,
3,
183,
1,
0,
0,
section_header_offset,
0,
64,
0,
0,
64,
len(section_headers),
0,
)
image[:64] = header
return bytes(image)
def write_artifact(path: Path, *, runtime_version: str, abis: set[str]) -> None:
with zipfile.ZipFile(path, "w") as archive:
for abi in abis:
prefix = f"lib/{abi}/"
archive.writestr(
prefix + native_compat.RUNTIME_LIBRARY,
elf_with_ort_symbol(defined=True, version=runtime_version),
)
for consumer in (
native_compat.REQUIRED_CONSUMER,
native_compat.ORT_JAVA_CONSUMER,
):
archive.writestr(
prefix + consumer,
elf_with_ort_symbol(defined=False, version="VERS_1.27.0"),
)
class AndroidNativeCompatTest(unittest.TestCase):
def test_parses_gnu_definition_and_requirement_versions(self) -> None:
provider = native_compat.read_versioned_symbols(
elf_with_ort_symbol(defined=True, version="VERS_1.27.0")
)
consumer = native_compat.read_versioned_symbols(
elf_with_ort_symbol(defined=False, version="VERS_1.27.0")
)
self.assertEqual(
[native_compat.VersionedSymbol(defined=True, version="VERS_1.27.0")],
provider[native_compat.ORT_ENTRY_POINT],
)
self.assertEqual(
[native_compat.VersionedSymbol(defined=False, version="VERS_1.27.0")],
consumer[native_compat.ORT_ENTRY_POINT],
)
def test_accepts_aligned_runtime_and_both_consumers(self) -> None:
with tempfile.TemporaryDirectory() as directory:
artifact = Path(directory) / "aligned.apk"
write_artifact(
artifact,
runtime_version="VERS_1.27.0",
abis=native_compat.SUPPORTED_ABIS,
)
self.assertEqual([], native_compat.check_artifact(artifact))
def test_rejects_runtime_symbol_version_mismatch(self) -> None:
with tempfile.TemporaryDirectory() as directory:
artifact = Path(directory) / "mismatch.apk"
write_artifact(
artifact,
runtime_version="VERS_1.29.0",
abis=native_compat.SUPPORTED_ABIS,
)
failures = native_compat.check_artifact(artifact)
self.assertTrue(any("requires OrtGetApiBase@VERS_1.27.0" in item for item in failures))
self.assertTrue(any(native_compat.ORT_JAVA_CONSUMER in item for item in failures))
def test_rejects_missing_supported_abi(self) -> None:
with tempfile.TemporaryDirectory() as directory:
artifact = Path(directory) / "missing-abi.apk"
write_artifact(
artifact,
runtime_version="VERS_1.27.0",
abis={"arm64-v8a"},
)
failures = native_compat.check_artifact(artifact)
self.assertTrue(any("packaged ABI set" in item for item in failures))
if __name__ == "__main__":
unittest.main()