Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0213dbf5db |
@@ -16,7 +16,9 @@ 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-release-notes.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',
|
||||
'.github/workflows/approve-release-android.yml',
|
||||
|
||||
@@ -16,6 +16,8 @@ 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,7 +33,9 @@ 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"
|
||||
@@ -80,6 +82,9 @@ 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
|
||||
|
||||
@@ -109,6 +114,12 @@ 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' }}
|
||||
@@ -225,3 +236,9 @@ 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,6 +263,19 @@ 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,8 +17,8 @@ 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.
|
||||
- **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.
|
||||
- **Android New Chat keeps the current profile and stays fresh across profile switches.** Starting from All Profiles no longer forces the literal default profile, choosing another profile from an empty draft no longer reopens that profile's previous session after route settlement or restart, and leaving a provisional phone Thread cannot route the next turn to its old chat under the new profile.
|
||||
|
||||
### Removed
|
||||
|
||||
|
||||
@@ -196,7 +196,6 @@ internal class HermesRuntimeBinder(
|
||||
chat.profileSessionPinner = connection::setSessionPinned
|
||||
chat.profileSessionArchiver = connection::setSessionArchived
|
||||
chat.onSessionChanged = connection::saveLastSessionId
|
||||
chat.onFreshDraftSelected = connection::saveFreshDraft
|
||||
chat.setDemoModeWiring(
|
||||
isDemo = { connection.isDemoMode.value },
|
||||
handler = { connection.chatHandler },
|
||||
|
||||
@@ -215,6 +215,7 @@ fun SessionDrawerContent(
|
||||
/** Opens the separate Bot Mode messenger workspace; never changes drawer filters. */
|
||||
onOpenBotMode: (() -> Unit)? = null,
|
||||
onNewChat: () -> Unit,
|
||||
onNewDefaultChat: (() -> Unit)? = null,
|
||||
onSelectSession: (String) -> Unit,
|
||||
onDeleteSession: (String) -> Unit,
|
||||
onRenameSession: (String, String) -> Unit,
|
||||
@@ -534,7 +535,13 @@ fun SessionDrawerContent(
|
||||
|
||||
// New Chat button
|
||||
Button(
|
||||
onClick = onNewChat,
|
||||
onClick = {
|
||||
if (showAllProfiles) {
|
||||
onNewDefaultChat?.invoke() ?: onNewChat()
|
||||
} else {
|
||||
onNewChat()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = newChatEnabled,
|
||||
) {
|
||||
|
||||
@@ -2385,15 +2385,8 @@ fun ChatScreen(
|
||||
}
|
||||
val selectProfileFromShelf: (com.hermesandroid.relay.data.Profile?) -> Unit = { profile ->
|
||||
if (AgentDisplay.profileSessionKey(profile?.name) != selectedProfileKey) {
|
||||
val profileName = profile?.name
|
||||
chatViewModel.selectProfileFromHeader(
|
||||
profileName = profileName,
|
||||
profile = profile,
|
||||
contextKey = AgentDisplay.profileContextKey(
|
||||
connectionId = activeConnection?.id,
|
||||
profileName = profileName,
|
||||
),
|
||||
)
|
||||
connectionViewModel.selectProfile(profile)
|
||||
chatViewModel.activateGatewayProfile(profile)
|
||||
}
|
||||
}
|
||||
val hasLiveConversationSurface = messages.isNotEmpty() || isStreaming
|
||||
@@ -2476,6 +2469,25 @@ fun ChatScreen(
|
||||
scope.launch { drawerState.close() }
|
||||
}
|
||||
},
|
||||
onNewDefaultChat = {
|
||||
if (isProfileLocked) return@SessionDrawerContent
|
||||
val defaultProfile = agentProfiles.firstOrNull {
|
||||
it.name.equals("default", ignoreCase = true)
|
||||
} ?: com.hermesandroid.relay.data.Profile(
|
||||
name = "default",
|
||||
model = "",
|
||||
description = "Default",
|
||||
)
|
||||
val opened = chatViewModel.createProfileChat(
|
||||
profileName = "default",
|
||||
profile = defaultProfile,
|
||||
contextKey = AgentDisplay.profileContextKey(
|
||||
connectionId = activeConnection?.id,
|
||||
profileName = "default",
|
||||
),
|
||||
)
|
||||
if (opened) scope.launch { drawerState.close() }
|
||||
},
|
||||
onSelectSession = { sessionId ->
|
||||
chatViewModel.switchSession(sessionId)
|
||||
scope.launch { drawerState.close() }
|
||||
|
||||
@@ -37,7 +37,6 @@ import com.hermesandroid.relay.data.applyMessageReaction
|
||||
import com.hermesandroid.relay.data.parseChatQuotedPrompt
|
||||
import com.hermesandroid.relay.data.prepareTextTransportAttachments
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.SessionTransport
|
||||
import com.hermesandroid.relay.data.ProactiveInboxEntry
|
||||
import com.hermesandroid.relay.data.RealtimeConversationContextMessage
|
||||
import com.hermesandroid.relay.data.RealtimeTurnTrace
|
||||
@@ -661,7 +660,6 @@ class ChatViewModel : ViewModel() {
|
||||
|
||||
/** Callback to persist session ID — set by RelayApp */
|
||||
var onSessionChanged: ((String?) -> Unit)? = null
|
||||
var onFreshDraftSelected: ((String?, SessionTransport) -> Unit)? = null
|
||||
|
||||
/**
|
||||
* Send a user message into an agent **Thread** (a `source=phone` session)
|
||||
@@ -680,7 +678,6 @@ class ChatViewModel : ViewModel() {
|
||||
*/
|
||||
private data class PendingThread(val chatId: String, val name: String)
|
||||
private var pendingThread: PendingThread? = null
|
||||
private val threadNavigationGeneration = AtomicLong(0L)
|
||||
|
||||
/**
|
||||
* A "+ New Thread" whose first message has been sent — we're now polling for
|
||||
@@ -696,17 +693,6 @@ class ChatViewModel : ViewModel() {
|
||||
)
|
||||
private var creatingThread: CreatingThread? = null
|
||||
|
||||
/**
|
||||
* Provisional phone Threads are route-owned drafts, not transferable chat
|
||||
* drafts. Leaving that surface retires only the pending local route; durable
|
||||
* inbox/session rows and learned session-to-chat-id mappings stay intact.
|
||||
*/
|
||||
private fun exitProvisionalThread() {
|
||||
threadNavigationGeneration.incrementAndGet()
|
||||
pendingThread = null
|
||||
creatingThread = null
|
||||
}
|
||||
|
||||
/**
|
||||
* `sessionId` → phone-platform `chat_id`, learned for threads this app
|
||||
* created ([switchToCreatedThread]) or received a message in
|
||||
@@ -4222,7 +4208,6 @@ class ChatViewModel : ViewModel() {
|
||||
sessionRefreshJob?.cancel()
|
||||
_isLoadingSessions.value = false
|
||||
conversationBindingController.reset()
|
||||
exitProvisionalThread()
|
||||
relayCapabilityGeneration.incrementAndGet()
|
||||
relayReasoningCapabilities.value = emptyMap()
|
||||
_reasoningCapabilityRevision.value += 1L
|
||||
@@ -4270,7 +4255,6 @@ class ChatViewModel : ViewModel() {
|
||||
sessionId: String,
|
||||
): Boolean {
|
||||
if (!selectConversationProfile(profileName, profile)) return false
|
||||
exitProvisionalThread()
|
||||
// Detach the old live gateway session without reading launch/global
|
||||
// model options: session.info for the resumed owner is authoritative.
|
||||
activateGatewayProfile(profile, refreshModelOptions = false)
|
||||
@@ -4280,7 +4264,6 @@ class ChatViewModel : ViewModel() {
|
||||
sessionId = sessionId,
|
||||
explicitProfileName = profileName,
|
||||
explicitDisplayProfile = profile,
|
||||
explicitBinding = true,
|
||||
)
|
||||
return true
|
||||
}
|
||||
@@ -4291,15 +4274,11 @@ class ChatViewModel : ViewModel() {
|
||||
* `default` profile wins over the server's sticky active profile everywhere.
|
||||
*/
|
||||
fun createProfileChat(
|
||||
profileName: String?,
|
||||
profileName: String,
|
||||
profile: Profile?,
|
||||
contextKey: String,
|
||||
): Boolean {
|
||||
if (!selectConversationProfile(profileName, profile)) return false
|
||||
// A provisional phone Thread belongs to its original connection/chat_id
|
||||
// and cannot transfer to another profile. Exit it before binding or
|
||||
// persisting the destination draft so the next send uses session.create.
|
||||
exitProvisionalThread()
|
||||
activateGatewayProfile(profile, refreshModelOptions = false)
|
||||
refreshActiveAgentName(profile, relabelGenericMessages = true)
|
||||
switchProfileContextInternal(
|
||||
@@ -4307,49 +4286,11 @@ class ChatViewModel : ViewModel() {
|
||||
sessionId = null,
|
||||
explicitProfileName = profileName,
|
||||
explicitDisplayProfile = profile,
|
||||
explicitBinding = true,
|
||||
)
|
||||
// Selection has already moved persistence to the target profile, so
|
||||
// clear that profile/transport's stored last-session slot as part of
|
||||
// the same draft transfer. A restart must reopen the draft, not the
|
||||
// target profile's previous conversation.
|
||||
persistFreshDraft(profileName)
|
||||
AppAnalytics.onSessionCreated()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic owner switch for the Chat header.
|
||||
*
|
||||
* Empty ordinary drafts and provisional phone Threads both become a fresh
|
||||
* destination-profile draft, but only after provisional routing is retired.
|
||||
* Durable sessions keep the established profile-selection lifecycle, whose
|
||||
* binder may restore the destination profile's compatible last session.
|
||||
*/
|
||||
fun selectProfileFromHeader(
|
||||
profileName: String?,
|
||||
profile: Profile?,
|
||||
contextKey: String,
|
||||
): Boolean {
|
||||
val handler = chatHandler ?: return false
|
||||
val currentSessionId = handler.currentSessionId.value
|
||||
val activeSession = handler.sessions.value.firstOrNull {
|
||||
it.sessionId == currentSessionId
|
||||
}
|
||||
if (currentSessionId == null || activeSession?.source == "phone") {
|
||||
return createProfileChat(profileName, profile, contextKey)
|
||||
}
|
||||
if (!selectConversationProfile(profileName, profile)) return false
|
||||
exitProvisionalThread()
|
||||
activateGatewayProfile(profile)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun persistFreshDraft(profileName: String?) {
|
||||
val transport = SessionTransport.forEndpoint(streamingEndpoint)
|
||||
onFreshDraftSelected?.invoke(profileName, transport) ?: onSessionChanged?.invoke(null)
|
||||
}
|
||||
|
||||
fun switchProfileContext(contextKey: String, sessionId: String?) {
|
||||
clearOpenedSessionOwner()
|
||||
switchProfileContextInternal(contextKey, sessionId)
|
||||
@@ -4372,19 +4313,14 @@ class ChatViewModel : ViewModel() {
|
||||
sessionId: String?,
|
||||
explicitProfileName: String? = null,
|
||||
explicitDisplayProfile: Profile? = null,
|
||||
explicitBinding: Boolean = false,
|
||||
reconciliation: Boolean = false,
|
||||
) {
|
||||
val handler = chatHandler ?: return
|
||||
dismissChatFailure()
|
||||
val previousBinding = conversationBinding.value
|
||||
val isInitialContextBinding = !previousBinding.isBound
|
||||
val targetProfileName = if (explicitBinding) {
|
||||
explicitProfileName
|
||||
} else {
|
||||
sessionProfileNameProvider()
|
||||
}
|
||||
if (explicitBinding) {
|
||||
val targetProfileName = explicitProfileName ?: sessionProfileNameProvider()
|
||||
if (explicitProfileName != null) {
|
||||
val accepted = conversationBindingController.openExplicit(
|
||||
contextKey = contextKey,
|
||||
profileName = explicitProfileName,
|
||||
@@ -4742,12 +4678,9 @@ class ChatViewModel : ViewModel() {
|
||||
if (supervisedModePolicy.enabled && !supervisedModePolicy.capabilities.newChat) return
|
||||
val handler = chatHandler ?: return
|
||||
recordPreResetEvidence(handler, "new_chat")
|
||||
// A new chat clears only the durable session identity. Keep the bound
|
||||
// profile/context so an All Profiles conversation becomes a fresh
|
||||
// draft for that same owner instead of falling back to the globally
|
||||
// restored default profile.
|
||||
conversationBindingController.startFreshDraft()
|
||||
exitProvisionalThread()
|
||||
clearOpenedSessionOwner()
|
||||
pendingThread = null
|
||||
creatingThread = null
|
||||
|
||||
// Gateway turns continue as detached siblings; SSE remains exclusive.
|
||||
releaseTurnForNavigation(handler)
|
||||
@@ -4777,7 +4710,7 @@ class ChatViewModel : ViewModel() {
|
||||
_fastEnabled.value = null
|
||||
approvalModeRevision.incrementAndGet()
|
||||
pendingYolo = null
|
||||
persistFreshDraft(currentSessionProfileName())
|
||||
onSessionChanged?.invoke(null)
|
||||
AppAnalytics.onSessionCreated()
|
||||
onReady?.invoke(null)
|
||||
return
|
||||
@@ -4847,7 +4780,6 @@ class ChatViewModel : ViewModel() {
|
||||
*/
|
||||
fun startNewThread(name: String) {
|
||||
val handler = chatHandler ?: return
|
||||
exitProvisionalThread()
|
||||
recordPreResetEvidence(handler, "new_thread")
|
||||
releaseTurnForNavigation(handler)
|
||||
cancelAnswerRecovery(settleUi = false)
|
||||
@@ -4884,7 +4816,6 @@ class ChatViewModel : ViewModel() {
|
||||
.sortedBy { it.receivedAt }
|
||||
if (ordered.isEmpty()) return
|
||||
|
||||
exitProvisionalThread()
|
||||
recordPreResetEvidence(handler, "open_proactive_thread")
|
||||
|
||||
releaseTurnForNavigation(handler)
|
||||
@@ -4894,6 +4825,7 @@ class ChatViewModel : ViewModel() {
|
||||
chatId = normalizedChatId,
|
||||
name = ordered.last().title.ifBlank { "Hermes" },
|
||||
)
|
||||
creatingThread = null
|
||||
gatewayClient?.clearSession()
|
||||
handler.setSessionId(null)
|
||||
selectBackgroundProcessSession(null)
|
||||
@@ -4946,20 +4878,11 @@ class ChatViewModel : ViewModel() {
|
||||
*/
|
||||
private fun switchToCreatedThread() {
|
||||
val creating = creatingThread ?: return
|
||||
val generation = threadNavigationGeneration.get()
|
||||
viewModelScope.launch {
|
||||
for (delayMs in longArrayOf(900L, 1300L, 1800L, 2500L, 3500L, 4500L)) {
|
||||
delay(delayMs)
|
||||
if (
|
||||
threadNavigationGeneration.get() != generation ||
|
||||
creatingThread != creating
|
||||
) return@launch
|
||||
refreshSessions()
|
||||
delay(400L) // let the refresh job land in the sessions flow
|
||||
if (
|
||||
threadNavigationGeneration.get() != generation ||
|
||||
creatingThread != creating
|
||||
) return@launch
|
||||
val match = chatHandler?.sessions?.value?.firstOrNull {
|
||||
it.source == "phone" && it.sessionId !in creating.knownIds
|
||||
}
|
||||
@@ -4989,7 +4912,8 @@ class ChatViewModel : ViewModel() {
|
||||
val handler = chatHandler ?: return
|
||||
dismissChatFailure()
|
||||
if (streamingEndpoint != "gateway" && apiClient == null) return
|
||||
exitProvisionalThread()
|
||||
pendingThread = null
|
||||
creatingThread = null
|
||||
|
||||
// Keep a Gateway sibling alive and detach its callbacks. SSE remains a
|
||||
// single exclusive stream and is interrupted on navigation.
|
||||
|
||||
@@ -6884,7 +6884,6 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
// ground truth about which transport can resume it, robust to a
|
||||
// turn that fell back from gateway to SSE.
|
||||
val transport = SessionTransport.forSessionId(sessionId)
|
||||
profileController.markSessionPersisted(connectionId, profileName, transport)
|
||||
profileController.profileSessionStore.setSessionId(
|
||||
connectionId,
|
||||
profileName,
|
||||
@@ -6921,20 +6920,6 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist an intentional empty draft without conflating it with transient null state. */
|
||||
fun saveFreshDraft(profileName: String?, transport: SessionTransport) {
|
||||
_lastSessionId.value = null
|
||||
val connectionId = activeConnectionId.value ?: return
|
||||
profileController.markFreshDraft(connectionId, profileName, transport)
|
||||
if (profileName == null) {
|
||||
viewModelScope.launch {
|
||||
getApplication<Application>().relayDataStore.edit { preferences ->
|
||||
preferences.remove(KEY_LAST_SESSION_ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Shared methods ---
|
||||
|
||||
fun setTheme(theme: String) {
|
||||
|
||||
-12
@@ -102,18 +102,6 @@ internal class ConversationBindingController {
|
||||
)
|
||||
}
|
||||
|
||||
/** A user-requested draft keeps its owner and fences persisted-session reconciliation. */
|
||||
fun startFreshDraft() {
|
||||
val current = _state.value
|
||||
if (!current.isBound) return
|
||||
if (current.sessionId == null && current.hasExplicitOwner) return
|
||||
_state.value = current.copy(
|
||||
sessionId = null,
|
||||
origin = ConversationBindingOrigin.ExplicitSession,
|
||||
revision = current.revision + 1,
|
||||
)
|
||||
}
|
||||
|
||||
fun releaseExplicitOwner() {
|
||||
if (!_state.value.hasExplicitOwner) return
|
||||
reset()
|
||||
|
||||
@@ -155,14 +155,6 @@ class ProfileController(
|
||||
private val avatarRefreshGeneration = AtomicLong(0L)
|
||||
private val petRefreshGeneration = AtomicLong(0L)
|
||||
private val petGalleryGeneration = AtomicLong(0L)
|
||||
private val sessionRestoreGeneration = AtomicLong(0L)
|
||||
private val freshDraftScopes = ConcurrentHashMap.newKeySet<SessionScopeKey>()
|
||||
|
||||
private data class SessionScopeKey(
|
||||
val connectionId: String,
|
||||
val profileName: String?,
|
||||
val transport: SessionTransport,
|
||||
)
|
||||
private val petThumbnailRequests = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
val agentProfiles: StateFlow<List<Profile>> = combine(
|
||||
@@ -1471,48 +1463,10 @@ class ProfileController(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a user-requested empty draft for one exact conversation scope.
|
||||
*
|
||||
* The in-memory marker fences any stored-session read that was already in
|
||||
* flight, while clearing the exact transport slot makes the draft survive a
|
||||
* process restart. Other profiles, connections, transports, and the server's
|
||||
* actual session/history rows are untouched.
|
||||
*/
|
||||
fun markFreshDraft(
|
||||
connectionId: String,
|
||||
profileName: String?,
|
||||
transport: SessionTransport,
|
||||
) {
|
||||
val scopeKey = SessionScopeKey(connectionId, profileName, transport)
|
||||
freshDraftScopes += scopeKey
|
||||
sessionRestoreGeneration.incrementAndGet()
|
||||
if (
|
||||
activeConnectionId.value == connectionId &&
|
||||
_selectedProfile.value?.name == profileName
|
||||
) {
|
||||
setLastSessionId(null)
|
||||
}
|
||||
scope.launch {
|
||||
profileSessionStore.setSessionId(connectionId, profileName, transport, null)
|
||||
}
|
||||
}
|
||||
|
||||
/** A real session supersedes the fresh-draft marker for its exact scope. */
|
||||
fun markSessionPersisted(
|
||||
connectionId: String,
|
||||
profileName: String?,
|
||||
transport: SessionTransport,
|
||||
) {
|
||||
freshDraftScopes -= SessionScopeKey(connectionId, profileName, transport)
|
||||
sessionRestoreGeneration.incrementAndGet()
|
||||
}
|
||||
|
||||
fun refreshLastSessionForProfile(
|
||||
connectionId: String?,
|
||||
profileName: String?,
|
||||
) {
|
||||
val generation = sessionRestoreGeneration.incrementAndGet()
|
||||
setLastSessionId(null)
|
||||
if (connectionId == null) return
|
||||
// Defer until the active transport is known — restoring an id the
|
||||
@@ -1524,8 +1478,6 @@ class ProfileController(
|
||||
// `default` (or any other name), but its last-session slot must remain
|
||||
// distinct from explicitly selecting that named profile.
|
||||
val sessionProfileName = profileName
|
||||
val scopeKey = SessionScopeKey(connectionId, sessionProfileName, transport)
|
||||
if (scopeKey in freshDraftScopes) return
|
||||
scope.launch {
|
||||
val profileScoped = profileSessionStore
|
||||
.sessionIdFlow(connectionId, sessionProfileName, transport)
|
||||
@@ -1541,8 +1493,6 @@ class ProfileController(
|
||||
null
|
||||
}
|
||||
if (
|
||||
sessionRestoreGeneration.get() == generation &&
|
||||
scopeKey !in freshDraftScopes &&
|
||||
activeConnectionId.value == connectionId &&
|
||||
_selectedProfile.value?.name == profileName &&
|
||||
activeSessionTransport() == transport
|
||||
|
||||
@@ -17,8 +17,7 @@ import kotlinx.coroutines.sync.withLock
|
||||
|
||||
class ProfileSessionStoreTest {
|
||||
|
||||
private val dataStore = InMemoryPreferencesDataStore()
|
||||
private val store = ProfileSessionStore(dataStore)
|
||||
private val store = ProfileSessionStore(InMemoryPreferencesDataStore())
|
||||
|
||||
@Test
|
||||
fun setAndGet_defaultProfileSession() = runBlocking {
|
||||
@@ -86,26 +85,6 @@ class ProfileSessionStoreTest {
|
||||
assertEquals("session-sse", store.sessionIdFlow("conn-1", "mizu", SSE).first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearedDraftSurvivesStoreRecreationAndPreservesOtherScopes() = runBlocking {
|
||||
store.setSessionId("conn-1", "mizu", GATEWAY, "session-gw")
|
||||
store.setSessionId("conn-1", "mizu", SSE, "session-sse")
|
||||
store.setSessionId("conn-2", "mizu", GATEWAY, "session-other")
|
||||
|
||||
store.setSessionId("conn-1", "mizu", GATEWAY, null)
|
||||
val restartedStore = ProfileSessionStore(dataStore)
|
||||
|
||||
assertNull(restartedStore.sessionIdFlow("conn-1", "mizu", GATEWAY).first())
|
||||
assertEquals(
|
||||
"session-sse",
|
||||
restartedStore.sessionIdFlow("conn-1", "mizu", SSE).first(),
|
||||
)
|
||||
assertEquals(
|
||||
"session-other",
|
||||
restartedStore.sessionIdFlow("conn-2", "mizu", GATEWAY).first(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearConnectionRemovesAllProfilesAndTransportsForThatConnectionOnly() = runBlocking {
|
||||
store.setSessionId("conn-1", null, GATEWAY, "session-default")
|
||||
|
||||
@@ -304,8 +304,9 @@ class SessionDrawerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `new chat from all profiles keeps the current conversation owner`() {
|
||||
fun `new chat from all profiles requests an explicit default draft`() {
|
||||
var scopedNewChats = 0
|
||||
var defaultNewChats = 0
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
SessionDrawerContent(
|
||||
@@ -318,6 +319,7 @@ class SessionDrawerTest {
|
||||
onRefreshAllProfiles = {},
|
||||
onSelectProfileSession = { _, _ -> },
|
||||
onNewChat = { scopedNewChats++ },
|
||||
onNewDefaultChat = { defaultNewChats++ },
|
||||
onSelectSession = {},
|
||||
onDeleteSession = {},
|
||||
onRenameSession = { _, _ -> },
|
||||
@@ -330,7 +332,8 @@ class SessionDrawerTest {
|
||||
compose.onNodeWithText("New Chat").performClick()
|
||||
|
||||
compose.runOnIdle {
|
||||
assertEquals(1, scopedNewChats)
|
||||
assertEquals(0, scopedNewChats)
|
||||
assertEquals(1, defaultNewChats)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-266
@@ -14,12 +14,9 @@ import com.hermesandroid.relay.data.ChatTurnUserCheckpoint
|
||||
import com.hermesandroid.relay.data.HermesCardDispatch
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.ProactiveInboxEntry
|
||||
import com.hermesandroid.relay.data.SessionTransport
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import com.hermesandroid.relay.network.relay.ProactiveMessage
|
||||
import com.hermesandroid.relay.network.upstream.ChatHandler
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
||||
@@ -434,17 +431,8 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
assertEquals(owner.name, viewModel.conversationBinding.value.profileName)
|
||||
|
||||
viewModel.createNewChat()
|
||||
assertTrue(viewModel.conversationBinding.value.hasExplicitOwner)
|
||||
assertEquals(owner.name, viewModel.conversationBinding.value.profileName)
|
||||
assertNull(viewModel.conversationBinding.value.sessionId)
|
||||
assertEquals(owner.name, gatewayClient.sessionProfileProvider())
|
||||
|
||||
viewModel.reconcileProfileContext(
|
||||
AgentDisplay.profileContextKey("connection-a", owner.name),
|
||||
sessionId = "x-bot-session",
|
||||
)
|
||||
assertNull(viewModel.conversationBinding.value.sessionId)
|
||||
assertNull(handler.currentSessionId.value)
|
||||
assertFalse(viewModel.conversationBinding.value.hasExplicitOwner)
|
||||
assertEquals(global.name, gatewayClient.sessionProfileProvider())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -659,258 +647,7 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
assertEquals("default", gatewayClient.sessionProfileProvider())
|
||||
assertEquals(null, handler.currentSessionId.value)
|
||||
assertEquals("Hermes", handler.activeAgentName)
|
||||
assertEquals("cleared", persistedSession)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun freshDraftTransferKeepsNullableServerDefaultAndRejectsOldSessionRestore() {
|
||||
val named = Profile(name = "x-bot", model = "grok-4.3", description = "X Bot")
|
||||
var selected: Profile? = named
|
||||
var persistedDraft: Pair<String?, SessionTransport>? = null
|
||||
viewModel.setSelectedProfileProvider { selected }
|
||||
viewModel.setSessionProfileNameProvider { selected?.name }
|
||||
viewModel.setProfileSelectionHandler { profile ->
|
||||
selected = profile
|
||||
true
|
||||
}
|
||||
viewModel.onFreshDraftSelected = { profileName, transport ->
|
||||
persistedDraft = profileName to transport
|
||||
}
|
||||
|
||||
assertTrue(
|
||||
viewModel.createProfileChat(
|
||||
profileName = null,
|
||||
profile = null,
|
||||
contextKey = AgentDisplay.profileContextKey("connection-a", null),
|
||||
),
|
||||
)
|
||||
|
||||
assertNull(selected)
|
||||
assertTrue(viewModel.conversationBinding.value.hasExplicitOwner)
|
||||
assertNull(viewModel.conversationBinding.value.profileName)
|
||||
assertNull(viewModel.conversationBinding.value.sessionId)
|
||||
assertEquals(null to SessionTransport.GATEWAY, persistedDraft)
|
||||
assertNull(gatewayClient.sessionProfileProvider())
|
||||
|
||||
viewModel.reconcileProfileContext(
|
||||
AgentDisplay.profileContextKey("connection-a", null),
|
||||
sessionId = "old-default-session",
|
||||
)
|
||||
assertNull(viewModel.conversationBinding.value.sessionId)
|
||||
assertNull(handler.currentSessionId.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun freshDraftTransferToNamedProfileCreatesInsteadOfResumingItsOldSession() {
|
||||
val alpha = Profile(name = "alpha", model = "model-a", description = "Alpha")
|
||||
val beta = Profile(name = "beta", model = "model-b", description = "Beta")
|
||||
var selected: Profile? = alpha
|
||||
var persistedDraft: Pair<String?, SessionTransport>? = null
|
||||
viewModel.setSelectedProfileProvider { selected }
|
||||
viewModel.setSessionProfileNameProvider { selected?.name }
|
||||
viewModel.setProfileSelectionHandler { profile ->
|
||||
selected = profile
|
||||
true
|
||||
}
|
||||
viewModel.onFreshDraftSelected = { profileName, transport ->
|
||||
persistedDraft = profileName to transport
|
||||
}
|
||||
|
||||
viewModel.openProfileSession(
|
||||
profileName = alpha.name,
|
||||
profile = alpha,
|
||||
contextKey = AgentDisplay.profileContextKey("connection-a", alpha.name),
|
||||
sessionId = "alpha-session",
|
||||
)
|
||||
viewModel.createNewChat()
|
||||
assertTrue(
|
||||
viewModel.selectProfileFromHeader(
|
||||
profileName = beta.name,
|
||||
profile = beta,
|
||||
contextKey = AgentDisplay.profileContextKey("connection-a", beta.name),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(beta, selected)
|
||||
assertEquals(beta.name to SessionTransport.GATEWAY, persistedDraft)
|
||||
viewModel.reconcileProfileContext(
|
||||
AgentDisplay.profileContextKey("connection-a", beta.name),
|
||||
sessionId = "beta-old-session",
|
||||
)
|
||||
assertNull(handler.currentSessionId.value)
|
||||
|
||||
gatewayHarness.createdSessionProfileName = beta.name
|
||||
val resumeCountBeforeFreshSend = gatewayHarness.rpcLog.count {
|
||||
it.first == "session.resume"
|
||||
}
|
||||
viewModel.sendMessage("Fresh beta turn")
|
||||
val create = gatewayHarness.awaitRpc("session.create")
|
||||
assertEquals(beta.name, (create["profile"] as JsonPrimitive).content)
|
||||
assertEquals(
|
||||
resumeCountBeforeFreshSend,
|
||||
gatewayHarness.rpcLog.count { it.first == "session.resume" },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun headerProfileSwitchExitsProvisionalThreadBeforeFreshProfileSend() {
|
||||
val alpha = Profile(name = "alpha", model = "model-a", description = "Alpha")
|
||||
val beta = Profile(name = "beta", model = "model-b", description = "Beta")
|
||||
var selected: Profile? = alpha
|
||||
val proactiveChatIds = mutableListOf<String?>()
|
||||
viewModel.setSelectedProfileProvider { selected }
|
||||
viewModel.setSessionProfileNameProvider { selected?.name }
|
||||
viewModel.setProfileSelectionHandler { profile ->
|
||||
selected = profile
|
||||
true
|
||||
}
|
||||
viewModel.onProactiveReply = { _, chatId, _, _ -> proactiveChatIds += chatId }
|
||||
|
||||
viewModel.openProactiveThread(
|
||||
chatId = "old-phone-chat",
|
||||
entries = listOf(
|
||||
ProactiveInboxEntry(
|
||||
id = "inbox-1",
|
||||
title = "Old phone thread",
|
||||
text = "Continue here",
|
||||
receivedAt = 1L,
|
||||
chatId = "old-phone-chat",
|
||||
connectionId = "connection-a",
|
||||
),
|
||||
),
|
||||
)
|
||||
assertNull(handler.currentSessionId.value)
|
||||
|
||||
assertTrue(
|
||||
viewModel.selectProfileFromHeader(
|
||||
profileName = beta.name,
|
||||
profile = beta,
|
||||
contextKey = AgentDisplay.profileContextKey("connection-a", beta.name),
|
||||
),
|
||||
)
|
||||
viewModel.sendMessage("Fresh beta turn")
|
||||
|
||||
val create = gatewayHarness.awaitRpc("session.create")
|
||||
assertEquals(beta.name, (create["profile"] as JsonPrimitive).content)
|
||||
assertTrue(proactiveChatIds.isEmpty())
|
||||
assertEquals(beta.name, viewModel.conversationBinding.value.profileName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun headerProfileSwitchExitsPromotedPhoneSessionWithoutReusingItsChatId() {
|
||||
val alpha = Profile(name = "alpha", model = "model-a", description = "Alpha")
|
||||
val beta = Profile(name = "beta", model = "model-b", description = "Beta")
|
||||
var selected: Profile? = alpha
|
||||
val proactiveChatIds = mutableListOf<String?>()
|
||||
viewModel.setSelectedProfileProvider { selected }
|
||||
viewModel.setSessionProfileNameProvider { selected?.name }
|
||||
viewModel.setProfileSelectionHandler { profile ->
|
||||
selected = profile
|
||||
true
|
||||
}
|
||||
viewModel.onProactiveReply = { _, chatId, _, _ -> proactiveChatIds += chatId }
|
||||
handler.addSession(
|
||||
com.hermesandroid.relay.data.ChatSession(
|
||||
sessionId = "promoted-phone-session",
|
||||
title = "Promoted thread",
|
||||
model = null,
|
||||
source = "phone",
|
||||
),
|
||||
)
|
||||
handler.setSessionId("promoted-phone-session")
|
||||
|
||||
assertTrue(
|
||||
viewModel.selectProfileFromHeader(
|
||||
profileName = beta.name,
|
||||
profile = beta,
|
||||
contextKey = AgentDisplay.profileContextKey("connection-a", beta.name),
|
||||
),
|
||||
)
|
||||
assertNull(handler.currentSessionId.value)
|
||||
viewModel.sendMessage("Fresh beta after Thread")
|
||||
|
||||
val create = gatewayHarness.awaitRpc("session.create")
|
||||
assertEquals(beta.name, (create["profile"] as JsonPrimitive).content)
|
||||
assertTrue(proactiveChatIds.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun newChatAndConnectionSwitchRetireProvisionalThreadRouting() {
|
||||
val entry = ProactiveInboxEntry(
|
||||
id = "inbox-1",
|
||||
title = "Old phone thread",
|
||||
text = "Continue here",
|
||||
receivedAt = 1L,
|
||||
chatId = "old-phone-chat",
|
||||
connectionId = "connection-a",
|
||||
)
|
||||
val inbound = ProactiveMessage(
|
||||
messageId = "late-1",
|
||||
chatId = "old-phone-chat",
|
||||
text = "Late old-thread message",
|
||||
title = "Old phone thread",
|
||||
surfacing = "thread",
|
||||
sentAt = 2L,
|
||||
)
|
||||
|
||||
viewModel.openProactiveThread("old-phone-chat", listOf(entry))
|
||||
viewModel.createNewChat()
|
||||
assertFalse(viewModel.injectThreadMessage(inbound))
|
||||
|
||||
val switches = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||
viewModel.observeConnectionSwitches(switches)
|
||||
viewModel.openProactiveThread("old-phone-chat", listOf(entry))
|
||||
switches.tryEmit("connection-b")
|
||||
awaitCondition { handler.messages.value.isEmpty() }
|
||||
assertFalse(viewModel.injectThreadMessage(inbound))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleThreadPromotionCannotReplaceTransferredProfileDraft() {
|
||||
val beta = Profile(name = "beta", model = "model-b", description = "Beta")
|
||||
var selected: Profile? = Profile(name = "alpha", model = "model-a")
|
||||
viewModel.setSelectedProfileProvider { selected }
|
||||
viewModel.setSessionProfileNameProvider { selected?.name }
|
||||
viewModel.setProfileSelectionHandler { profile ->
|
||||
selected = profile
|
||||
true
|
||||
}
|
||||
viewModel.onProactiveReply = { _, _, _, _ -> }
|
||||
viewModel.openProactiveThread(
|
||||
"old-phone-chat",
|
||||
listOf(
|
||||
ProactiveInboxEntry(
|
||||
id = "inbox-1",
|
||||
title = "Old phone thread",
|
||||
text = "Continue here",
|
||||
receivedAt = 1L,
|
||||
chatId = "old-phone-chat",
|
||||
connectionId = "connection-a",
|
||||
),
|
||||
),
|
||||
)
|
||||
viewModel.sendMessage("Promote the old Thread")
|
||||
|
||||
assertTrue(
|
||||
viewModel.selectProfileFromHeader(
|
||||
profileName = beta.name,
|
||||
profile = beta,
|
||||
contextKey = AgentDisplay.profileContextKey("connection-a", beta.name),
|
||||
),
|
||||
)
|
||||
handler.addSession(
|
||||
com.hermesandroid.relay.data.ChatSession(
|
||||
sessionId = "late-promoted-thread",
|
||||
title = "Late promoted thread",
|
||||
model = null,
|
||||
source = "phone",
|
||||
),
|
||||
)
|
||||
shadowOf(Looper.getMainLooper()).idleFor(2, TimeUnit.SECONDS)
|
||||
Thread.sleep(100)
|
||||
|
||||
assertNull(handler.currentSessionId.value)
|
||||
assertEquals(beta.name, viewModel.conversationBinding.value.profileName)
|
||||
assertEquals("unchanged", persistedSession)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
-27
@@ -58,33 +58,6 @@ class ConversationBindingControllerTest {
|
||||
assertEquals("a2", controller.state.value.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun newDraftKeepsExplicitAllProfilesOwnerAndRejectsStaleRestore() {
|
||||
val alpha = Profile("alpha", "model-a", "Alpha")
|
||||
controller.openExplicit("c::alpha", alpha.name, "a1", alpha, null)
|
||||
|
||||
controller.startFreshDraft()
|
||||
|
||||
assertEquals("c::alpha", controller.state.value.contextKey)
|
||||
assertEquals("alpha", controller.state.value.profileName)
|
||||
assertNull(controller.state.value.sessionId)
|
||||
assertEquals(alpha, controller.state.value.displayProfile)
|
||||
assertTrue(controller.state.value.hasExplicitOwner)
|
||||
assertFalse(controller.reconcileGlobal("c::alpha", "alpha", "a1"))
|
||||
assertNull(controller.state.value.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun newDraftPromotesGlobalOwnerAndRejectsItsStoredSession() {
|
||||
controller.forceGlobal("c::alpha", "alpha", "a1")
|
||||
|
||||
controller.startFreshDraft()
|
||||
|
||||
assertTrue(controller.state.value.hasExplicitOwner)
|
||||
assertNull(controller.state.value.sessionId)
|
||||
assertFalse(controller.reconcileGlobal("c::alpha", "alpha", "a1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun profileLockRejectsOtherOwnersAndAllowsTheLockedOwner() {
|
||||
val locked = AgentDisplay.profileSessionKey("beta")
|
||||
|
||||
-39
@@ -4,7 +4,6 @@ import android.content.Context
|
||||
import com.hermesandroid.relay.auth.AuthManager
|
||||
import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.SessionTransport
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.DashboardProfileScope
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
@@ -367,42 +366,4 @@ class ProfileControllerLockTest {
|
||||
controller.selectProfile(coder)
|
||||
assertEquals(coder, controller.selectedProfile.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun freshDraftFencesRestoreAndClearsOnlyItsConnectionProfileTransport() = runBlocking {
|
||||
val sessions = controller.profileSessionStore
|
||||
sessions.setSessionId(connectionId, mizu.name, SessionTransport.SSE, "old-sse")
|
||||
sessions.setSessionId(connectionId, mizu.name, SessionTransport.GATEWAY, "old-gateway")
|
||||
sessions.setSessionId("other-connection", mizu.name, SessionTransport.SSE, "other-sse")
|
||||
controller.selectProfile(mizu)
|
||||
awaitSelected(mizu.name)
|
||||
|
||||
controller.markFreshDraft(connectionId, mizu.name, SessionTransport.SSE)
|
||||
withTimeout(5_000) {
|
||||
sessions.sessionIdFlow(connectionId, mizu.name, SessionTransport.SSE)
|
||||
.first { it == null }
|
||||
}
|
||||
|
||||
// Simulate an older read observing the pre-clear value: the live intent
|
||||
// fence still wins until a real session supersedes the draft.
|
||||
sessions.setSessionId(connectionId, mizu.name, SessionTransport.SSE, "stale-sse")
|
||||
controller.refreshLastSessionForProfile(connectionId, mizu.name)
|
||||
assertNull(lastSessionIds.last())
|
||||
|
||||
assertEquals(
|
||||
"old-gateway",
|
||||
sessions.sessionIdFlow(connectionId, mizu.name, SessionTransport.GATEWAY).first(),
|
||||
)
|
||||
assertEquals(
|
||||
"other-sse",
|
||||
sessions.sessionIdFlow("other-connection", mizu.name, SessionTransport.SSE).first(),
|
||||
)
|
||||
|
||||
controller.markSessionPersisted(connectionId, mizu.name, SessionTransport.SSE)
|
||||
sessions.setSessionId(connectionId, mizu.name, SessionTransport.SSE, "new-sse")
|
||||
controller.refreshLastSessionForProfile(connectionId, mizu.name)
|
||||
withTimeout(5_000) {
|
||||
while (lastSessionIds.lastOrNull() != "new-sse") Thread.sleep(10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2836,20 +2836,6 @@ session or starts a draft, never hot-swaps a live session. Model/provider,
|
||||
personality, reasoning, approval, Fast, and YOLO state reset at the ViewModel
|
||||
context boundary before destination session truth can repopulate them.
|
||||
|
||||
New Chat retains the current concrete conversation owner even when the drawer is
|
||||
browsing All Profiles. A profile choice made from that empty draft transfers an
|
||||
explicit fresh-draft intent rather than restoring the destination's previous
|
||||
session. Android persists and generation-fences that intent by exact
|
||||
connection/profile/transport; it clears only the resumable pointer, leaving the
|
||||
stored conversation, transcript, and per-owner composer drafts intact.
|
||||
|
||||
Phone Threads keep their connection/chat-id ownership when leaving that surface.
|
||||
They are never transferred into a different profile binding: the atomic header
|
||||
switch retires provisional or in-progress promotion state before creating the
|
||||
destination profile draft, while durable inbox rows, promoted sessions,
|
||||
notification ownership, and session-to-chat-id indexes remain untouched. A
|
||||
generation fence prevents a delayed promotion from replacing the new draft.
|
||||
|
||||
**Consequences.** The hamburger remains exclusively the Session Drawer. Agent
|
||||
Passport stays focused on inspection and configuration. The drawer may widen
|
||||
its read-only browse scope to all profiles and organize that combined set by
|
||||
|
||||
+1
-1
@@ -475,7 +475,7 @@ Bottom navigation bar with 4 tabs:
|
||||
- **Upstream animated pets** — the agent sheet consumes the profile-scoped Gateway `pet.info`, `pet.gallery`, `pet.select`, and `pet.disable` contracts. Android caches the bounded PNG/WebP sprite sheet by connection, effective profile, and `spritesheetRevision`; it sends `knownRevision` on refresh and reuses the existing bounded pet renderer for the returned geometry, row taxonomy, and activity states. The active upstream pet becomes the phone companion unless the user explicitly selected a phone-local floating pet. Selection and disable write Hermes `display.pet.*` state and therefore follow the profile across current Hermes surfaces; a method-not-found response leaves older hosts on the established local pet flow.
|
||||
- **Profile creation** — Manage uses `profiles.create` on current Gateways and labels authentication as shared sign-in, copied credential snapshot, or isolated/no-copy. Android serializes `mirror_credentials` and `share_auth` explicitly, reports best-effort SOUL/model/credential results without claiming full success, and never receives or logs credentials. The user may explicitly enable the authenticated Dashboard create route as an older-host fallback only for the legacy shared/default choice; explicit isolation never degrades to an ambiguous older mutation.
|
||||
- **Deletion boundary** — Hermes exposes no `profiles.delete` Gateway RPC. Android continues to delete profiles only through authenticated Dashboard `DELETE /api/profiles/{name}`.
|
||||
- **Profile switch lifecycle** — selecting an inactive profile never changes Hermes' sticky server default and never hot-swaps a live session. Android switches connection/profile context, restores that profile's last session only from the compatible Gateway or SSE transport slot, or opens a fresh draft. New Chat from All Profiles keeps the current conversation owner, and selecting another profile while that draft is empty transfers fresh-draft intent to the destination instead of restoring its prior session. That intent is fenced and persisted by exact connection/profile/transport while the prior session and history remain available in the drawer. Provisional and promoted phone Threads are not transferable profile drafts: a header profile switch exits their local routing state before establishing the destination draft, preserves their durable inbox/session/index data, and generation-fences any pending promotion. Gateway turns detach and reconcile in their original durable session; live SSE switching is disabled. Model/provider, personality, reasoning, approval, Fast, and YOLO state are cleared before destination session truth re-seeds them.
|
||||
- **Profile switch lifecycle** — selecting an inactive profile never changes Hermes' sticky server default and never hot-swaps a live session. Android switches connection/profile context, restores that profile's last session only from the compatible Gateway or SSE transport slot, or opens a fresh draft. Gateway turns detach and reconcile in their original durable session; live SSE switching is disabled. Model/provider, personality, reasoning, approval, Fast, and YOLO state are cleared before destination session truth re-seeds them.
|
||||
- **Bot Mode workspace** — the session drawer exposes one entry into a separate full-screen messenger surface; it does not add Bot or group rows to the ordinary session taxonomy. Android refreshes every saved Dashboard/Gateway with bounded concurrency, preserves last-good rows as visibly offline, and collapses duplicate routes by upstream `install_id` before assigning source-qualified handles. Every Bot carries an immutable `(connectionId, profile)` owner; labels, installation metadata, and the currently resolved URL are presentation/routing data rather than identity. All gateways and one-gateway filters never mutate the foreground connection.
|
||||
- **Canonical Bot Chat** — each individual row resolves the exact hidden session titled `Bot Chat` on its owning Gateway. Lookup failure is not absence, so Android creates and materializes the lazy row with `session.title` only after an authoritative empty exact-title result. The dedicated Bot Chat destination retains that route's pooled Gateway client, loads history through the same connection/profile Dashboard, sends only through Gateway, and returns directly to Bot Mode without rebinding Standard Chat or the global connection. `/new` or `/reset` compacts the canonical conversation instead of forking it. The route pool mints a fresh WebSocket ticket per dial, includes the immutable profile in the WebSocket URL, isolates credentials by exact trusted connection origin, and tears down only the removed connection's clients.
|
||||
- **Bot group projection** — Android merges the bounded `ui_meta["hermes-bots-groups"]` v3 projection across gateways by durable room identity and newest revision. Rooms and recent messages are visibly read-only; Android does not create, rename, disband, join, send, coordinate member turns, or become a second room-log authority. Binary room images are ignored at this metadata boundary.
|
||||
|
||||
@@ -31,7 +31,7 @@ play-publisher = "4.1.1"
|
||||
media3 = "1.11.0"
|
||||
androidVad = "2.0.10"
|
||||
sherpaOnnx = "v1.13.4"
|
||||
onnxRuntime = "1.29.0"
|
||||
onnxRuntime = "1.27.0"
|
||||
spatialsdk = "0.13.2"
|
||||
play-app-update = "2.1.0"
|
||||
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,164 @@
|
||||
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