Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions android/samples/mobile-2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,30 @@ An Android Jetpack Compose chat client that connects to a TypeAgent agent-server
[Conversation persistence](#conversation-persistence))
- DevTunnel authentication via `X-Tunnel-Authorization` header
- Build-time configuration via environment variables and `BuildConfig`
- Wear OS prompt handoff from the companion sample

## Wear OS prompt handoff

The companion project at [`../wearos`](../wearos/) recognizes speech on the
watch and opens this app with a `typeagentchat://main` deep link. The phone owns
the only TypeAgent WebSocket connection and submits the prompt after that
connection is ready.

This is intentionally a fire-and-forget POC. The watch reports whether Android
handed the prompt to the paired phone, but TypeAgent responses remain in the
phone chat.

External prompts fill the composer and wait for an explicit Send tap by default.
To enable automatic execution for controlled POC testing, build with:

```powershell
.\gradlew.bat -Ptypeagent.wear.autoexecute=true assembleDebug
```

The deep link is `BROWSABLE`, as required by `RemoteActivityHelper`, and can
therefore be invoked by another app. The build flag is a demo switch, not an
authentication boundary. Use the Wear Data Layer before enabling automatic
execution in a production app.

## Conversation persistence

Expand Down Expand Up @@ -174,6 +198,7 @@ The app connects automatically on launch. Tap **Retry** in the status bar if the

- **Token storage**: `TYPEAGENT_TUNNEL_TOKEN` is compiled into `BuildConfig`. Do not distribute APKs built with a sensitive or long-lived token.
- **Token transmission**: The token is sent only as an HTTP upgrade header and is never logged.
- **Wear prompt transport**: The POC deep link is externally reachable. Only enable automatic execution with `-Ptypeagent.wear.autoexecute=true` on controlled test devices.

[devtunnel]: https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/
[devtunnel-cli]: https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/get-started
10 changes: 10 additions & 0 deletions android/samples/mobile-2/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ val tunnelTokenFromEnv = providers.environmentVariable("TYPEAGENT_TUNNEL_TOKEN")
.orElse("")
.get()
.escapeForBuildConfig()
val wearPromptAutoExecute = providers.gradleProperty("typeagent.wear.autoexecute")
.orElse("false")
.get()
.also { value ->
require(value == "true" || value == "false") {
"typeagent.wear.autoexecute must be true or false"
}
}

android {
namespace = "com.example.typeagentchat"
Expand All @@ -33,6 +41,7 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "TYPEAGENT_SERVER_URL", "\"$tunnelUrlFromEnv\"")
buildConfigField("String", "TYPEAGENT_TUNNEL_TOKEN", "\"$tunnelTokenFromEnv\"")
buildConfigField("boolean", "WEAR_PROMPT_AUTOEXECUTE", wearPromptAutoExecute)
}

