Compare commits

..
Author SHA1 Message Date
Bailey Dixon 76ead50c60 fix(android): remove provisional threads safely 2026-08-28 22:08:47 -04:00
31 changed files with 526 additions and 556 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 provisional Threads can be removed without touching server history.** The drawer now offers a local-only removal action, reconciles promoted phone sessions without duplicate rows, and keeps Thread routing isolated to the active saved connection.
- **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
@@ -38,6 +38,8 @@ data class ProactiveInboxEntry(
val connectionId: String? = null,
/** Relay proved this row came from its bounded offline queue. */
val arrivedWhileAway: Boolean = false,
/** Exact Android notification slot, when recorded by the receiving build. */
val notificationId: Int? = null,
)
private val Context.proactiveInboxStore: DataStore<Preferences> by
@@ -58,15 +60,19 @@ private const val MAX_ENTRIES = 100
* bounded store also backs the provisional Thread until the user's first reply
* promotes it to a real `source=phone` session.
*/
class ProactiveInboxRepository(private val context: Context) {
class ProactiveInboxRepository internal constructor(
private val store: DataStore<Preferences>,
) {
constructor(context: Context) : this(context.proactiveInboxStore)
private val json = Json { ignoreUnknownKeys = true }
val entries: Flow<List<ProactiveInboxEntry>> =
context.proactiveInboxStore.data.map { prefs -> decode(prefs[INBOX_JSON]) }
store.data.map { prefs -> decode(prefs[INBOX_JSON]) }
suspend fun add(entry: ProactiveInboxEntry) {
context.proactiveInboxStore.edit { prefs ->
store.edit { prefs ->
val current = decode(prefs[INBOX_JSON]).toMutableList()
current.removeAll { it.id == entry.id }
current.add(0, entry)
@@ -76,7 +82,40 @@ class ProactiveInboxRepository(private val context: Context) {
}
suspend fun clear() {
context.proactiveInboxStore.edit { it.remove(INBOX_JSON) }
store.edit { it.remove(INBOX_JSON) }
}
/**
* Remove one provisional Thread owned by one saved connection.
*
* This only edits the bounded local inbox. A promoted Thread is server
* history and is deliberately outside this repository, so this operation
* can never delete it. Legacy entries without a connection owner are
* removed with the active row because they are rendered in that row; rows
* explicitly owned by another connection remain isolated.
*/
suspend fun removeThread(
chatId: String,
connectionId: String,
): List<ProactiveInboxEntry> {
val normalizedChatId = chatId.ifBlank { "phone" }
var removed = emptyList<ProactiveInboxEntry>()
store.edit { prefs ->
val current = decode(prefs[INBOX_JSON])
removed = current.filter {
(it.connectionId == null || it.connectionId == connectionId) &&
(it.chatId ?: "phone") == normalizedChatId
}
if (removed.isNotEmpty()) {
val retained = current.filterNot { it in removed }
if (retained.isEmpty()) {
prefs.remove(INBOX_JSON)
} else {
prefs[INBOX_JSON] = json.encodeToString(retained)
}
}
}
return removed
}
private fun decode(raw: String?): List<ProactiveInboxEntry> {
@@ -102,22 +102,18 @@ class ProactiveMessageHandler(
/** Route a parsed message: into the open Thread if it belongs there, else
* the durable inbox log + the surface its hint selects. */
private fun dispatch(msg: ProactiveMessage) {
// Persist first even when the currently open Thread consumes the live
// message. Agent-initiated outbound sends do not create a gateway
// session until the phone replies, so this cache is the provisional
// Thread transcript during that gap.
toInbox?.invoke(msg)
// The surfacing hint selects the additional surface. Thread injection
// is best-effort presentation of the persisted row, not itself a reason
// to suppress an explicitly requested notification.
when (msg.surfacing?.lowercase()) {
val notificationId = when (msg.surfacing?.lowercase()) {
"inbox" -> {
injectIntoThread?.invoke(msg)
null
}
"session" -> {
val delivered = injectIntoThread?.invoke(msg) == true ||
toSession?.invoke(msg) == true
if (!delivered) notify(msg)
if (delivered) null else notify(msg)
}
// null / "default" / "notification" / anything unrecognized.
else -> {
@@ -125,9 +121,13 @@ class ProactiveMessageHandler(
notify(msg)
}
}
// Every message remains in the bounded local cache. Persist the exact
// posted notification slot as part of that row so a later local Thread
// removal can cancel only its own notification.
toInbox?.invoke(msg.copy(notificationId = notificationId))
}
private fun notify(msg: ProactiveMessage) {
private fun notify(msg: ProactiveMessage): Int? =
ProactiveMessageNotifier.notify(
context = context,
title = msg.title,
@@ -135,7 +135,6 @@ class ProactiveMessageHandler(
messageId = msg.messageId,
chatId = msg.chatId,
)
}
private fun parse(payload: JsonObject): ProactiveMessage? {
val text = payload["text"]?.jsonPrimitive?.contentOrNull
@@ -172,4 +171,6 @@ data class ProactiveMessage(
val replyTo: String? = null,
/** True only when Relay explicitly marked this as a reconnect queue flush. */
val arrivedWhileAway: Boolean = false,
/** Exact Android notification slot when this delivery posted one. */
val notificationId: Int? = null,
)
@@ -74,13 +74,13 @@ object ProactiveMessageNotifier {
text: String,
messageId: String?,
chatId: String?,
) {
): Int? {
ensureChannel(context)
if (!hasPostNotificationsPermission(context)) {
Log.i(TAG, "POST_NOTIFICATIONS not granted — skipping proactive notification")
return
return null
}
if (text.isBlank()) return
if (text.isBlank()) return null
val tapIntent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
@@ -89,7 +89,7 @@ object ProactiveMessageNotifier {
val pendingFlags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
// Distinct requestCode per slot so each notification gets its own
// PendingIntent rather than all sharing slot 0's intent.
val notificationId = slotFor(messageId)
val notificationId = slotFor(messageId, chatId)
val tapPending =
PendingIntent.getActivity(context, notificationId, tapIntent, pendingFlags)
@@ -108,9 +108,15 @@ object ProactiveMessageNotifier {
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setPriority(NotificationCompat.PRIORITY_HIGH)
runCatching {
return runCatching {
NotificationManagerCompat.from(context).notify(notificationId, builder.build())
}.onFailure { Log.w(TAG, "notify failed", it) }
notificationId
}.onFailure { Log.w(TAG, "notify failed", it) }.getOrNull()
}
/** Cancel one exact slot previously returned by [notificationIdFor]. */
fun cancel(context: Context, notificationId: Int) {
NotificationManagerCompat.from(context).cancel(notificationId)
}
/**
@@ -206,8 +212,12 @@ object ProactiveMessageNotifier {
}
/** Derive a stable notification slot from the message id. */
private fun slotFor(messageId: String?): Int {
val key = messageId?.takeIf { it.isNotBlank() } ?: return ID_BASE
internal fun notificationIdFor(messageId: String?, chatId: String?): Int =
slotFor(messageId, chatId)
private fun slotFor(messageId: String?, chatId: String?): Int {
val key = messageId?.takeIf { it.isNotBlank() }
?: "chat:${chatId?.takeIf { it.isNotBlank() } ?: "phone"}"
// Keep within a small positive window above the base so re-delivery of
// the same id collapses to one slot and distinct ids spread out.
return ID_BASE + (key.hashCode() and 0xFFFF)
@@ -2217,9 +2217,12 @@ fun RelayApp() {
(it.connectionId == null || it.connectionId == activeConnectionId) &&
(it.chatId ?: "phone") == chatId
}
if (entries.isEmpty()) return@LaunchedEffect
chatViewModel.openProactiveThread(chatId, entries)
if (entries.isNotEmpty()) {
chatViewModel.openProactiveThread(chatId, entries)
}
}
// Consume the request even when deletion removed its
// local row before a stale notification tap arrived.
backStackEntry.arguments?.putString(
Screen.Chat.ARG_PROACTIVE_CHAT_ID,
null,
@@ -237,6 +237,8 @@ fun SessionDrawerContent(
onNewThread: ((String) -> Unit)? = null,
provisionalThreads: List<ProvisionalThreadRow> = emptyList(),
onSelectProvisionalThread: ((String) -> Unit)? = null,
/** Deletes only the local provisional inbox row; never a server session. */
onDeleteProvisionalThread: ((String) -> Unit)? = null,
/** Gateway sources currently hidden from the drawer (default: cron+webhook). */
hiddenSources: Set<String> = emptySet(),
/** Toggle a source's visibility (persisted). Null hides the source filter. */
@@ -789,13 +791,17 @@ fun SessionDrawerContent(
showTokens = viewOptions.showTokens,
showCost = viewOptions.showCost,
nowMillis = drawerNowMillis,
actionsEnabled = !provisional && (
actionsEnabled = if (provisional) {
onDeleteProvisionalThread != null &&
supervisedSessionActions?.delete != false
} else {
supervisedSessionActions == null ||
supervisedSessionActions.pin ||
supervisedSessionActions.rename ||
supervisedSessionActions.delete ||
(supervisedSessionActions.archive && archiveSupported)
),
},
provisional = provisional,
isActive = !showAllProfiles && session.sessionId == currentSessionId,
activityState = activityState,
animationEnabled = animationEnabled && isOpen,
@@ -934,15 +940,38 @@ fun SessionDrawerContent(
// Delete confirmation dialog
deleteDialogTarget?.let { (row, allProfiles) ->
val session = row.session
val provisional = session.sessionId.startsWith(PROVISIONAL_THREAD_PREFIX)
AlertDialog(
onDismissRequest = { deleteDialogTarget = null },
title = { Text(stringResource(R.string.drawer_delete_session_title)) },
title = {
Text(
stringResource(
if (provisional) {
R.string.drawer_remove_provisional_thread_title
} else {
R.string.drawer_delete_session_title
},
),
)
},
text = {
Text(stringResource(R.string.drawer_delete_session_prefix) + (session.title ?: stringResource(R.string.drawer_untitled)) + stringResource(R.string.drawer_delete_session_suffix))
val title = session.title ?: stringResource(R.string.drawer_untitled)
Text(
if (provisional) {
stringResource(R.string.drawer_remove_provisional_thread_message, title)
} else {
stringResource(R.string.drawer_delete_session_prefix) + title +
stringResource(R.string.drawer_delete_session_suffix)
},
)
},
confirmButton = {
TextButton(onClick = {
if (allProfiles) {
if (session.sessionId.startsWith(PROVISIONAL_THREAD_PREFIX)) {
onDeleteProvisionalThread?.invoke(
session.sessionId.removePrefix(PROVISIONAL_THREAD_PREFIX),
)
} else if (allProfiles) {
onDeleteProfileSession?.invoke(row.profile, session.sessionId)
} else {
onDeleteSession(session.sessionId)
@@ -1358,6 +1387,7 @@ private fun SessionItem(
showCost: Boolean,
nowMillis: Long,
actionsEnabled: Boolean,
provisional: Boolean,
isActive: Boolean,
activityState: SessionActivityState?,
animationEnabled: Boolean,
@@ -1528,7 +1558,7 @@ private fun SessionItem(
expanded = menuOpen,
onDismissRequest = { menuOpen = false },
) {
if (supervisedSessionActions?.pin != false) DropdownMenuItem(
if (!provisional && supervisedSessionActions?.pin != false) DropdownMenuItem(
text = {
Text(
if (pinned) {
@@ -1554,7 +1584,7 @@ private fun SessionItem(
onTogglePinned()
},
)
if (supervisedSessionActions == null) DropdownMenuItem(
if (!provisional && supervisedSessionActions == null) DropdownMenuItem(
text = { Text(stringResource(R.string.chat_copy_session_id)) },
leadingIcon = {
Icon(Icons.Filled.ContentCopy, contentDescription = null)
@@ -1564,7 +1594,7 @@ private fun SessionItem(
onCopySessionId()
},
)
if (supervisedSessionActions?.rename != false) DropdownMenuItem(
if (!provisional && supervisedSessionActions?.rename != false) DropdownMenuItem(
text = { Text(stringResource(R.string.drawer_rename)) },
leadingIcon = {
Icon(Icons.Filled.Edit, contentDescription = null)
@@ -1574,7 +1604,7 @@ private fun SessionItem(
onRename()
},
)
if (archiveSupported && supervisedSessionActions?.archive != false) {
if (!provisional && archiveSupported && supervisedSessionActions?.archive != false) {
DropdownMenuItem(
text = { Text(if (archived) stringResource(R.string.drawer_restore) else stringResource(R.string.drawer_archive)) },
leadingIcon = {
@@ -2431,6 +2431,26 @@ fun ChatScreen(
activeConnectionId = activeConnection?.id,
realThreadChatIds = phoneThreadChatIds.values,
)
val realPhoneSessionIds = remember(sessions) {
sessions.asSequence()
.filter { it.source.equals("phone", ignoreCase = true) }
.map { it.sessionId }
.toSet()
}
val provisionalThreadChatIds = provisionalThreadEntries.keys
LaunchedEffect(
activeConnection?.id,
realPhoneSessionIds,
provisionalThreadChatIds,
) {
// A reply promotes the local provisional row to a real Gateway
// source=phone session. Refresh the relay-owned chat_id index at
// that boundary so the local duplicate disappears immediately,
// without guessing a chat_id from the opaque session id.
if (realPhoneSessionIds.isNotEmpty() && provisionalThreadChatIds.isNotEmpty()) {
connectionViewModel.refreshPhoneThreadChatIds()
}
}
val provisionalThreads = provisionalThreadEntries.map { (chatId, entries) ->
val latest = entries.maxBy { it.receivedAt }
ProvisionalThreadRow(
@@ -2549,6 +2569,11 @@ fun ChatScreen(
)
scope.launch { drawerState.close() }
},
onDeleteProvisionalThread = { chatId ->
activeConnection?.id?.let { connectionId ->
connectionViewModel.removeProvisionalThread(chatId, connectionId)
}
},
hiddenSources = hiddenSources,
onToggleSourceHidden = { source, hidden ->
connectionViewModel.setSourceHidden(source, hidden)
@@ -729,6 +729,7 @@ class ChatViewModel : ViewModel() {
* including one this app didn't create, or any Thread after a restart.
*/
fun seedThreadChatIds(map: Map<String, String>) {
threadChatIds.clear()
threadChatIds.putAll(map)
}
@@ -116,6 +116,7 @@ import com.hermesandroid.relay.accessibility.BridgeStatusReporter
import com.hermesandroid.relay.accessibility.ScreenCapture
import com.hermesandroid.relay.network.relay.BridgeCommandHandler
import com.hermesandroid.relay.network.relay.ProactiveMessageHandler
import com.hermesandroid.relay.notifications.ProactiveMessageNotifier
import com.hermesandroid.relay.network.relay.models.Envelope
// === END PHASE3-accessibility ===
import com.hermesandroid.relay.util.AppForegroundTracker
@@ -164,6 +165,33 @@ internal data class RelayUiInputs(
val configured: Boolean,
)
internal data class PhoneThreadChatIdIndex(
val connectionId: String? = null,
val values: Map<String, String> = emptyMap(),
)
internal fun visiblePhoneThreadChatIds(
activeConnectionId: String?,
index: PhoneThreadChatIdIndex,
): Map<String, String> =
index.values.takeIf { index.connectionId == activeConnectionId }.orEmpty()
internal fun reconcilePhoneThreadChatIdIndex(
current: PhoneThreadChatIdIndex,
requestedConnectionId: String,
activeConnectionId: String?,
fetched: Result<Map<String, String>>,
): PhoneThreadChatIdIndex = fetched.fold(
onSuccess = { values ->
if (requestedConnectionId == activeConnectionId) {
PhoneThreadChatIdIndex(requestedConnectionId, values)
} else {
current
}
},
onFailure = { current },
)
data class HostResourcePressureStatus(
val memoryPressure: String? = null,
val memoryAvailableMb: Int? = null,
@@ -2568,6 +2596,18 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
val inboxMessages: StateFlow<List<ProactiveInboxEntry>> =
proactiveInbox.entries.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
/** Delete one connection-scoped provisional Thread from local storage only. */
fun removeProvisionalThread(chatId: String, connectionId: String) {
viewModelScope.launch {
proactiveInbox.removeThread(chatId = chatId, connectionId = connectionId)
.mapNotNull(ProactiveInboxEntry::notificationId)
.distinct()
.forEach { notificationId ->
ProactiveMessageNotifier.cancel(getApplication(), notificationId)
}
}
}
// The handler centralizes surfacing (notification / inbox / session). The
// inbox sink persists messages here; the session sink lands in Phase 2b.
val proactiveMessageHandler = ProactiveMessageHandler(
@@ -2583,6 +2623,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
chatId = msg.chatId,
connectionId = connectionStore.activeConnectionId.value,
arrivedWhileAway = msg.arrivedWhileAway,
notificationId = msg.notificationId,
),
)
}
@@ -2620,16 +2661,30 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
// composer's reply routing so a Thread the app didn't create — or any Thread
// after restart — routes to the right conversation. Fail-soft: empty on an
// older relay / fetch error, and the client's learned map still applies.
private val _phoneThreadChatIds = MutableStateFlow<Map<String, String>>(emptyMap())
val phoneThreadChatIds: StateFlow<Map<String, String>> = _phoneThreadChatIds.asStateFlow()
private val _phoneThreadChatIdIndex = MutableStateFlow(PhoneThreadChatIdIndex())
private val phoneThreadChatIdRefreshMutex = Mutex()
val phoneThreadChatIds: StateFlow<Map<String, String>> = combine(
connectionStore.activeConnectionId,
_phoneThreadChatIdIndex,
) { activeConnectionId, index ->
visiblePhoneThreadChatIds(activeConnectionId, index)
}.stateIn(viewModelScope, SharingStarted.Eagerly, emptyMap())
fun refreshPhoneThreadChatIds() {
val connectionId = connectionStore.activeConnectionId.value ?: return
viewModelScope.launch {
relayHttpClient.fetchPhoneThreads().onSuccess { threads ->
val map = threads
.filter { it.sessionId.isNotBlank() && it.chatId.isNotBlank() }
.associate { it.sessionId to it.chatId }
if (map.isNotEmpty()) _phoneThreadChatIds.value = map
phoneThreadChatIdRefreshMutex.withLock {
val fetched = relayHttpClient.fetchPhoneThreads().map { threads ->
threads
.filter { it.sessionId.isNotBlank() && it.chatId.isNotBlank() }
.associate { it.sessionId to it.chatId }
}
_phoneThreadChatIdIndex.value = reconcilePhoneThreadChatIdIndex(
current = _phoneThreadChatIdIndex.value,
requestedConnectionId = connectionId,
activeConnectionId = connectionStore.activeConnectionId.value,
fetched = fetched,
)
}
}
}
@@ -916,6 +916,8 @@
<string name="drawer_delete_session_title">Excluir sessão?</string>
<string name="drawer_delete_session_prefix">Isso excluirá permanentemente \"</string>
<string name="drawer_delete_session_suffix">\" e o histórico de mensagens.</string>
<string name="drawer_remove_provisional_thread_title">Remover Thread?</string>
<string name="drawer_remove_provisional_thread_message">Isso remove \"%1$s\" deste dispositivo. O histórico promovido ou armazenado no servidor não será excluído.</string>
<string name="drawer_untitled">Sem título</string>
<string name="drawer_delete">Excluir</string>
<string name="drawer_thread">Thread</string>
@@ -960,6 +960,8 @@
<string name="drawer_delete_session_title">删除会话?</string>
<string name="drawer_delete_session_prefix">这将永久删除\"</string>
<string name="drawer_delete_session_suffix">\"及其消息历史。</string>
<string name="drawer_remove_provisional_thread_title">移除话题?</string>
<string name="drawer_remove_provisional_thread_message">这会从此设备移除“%1$s”,不会删除已提升或服务器端的历史记录。</string>
<string name="drawer_untitled">未命名</string>
<string name="drawer_delete">删除</string>
<string name="drawer_thread">话题</string>
+2
View File
@@ -965,6 +965,8 @@
<string name="drawer_delete_session_title">Sitzung löschen?</string>
<string name="drawer_delete_session_prefix">Dadurch werden \"</string>
<string name="drawer_delete_session_suffix">\" und der Nachrichtenverlauf dauerhaft gelöscht.</string>
<string name="drawer_remove_provisional_thread_title">Thread entfernen?</string>
<string name="drawer_remove_provisional_thread_message">Dadurch wird „%1$s“ von diesem Gerät entfernt. Hochgestufte oder serverseitige Verläufe werden nicht gelöscht.</string>
<string name="drawer_untitled">Ohne Titel</string>
<string name="drawer_delete">Löschen</string>
<string name="drawer_thread">Thread</string>
+2
View File
@@ -880,6 +880,8 @@
<string name="drawer_delete_session_title">¿Eliminar sesión?</string>
<string name="drawer_delete_session_prefix">Esto eliminará permanentemente \"</string>
<string name="drawer_delete_session_suffix">\" y su historial de mensajes.</string>
<string name="drawer_remove_provisional_thread_title">¿Quitar hilo?</string>
<string name="drawer_remove_provisional_thread_message">Esto quita «%1$s» de este dispositivo. No elimina el historial promocionado ni el del servidor.</string>
<string name="drawer_untitled">Intitulado</string>
<string name="drawer_delete">Borrar</string>
<string name="drawer_thread">Hilo</string>
+2
View File
@@ -976,6 +976,8 @@
<string name="drawer_delete_session_title">セッションを削除しますか?</string>
<string name="drawer_delete_session_prefix">「</string>
<string name="drawer_delete_session_suffix">」とそのメッセージ履歴を完全に削除します。</string>
<string name="drawer_remove_provisional_thread_title">スレッドを削除しますか?</string>
<string name="drawer_remove_provisional_thread_message">「%1$s」をこのデバイスから削除します。昇格済みまたはサーバー上の履歴は削除されません。</string>
<string name="drawer_untitled">無題</string>
<string name="drawer_delete">消去</string>
<string name="drawer_thread">糸</string>
+2
View File
@@ -992,6 +992,8 @@
<string name="drawer_delete_session_title">Удалить сессию?</string>
<string name="drawer_delete_session_prefix">Это навсегда удалит &quot;</string>
<string name="drawer_delete_session_suffix">&quot; и историю сообщений.</string>
<string name="drawer_remove_provisional_thread_title">Удалить поток?</string>
<string name="drawer_remove_provisional_thread_message">Это удалит «%1$s» с этого устройства. Повышенная или серверная история не будет удалена.</string>
<string name="drawer_untitled">Без названия</string>
<string name="drawer_delete">Удалить</string>
<string name="drawer_thread">Ветка</string>
+2
View File
@@ -1090,6 +1090,8 @@
<string name="drawer_delete_session_title">Delete Session?</string>
<string name="drawer_delete_session_prefix">This will permanently delete \"</string>
<string name="drawer_delete_session_suffix">\" and its message history.</string>
<string name="drawer_remove_provisional_thread_title">Remove Thread?</string>
<string name="drawer_remove_provisional_thread_message">This removes \"%1$s\" from this device. It does not delete promoted or server history.</string>
<string name="drawer_untitled">Untitled</string>
<string name="drawer_delete">Delete</string>
<string name="drawer_thread">Thread</string>
@@ -0,0 +1,73 @@
package com.hermesandroid.relay.data
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Test
class ProactiveInboxStoreTest {
@Test
fun `remove thread matches the rendered connection row and preserves other rows`() = runBlocking {
val repository = ProactiveInboxRepository(InMemoryPreferencesDataStore())
repository.add(entry("owned", "reminders", "connection-a", notificationId = 42))
repository.add(entry("legacy", "reminders", null))
repository.add(entry("other-connection", "reminders", "connection-b"))
repository.add(entry("other-thread", "updates", "connection-a"))
val removed = repository.removeThread("reminders", "connection-a")
assertEquals(setOf("owned", "legacy"), removed.map { it.id }.toSet())
assertEquals(listOf(42), removed.mapNotNull { it.notificationId })
assertEquals(
setOf("other-connection", "other-thread"),
repository.entries.first().map { it.id }.toSet(),
)
}
@Test
fun `blank chat id removes only the local phone fallback row`() = runBlocking {
val repository = ProactiveInboxRepository(InMemoryPreferencesDataStore())
repository.add(entry("default", null, "connection-a"))
repository.add(entry("named", "reminders", "connection-a"))
repository.removeThread("phone", "connection-a")
assertEquals(listOf("named"), repository.entries.first().map { it.id })
}
private fun entry(
id: String,
chatId: String?,
connectionId: String?,
notificationId: Int? = null,
) =
ProactiveInboxEntry(
id = id,
title = "Hermes",
text = id,
receivedAt = 1L,
chatId = chatId,
connectionId = connectionId,
notificationId = notificationId,
)
private class InMemoryPreferencesDataStore : DataStore<Preferences> {
private val state = MutableStateFlow<Preferences>(emptyPreferences())
override val data: Flow<Preferences> = state
override suspend fun updateData(
transform: suspend (t: Preferences) -> Preferences,
): Preferences {
val next = transform(state.value)
state.value = next
return next
}
}
}
@@ -3,9 +3,7 @@ package com.hermesandroid.relay.network.relay
import android.content.Context
import com.hermesandroid.relay.network.relay.models.Envelope
import com.hermesandroid.relay.notifications.ProactiveMessageNotifier
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkObject
@@ -26,7 +24,7 @@ class ProactiveMessageHandlerTest {
mockkObject(ProactiveMessageNotifier)
every {
ProactiveMessageNotifier.notify(any(), any(), any(), any(), any())
} just Runs
} returns 42
}
@After
@@ -44,6 +42,7 @@ class ProactiveMessageHandlerTest {
handler.onMessage(messageEnvelope(surfacing = "notification"))
assertEquals(1, persisted.size)
assertEquals(42, persisted.single().notificationId)
verify(exactly = 1) {
ProactiveMessageNotifier.notify(context, "Hermes", "ready", "m-1", "phone")
}
@@ -51,7 +50,8 @@ class ProactiveMessageHandlerTest {
@Test
fun `inbox surfacing persists silently`() {
val handler = ProactiveMessageHandler(context, toInbox = {}).apply {
val persisted = mutableListOf<ProactiveMessage>()
val handler = ProactiveMessageHandler(context, toInbox = persisted::add).apply {
injectIntoThread = { true }
}
@@ -60,6 +60,7 @@ class ProactiveMessageHandlerTest {
verify(exactly = 0) {
ProactiveMessageNotifier.notify(any(), any(), any(), any(), any())
}
assertEquals(null, persisted.single().notificationId)
}
@Test
@@ -0,0 +1,74 @@
package com.hermesandroid.relay.notifications
import android.Manifest
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.UPSIDE_DOWN_CAKE])
class ProactiveMessageNotifierTest {
private lateinit var context: Context
private lateinit var manager: NotificationManager
@Before
fun setUp() {
context = RuntimeEnvironment.getApplication()
manager = context.getSystemService(NotificationManager::class.java)
manager.cancelAll()
shadowOf(RuntimeEnvironment.getApplication()).grantPermissions(
Manifest.permission.POST_NOTIFICATIONS,
)
}
@After
fun tearDown() {
manager.cancelAll()
}
@Test
fun `notification identity is stable per wire message id`() {
assertEquals(
ProactiveMessageNotifier.notificationIdFor("message-1", "reminders"),
ProactiveMessageNotifier.notificationIdFor("message-1", "updates"),
)
assertNotEquals(
ProactiveMessageNotifier.notificationIdFor("message-1", "reminders"),
ProactiveMessageNotifier.notificationIdFor("message-2", "reminders"),
)
}
@Test
fun `blank message ids keep independent thread slots`() {
assertNotEquals(
ProactiveMessageNotifier.notificationIdFor(null, "reminders"),
ProactiveMessageNotifier.notificationIdFor(null, "updates"),
)
}
@Test
fun `cancel removes only the persisted notification slot`() {
ProactiveMessageNotifier.notify(context, "Hermes", "first", "message-1", "reminders")
ProactiveMessageNotifier.notify(context, "Hermes", "second", "message-2", "updates")
ProactiveMessageNotifier.cancel(
context,
ProactiveMessageNotifier.notificationIdFor("message-1", "reminders"),
)
assertEquals(
listOf(ProactiveMessageNotifier.notificationIdFor("message-2", "updates")),
manager.activeNotifications.map { it.id },
)
}
}
@@ -18,6 +18,7 @@ import androidx.compose.ui.test.performScrollToNode
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.data.SessionActivityState
import com.hermesandroid.relay.data.SupervisedSessionActions
import com.hermesandroid.relay.ui.theme.ProfileAccentSwatches
import org.junit.Rule
import org.junit.Test
@@ -44,6 +45,72 @@ class SessionDrawerTest {
assertEquals(0.45f, UNPINNED_STAR_ALPHA, 0.0f)
}
@Test
fun `provisional thread exposes local delete only`() {
var deletedProvisional: String? = null
var deletedServerSession: String? = null
compose.setContent {
MaterialTheme {
SessionDrawerContent(
sessions = emptyList(),
currentSessionId = null,
threadsCapabilityActive = true,
provisionalThreads = listOf(
ProvisionalThreadRow(
chatId = "reminders",
title = "Reminder",
messageCount = 1,
lastActivityAt = 1L,
),
),
onDeleteProvisionalThread = { deletedProvisional = it },
onNewChat = {},
onSelectSession = {},
onDeleteSession = { deletedServerSession = it },
onRenameSession = { _, _ -> },
)
}
}
compose.onNodeWithContentDescription("Session actions").performClick()
compose.onNodeWithText("Pin session").assertDoesNotExist()
compose.onNodeWithText("Rename").assertDoesNotExist()
compose.onNodeWithText("Delete").performClick()
compose.onNodeWithText("Remove Thread?").assertIsDisplayed()
compose.onNodeWithText(
"This removes \"Reminder\" from this device. It does not delete promoted or server history.",
).assertIsDisplayed()
compose.onNodeWithText("Delete").performClick()
compose.runOnIdle {
assertEquals("reminders", deletedProvisional)
assertEquals(null, deletedServerSession)
}
}
@Test
fun `provisional thread hides actions when supervised deletion is disabled`() {
compose.setContent {
MaterialTheme {
SessionDrawerContent(
sessions = emptyList(),
currentSessionId = null,
supervisedSessionActions = SupervisedSessionActions(delete = false),
provisionalThreads = listOf(
ProvisionalThreadRow("reminders", "Reminder", 1, 1L),
),
onDeleteProvisionalThread = {},
onNewChat = {},
onSelectSession = {},
onDeleteSession = {},
onRenameSession = { _, _ -> },
)
}
}
compose.onNodeWithContentDescription("Session actions").assertDoesNotExist()
}
@Test
fun `archive filter resets when connection cannot restore archived sessions`() {
assertEquals(
@@ -36,6 +36,21 @@ class ProvisionalThreadRowsTest {
assertTrue("phone" in rows)
}
@Test
fun promotedChatIdSuppressesOnlyItsProvisionalRow() {
val rows = buildProvisionalThreadRows(
entries = listOf(
entry("promoted", connectionId = "connection-a", chatId = "reminders"),
entry("still-local", connectionId = "connection-a", chatId = "updates"),
),
activeConnectionId = "connection-a",
realThreadChatIds = listOf("reminders"),
)
assertFalse("reminders" in rows)
assertEquals(listOf("still-local"), rows.getValue("updates").map { it.id })
}
private fun entry(id: String, connectionId: String?, chatId: String?) =
ProactiveInboxEntry(
id = id,
@@ -0,0 +1,37 @@
package com.hermesandroid.relay.viewmodel
import org.junit.Assert.assertEquals
import org.junit.Test
class PhoneThreadChatIdIndexTest {
@Test
fun `index is visible only to its owning connection`() {
val index = PhoneThreadChatIdIndex(
connectionId = "connection-a",
values = mapOf("session-a" to "reminders"),
)
assertEquals(index.values, visiblePhoneThreadChatIds("connection-a", index))
assertEquals(emptyMap<String, String>(), visiblePhoneThreadChatIds("connection-b", index))
assertEquals(emptyMap<String, String>(), visiblePhoneThreadChatIds(null, index))
}
@Test
fun `a later failed refresh preserves the last successful index`() {
val successful = reconcilePhoneThreadChatIdIndex(
current = PhoneThreadChatIdIndex(),
requestedConnectionId = "connection-a",
activeConnectionId = "connection-a",
fetched = Result.success(mapOf("session-a" to "reminders")),
)
val afterFailure = reconcilePhoneThreadChatIdIndex(
current = successful,
requestedConnectionId = "connection-a",
activeConnectionId = "connection-a",
fetched = Result.failure(IllegalStateException("offline")),
)
assertEquals(successful, afterFailure)
}
}
+6 -6
View File
@@ -13,7 +13,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
"main": "3f7ec5aea36744d36ed7e585bf99cce04d8db3a6f9b51399695389b306f40a8e",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -48,7 +48,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
"main": "3f7ec5aea36744d36ed7e585bf99cce04d8db3a6f9b51399695389b306f40a8e",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -72,7 +72,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
"main": "3f7ec5aea36744d36ed7e585bf99cce04d8db3a6f9b51399695389b306f40a8e",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -96,7 +96,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
"main": "3f7ec5aea36744d36ed7e585bf99cce04d8db3a6f9b51399695389b306f40a8e",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -120,7 +120,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
"main": "3f7ec5aea36744d36ed7e585bf99cce04d8db3a6f9b51399695389b306f40a8e",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -135,7 +135,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "28d58a3b9803968124ea6581fd9dbb3a0ce2a9ee1c946228bb6f0b79f987a24a",
"main": "3f7ec5aea36744d36ed7e585bf99cce04d8db3a6f9b51399695389b306f40a8e",
"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"
+29
View File
@@ -28,6 +28,7 @@ import json
import unittest
from typing import Any
from plugin.phone_platform import _normalize_reply
from plugin.relay.channels.proactive import ProactiveChannel, ProactiveError
@@ -336,6 +337,34 @@ class ProactiveChannelTests(unittest.TestCase):
_run(run())
def test_custom_chat_id_survives_relay_drain_and_adapter_normalization(self) -> None:
async def run() -> None:
ch = ProactiveChannel()
ws = _FakeWs()
await ch.handle(
ws,
{
"type": "proactive.reply",
"payload": {
"text": "continue this thread",
"chat_id": "thread-project-461",
"reply_to": "prompt-1",
"message_id": "reply-1",
},
},
)
replies = await ch.take_replies(timeout=0.1)
self.assertEqual(len(replies), 1)
normalized = _normalize_reply(replies[0], "configured-home")
self.assertIsNotNone(normalized)
assert normalized is not None
self.assertEqual(normalized["chat_id"], "thread-project-461")
self.assertEqual(normalized["reply_to"], "prompt-1")
self.assertEqual(normalized["message_id"], "reply-1")
_run(run())
def test_reply_empty_text_dropped(self) -> None:
async def run() -> None:
ch = ProactiveChannel()
-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()