Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76ead50c60 |
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
+10
-9
@@ -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,
|
||||
)
|
||||
|
||||
+18
-8
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -992,6 +992,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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -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
|
||||
|
||||
+74
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user