buildTypes {
Expand Down Expand Up @@ -69,6 +78,7 @@ dependencies {
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.commonmark)
implementation(libs.squareup.okhttp)
Expand Down
17 changes: 17 additions & 0 deletions android/samples/mobile-2/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -139,13 +139,30 @@
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:launchMode="singleTask"
android:theme="@style/Theme.TypeAgentChat"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!--
POC entry point for the paired Wear OS sample. BROWSABLE is required
by RemoteActivityHelper, so other apps can invoke it too. Automatic
execution is therefore controlled by a build flag and must not be
treated as a trusted production transport.
-->
<intent-filter>
<action android:name="android.intent.action.VIEW" />

<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />

<data
android:host="main"
android:scheme="typeagentchat" />
</intent-filter>
</activity>
</application>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull

/**
* A client action that the agent asked the app to perform and that can only be
Expand Down Expand Up @@ -150,6 +152,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
*/
private val clientActionEvents = Channel<ClientAction>(Channel.UNLIMITED)
internal val clientActions: Flow<ClientAction> = clientActionEvents.receiveAsFlow()
private val externalPromptEvents = Channel<ExternalPrompt>(Channel.UNLIMITED)
private val externalPromptDrafts = ExternalPromptDrafts()

private var hasConnected = false

Expand Down Expand Up @@ -303,6 +307,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {

observeConversationForPersistence()
observeConversationIdForPersistence()
viewModelScope.launch {
for (prompt in externalPromptEvents) {
deliverExternalPrompt(prompt)
}
}
}

/**
Expand Down Expand Up @@ -436,7 +445,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}

fun onInputTextChange(text: String) {
_inputText.value = text
_inputText.value = if (text.isBlank()) {
externalPromptDrafts.currentRemoved("")
} else {
text
}
}

private val isConnected: Boolean
Expand All @@ -447,7 +460,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {

/** @return true when the message was handed to the socket and the input was cleared. */
fun submitMessage(): Boolean {
return sendText(_inputText.value)
return submitComposerText(_inputText.value)
}

/**
Expand All @@ -464,7 +477,48 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
_inputText.value = merged
return false
}
return sendText(merged)
return submitComposerText(merged)
}

fun submitExternalPrompt(prompt: String, autoExecute: Boolean) {
val text = prompt.trim()
if (text.isEmpty()) {
return
}
if (!autoExecute) {
parkInInput(text)
return
}
if (externalPromptEvents.trySend(ExternalPrompt(text)).isFailure) {
Log.w(TAG, "Could not queue external prompt: the chat screen is gone")
parkInInput(text)
}
}

private suspend fun deliverExternalPrompt(prompt: ExternalPrompt) {
if (
!awaitExternalPromptConnection() ||
!webSocketManager.trySendExternalCommand(prompt.text)
) {
parkInInput(prompt.text)
}
}

private suspend fun awaitExternalPromptConnection(): Boolean {
if (isConnected) {
return true
}
val settled = withTimeoutOrNull(EXTERNAL_PROMPT_CONNECT_TIMEOUT_MILLIS) {
connectionStatus.first {
it.state == ConnectionStatus.State.CONNECTED ||
it.state == ConnectionStatus.State.ERROR
}
}
return settled?.state == ConnectionStatus.State.CONNECTED
}

private fun parkInInput(text: String) {
_inputText.value = externalPromptDrafts.park(_inputText.value, text)
}

fun respondToPendingYesNo(yes: Boolean): Boolean = webSocketManager.respondToPendingYesNo(yes)
Expand Down Expand Up @@ -500,13 +554,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
}

private fun sendText(text: String): Boolean {
private fun submitComposerText(text: String): Boolean {
val message = text.trim()
if (!isConnected || message.isBlank()) {
return false
}
webSocketManager.sendMessage(message)
_inputText.value = ""
if (externalPromptDrafts.isShowingExternalPrompt) {
if (!webSocketManager.trySendExternalCommand(message)) {
return false
}
} else {
webSocketManager.sendMessage(message)
}
_inputText.value = externalPromptDrafts.currentRemoved("")
return true
}

Expand All @@ -515,6 +575,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
webSocketManager.setStaleConversationHandler(null)
webSocketManager.disconnect()
clientActionEvents.close()
externalPromptEvents.close()
flushConversationToDisk()
super.onCleared()
}
Expand Down Expand Up @@ -546,6 +607,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {

private companion object {
private const val TAG = "ChatViewModel"
private const val EXTERNAL_PROMPT_CONNECT_TIMEOUT_MILLIS = 15_000L

/**
* Long enough to collapse a burst of streamed display chunks into one
Expand All @@ -556,6 +618,34 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
}

private data class ExternalPrompt(val text: String)

internal class ExternalPromptDrafts {
private val queuedPrompts = ArrayDeque<String>()

var isShowingExternalPrompt = false
private set

fun park(currentText: String, prompt: String): String {
queuedPrompts.addLast(prompt)
return showNextIfAvailable(currentText)
}

fun currentRemoved(currentText: String): String {
isShowingExternalPrompt = false
return showNextIfAvailable(currentText)
}

private fun showNextIfAvailable(currentText: String): String {
if (isShowingExternalPrompt || currentText.isNotBlank()) {
return currentText
}
val nextPrompt = queuedPrompts.removeFirstOrNull() ?: return currentText
isShowingExternalPrompt = true
return nextPrompt
}
}

internal fun mergeSpeechInputText(
currentText: String,
recognizedText: String
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ class MainActivity : ComponentActivity() {
tunnelToken = tunnelToken,
schemaContent = agentSchemaContent
)
if (savedInstanceState == null) {
handleExternalPrompt(intent)
}

// Collected for the Activity's whole lifetime rather than only while
// RESUMED. An agent-driven action has an `executeAction` RPC waiting on
Expand Down Expand Up @@ -171,6 +174,37 @@ class MainActivity : ComponentActivity() {
}
}

override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleExternalPrompt(intent)
}

private fun handleExternalPrompt(intent: Intent?) {
if (intent?.action != Intent.ACTION_VIEW) {
return
}

when (val result = parseWearPrompt(intent.toWearLinkFields())) {
is WearPromptResult.Accepted -> {
val autoExecute =
BuildConfig.WEAR_PROMPT_AUTOEXECUTE && result.prompt.requestsExecute
Log.d(
TAG,
"External prompt accepted length=${result.prompt.text.length} " +
"autoExecute=$autoExecute"
)
viewModel.submitExternalPrompt(result.prompt.text, autoExecute)
}

is WearPromptResult.Rejected -> {
if (result.reason != WearPromptRejection.NOT_A_PROMPT_LINK) {
Log.w(TAG, "External prompt rejected: ${result.reason}")
}
}
}
}

// No onDestroy teardown: the socket is owned by ChatViewModel and released
// in its onCleared. Disconnecting here would tear the connection down on
// every rotation, theme or locale change.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.example.typeagentchat

import android.content.Intent

internal fun Intent.toWearLinkFields(): WearLinkFields {
val uri = data
val hierarchicalUri = uri?.takeIf { it.isHierarchical }
return WearLinkFields(
scheme = uri?.scheme,
host = uri?.host,
promptQuery = hierarchicalUri?.getQueryParameter(WEAR_PROMPT_PARAM),
executeQuery = hierarchicalUri?.getQueryParameter(WEAR_EXECUTE_PARAM),
promptExtra = getStringExtra(WEAR_PROMPT_PARAM)
)
}
Loading