diff --git a/android/samples/mobile-2/README.md b/android/samples/mobile-2/README.md index fcbec853cc..4252467ecb 100644 --- a/android/samples/mobile-2/README.md +++ b/android/samples/mobile-2/README.md @@ -14,9 +14,73 @@ An Android Jetpack Compose chat client that connects to a TypeAgent agent-server - Incremental assistant response streaming into a single bubble per `requestId`, honouring the SDK's `DisplayAppendMode` (`inline`, `block`, `temporary`, `step`) and `DisplayMessageKind` styling the same way the Electron shell does +- Chat history that survives both configuration changes and process death, and + resumes the same server-side conversation (see + [Conversation persistence](#conversation-persistence)) - DevTunnel authentication via `X-Tunnel-Authorization` header - Build-time configuration via environment variables and `BuildConfig` +## Conversation persistence + +The chat conversation is owned by a `ViewModel`, so rotation, theme, font-scale +and locale changes no longer tear down the socket and the transcript. + +A `ViewModel` dies with its process though, which Android does routinely once the +app is backgrounded. The transcript is therefore mirrored to `SharedPreferences` +by `ConversationStore` and restored on the next start, capped at the most recent +`ConversationSerializer.MAX_PERSISTED_MESSAGES` messages. The server cannot fill +this gap for this client: it reads no display history, so the client has to own +its own transcript. + +The joined `conversationId` is stored alongside the messages and passed back into +`joinConversation` as a connect option on the next launch, so the client resumes +the exact conversation the transcript belongs to rather than landing on the +server's default one. If the server no longer has that conversation it answers +`Conversation not found`; the join then falls back to the default conversation +once and the orphaned transcript is dropped from both screen and disk. Every +other join failure - transport, tunnel auth - still surfaces as a connection +error, so an outage cannot silently move the user into a different conversation. + +> **Terminology.** This is a *conversation* (user-facing identity and chat +> history), not a dispatcher *session* (configuration, caches, agent state). +> The `SharedPreferences` file is still named `typeagent_chat_session.xml` +> because that name is pinned in the backup rules and already exists on devices; +> renaming it would orphan stored transcripts. + +### What is stored, and for how long + +Everything lives in one private `SharedPreferences` file inside the app's own +sandbox (`typeagent_chat_session.xml`), readable only by this app. Nothing is +written to shared or external storage. + +Two independent limits keep it from growing without end: + +| Limit | Constant | Effect | +|---|---|---| +| Size | `MAX_PERSISTED_MESSAGES` (200) | Only the newest 200 messages are kept. A full 200-message transcript measures ~59 KB. | +| Age | `MAX_MESSAGE_AGE_MILLIS` (30 days) | Messages older than the window are deleted, including while the app is not running. | + +Retention runs on both save and load. Because a load only *filters* what it +reads, a read that drops anything immediately rewrites the file, so expired +messages are erased rather than merely hidden. Expiry is applied to what is +stored, not to what is already on screen: messages already visible stay for the +rest of the conversation rather than disappearing mid-chat. + +Saving is debounced (`ChatViewModel.SAVE_DEBOUNCE_MS`). `SharedPreferences` +rewrites its entire file on every commit and the message list re-emits on every +streaming chunk, so an undebounced save would rewrite the whole blob dozens of +times per reply. + +The transcript is excluded from Android's Auto Backup (`backup_rules.xml` and +`data_extraction_rules.xml`), so conversations are never uploaded to the user's +cloud account. A direct device-to-device transfer does carry it, since that +copies straight to the new phone without a cloud round trip. + +**Clear chat** in the header removes the transcript from both the screen and disk +after a confirmation. It is a client-side reset only, matching `@clear` on the +other TypeAgent canvases: the conversation itself is untouched, so the agent +keeps its memory and the next launch resumes the same conversation. + ## Client-hosted Android agent After joining a conversation, the app registers `androidDevice` as a diff --git a/android/samples/mobile-2/app/build.gradle.kts b/android/samples/mobile-2/app/build.gradle.kts index c779d27dba..5ccb02d424 100644 --- a/android/samples/mobile-2/app/build.gradle.kts +++ b/android/samples/mobile-2/app/build.gradle.kts @@ -62,6 +62,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.compose) implementation(libs.commonmark) implementation(libs.squareup.okhttp) testImplementation(libs.json) diff --git a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/ChatViewModel.kt b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/ChatViewModel.kt new file mode 100644 index 0000000000..436c128269 --- /dev/null +++ b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/ChatViewModel.kt @@ -0,0 +1,421 @@ +package com.example.typeagentchat + +import android.app.Application +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * A client action that the agent asked the app to perform and that can only be + * carried out by an Activity (it ends in `startActivity`). + * + * These are delivered as events rather than state: the ViewModel outlives the + * Activity across configuration changes, so it must not hold a reference to the + * Activity that will ultimately handle them. + * + * [ClientAction.Alarm] and [ClientAction.Timer] carry the `executeAction` + * completion, because the server is holding an RPC open waiting for the result. + * `SearchNearby` arrives over the legacy fire-and-forget `takeAction` path and + * has nothing to report back to. + */ +internal sealed interface ClientAction { + data class Alarm( + val action: SetAlarmAction, + val completion: (AndroidDeviceExecutionResult) -> Unit + ) : ClientAction + + data class Timer( + val action: SetTimerAction, + val completion: (AndroidDeviceExecutionResult) -> Unit + ) : ClientAction + + data class SearchNearby(val action: SearchNearbyAction) : ClientAction +} + +/** + * Owns the chat conversation so it survives configuration changes, and + * persists it so it also survives process death. + * + * The [WebSocketManager] used to be a field on `MainActivity`, which meant a + * theme change, rotation, font-scale change or locale change destroyed the + * socket and the entire message list and started a brand new conversation. + * `WebSocketManager.disconnect()` also shuts the OkHttp client's executor down + * for good, so the instance could not be reused even in principle. Holding it + * here scopes it to the logical screen instead of the Activity instance. + * + * A ViewModel still dies with its process though, which is routine as soon as + * the user switches to another app. The transcript is therefore mirrored to + * [ConversationStore] and restored on the next start, alongside the id of the + * conversation it belongs to. That id is passed back into + * [WebSocketManager.connect] so the client rejoins the exact same server-side + * conversation - the restored transcript then always lines up with what the + * agent remembers, with no post-hoc reconciliation needed. + */ +class ChatViewModel(application: Application) : AndroidViewModel(application) { + + private val webSocketManager = WebSocketManager() + private val conversationStore = ConversationStore(application) + + val messages: StateFlow> = webSocketManager.messages + val connectionStatus: StateFlow = webSocketManager.connectionStatus + val pendingYesNoPrompt: StateFlow = webSocketManager.pendingYesNoPrompt + + private val _inputText = MutableStateFlow("") + val inputText: StateFlow = _inputText + + /** + * Buffered so an action that arrives during the gap between the old + * Activity being destroyed and the new one being created - a rotation, + * theme or locale change - is delivered to the new Activity instead of + * being dropped on the floor. + * + * The consumer collects for the Activity's whole lifetime, not just while + * it is resumed, so a queued action is never left waiting on the user + * returning to the app. It does wait briefly for a recreated Activity to + * reach RESUMED before dispatching. Foreground-only enforcement lives in + * `MainActivity.launchExternalIntent`, which reports the refusal. + */ + private val clientActionEvents = Channel(Channel.UNLIMITED) + internal val clientActions: Flow = clientActionEvents.receiveAsFlow() + + private var hasConnected = false + + /** + * The conversation the restored transcript was recorded against, and the + * one the next connect asks the server to resume. + */ + @Volatile + private var savedConversationId: String? = null + + /** + * Completes once the persisted transcript has been read back and handed to + * the socket. Connecting waits on it so a slow disk read can never race the + * first inbound message, and so the saved conversation id is known before + * the join is issued. + */ + private val restored = CompletableDeferred() + + init { + webSocketManager.setClientActionHandler(object : WebSocketManager.ClientActionHandler { + override fun onSetAlarm( + action: SetAlarmAction, + completion: (AndroidDeviceExecutionResult) -> Unit + ) { + dispatchClientAction(ClientAction.Alarm(action, completion), completion) + } + + override fun onSetTimer( + action: SetTimerAction, + completion: (AndroidDeviceExecutionResult) -> Unit + ) { + dispatchClientAction(ClientAction.Timer(action, completion), completion) + } + + override fun onSearchNearby(action: SearchNearbyAction) { + dispatchClientAction(ClientAction.SearchNearby(action)) + } + }) + + webSocketManager.setStaleConversationHandler { + // The saved conversation no longer exists on the server, so the + // join fell back to the default one. The restored transcript is a + // record of a conversation nothing remembers - drop it rather than + // graft it onto a different one. + Log.w(TAG, "Saved conversation is gone; discarding the restored transcript") + savedConversationId = null + webSocketManager.clearMessages() + viewModelScope.launch { + withContext(Dispatchers.IO) { conversationStore.clear() } + } + } + + viewModelScope.launch { + val conversation = withContext(Dispatchers.IO) { conversationStore.load() } + savedConversationId = conversation.conversationId + webSocketManager.restoreMessages(conversation.messages) + Log.d( + TAG, + "Restored ${conversation.messages.size} messages " + + "for conversationId=${conversation.conversationId ?: "none"}" + ) + restored.complete(Unit) + } + + observeConversationForPersistence() + observeConversationIdForPersistence() + } + + /** + * The conversation the transcript currently belongs to. + * + * Prefers the live join over the restored value, and is null only while no + * join has landed yet - including the gap between a missing conversation + * being detected and the fallback join completing, where the old id is + * deliberately no longer trusted. + */ + private fun currentConversationId(): String? = + webSocketManager.lastJoinedConversationId.value ?: savedConversationId + + /** + * Persists the conversation id as soon as a join lands. + * + * [observeConversationForPersistence] only writes when the message list + * changes, so a conversation that is joined but not yet spoken in never + * reaches disk. That matters most right after the not-found fallback: the + * stale handler has just wiped the stored record, and without this the new + * id would sit only in memory until the user happened to send something. + */ + private fun observeConversationIdForPersistence() { + viewModelScope.launch { + restored.await() + webSocketManager.lastJoinedConversationId + .filterNotNull() + .collect { conversationId -> + if (conversationId == savedConversationId) { + return@collect + } + savedConversationId = conversationId + withContext(Dispatchers.IO) { + conversationStore.save( + PersistedConversation( + conversationId = conversationId, + messages = webSocketManager.messages.value + ) + ) + } + } + } + } + + /** + * Mirrors the transcript back to disk. + * + * Writes are debounced because `_messages` is re-emitted for every streamed + * display chunk, while `SharedPreferences` rewrites the whole file on each + * commit - so saving per emission would re-encode and rewrite the entire + * transcript dozens of times for a single agent response. `collectLatest` + * cancels the pending delay whenever a newer value arrives, so a burst of + * chunks collapses into one write once the stream settles. + * + * The initial value is dropped so the empty list the socket starts with + * cannot overwrite a transcript that is still being read back. + */ + private fun observeConversationForPersistence() { + viewModelScope.launch { + restored.await() + webSocketManager.messages.drop(1).collectLatest { messages -> + delay(SAVE_DEBOUNCE_MS) + val conversationId = currentConversationId() + withContext(Dispatchers.IO) { + conversationStore.save( + PersistedConversation( + conversationId = conversationId, + messages = messages + ) + ) + } + } + } + } + + /** + * Connects on first use only, so Activity recreation does not restart the + * conversation. The saved conversation id is passed through so the server + * rejoins it directly. + */ + fun connectIfNeeded(url: String, tunnelToken: String?, schemaContent: String) { + if (hasConnected) { + return + } + hasConnected = true + viewModelScope.launch { + restored.await() + webSocketManager.connect( + url = url, + tunnelToken = tunnelToken, + schemaContent = schemaContent, + resumeConversationId = savedConversationId + ) + } + } + + fun reconnect(url: String, tunnelToken: String?, schemaContent: String) { + hasConnected = true + viewModelScope.launch { + restored.await() + webSocketManager.connect( + url = url, + tunnelToken = tunnelToken, + schemaContent = schemaContent, + resumeConversationId = webSocketManager.lastJoinedConversationId.value + ?: savedConversationId + ) + } + } + + /** + * Queues a client action for the chat Activity. + * + * The channel is unbounded, so the only way the send fails is if it has + * already been closed in [onCleared]. When that happens the agent is still + * holding an `executeAction` RPC open, so the completion is failed here + * rather than left hanging until the connection drops. + */ + private fun dispatchClientAction( + action: ClientAction, + completion: ((AndroidDeviceExecutionResult) -> Unit)? = null + ) { + if (clientActionEvents.trySend(action).isFailure) { + Log.w(TAG, "Dropping client action: the chat screen is gone") + completion?.invoke( + AndroidDeviceExecutionResult.Failure( + "The Android app is no longer able to run this action." + ) + ) + } + } + + fun onInputTextChange(text: String) { + _inputText.value = text + } + + private val isConnected: Boolean + get() = connectionStatus.value.state == ConnectionStatus.State.CONNECTED + + val canSend: Boolean + get() = isConnected && _inputText.value.isNotBlank() + + /** @return true when the message was handed to the socket and the input was cleared. */ + fun submitMessage(): Boolean { + return sendText(_inputText.value) + } + + /** + * Sends dictated speech straight through instead of parking it in the input + * box and waiting for a Send tap. If the socket is down the text is kept in + * the input box so nothing spoken is lost. + */ + fun onRecognizedText(recognizedText: String): Boolean { + val merged = mergeSpeechInputText( + currentText = _inputText.value, + recognizedText = recognizedText + ) + if (!isConnected) { + _inputText.value = merged + return false + } + return sendText(merged) + } + + fun respondToPendingYesNo(yes: Boolean): Boolean = webSocketManager.respondToPendingYesNo(yes) + + /** + * Clears the chat history: the on-screen transcript and the copy on disk. + * + * This is a client-side reset only, matching `@clear` on the other + * TypeAgent canvases. The server-side conversation is untouched, so the + * agent keeps its own memory and the next connect resumes the same + * conversation. Starting a genuinely new server-side conversation would + * need `createConversation` / `leaveConversation`, which this client does + * not yet drive. + * + * An empty record is written rather than the whole entry removed, because + * removing it would drop the conversation id too. The debounced writer + * would put it back a moment later, but a force-stop in that window would + * leave nothing to resume and the next launch would silently land in the + * default conversation - breaking the promise above. + */ + fun clearChatHistory() { + webSocketManager.clearMessages() + val conversationId = currentConversationId() + viewModelScope.launch { + withContext(Dispatchers.IO) { + conversationStore.save( + PersistedConversation( + conversationId = conversationId, + messages = emptyList() + ) + ) + } + } + } + + private fun sendText(text: String): Boolean { + val message = text.trim() + if (!isConnected || message.isBlank()) { + return false + } + webSocketManager.sendMessage(message) + _inputText.value = "" + return true + } + + override fun onCleared() { + webSocketManager.setClientActionHandler(null) + webSocketManager.setStaleConversationHandler(null) + webSocketManager.disconnect() + clientActionEvents.close() + flushConversationToDisk() + super.onCleared() + } + + /** + * Writes the transcript one last time as the screen goes away. + * + * `viewModelScope` is cancelled *before* [onCleared] runs, which takes the + * debounced writer down with it. Without this, anything changed in the last + * [SAVE_DEBOUNCE_MS] never reaches disk, and neither do the open bubbles + * that [WebSocketManager.disconnect] just sealed above - those are mutated + * after the writer is already dead, so they could never be saved at all. + * + * Skipped until the restore has finished: a teardown that races the initial + * read would otherwise persist the still-empty transcript over the stored + * one and lose the whole history. + */ + private fun flushConversationToDisk() { + if (!restored.isCompleted) { + return + } + conversationStore.save( + PersistedConversation( + conversationId = currentConversationId(), + messages = webSocketManager.messages.value + ) + ) + } + + private companion object { + private const val TAG = "ChatViewModel" + + /** + * Long enough to collapse a burst of streamed display chunks into one + * write, short enough that an unexpected process kill loses at most + * this much of the transcript. + */ + private const val SAVE_DEBOUNCE_MS = 400L + } +} + +internal fun mergeSpeechInputText( + currentText: String, + recognizedText: String +): String { + return if (currentText.isBlank()) { + recognizedText + } else { + "$currentText $recognizedText" + } +} diff --git a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/ConversationStore.kt b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/ConversationStore.kt new file mode 100644 index 0000000000..9c0d388a63 --- /dev/null +++ b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/ConversationStore.kt @@ -0,0 +1,263 @@ +package com.example.typeagentchat + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import org.json.JSONArray +import org.json.JSONObject + +/** + * The chat transcript plus the server conversation it belongs to. + * + * [conversationId] is the conversation the transcript was recorded against. It + * is passed back to `joinConversation` on the next connect so the client + * resumes the same conversation rather than landing on the default one. + * + * Note this is a *conversation*, not a dispatcher *session*: AgentServer + * reserves "session" for dispatcher runtime state (configuration, caches, agent + * state), while a conversation is the user-facing identity and chat history. + */ +data class PersistedConversation( + val conversationId: String?, + val messages: List +) { + companion object { + val EMPTY = PersistedConversation(conversationId = null, messages = emptyList()) + } +} + +/** + * A decoded conversation plus the number of stored messages that retention + * discarded (aged out, or trimmed by the message cap). + * + * A non-zero count means the file on disk still contains messages the app will + * no longer show, so the caller should rewrite it to actually delete them. + */ +data class DecodedConversation( + val conversation: PersistedConversation, + val droppedCount: Int +) + + +/** + * Persists the chat transcript so it survives process death. + * + * A `ViewModel` only survives configuration changes, so without this the whole + * conversation disappeared the first time Android reclaimed the backgrounded + * app process. The TypeAgent server cannot fill the gap: it exposes no history + * API to this client (`getChatHistory`, `getMessages` and friends all answer + * "No invoke handler"), so the client has to own its own transcript. + * + * `SharedPreferences` is used rather than Room/DataStore to avoid adding + * dependencies for what is a single small blob. Reads and writes are blocking, + * so callers must keep them off the main thread. + */ +class ConversationStore(context: Context) { + + private val prefs: SharedPreferences = + context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + fun load(): PersistedConversation { + val raw = prefs.getString(KEY_CONVERSATION, null) ?: return PersistedConversation.EMPTY + return try { + val decoded = ConversationSerializer.decodeDetailed(raw) + if (decoded.droppedCount > 0) { + // Retention filters on read, but the expired messages are still + // sitting in the file until something else triggers a save. + // Purge them now so "deleted" means deleted rather than hidden. + Log.d(TAG, "Purging ${decoded.droppedCount} stale messages from disk") + save(decoded.conversation) + } + decoded.conversation + } catch (error: Exception) { + // A partially written or stale-format blob must not brick startup. + Log.e(TAG, "Discarding unreadable persisted conversation", error) + clear() + PersistedConversation.EMPTY + } + } + + /** + * Writes with `commit()` rather than `apply()`. Every caller is already off + * the main thread except the teardown flush, which needs the write to have + * actually landed before the process is free to go away - `apply()` only + * queues it. + */ + fun save(conversation: PersistedConversation) { + try { + prefs.edit() + .putString(KEY_CONVERSATION, ConversationSerializer.encode(conversation)) + .commit() + } catch (error: Exception) { + Log.e(TAG, "Failed to persist conversation", error) + } + } + + fun clear() { + prefs.edit().remove(KEY_CONVERSATION).commit() + } + + private companion object { + private const val TAG = "ConversationStore" + + /** + * Backing file is `shared_prefs/$PREFS_NAME.xml`. That exact name is + * excluded from backups in `res/xml/backup_rules.xml` and + * `res/xml/data_extraction_rules.xml`, so renaming it here without + * updating both silently starts uploading transcripts to the cloud. + * It also names the file already on users' devices, so a rename would + * orphan every stored transcript - hence the "session" wording here + * outliving the class rename. + */ + private const val PREFS_NAME = "typeagent_chat_session" + private const val KEY_CONVERSATION = "session" + } +} + +/** + * JSON encoding for [PersistedConversation]. + * + * Kept free of Android framework types so the round-trip is covered by plain + * JVM unit tests. + */ +object ConversationSerializer { + + /** + * Caps how much transcript is written back to disk. `SharedPreferences` + * holds the whole blob in memory and rewrites it in full on every commit, + * so an unbounded transcript would keep growing the cost of every save. + */ + const val MAX_PERSISTED_MESSAGES = 200 + + /** + * How long a stored message is kept. The message cap alone bounds *size* + * but not *time*: without this the newest 200 messages would sit on the + * device forever. Retention is applied on both save and load, so messages + * also age out while the app is not running. + */ + const val MAX_MESSAGE_AGE_MILLIS = 30L * 24 * 60 * 60 * 1000 + + private const val VERSION = 1 + + fun encode( + conversation: PersistedConversation, + now: Long = System.currentTimeMillis() + ): String { + val messages = retain(conversation.messages, now) + val array = JSONArray() + messages.forEach { array.put(encodeMessage(it)) } + return JSONObject() + .put("version", VERSION) + .putOpt("conversationId", conversation.conversationId) + .put("messages", array) + .toString() + } + + fun decode( + raw: String, + now: Long = System.currentTimeMillis() + ): PersistedConversation = decodeDetailed(raw, now).conversation + + /** + * Same as [decode], but also reports how many stored messages the retention + * window dropped. The caller needs this to know whether the file on disk + * still holds expired content that should be rewritten: filtering on read + * hides old messages, it does not delete them. + */ + fun decodeDetailed( + raw: String, + now: Long = System.currentTimeMillis() + ): DecodedConversation { + val root = JSONObject(raw) + if (root.optInt("version") != VERSION) { + return DecodedConversation(PersistedConversation.EMPTY, droppedCount = 0) + } + val conversationId = root.optString("conversationId").takeIf { it.isNotBlank() } + val array = root.optJSONArray("messages") ?: JSONArray() + val messages = ArrayList(array.length()) + for (index in 0 until array.length()) { + val item = array.optJSONObject(index) ?: continue + decodeMessage(item, now)?.let { messages += it } + } + val retained = retain(messages, now) + return DecodedConversation( + conversation = PersistedConversation( + conversationId = conversationId, + messages = retained + ), + droppedCount = messages.size - retained.size + ) + } + + /** + * Drops messages that are older than the retention window, then keeps only + * the newest [MAX_PERSISTED_MESSAGES]. + * + * A timestamp in the future (clock changes, timezone-induced skew) is + * treated as current rather than expired, so a wrong clock cannot silently + * delete a live conversation. + */ + private fun retain(messages: List, now: Long): List { + return messages + .filter { now - it.timestampMillis <= MAX_MESSAGE_AGE_MILLIS } + .takeLast(MAX_PERSISTED_MESSAGES) + } + + private fun encodeMessage(message: Message): JSONObject { + val segments = JSONArray() + message.segments.forEach { segment -> + segments.put( + JSONObject() + .put("text", segment.text) + .put("format", segment.format.name) + .put("kind", segment.kind.name) + ) + } + return JSONObject() + .put("id", message.id) + .put("isUser", message.isUser) + .putOpt("requestId", message.requestId) + .put("timestamp", message.timestampMillis) + .put("segments", segments) + } + + private fun decodeMessage(item: JSONObject, now: Long): Message? { + val segmentsArray = item.optJSONArray("segments") ?: return null + val segments = ArrayList(segmentsArray.length()) + for (index in 0 until segmentsArray.length()) { + val segment = segmentsArray.optJSONObject(index) ?: continue + segments += MessageSegment( + text = segment.optString("text"), + format = parseFormat(segment.optString("format")), + kind = MessageKind.parse(segment.optString("kind")) + ) + } + if (segments.isEmpty()) { + return null + } + val id = item.optString("id").takeIf { it.isNotBlank() } ?: return null + return Message( + id = id, + segments = segments, + isUser = item.optBoolean("isUser"), + requestId = item.optString("requestId").takeIf { it.isNotBlank() }, + // Restored bubbles are always sealed. Nothing can still be streaming + // into them after a restart, and an unsealed bubble would both render + // "Responding..." forever and be a candidate for the + // `indexOfLast { !isUser && !isFinal }` lookup in + // WebSocketManager.finalizeAssistantMessage. + isFinal = true, + // Transcripts written before timestamps existed are treated as + // current, so upgrading the app grants them one full retention + // window rather than deleting the user's history on first launch. + timestampMillis = item.optLong("timestamp", now).takeIf { it > 0L } ?: now + ) + } + + private fun parseFormat(raw: String?): MessageFormat { + return when (raw?.trim()?.uppercase()) { + MessageFormat.MARKDOWN.name -> MessageFormat.MARKDOWN + else -> MessageFormat.TEXT + } + } +} diff --git a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/MainActivity.kt b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/MainActivity.kt index 2645d65e0a..e12099ea5e 100644 --- a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/MainActivity.kt +++ b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/MainActivity.kt @@ -17,6 +17,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -25,6 +26,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -35,6 +37,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Mic +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon @@ -68,11 +71,16 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.withStateAtLeast import com.example.typeagentchat.ui.theme.TypeAgentChatTheme +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull class MainActivity : ComponentActivity() { - private val webSocketManager = WebSocketManager() + private val viewModel: ChatViewModel by viewModels() private val tunnelUrl = BuildConfig.TYPEAGENT_SERVER_URL.trim() private val tunnelToken = BuildConfig.TYPEAGENT_TUNNEL_TOKEN.trim().ifBlank { null } private val agentSchemaContent by lazy { @@ -112,21 +120,83 @@ class MainActivity : ComponentActivity() { schemaContent = agentSchemaContent ) + // Collected for the Activity's whole lifetime rather than only while + // RESUMED. An agent-driven action has an `executeAction` RPC waiting on + // its completion, so it must be answered promptly even when the app is + // backgrounded - launchExternalIntent does the foreground check itself + // and reports the refusal. Gating collection on RESUMED would instead + // leave the action queued, and the server's call hanging, until the + // user happened to come back. + lifecycleScope.launch { + viewModel.clientActions.collect { action -> + try { + awaitResumedOrGiveUp() + when (action) { + is ClientAction.Alarm -> + launchSetAlarmIntent(action.action, action.completion) + is ClientAction.Timer -> + launchSetTimerIntent(action.action, action.completion) + is ClientAction.SearchNearby -> launchSearchNearbyIntent(action.action) + } + } catch (cancellation: CancellationException) { + // The action was already taken off the channel, so no other + // Activity will ever see it. Answer the waiting RPC before + // unwinding rather than leaving the agent blocked. + action.failWith(ACTIVITY_GONE_MESSAGE) + throw cancellation + } + } + } + setContent { TypeAgentChatTheme { ChatApp( - webSocketManager = webSocketManager, + viewModel = viewModel, tunnelUrl = tunnelUrl, - tunnelToken = tunnelToken + tunnelToken = tunnelToken, + schemaContent = agentSchemaContent ) } } } - override fun onDestroy() { - webSocketManager.setClientActionHandler(null) - webSocketManager.disconnect() - super.onDestroy() + // 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. + + /** + * Waits a short while for the Activity to reach RESUMED, giving up quietly + * if it does not. + * + * `lifecycleScope` dispatches with `Dispatchers.Main.immediate`, so the + * collector above starts running inline inside `onCreate` and an action + * buffered across a configuration change is picked up while the new + * Activity is still CREATED. Dispatching it right then would hit + * [launchExternalIntent]'s foreground guard and refuse a perfectly valid + * action, blaming a backgrounded app that is in fact mid-recreation. + * onResume follows within a frame or two, so a brief wait lets it through. + * + * The wait is bounded so a genuinely backgrounded app still fails fast and + * releases the agent's `executeAction` call instead of holding it until the + * user returns. + */ + private suspend fun awaitResumedOrGiveUp() { + if (lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + return + } + withTimeoutOrNull(RESUME_GRACE_MILLIS) { + lifecycle.withStateAtLeast(Lifecycle.State.RESUMED) { } + } + } + + private fun ClientAction.failWith(message: String) { + val result = AndroidDeviceExecutionResult.Failure(message) + when (this) { + is ClientAction.Alarm -> completion(result) + is ClientAction.Timer -> completion(result) + // Nothing is waiting on this one, it is fire and forget. + is ClientAction.SearchNearby -> Unit + } } private fun launchSetAlarmIntent( @@ -289,39 +359,48 @@ class MainActivity : ComponentActivity() { private companion object { private const val TAG = "MainActivity" + + /** + * How long a client action waits for the Activity to resume before it + * is treated as arriving while the app is backgrounded. Long enough to + * cover an Activity recreation, short enough that the agent is not left + * waiting on a user who has switched away. + */ + private const val RESUME_GRACE_MILLIS = 2_000L + + private const val ACTIVITY_GONE_MESSAGE = + "The Android app closed the chat screen before the action could run." } } @Composable private fun ChatApp( - webSocketManager: WebSocketManager, + viewModel: ChatViewModel, tunnelUrl: String, - tunnelToken: String? + tunnelToken: String?, + schemaContent: String ) { - val messages by webSocketManager.messages.collectAsState() - val connectionStatus by webSocketManager.connectionStatus.collectAsState() - val pendingYesNoPrompt by webSocketManager.pendingYesNoPrompt.collectAsState() - var inputText by remember { mutableStateOf("") } + val messages by viewModel.messages.collectAsState() + val connectionStatus by viewModel.connectionStatus.collectAsState() + val pendingYesNoPrompt by viewModel.pendingYesNoPrompt.collectAsState() + val inputText by viewModel.inputText.collectAsState() val listState = rememberLazyListState() val focusManager = LocalFocusManager.current - val canSend = connectionStatus.state == ConnectionStatus.State.CONNECTED && inputText.isNotBlank() + val isConnected = connectionStatus.state == ConnectionStatus.State.CONNECTED + val canSend = isConnected && inputText.isNotBlank() + val voiceInput = rememberVoiceInputController( onRecognizedText = { recognizedText -> - inputText = mergeSpeechInputText( - currentText = inputText, - recognizedText = recognizedText - ) + if (viewModel.onRecognizedText(recognizedText)) { + focusManager.clearFocus() + } } ) fun submitMessage() { - if (!canSend) { - return + if (viewModel.submitMessage()) { + focusManager.clearFocus() } - val message = inputText.trim() - webSocketManager.sendMessage(message) - inputText = "" - focusManager.clearFocus() } LaunchedEffect(messages.size) { @@ -335,16 +414,21 @@ private fun ChatApp( modifier = Modifier .fillMaxSize() .padding(innerPadding) - .padding(16.dp), + .padding(16.dp) + .imePadding(), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - ChatHeader() + ChatHeader( + canClearChat = messages.isNotEmpty(), + onClearChat = { viewModel.clearChatHistory() } + ) ConnectionStatusIndicator( status = connectionStatus, onReconnect = { - webSocketManager.connect( + viewModel.reconnect( url = tunnelUrl, - tunnelToken = tunnelToken + tunnelToken = tunnelToken, + schemaContent = schemaContent ) } ) @@ -387,15 +471,15 @@ private fun ChatApp( ChatInputBar( inputText = inputText, - onInputTextChange = { inputText = it }, - isConnected = connectionStatus.state == ConnectionStatus.State.CONNECTED, + onInputTextChange = { viewModel.onInputTextChange(it) }, + isConnected = isConnected, canSend = canSend, onSend = { submitMessage() }, isVoiceInputAvailable = voiceInput.isAvailable, onVoiceInputClick = voiceInput.onStartRequested, pendingYesNoPrompt = pendingYesNoPrompt, - onConfirmYes = { webSocketManager.respondToPendingYesNo(true) }, - onConfirmNo = { webSocketManager.respondToPendingYesNo(false) } + onConfirmYes = { viewModel.respondToPendingYesNo(true) }, + onConfirmNo = { viewModel.respondToPendingYesNo(false) } ) } } @@ -495,17 +579,6 @@ private fun rememberVoiceInputController( } } -private fun mergeSpeechInputText( - currentText: String, - recognizedText: String -): String { - return if (currentText.isBlank()) { - recognizedText - } else { - "$currentText $recognizedText" - } -} - @Composable private fun ChatInputBar( inputText: String, @@ -613,28 +686,76 @@ private fun ChatInputBar( } @Composable -private fun ChatHeader() { +private fun ChatHeader( + canClearChat: Boolean, + onClearChat: () -> Unit +) { + var showConfirmation by remember { mutableStateOf(false) } + Surface( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), tonalElevation = 3.dp ) { - Column( + Row( modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Text( - text = "TypeAgent Chat", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold - ) - Text( - text = "A simple local chat client for your TypeAgent server.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = "TypeAgent Chat", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + Text( + text = "A simple local chat client for your TypeAgent server.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + TextButton( + onClick = { showConfirmation = true }, + enabled = canClearChat + ) { + Text("Clear chat") + } } } + + // Clearing deletes the only copy of the transcript, so it is confirmed + // rather than fired on a single stray tap. + if (showConfirmation) { + AlertDialog( + onDismissRequest = { showConfirmation = false }, + title = { Text("Clear this chat?") }, + text = { + Text( + "This deletes the messages on this device and cannot be undone. " + + "The conversation itself is not affected - the agent keeps its " + + "own memory of it." + ) + }, + confirmButton = { + TextButton( + onClick = { + showConfirmation = false + onClearChat() + } + ) { + Text("Clear chat") + } + }, + dismissButton = { + TextButton(onClick = { showConfirmation = false }) { + Text("Cancel") + } + } + ) + } } @Composable diff --git a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/Message.kt b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/Message.kt index 465825ccb4..9b839a99ff 100644 --- a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/Message.kt +++ b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/Message.kt @@ -7,19 +7,26 @@ data class Message( val segments: List, val isUser: Boolean, val requestId: String? = null, - val isFinal: Boolean = false + val isFinal: Boolean = false, + /** + * When the message was created, as epoch milliseconds. Persisted so stored + * transcripts can be aged out - see [ConversationSerializer.MAX_MESSAGE_AGE_MILLIS]. + */ + val timestampMillis: Long = System.currentTimeMillis() ) { constructor( text: String, format: MessageFormat = MessageFormat.TEXT, isUser: Boolean, requestId: String? = null, - isFinal: Boolean = false + isFinal: Boolean = false, + timestampMillis: Long = System.currentTimeMillis() ) : this( segments = listOf(MessageSegment(text = text, format = format)), isUser = isUser, requestId = requestId, - isFinal = isFinal + isFinal = isFinal, + timestampMillis = timestampMillis ) val text: String diff --git a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/WebSocketManager.kt b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/WebSocketManager.kt index 8c3e03c051..c2afdeb35b 100644 --- a/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/WebSocketManager.kt +++ b/android/samples/mobile-2/app/src/main/java/com/example/typeagentchat/WebSocketManager.kt @@ -34,11 +34,29 @@ class WebSocketManager { private var pendingUserInteraction: PendingUserInteraction? = null private var clientActionHandler: ClientActionHandler? = null + /** + * The conversation this connection asked to resume, if any. Kept separate + * from [conversationId] so an in-flight resume is never mistaken for a + * conversation that has actually been joined. + */ + private var requestedConversationId: String? = null + private var staleConversationHandler: (() -> Unit)? = null + private val _messages = MutableStateFlow>(emptyList()) val messages: StateFlow> = _messages private val _pendingYesNoPrompt = MutableStateFlow(null) val pendingYesNoPrompt: StateFlow = _pendingYesNoPrompt + /** + * The conversation the server handed back on the last successful join. + * + * Survives a disconnect deliberately: it is persisted alongside the + * transcript and passed back into the next [connect] so the client resumes + * the same conversation instead of landing on the server's default one. + */ + private val _lastJoinedConversationId = MutableStateFlow(null) + val lastJoinedConversationId: StateFlow = _lastJoinedConversationId + private val _connectionStatus = MutableStateFlow( ConnectionStatus( text = "Disconnected", @@ -53,10 +71,63 @@ class WebSocketManager { } } + /** + * Called when a resume was requested for a conversation the server no + * longer has. The client fell back to the default conversation, so the + * restored transcript belongs to nothing and should be discarded. + */ + internal fun setStaleConversationHandler(handler: (() -> Unit)?) { + synchronized(lock) { + staleConversationHandler = handler + } + } + + /** + * Seeds the transcript with messages recovered from disk. + * + * Must be called before [connect]; it deliberately refuses once anything is + * already in the list so a late restore can never clobber live messages. + */ + fun restoreMessages(restored: List) { + if (restored.isEmpty()) { + return + } + synchronized(lock) { + if (_messages.value.isNotEmpty()) { + Log.w(TAG, "Ignoring restore: transcript already has messages") + return + } + _messages.value = restored + } + } + + /** + * Drops the local transcript. + * + * Used by the client-side "Clear chat" action, and when a resume lands on a + * conversation the restored transcript does not belong to. + */ + fun clearMessages() { + synchronized(lock) { + displayThreads.clear() + displayMessageIds.clear() + _messages.value = emptyList() + } + } + + /** + * @param resumeConversationId the conversation to resume. When present it + * is passed straight to `joinConversation`, so the client rejoins the + * exact conversation it was last in. When the server no longer has it, + * the join falls back to the default conversation and the + * stale-conversation handler fires. When absent the server joins (or + * creates) the default conversation. + */ fun connect( url: String, tunnelToken: String? = null, - schemaContent: String? = null + schemaContent: String? = null, + resumeConversationId: String? = null ) { val targetUrl = url.trim() if (targetUrl.isBlank()) { @@ -87,6 +158,7 @@ class WebSocketManager { pendingUserInteraction = null conversationId = null connectionId = null + requestedConversationId = resumeConversationId?.takeIf { it.isNotBlank() } agentSchemaContent = resolvedSchemaContent isClientAgentRegistered = false displayThreads.clear() @@ -111,7 +183,7 @@ class WebSocketManager { override fun onOpen(webSocket: WebSocket, response: Response) { if (connectionGeneration.get() != generation) return Log.d(TAG, "WebSocket connected") - joinConversation() + joinConversation(synchronized(lock) { requestedConversationId }) } override fun onMessage(webSocket: WebSocket, text: String) { @@ -281,10 +353,19 @@ class WebSocketManager { return true } - private fun joinConversation() { + /** + * Joins [resumeConversationId] when supplied, otherwise the server's + * default conversation (which it creates if none exists). + * + * If the requested conversation is gone the server answers + * "Conversation not found", and this retries once against the default. The + * retry passes `null`, so it cannot recurse. + */ + private fun joinConversation(resumeConversationId: String?) { val options = JSONObject() .put("clientType", "extension") .put("filter", false) + .putOpt("conversationId", resumeConversationId) sendInvoke( channelName = AGENT_SERVER_CHANNEL, @@ -307,7 +388,9 @@ class WebSocketManager { synchronized(lock) { conversationId = joinedConversationId connectionId = joinedConnectionId + requestedConversationId = null } + _lastJoinedConversationId.value = joinedConversationId Log.d( TAG, @@ -317,6 +400,30 @@ class WebSocketManager { }, onError = { error -> Log.e(TAG, "joinConversation error: $error") + if (resumeConversationId != null && isConversationNotFoundError(error)) { + // The saved conversation is gone (server data wiped, + // deleted elsewhere). Fall back to the default conversation + // and tell the client its restored transcript is orphaned. + Log.w( + TAG, + "Conversation $resumeConversationId no longer exists; joining the default" + ) + // Read the handler under the lock but invoke it outside, so + // a client callback can never re-enter and deadlock. + val onStale = synchronized(lock) { + requestedConversationId = null + staleConversationHandler + } + // Drop the dead id before handing control to the client. + // It is still the "last joined" one, so a debounced save or + // a teardown flush landing while the fallback join is in + // flight would write the deleted conversation back to disk, + // and a reconnect in that window would try to resume it. + _lastJoinedConversationId.value = null + onStale?.invoke() + joinConversation(null) + return@sendInvoke + } _connectionStatus.value = ConnectionStatus( text = "Error: $error", state = ConnectionStatus.State.ERROR @@ -1381,6 +1488,17 @@ class WebSocketManager { } } +/** + * Recognises the server's "Conversation not found" join failure so a resume of + * a conversation that no longer exists can fall back to the default one, + * instead of being surfaced as a connection error like a transport or auth + * failure would be. + * + * Mirrors `isConversationNotFoundError` in the agentServer TypeScript client. + */ +internal fun isConversationNotFoundError(error: String?): Boolean = + error?.trimStart()?.startsWith("Conversation not found", ignoreCase = true) == true + data class ConnectionStatus( val text: String, val state: State diff --git a/android/samples/mobile-2/app/src/main/res/xml/backup_rules.xml b/android/samples/mobile-2/app/src/main/res/xml/backup_rules.xml index 4df9255824..0f9133245c 100644 --- a/android/samples/mobile-2/app/src/main/res/xml/backup_rules.xml +++ b/android/samples/mobile-2/app/src/main/res/xml/backup_rules.xml @@ -1,13 +1,15 @@ + The chat transcript is personal content and stays on the device it was + written on. Without this it is uploaded to the user's Google account and + restored onto any other device they sign in from. + Path must track ConversationStore.PREFS_NAME + ".xml". + --> + \ No newline at end of file diff --git a/android/samples/mobile-2/app/src/main/res/xml/data_extraction_rules.xml b/android/samples/mobile-2/app/src/main/res/xml/data_extraction_rules.xml index 9ee9997b0b..3038cb77aa 100644 --- a/android/samples/mobile-2/app/src/main/res/xml/data_extraction_rules.xml +++ b/android/samples/mobile-2/app/src/main/res/xml/data_extraction_rules.xml @@ -1,19 +1,20 @@ - + \ No newline at end of file diff --git a/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/ConversationNotFoundErrorTest.kt b/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/ConversationNotFoundErrorTest.kt new file mode 100644 index 0000000000..4c9e36a791 --- /dev/null +++ b/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/ConversationNotFoundErrorTest.kt @@ -0,0 +1,36 @@ +package com.example.typeagentchat + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The resume flow only falls back to the default conversation for this one + * server error. Transport, auth and permission failures must keep surfacing as + * connection errors, otherwise a temporary outage would silently drop the user + * into a different conversation than the one they were reading. + */ +class ConversationNotFoundErrorTest { + + @Test + fun `the server's missing-conversation error is recognised`() { + assertTrue(isConversationNotFoundError("Conversation not found: abc-123")) + assertTrue(isConversationNotFoundError("Conversation not found")) + } + + @Test + fun `leading whitespace from RPC wrapping does not hide it`() { + assertTrue(isConversationNotFoundError(" Conversation not found: abc-123")) + } + + @Test + fun `other failures do not trigger the fallback`() { + assertFalse(isConversationNotFoundError("Disconnected")) + assertFalse(isConversationNotFoundError("Tunnel auth failed. Check token.")) + assertFalse(isConversationNotFoundError("WebSocket is not connected.")) + // Must not match on a mere mention of the phrase mid-message. + assertFalse(isConversationNotFoundError("Error: Conversation not found")) + assertFalse(isConversationNotFoundError("")) + assertFalse(isConversationNotFoundError(null)) + } +} diff --git a/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/ConversationSerializerTest.kt b/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/ConversationSerializerTest.kt new file mode 100644 index 0000000000..2a5facc935 --- /dev/null +++ b/android/samples/mobile-2/app/src/test/java/com/example/typeagentchat/ConversationSerializerTest.kt @@ -0,0 +1,243 @@ +package com.example.typeagentchat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConversationSerializerTest { + + private fun conversation( + conversationId: String? = "conversation-1", + messages: List + ) = PersistedConversation(conversationId = conversationId, messages = messages) + + @Test + fun `round trip preserves the transcript`() { + val original = conversation( + messages = listOf( + Message(text = "what is on my list", isUser = true), + Message( + segments = listOf( + MessageSegment("routed to list", MessageFormat.TEXT, MessageKind.INFO), + MessageSegment("- milk\n- eggs", MessageFormat.MARKDOWN) + ), + isUser = false, + requestId = "req-7", + isFinal = true + ) + ) + ) + + val decoded = ConversationSerializer.decode(ConversationSerializer.encode(original)) + + assertEquals("conversation-1", decoded.conversationId) + assertEquals(2, decoded.messages.size) + assertEquals("what is on my list", decoded.messages[0].text) + assertTrue(decoded.messages[0].isUser) + assertEquals(original.messages[0].id, decoded.messages[0].id) + + val agent = decoded.messages[1] + assertEquals("req-7", agent.requestId) + assertEquals(MessageFormat.MARKDOWN, agent.format) + assertEquals(MessageKind.INFO, agent.segments[0].kind) + assertEquals("- milk\n- eggs", agent.segments[1].text) + } + + @Test + fun `restored messages are always sealed`() { + val original = conversation( + messages = listOf( + Message(text = "streaming...", isUser = false, isFinal = false) + ) + ) + + val decoded = ConversationSerializer.decode(ConversationSerializer.encode(original)) + + // An unsealed restored bubble would render "Responding..." forever and + // could be retro-targeted by WebSocketManager.finalizeAssistantMessage. + assertTrue(decoded.messages.single().isFinal) + } + + @Test + fun `only the most recent messages are persisted`() { + val messages = (1..ConversationSerializer.MAX_PERSISTED_MESSAGES + 25).map { + Message(text = "message $it", isUser = true) + } + + val decoded = ConversationSerializer.decode( + ConversationSerializer.encode(conversation(messages = messages)) + ) + + assertEquals(ConversationSerializer.MAX_PERSISTED_MESSAGES, decoded.messages.size) + assertEquals("message 26", decoded.messages.first().text) + assertEquals( + "message ${ConversationSerializer.MAX_PERSISTED_MESSAGES + 25}", + decoded.messages.last().text + ) + } + + @Test + fun `a missing conversation id round trips as null`() { + val decoded = ConversationSerializer.decode( + ConversationSerializer.encode( + conversation( + conversationId = null, + messages = listOf(Message(text = "hi", isUser = true)) + ) + ) + ) + + assertNull(decoded.conversationId) + assertEquals(1, decoded.messages.size) + } + + @Test + fun `an unknown payload version is ignored`() { + val decoded = ConversationSerializer.decode( + """{"version":99,"conversationId":"c","messages":[{"id":"a","segments":[]}]}""" + ) + + assertNull(decoded.conversationId) + assertTrue(decoded.messages.isEmpty()) + } + + @Test + fun `messages without usable segments are dropped`() { + val decoded = ConversationSerializer.decode( + """{"version":1,"messages":[{"id":"a","isUser":true},{"id":"b","segments":[]}]}""" + ) + + assertTrue(decoded.messages.isEmpty()) + } + + @Test + fun `an empty conversation encodes and decodes cleanly`() { + val decoded = ConversationSerializer.decode( + ConversationSerializer.encode(PersistedConversation.EMPTY) + ) + + assertNull(decoded.conversationId) + assertTrue(decoded.messages.isEmpty()) + } + + @Test + fun `messages past the retention window are not written`() { + val now = 1_800_000_000_000L + val day = 24L * 60 * 60 * 1000 + val messages = listOf( + Message(text = "ancient", isUser = true, timestampMillis = now - 400 * day), + Message(text = "old", isUser = true, timestampMillis = now - 31 * day), + Message(text = "recent", isUser = true, timestampMillis = now - 2 * day), + Message(text = "now", isUser = true, timestampMillis = now) + ) + + val decoded = ConversationSerializer.decode( + ConversationSerializer.encode(conversation(messages = messages), now = now), + now = now + ) + + assertEquals(listOf("recent", "now"), decoded.messages.map { it.text }) + } + + @Test + fun `messages expire while the app is not running`() { + val written = 1_800_000_000_000L + val day = 24L * 60 * 60 * 1000 + val raw = ConversationSerializer.encode( + conversation( + messages = listOf(Message(text = "hi", isUser = true, timestampMillis = written)) + ), + now = written + ) + + // Same payload, read back long after it was written. + val fresh = ConversationSerializer.decode(raw, now = written + 5 * day) + val stale = ConversationSerializer.decode(raw, now = written + 45 * day) + + assertEquals(1, fresh.messages.size) + assertTrue(stale.messages.isEmpty()) + } + + @Test + fun `timestamps survive a round trip`() { + val stamp = 1_700_000_000_000L + val decoded = ConversationSerializer.decode( + ConversationSerializer.encode( + conversation( + messages = listOf(Message(text = "hi", isUser = true, timestampMillis = stamp)) + ), + now = stamp + ), + now = stamp + ) + + assertEquals(stamp, decoded.messages.single().timestampMillis) + } + + @Test + fun `transcripts written before timestamps existed are kept`() { + val now = 1_800_000_000_000L + // A payload from the previous app version: no "timestamp" field. + val legacy = """ + {"version":1,"conversationId":"c","messages":[ + {"id":"a","isUser":true,"segments":[{"text":"legacy","format":"TEXT","kind":"NONE"}]} + ]} + """.trimIndent() + + val decoded = ConversationSerializer.decode(legacy, now = now) + + assertEquals("legacy", decoded.messages.single().text) + assertEquals(now, decoded.messages.single().timestampMillis) + } + + @Test + fun `a clock that jumps backwards does not delete live messages`() { + val now = 1_800_000_000_000L + val day = 24L * 60 * 60 * 1000 + // Message stamped in the future relative to `now`. + val messages = listOf(Message(text = "future", isUser = true, timestampMillis = now + 10 * day)) + + val decoded = ConversationSerializer.decode( + ConversationSerializer.encode(conversation(messages = messages), now = now), + now = now + ) + + assertEquals(1, decoded.messages.size) + } + + @Test + fun `a read that drops expired messages asks for the file to be rewritten`() { + val written = 1_800_000_000_000L + val day = 24L * 60 * 60 * 1000 + val raw = ConversationSerializer.encode( + conversation( + messages = listOf( + Message(text = "old", isUser = true, timestampMillis = written), + Message(text = "new", isUser = true, timestampMillis = written + 40 * day) + ) + ), + now = written + ) + + val decoded = ConversationSerializer.decodeDetailed(raw, now = written + 45 * day) + + // Without this signal the expired message stays in the file: filtering + // on read only hides it from the UI. + assertEquals(1, decoded.droppedCount) + assertEquals(listOf("new"), decoded.conversation.messages.map { it.text }) + } + + @Test + fun `a read with nothing to drop does not ask for a rewrite`() { + val now = 1_800_000_000_000L + val raw = ConversationSerializer.encode( + conversation( + messages = listOf(Message(text = "hi", isUser = true, timestampMillis = now)) + ), + now = now + ) + + assertEquals(0, ConversationSerializer.decodeDetailed(raw, now = now).droppedCount) + } +} diff --git a/android/samples/mobile-2/gradle/libs.versions.toml b/android/samples/mobile-2/gradle/libs.versions.toml index b1a0cdc66d..f5dda036fe 100644 --- a/android/samples/mobile-2/gradle/libs.versions.toml +++ b/android/samples/mobile-2/gradle/libs.versions.toml @@ -18,6 +18,7 @@ junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }