FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

Support multiple android devices connected to Agent server by jebrans · Pull Request #2914 · microsoft/TypeAgent · GitHub

Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ internal object AndroidDeviceAgent {

fun createRegistrationParams(
conversationId: String,
schemaContent: String
schemaContent: String,
instanceId: String,
displayName: String
): JSONObject {
val schemaFile = JSONObject()
.put("format", "ts")
Expand All @@ -37,11 +39,21 @@ internal object AndroidDeviceAgent {
.put("conversationId", conversationId)
.put("manifest", manifest)
.put("agentInterface", JSONArray().put("executeAction"))
// Identifies this device so several devices can share one
// `androidDevice` agent, and so a reconnect replaces this device
// instead of adding another. `multiInstance` is the opt-in: without
// it the server rejects the second device, as it does for clients
// that expect to be the only host of their agent.
.put("instanceId", instanceId)
.put("displayName", displayName)
.put("multiInstance", true)
}

/**
* Params for `unregisterClientAgent`, which removes the agent from the
* conversation whichever connection registered it.
* Params for `unregisterClientAgent`. Carries no `instanceId` on purpose:
* the server resolves the call to the calling connection's own
* registration, so it is inert when that connection has none. Naming an
* instance would give the collision shim a way to drop another device.
*/
fun createUnregistrationParams(conversationId: String): JSONObject {
return JSONObject()
Expand Down
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ internal sealed interface ClientAction {
*/
class ChatViewModel(application: Application) : AndroidViewModel(application) {

private val webSocketManager = WebSocketManager()
private val deviceIdentity = StoredDeviceIdentity(application)
private val webSocketManager = WebSocketManager(deviceIdentity)
private val conversationStore = ConversationStore(application)

val messages: StateFlow<List<Message>> = webSocketManager.messages
Expand Down
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.example.typeagentchat

import android.content.Context
import android.content.SharedPreferences
import android.os.Build
import java.util.UUID

/**
* Who this device is, from the server's point of view.
*
* The server keys one `androidDevice` agent by `instanceId`, so several
* devices can share it and a reconnect replaces this device rather than adding
* another. An interface so `WebSocketManager` stays constructible in plain JVM
* unit tests, which have no `Context`.
*/
interface DeviceIdentity {
val instanceId: String
val displayName: String
}

/**
* `SharedPreferences`-backed identity. The id is generated once and kept, so
* it survives restarts. It is a random UUID, never a hardware identifier,
* which would need permissions and would follow the user across apps.
*/
class StoredDeviceIdentity(context: Context) : DeviceIdentity {

private val prefs: SharedPreferences =
context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)

override val instanceId: String = loadOrCreateInstanceId()

/** The device model, which is what the user recognizes in a device list. */
override val displayName: String
get() = Build.MODEL?.takeIf { it.isNotBlank() } ?: "Android device"

private fun loadOrCreateInstanceId(): String {
val existing = prefs.getString(KEY_INSTANCE_ID, null)
if (!existing.isNullOrBlank()) {
return existing
}
val generated = UUID.randomUUID().toString()
prefs.edit().putString(KEY_INSTANCE_ID, generated).apply()
return generated
}

private companion object {
const val PREFS_NAME = "typeagent_device_identity"
const val KEY_INSTANCE_ID = "instance_id"
}
}
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,16 @@ import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger

class WebSocketManager internal constructor(
/**
* Who this device is on the server. Required rather than defaulted so the
* production path cannot fall back to a throwaway identity, which would
* make every reconnect look like a new device. Tests pass a fake, because
* the real one needs a `Context`.
*/
private val deviceIdentity: DeviceIdentity,
/**
* Overridden by unit tests with a fake so the connect and registration
* handshake can be driven without a server. Production callers use the
* no-argument constructor and get [client].
* handshake can be driven without a server. Production callers get [client].
*/
webSocketFactory: WebSocket.Factory? = null
) {
Expand Down Expand Up @@ -389,7 +395,7 @@ class WebSocketManager internal constructor(
*/
private fun joinConversation(resumeConversationId: String?) {
val options = JSONObject()
.put("clientType", "extension")
.put("clientType", "android")
.put("filter", false)
.putOpt("conversationId", resumeConversationId)

Expand Down Expand Up @@ -487,7 +493,9 @@ class WebSocketManager internal constructor(
args = listOf(
AndroidDeviceAgent.createRegistrationParams(
conversationId = joinedConversationId,
schemaContent = schemaContent
schemaContent = schemaContent,
instanceId = deviceIdentity.instanceId,
displayName = deviceIdentity.displayName
)
),
onResult = {
Expand Down Expand Up @@ -522,16 +530,24 @@ class WebSocketManager internal constructor(
}

/**
* Recovers from the server reporting `androidDevice` as already registered
* for this conversation.
* Compatibility shim for a server that still rejects a second registration
* of `androidDevice` on one conversation.
*
* A server that tracks devices by `instanceId` replaces this device in
* place on reconnect, so this cannot fire. It only runs against an older
* server.
*
* The stale entry is bound to a socket that is gone, so keeping it leaves
* actions routed into a dead channel. `unregisterClientAgent` removes the
* entry whichever connection made it, so evicting it and registering again
* rebinds the route to this connection.
* On an older server the stale entry is bound to a dead socket, so
* evicting it and registering again rebinds the route here. Against a
* fixed server the eviction is inert, which is deliberate: it must never
* be able to drop another device's live registration.
*
* Tried once per connection: a second collision means the eviction did not
* clear the entry, and retrying would loop.
*
* TODO: delete this method, [reuseExistingRegistration],
* [isAgentAlreadyRegisteredError] and their tests once every supported
* server tracks client agents by `instanceId`.
*/
private fun handleRegistrationCollision(joinedConversationId: String) {
val alreadyAttempted = synchronized(lock) {
Expand Down Expand Up @@ -577,16 +593,18 @@ class WebSocketManager internal constructor(
}

/**
* Last resort when the stale registration cannot be evicted. Actions stay
* routed at the connection it was made on, so they will not reach this
* device until that entry is gone, which only happens once the server drops
* the dispatcher for the conversation.
* Last resort when the stale registration cannot be evicted. It belongs to
* another connection, so actions stay routed there until the server drops
* the dispatcher for the conversation. The status stays distinct from
* [STATUS_AGENT_REGISTERED] and the log stays at warning level so a masked
* rejection is still traceable.
*/
private fun reuseExistingRegistration(joinedConversationId: String) {
markClientAgentRegistered(
logMessage = "Client agent ${AndroidDeviceAgent.NAME} is still registered for " +
"conversation $joinedConversationId and could not be reclaimed. Actions will " +
"not reach this device until that registration is removed.",
"conversation $joinedConversationId by another connection and could not be " +
"reclaimed. Actions will not reach this device until that registration is " +
"removed.",
statusText = STATUS_AGENT_REGISTRATION_REUSED,
isRecovery = true
)
Expand Down Expand Up @@ -1700,9 +1718,15 @@ internal fun isConversationNotFoundError(error: String?): Boolean =
* leave, so restarting the app does not clear it while another client is
* joined. Left unhandled, the app then refuses every executeAction.
*
* A server that tracks devices by `instanceId` replaces this device in place,
* so this only matches against an older server or one with multi-instance
* support switched off.
*
* Matches the whole `App agent '<name>' already exists` phrase, not the agent
* name alone, because the caller reacts by claiming the agent is registered: a
* missed match degrades to the pre-existing failure, a false match hides it.
* missed match degrades to the pre-existing failure, a false match hides it. Do
* not widen it to any other error text - the schema-version-mismatch message in
* particular must reach the user.
*/
internal fun isAgentAlreadyRegisteredError(error: String?, agentName: String): Boolean {
val text = error?.trim().orEmpty()
Expand Down
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,12 @@
Path must track ConversationStore.PREFS_NAME + ".xml".
-->
<exclude domain="sharedpref" path="typeagent_chat_session.xml" />
<!--
The device id identifies one physical device to the server, which routes
actions by it. Restoring it onto a second device would give both the
same id, and the server would then treat them as one device and run
actions on whichever registered last.
Path must track StoredDeviceIdentity.PREFS_NAME + ".xml".
-->
<exclude domain="sharedpref" path="typeagent_device_identity.xml" />
</full-backup-content>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,22 @@
Path must track ConversationStore.PREFS_NAME + ".xml".
-->
<exclude domain="sharedpref" path="typeagent_chat_session.xml" />
<!--
The device id identifies one physical device to the server, which
routes actions by it. Restoring it onto a second device would give
both the same id, and the server would then treat them as one
device and run actions on whichever registered last.
Path must track StoredDeviceIdentity.PREFS_NAME + ".xml".
-->
<exclude domain="sharedpref" path="typeagent_device_identity.xml" />
</cloud-backup>
<!--
device-transfer is a direct device-to-device migration with no cloud
copy, so the transcript is allowed to follow the user to a new phone.
The device id still must not: a transfer leaves the old phone working,
so copying it would put two live devices behind one id.
-->
<device-transfer>
<exclude domain="sharedpref" path="typeagent_device_identity.xml" />
</device-transfer>
</data-extraction-rules>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.example.typeagentchat
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.Calendar
Expand All @@ -12,11 +13,16 @@ class AndroidDeviceAgentTest {
fun registrationIncludesInlineSchemaAndExecuteAction() {
val registration = AndroidDeviceAgent.createRegistrationParams(
conversationId = "conversation-1",
schemaContent = "export type AndroidDeviceAction = never;"
schemaContent = "export type AndroidDeviceAction = never;",
instanceId = "instance-1",
displayName = "Pixel 8"
)

assertEquals(AndroidDeviceAgent.NAME, registration.getString("name"))
assertEquals("conversation-1", registration.getString("conversationId"))
assertEquals("instance-1", registration.getString("instanceId"))
assertEquals("Pixel 8", registration.getString("displayName"))
assertEquals(true, registration.getBoolean("multiInstance"))
assertEquals(
"executeAction",
registration.getJSONArray("agentInterface").getString(0)
Expand All @@ -32,11 +38,14 @@ class AndroidDeviceAgentTest {
}

@Test
fun unregistrationIdentifiesTheAgentAndConversation() {
fun unregistrationIdentifiesTheAgentAndConversationButNoInstance() {
val unregistration = AndroidDeviceAgent.createUnregistrationParams("conversation-1")

assertEquals(AndroidDeviceAgent.NAME, unregistration.getString("name"))
assertEquals("conversation-1", unregistration.getString("conversationId"))
// The server resolves this to the calling connection's own instance, so
// naming one here would let it drop another device's registration.
assertFalse(unregistration.has("instanceId"))
}

@Test
Expand Down
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import okio.ByteString
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Test

Expand All @@ -21,7 +22,7 @@ import org.junit.Test
class RegistrationCollisionRecoveryTest {

private val transport = FakeTransport()
private val manager = WebSocketManager(transport)
private val manager = WebSocketManager(FakeDeviceIdentity(), transport)

@Test
fun `a collision evicts the stale registration and registers again`() {
Expand Down Expand Up @@ -138,6 +139,32 @@ class RegistrationCollisionRecoveryTest {
)
}

@Test
fun `registration carries this device's identity`() {
connectAndJoin()

val register = transport.takeInvoke("registerClientAgent")
assertEquals("device-under-test", register.firstArg().getString("instanceId"))
assertEquals("Test Phone", register.firstArg().getString("displayName"))
// Without this the server rejects the second device, the same way it
// does for a client that expects to be the only host of its agent.
assertEquals(true, register.firstArg().getBoolean("multiInstance"))
register.succeed()
}

@Test
fun `the eviction call names no instance`() {
connectAndJoin()

transport.takeInvoke("registerClientAgent")
.failWith("App agent 'androidDevice' already exists")

val unregister = transport.takeInvoke("unregisterClientAgent")
// Naming an instance would let this shim drop another device's live
// registration; against a fixed server the call must stay inert.
assertFalse(unregister.firstArg().has("instanceId"))
}

/** Connects, opens the socket, and answers `joinConversation`. */
private fun connectAndJoin() {
manager.connect(
Expand Down Expand Up @@ -263,6 +290,15 @@ class RegistrationCollisionRecoveryTest {
override fun cancel() = Unit
}

/**
* Stands in for [StoredDeviceIdentity], which needs a `Context` these plain
* JVM tests do not have.
*/
private class FakeDeviceIdentity : DeviceIdentity {
override val instanceId = "device-under-test"
override val displayName = "Test Phone"
}

private companion object {
const val CONVERSATION_ID = "conversation-1"
}
Expand Down
Loading
Loading

Back | FazBrowse Home | New Git URL