From 78c9cb29b98d1b75fb8cc2592f74e63b109757b7 Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Fri, 21 Aug 2026 17:18:00 -0300 Subject: [PATCH 1/6] android/core: JVM port of CompanionCore with fixture-backed tests Kotlin JVM module porting the iOS CompanionCore package: wire models, SSE parser, state fold, HTTP client, pairing-invite/connection parsing, failover, and the markdown block splitter, with the Swift test suites ported and fixtures read from ios/Tests/CompanionCoreTests/Fixtures as the single source of truth. 123 JVM tests; builds without the Android SDK (kotlin-jvm module, Gradle 9.2.1 wrapper, toolchain 17). Implemented by Codex; strictly reviewed by Grok (3 rounds: scoped-IPv6 dialing via synthetic-host Dns, literal '+' preserved in pairing-invite names). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01SkWZ3y7iKQMjH6tHNFBte7 --- android/.gitignore | 6 + android/build.gradle.kts | 5 + android/core/build.gradle.kts | 30 ++ .../com/openmausbot/companion/core/Client.kt | 382 +++++++++++++++++ .../openmausbot/companion/core/Connection.kt | 195 +++++++++ .../openmausbot/companion/core/Failover.kt | 70 +++ .../com/openmausbot/companion/core/Frames.kt | 237 +++++++++++ .../openmausbot/companion/core/Markdown.kt | 110 +++++ .../com/openmausbot/companion/core/Models.kt | 355 ++++++++++++++++ .../com/openmausbot/companion/core/Sse.kt | 118 ++++++ .../com/openmausbot/companion/core/Store.kt | 259 ++++++++++++ .../openmausbot/companion/core/ClientTest.kt | 223 ++++++++++ .../companion/core/ConnectionTest.kt | 249 +++++++++++ .../companion/core/DecodingTest.kt | 235 +++++++++++ .../companion/core/EventStreamTest.kt | 134 ++++++ .../companion/core/FailoverTest.kt | 169 ++++++++ .../companion/core/FixtureSupport.kt | 14 + .../companion/core/MarkdownTest.kt | 184 ++++++++ .../com/openmausbot/companion/core/SseTest.kt | 101 +++++ .../openmausbot/companion/core/StoreTest.kt | 397 ++++++++++++++++++ android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45633 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + android/gradlew | 248 +++++++++++ android/gradlew.bat | 93 ++++ android/settings.gradle.kts | 20 + 25 files changed, 3841 insertions(+) create mode 100644 android/.gitignore create mode 100644 android/build.gradle.kts create mode 100644 android/core/build.gradle.kts create mode 100644 android/core/src/main/kotlin/com/openmausbot/companion/core/Client.kt create mode 100644 android/core/src/main/kotlin/com/openmausbot/companion/core/Connection.kt create mode 100644 android/core/src/main/kotlin/com/openmausbot/companion/core/Failover.kt create mode 100644 android/core/src/main/kotlin/com/openmausbot/companion/core/Frames.kt create mode 100644 android/core/src/main/kotlin/com/openmausbot/companion/core/Markdown.kt create mode 100644 android/core/src/main/kotlin/com/openmausbot/companion/core/Models.kt create mode 100644 android/core/src/main/kotlin/com/openmausbot/companion/core/Sse.kt create mode 100644 android/core/src/main/kotlin/com/openmausbot/companion/core/Store.kt create mode 100644 android/core/src/test/kotlin/com/openmausbot/companion/core/ClientTest.kt create mode 100644 android/core/src/test/kotlin/com/openmausbot/companion/core/ConnectionTest.kt create mode 100644 android/core/src/test/kotlin/com/openmausbot/companion/core/DecodingTest.kt create mode 100644 android/core/src/test/kotlin/com/openmausbot/companion/core/EventStreamTest.kt create mode 100644 android/core/src/test/kotlin/com/openmausbot/companion/core/FailoverTest.kt create mode 100644 android/core/src/test/kotlin/com/openmausbot/companion/core/FixtureSupport.kt create mode 100644 android/core/src/test/kotlin/com/openmausbot/companion/core/MarkdownTest.kt create mode 100644 android/core/src/test/kotlin/com/openmausbot/companion/core/SseTest.kt create mode 100644 android/core/src/test/kotlin/com/openmausbot/companion/core/StoreTest.kt create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100755 android/gradlew create mode 100644 android/gradlew.bat create mode 100644 android/settings.gradle.kts diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 000000000..a93164716 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,6 @@ +.gradle/ +.kotlin/ +build/ +**/build/ +local.properties + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 000000000..3c728f63b --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + kotlin("jvm") version "2.2.21" apply false + kotlin("plugin.serialization") version "2.2.21" apply false +} + diff --git a/android/core/build.gradle.kts b/android/core/build.gradle.kts new file mode 100644 index 000000000..0b852ccdb --- /dev/null +++ b/android/core/build.gradle.kts @@ -0,0 +1,30 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + kotlin("jvm") + kotlin("plugin.serialization") +} + +kotlin { + jvmToolchain(17) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + + testImplementation(kotlin("test")) + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") +} + +sourceSets.test { + resources.srcDir(rootProject.projectDir.resolve("../ios/Tests/CompanionCoreTests/Fixtures")) +} + +tasks.test { + useJUnitPlatform() +} diff --git a/android/core/src/main/kotlin/com/openmausbot/companion/core/Client.kt b/android/core/src/main/kotlin/com/openmausbot/companion/core/Client.kt new file mode 100644 index 000000000..35d6be95d --- /dev/null +++ b/android/core/src/main/kotlin/com/openmausbot/companion/core/Client.kt @@ -0,0 +1,382 @@ +package com.openmausbot.companion.core + +import java.io.IOException +import java.net.Inet6Address +import java.net.InetAddress +import java.net.NetworkInterface +import java.net.UnknownHostException +import java.util.concurrent.TimeUnit +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.serialization.SerializationException +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Dns +import okhttp3.Headers +import okhttp3.HttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response + +internal data class ConnectionEndpoint(val baseUrl: HttpUrl, val dns: Dns) + +internal const val SCOPED_IPV6_HTTP_HOST = "scoped-ipv6.openmausbot.invalid" + +/** + * OkHttp deliberately rejects RFC 6874 zone identifiers in HttpUrl hosts. + * A bare IPv6 literal makes OkHttp bypass Dns entirely, so a zoned address + * uses a private synthetic hostname in HttpUrl. ScopedIpv6Dns maps that name + * straight to the scoped Inet6Address OkHttp passes to Socket.connect. + */ +internal fun Connection.httpEndpoint(fallbackDns: Dns): ConnectionEndpoint? = runCatching { + val bareHost = if (host.startsWith('[') && host.endsWith(']')) { + host.substring(1, host.length - 1) + } else { + host + } + val zoneAt = if (':' in bareHost) bareHost.indexOf('%') else -1 + require(zoneAt < 0 || zoneAt < bareHost.lastIndex) { "IPv6 zone identifier is empty" } + val addressHost = if (zoneAt >= 0) bareHost.substring(0, zoneAt) else bareHost + val httpHost = if (zoneAt >= 0) SCOPED_IPV6_HTTP_HOST else addressHost + val zone = if (zoneAt >= 0) bareHost.substring(zoneAt + 1) else null + val url = HttpUrl.Builder() + .scheme("http") + .host(httpHost) + .port(port) + .build() + val dns = if (zone == null) { + fallbackDns + } else { + ScopedIpv6Dns(url.host, addressHost, zone, fallbackDns) + } + ConnectionEndpoint(url, dns) +}.getOrNull() + +internal class ScopedIpv6Dns( + private val targetHost: String, + private val addressHost: String, + private val zone: String, + private val fallback: Dns, +) : Dns { + override fun lookup(hostname: String): List { + if (!hostname.equals(targetHost, ignoreCase = true)) return fallback.lookup(hostname) + val unscoped = InetAddress.getByName(addressHost) as? Inet6Address + ?: throw UnknownHostException("$addressHost is not an IPv6 address") + val scoped = zone.toIntOrNull()?.let { scopeId -> + if (scopeId <= 0) throw UnknownHostException("Invalid IPv6 scope id: $zone") + Inet6Address.getByAddress(addressHost, unscoped.address, scopeId) + } ?: run { + val networkInterface = NetworkInterface.getByName(zone) + ?: throw UnknownHostException("No network interface named $zone") + Inet6Address.getByAddress(addressHost, unscoped.address, networkInterface) + } + return listOf(scoped) + } +} + +class CompanionClient( + val connection: Connection, + private val token: String?, + baseClient: OkHttpClient = OkHttpClient(), +) { + private val endpoint = connection.httpEndpoint(baseClient.dns) + + private val actionClient = baseClient.newBuilder() + .dns(endpoint?.dns ?: baseClient.dns) + .callTimeout(ACTION_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .connectTimeout(ACTION_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(ACTION_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .writeTimeout(ACTION_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build() + + private val streamingClient = baseClient.newBuilder() + .dns(endpoint?.dns ?: baseClient.dns) + .callTimeout(0, TimeUnit.MILLISECONDS) + .connectTimeout(ACTION_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(STREAM_IDLE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .writeTimeout(ACTION_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build() + + suspend fun health(): JsonObject = send(makeRequest("GET", "/api/health")) + + suspend fun fleet(messages: Int? = 50): Fleet = send(makeRequest( + method = "GET", + path = "/api/bots", + query = messages?.let { listOf("messages" to it.toString()) }.orEmpty(), + )) + + suspend fun messages(threadId: String, before: String? = null, limit: Int = 50): ThreadPage { + val query = buildList { + add("limit" to limit.toString()) + before?.let { add("before" to it) } + } + return send(makeRequest("GET", "/api/threads/$threadId/messages", query)) + } + + suspend fun messagesAround(threadId: String, messageId: String, limit: Int = 50): ThreadPage = send( + makeRequest( + "GET", + "/api/threads/$threadId/messages", + listOf("limit" to limit.toString(), "around" to messageId), + ), + ) + + suspend fun search(query: String, limit: Int = 40): List = send( + makeRequest("GET", "/api/search", listOf("q" to query, "limit" to limit.toString())), + ).hits + + suspend fun export(threadId: String, format: String): TranscriptExport { + val raw = perform(makeRequest( + "GET", + "/api/threads/$threadId/export", + listOf("format" to format), + )) + check(raw) + val fallback = "transcript.${if (format == "json") "json" else "md"}" + val filename = raw.headers["Content-Disposition"] + .orEmpty() + .split(';') + .map(String::trim) + .firstOrNull { it.startsWith("filename=", ignoreCase = true) } + ?.drop("filename=".length) + ?.trim('"') + ?: fallback + return TranscriptExport( + data = raw.data, + filename = filename, + contentType = raw.headers["Content-Type"] ?: "application/octet-stream", + ) + } + + suspend fun instances(): List = send( + makeRequest("GET", "/api/instances"), + ).instances + + suspend fun config(): ConfigStatus = send(makeRequest("GET", "/api/config")) + + suspend fun image(threadId: String, messageId: String): ByteArray { + val raw = perform(makeRequest("GET", "/api/threads/$threadId/messages/$messageId/image")) + check(raw) + return raw.data + } + + suspend fun createBot(): Bot = send(makeRequest("POST", "/api/bots")).bot + + suspend fun sendToBot(botId: String, text: String) { + sendUnit(makeRequest("POST", "/api/bots/$botId/messages", body = jsonBody("text" to text))) + } + + suspend fun sendToRoom(groupId: String, text: String) { + sendUnit(makeRequest("POST", "/api/groups/$groupId/messages", body = jsonBody("text" to text))) + } + + suspend fun respond( + threadId: String, + requestId: String, + behavior: String, + message: String? = null, + ) { + val body = buildJsonObject { + put("requestId", requestId) + put("behavior", behavior) + message?.let { put("message", it) } + } + sendUnit(makeRequest("POST", "/api/threads/$threadId/respond", body = body)) + } + + suspend fun alwaysAllow(botId: String, key: String) { + sendUnit(makeRequest("POST", "/api/bots/$botId/always-allow", body = jsonBody("allowKey" to key))) + } + + suspend fun toggleReaction(threadId: String, messageId: String, emoji: String): Message = + send(makeRequest( + "POST", + "/api/threads/$threadId/messages/$messageId/reactions", + body = jsonBody("emoji" to emoji), + )).message + + suspend fun edit(botId: String, messageId: String, text: String) { + sendUnit(makeRequest( + "POST", + "/api/bots/$botId/messages/$messageId/edit", + body = jsonBody("text" to text), + )) + } + + suspend fun setActiveBranch(botId: String, messageId: String): String = + send(makeRequest( + "POST", + "/api/bots/$botId/active-branch", + body = jsonBody("messageId" to messageId), + )).activeLeafId + + suspend fun createTask(botId: String, title: String? = null): Bot { + val body = buildJsonObject { + title?.takeIf(String::isNotEmpty)?.let { put("title", it) } + } + return send(makeRequest("POST", "/api/bots/$botId/tasks", body = body)).bot + } + + suspend fun switchTask(botId: String, threadId: String): Bot = send( + makeRequest("POST", "/api/bots/$botId/tasks/$threadId"), + ).bot + + suspend fun renameTask(botId: String, threadId: String, title: String) { + sendUnit(makeRequest( + "PATCH", + "/api/bots/$botId/tasks/$threadId", + body = jsonBody("title" to title), + )) + } + + suspend fun deleteTask(botId: String, threadId: String): Bot = send( + makeRequest("DELETE", "/api/bots/$botId/tasks/$threadId"), + ).bot + + suspend fun interrupt(botId: String) { + sendUnit(makeRequest("POST", "/api/bots/$botId/interrupt")) + } + + suspend fun cloudDesktop(botId: String): CloudDesktopSession = send( + makeRequest("POST", "/api/bots/$botId/computer/join"), + ) + + suspend fun markBotRead(botId: String) { + sendUnit(makeRequest("POST", "/api/bots/$botId/read")) + } + + suspend fun markRoomRead(roomId: String) { + sendUnit(makeRequest("POST", "/api/groups/$roomId/read")) + } + + fun events(since: String?, screens: Boolean = false): Flow { + val query = buildList { + add("screens" to if (screens) "on" else "off") + since?.let { add("since" to it) } + } + val request = makeRequest("GET", "/api/events", query).newBuilder() + .header("Accept", "text/event-stream") + .build() + return eventStream(request, streamingClient) + } + + private fun makeRequest( + method: String, + path: String, + query: List> = emptyList(), + body: JsonObject? = null, + ): Request { + val base = endpoint?.baseUrl ?: throw APIError.BadUrl + val url = base.newBuilder().encodedPath(path).apply { + query.forEach { (name, value) -> addQueryParameter(name, value) } + }.build() + val requestBody = when { + body != null -> CompanionJson.encodeToString(JsonObject.serializer(), body) + .toRequestBody(JSON_MEDIA_TYPE) + method == "POST" || method == "PATCH" -> EMPTY_BODY + else -> null + } + return Request.Builder() + .url(url) + .method(method, requestBody) + .apply { token?.let { header("Authorization", "Bearer $it") } } + .build() + } + + private suspend inline fun send(request: Request): T { + val raw = perform(request) + check(raw) + return try { + CompanionJson.decodeFromString(raw.data.toString(Charsets.UTF_8)) + } catch (error: SerializationException) { + throw APIError.Transport("The computer sent something this app couldn't read.", error) + } + } + + private suspend fun sendUnit(request: Request) { + val raw = perform(request) + check(raw) + } + + private suspend fun perform(request: Request): RawResponse = suspendCancellableCoroutine { continuation -> + val call = actionClient.newCall(request) + continuation.invokeOnCancellation { call.cancel() } + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + if (continuation.isActive) { + continuation.resumeWithException( + APIError.Transport(e.message ?: "Could not reach the computer.", e), + ) + } + } + + override fun onResponse(call: Call, response: Response) { + try { + response.use { + val result = RawResponse( + code = response.code, + headers = response.headers, + data = response.body?.bytes() ?: ByteArray(0), + ) + if (continuation.isActive) continuation.resume(result) + } + } catch (error: IOException) { + if (continuation.isActive) { + continuation.resumeWithException( + APIError.Transport(error.message ?: "Could not reach the computer.", error), + ) + } + } + } + }) + } + + private fun check(response: RawResponse) { + if (response.code in 200..299) return + val message = runCatching { + CompanionJson.decodeFromString(response.data.toString(Charsets.UTF_8)).error + }.getOrNull() + throw APIError.Status(response.code, message) + } + + private data class RawResponse(val code: Int, val headers: Headers, val data: ByteArray) + + companion object { + private const val ACTION_TIMEOUT_SECONDS = 20L + private const val STREAM_IDLE_TIMEOUT_SECONDS = 90L + private val JSON_MEDIA_TYPE = "application/json".toMediaType() + private val EMPTY_BODY: RequestBody = ByteArray(0).toRequestBody(null) + + suspend fun pair( + connection: Connection, + credential: String, + deviceName: String, + client: OkHttpClient = OkHttpClient(), + ): PairResponse { + val field = if (credential.length == 6 && credential.all { it in '0'..'9' }) { + "code" + } else { + "credential" + } + val companion = CompanionClient(connection, token = null, baseClient = client) + return companion.send(companion.makeRequest( + "POST", + "/api/pair", + body = jsonBody(field to credential, "deviceName" to deviceName), + )) + } + + private fun jsonBody(vararg values: Pair): JsonObject = buildJsonObject { + values.forEach { (name, value) -> put(name, value) } + } + } +} diff --git a/android/core/src/main/kotlin/com/openmausbot/companion/core/Connection.kt b/android/core/src/main/kotlin/com/openmausbot/companion/core/Connection.kt new file mode 100644 index 000000000..adfdfc21e --- /dev/null +++ b/android/core/src/main/kotlin/com/openmausbot/companion/core/Connection.kt @@ -0,0 +1,195 @@ +package com.openmausbot.companion.core + +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.net.URI +import java.nio.ByteBuffer +import java.nio.charset.CodingErrorAction +import java.nio.charset.StandardCharsets +import java.util.UUID +import kotlinx.serialization.Serializable + +@Serializable +data class Connection( + val id: String = UUID.randomUUID().toString(), + val name: String, + val host: String, + val port: Int, + val hosts: List? = null, +) { + val baseUrl: URI? + get() = runCatching { + val normalized = urlHost(host).replace("%", "%25") + URI("http://$normalized:$port") + }.getOrNull() + + val orderedHosts: List + get() = buildList { + val seen = mutableSetOf() + for (candidate in listOf(host) + hosts.orEmpty()) { + val normalized = urlHost(candidate) + if (seen.add(normalized)) add(normalized) + } + } + + fun dialing(candidate: String): Connection = copy(host = urlHost(candidate)) + + fun promoting(winner: String): Connection { + val normalized = urlHost(winner) + val rest = orderedHosts.filterNot { it == normalized } + return copy(host = normalized, hosts = listOf(normalized) + rest) + } + + companion object { + fun urlHost(host: String): String { + val bare = if (host.startsWith("[") && host.endsWith("]")) { + host.substring(1, host.length - 1) + } else { + host + } + return if (':' in bare) "[$bare]" else bare + } + + fun parse(text: String, defaultPort: Int = 8810): Connection? { + var trimmed = text.trim() + for (prefix in listOf("http://", "https://")) { + if (trimmed.startsWith(prefix, ignoreCase = true)) { + trimmed = trimmed.drop(prefix.length) + break + } + } + trimmed = trimmed.trimEnd('/') + if (trimmed.isEmpty()) return null + + var parsedHost = trimmed + var parsedPort = defaultPort + if (trimmed.startsWith("[")) { + val close = trimmed.indexOf(']') + if (close < 0) return null + parsedHost = trimmed.substring(1, close) + val rest = trimmed.substring(close + 1) + if (rest.isNotEmpty()) { + if (!rest.startsWith(":")) return null + parsedPort = rest.drop(1).toIntOrNull() ?: return null + } + } else if (trimmed.count { it == ':' } == 1) { + val colon = trimmed.lastIndexOf(':') + parsedHost = trimmed.substring(0, colon) + parsedPort = trimmed.substring(colon + 1).toIntOrNull() ?: return null + } + + if (parsedHost.isEmpty() || parsedHost.any { it.isWhitespace() || it in "/?#[]" }) return null + if (parsedPort !in 1..65535) return null + return Connection(name = parsedHost, host = urlHost(parsedHost), port = parsedPort) + } + } +} + +data class PairingInvite(val connection: Connection, val credential: String) { + companion object { + fun parse(url: URI): PairingInvite? { + if (!url.scheme.equals("openmausbot", ignoreCase = true) || + !url.host.equals("pair", ignoreCase = true) + ) { + return null + } + + val values = linkedMapOf() + val query = url.rawQuery.orEmpty() + if (query.isNotEmpty()) { + for (item in query.split('&')) { + val equals = item.indexOf('=') + if (equals < 0) return null + val name = decodeQuery(item.substring(0, equals)) ?: return null + val value = decodeQuery(item.substring(equals + 1)) ?: return null + if (values.put(name, value) != null) return null + } + } + + val address = values["address"] ?: return null + val credential = credential(values) ?: return null + var connection = Connection.parse(address) ?: return null + + values["name"]?.trim()?.takeIf { it.isNotEmpty() }?.let { candidate -> + val clean = candidate.filter { character -> + character != '\n' && character != '\r' && + (character.code > 127 || (character.code >= 32 && character.code != 127)) + } + if (clean.isNotEmpty()) connection = connection.copy(name = clean.take(80)) + } + + values["hosts"]?.let { list -> + val candidates = list.split(',') + .map { it.trim(' ', '\t') } + .filter { candidate -> + candidate.isNotEmpty() && + candidate.toByteArray(StandardCharsets.UTF_8).size <= 253 && + candidate.none { it.isWhitespace() || it in "/?#" } + } + .take(8) + if (candidates.isNotEmpty()) connection = connection.copy(hosts = candidates) + } + + return PairingInvite(connection, credential) + } + + fun parse(url: String): PairingInvite? = runCatching { URI(url) }.getOrNull()?.let(::parse) + + private fun credential(values: Map): String? { + values["token"]?.let { token -> + val suffix = token.removePrefix("omb_pair_") + if (!token.startsWith("omb_pair_") || token.toByteArray().size != 52 || suffix.length != 43) { + return null + } + if (suffix.all { it.isLetterOrDigit() && it.code < 128 || it == '-' || it == '_' }) return token + return null + } + return values["code"]?.takeIf { code -> code.length == 6 && code.all { it in '0'..'9' } } + } + + /** Percent-decode a URI query component without form-url-decoding '+'. */ + private fun decodeQuery(value: String): String? = runCatching { + val bytes = ByteArrayOutputStream(value.length) + var index = 0 + while (index < value.length) { + if (value[index] == '%') { + if (index + 2 >= value.length) return null + val high = value[index + 1].digitToIntOrNull(16) ?: return null + val low = value[index + 2].digitToIntOrNull(16) ?: return null + bytes.write((high shl 4) or low) + index += 3 + } else { + val codePoint = value.codePointAt(index) + bytes.write(String(Character.toChars(codePoint)).toByteArray(StandardCharsets.UTF_8)) + index += Character.charCount(codePoint) + } + } + StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes.toByteArray())) + .toString() + }.getOrNull() + } +} + +sealed class APIError(message: String, cause: Throwable? = null) : IOException(message, cause) { + class Status(val code: Int, val serverMessage: String? = null) : + APIError(serverMessage ?: defaultMessage(code)) + + class Transport(val detail: String, cause: Throwable? = null) : APIError(detail, cause) + + data object BadUrl : APIError("That address doesn't look right.") + + val isUnauthorized: Boolean get() = this is Status && code == 401 + + companion object { + private fun defaultMessage(code: Int): String = when (code) { + 401 -> "This phone is not paired with that computer." + 403 -> "That can only be done on the computer itself." + 404 -> "That is no longer there." + 409 -> "The bot is busy — stop it first." + else -> "The computer answered with an error ($code)." + } + } +} diff --git a/android/core/src/main/kotlin/com/openmausbot/companion/core/Failover.kt b/android/core/src/main/kotlin/com/openmausbot/companion/core/Failover.kt new file mode 100644 index 000000000..eac6a47a6 --- /dev/null +++ b/android/core/src/main/kotlin/com/openmausbot/companion/core/Failover.kt @@ -0,0 +1,70 @@ +package com.openmausbot.companion.core + +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import javax.net.ssl.SSLException + +class CandidateRotation(val hosts: List) { + private var index = 0 + + val current: String get() = hosts.getOrNull(index).orEmpty() + val count: Int get() = hosts.size + + fun advance(): String { + if (hosts.isEmpty()) return "" + index = (index + 1) % hosts.size + return current + } + + fun promoted(): List { + val winner = hosts.getOrNull(index) ?: return hosts + return listOf(winner) + hosts.filterIndexed { candidateIndex, _ -> candidateIndex != index } + } +} + +enum class ConnectionFailure { + CANNOT_FIND_HOST, + CANNOT_CONNECT_TO_HOST, + TIMED_OUT, + SECURE_CONNECTION_FAILED, + NOT_CONNECTED_TO_INTERNET, + CANCELLED, + NETWORK_CONNECTION_LOST, + OTHER, +} + +object ConnectionAdvice { + fun shouldTryAnotherHost(failure: ConnectionFailure): Boolean = failure in setOf( + ConnectionFailure.CANNOT_FIND_HOST, + ConnectionFailure.CANNOT_CONNECT_TO_HOST, + ConnectionFailure.TIMED_OUT, + ConnectionFailure.SECURE_CONNECTION_FAILED, + ) + + fun shouldTryAnotherHost(error: Throwable): Boolean = when (error) { + is APIError.Transport -> error.cause?.let(::shouldTryAnotherHost) ?: false + is UnknownHostException, is ConnectException, is SocketTimeoutException, is SSLException -> true + else -> false + } + + fun message( + failure: ConnectionFailure, + host: String, + port: Int, + tryingNext: String? = null, + ): String { + val advice = when (failure) { + ConnectionFailure.CANNOT_FIND_HOST -> + "“$host” didn't resolve. If that's a Tailscale name, this phone may not be on the tailnet." + ConnectionFailure.CANNOT_CONNECT_TO_HOST -> + "Reached your computer, but the companion isn't answering on port $port — open OpenMausBot → Settings → Companion." + ConnectionFailure.TIMED_OUT -> + "No route to your computer at $host — different network, or a firewall." + ConnectionFailure.NOT_CONNECTED_TO_INTERNET -> "You're offline." + else -> "Could not reach $host." + } + val fallback = tryingNext?.let { " Trying $it next." }.orEmpty() + return advice + fallback + " The app keeps retrying automatically." + } +} diff --git a/android/core/src/main/kotlin/com/openmausbot/companion/core/Frames.kt b/android/core/src/main/kotlin/com/openmausbot/companion/core/Frames.kt new file mode 100644 index 000000000..bdec09695 --- /dev/null +++ b/android/core/src/main/kotlin/com/openmausbot/companion/core/Frames.kt @@ -0,0 +1,237 @@ +package com.openmausbot.companion.core + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +@Serializable +data class NotificationFrame( + val kind: String, + val botId: String, + val botName: String, + val threadId: String, + val title: String, + val body: String, +) { + val isBlocking: Boolean get() = kind == "approval" || kind == "question" +} + +@Serializable +data class RuntimeEvent( + val type: String, + val threadId: String, + val delta: String? = null, + val streamKind: String? = null, +) + +@Serializable(with = FrameSerializer::class) +sealed interface Frame { + data class Hello(val cursor: String, val resumed: Boolean) : Frame + data class Message(val threadId: String, val message: com.openmausbot.companion.core.Message) : Frame + data class MessagePatch(val threadId: String, val message: com.openmausbot.companion.core.Message) : Frame + data class Thread(val threadId: String, val activeLeafId: String?) : Frame + data class Bot(val bot: com.openmausbot.companion.core.Bot) : Frame + data class BotDeleted(val botId: String) : Frame + data class Room(val room: com.openmausbot.companion.core.Room) : Frame + data class RoomDeleted(val groupId: String) : Frame + data class Notify(val notification: NotificationFrame) : Frame + data class Screen(val botId: String, val png: String, val mime: String) : Frame + data class Computer(val botId: String, val state: String) : Frame + data object Config : Frame + data class Runtime(val event: RuntimeEvent) : Frame + data class Unknown(val kind: String) : Frame + +} + +val Frame.threadId: String? + get() = when (this) { + is Frame.Message -> threadId + is Frame.MessagePatch -> threadId + is Frame.Thread -> threadId + is Frame.Notify -> notification.threadId + is Frame.Runtime -> event.threadId + else -> null + } + +object FrameSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Frame") + + override fun deserialize(decoder: Decoder): Frame { + val input = decoder as? JsonDecoder + ?: throw SerializationException("Frame can only be decoded from JSON") + val objectValue = input.decodeJsonElement().jsonObject + val kind = objectValue.requiredString("kind") + + return when (kind) { + "hello" -> Frame.Hello( + cursor = objectValue.requiredString("cursor"), + resumed = objectValue["resumed"]?.jsonPrimitive?.booleanOrNull ?: false, + ) + "message" -> Frame.Message( + threadId = objectValue.requiredString("threadId"), + message = input.json.decodeFromJsonElement( + com.openmausbot.companion.core.Message.serializer(), + objectValue.required("message"), + ), + ) + "message.patch" -> Frame.MessagePatch( + threadId = objectValue.requiredString("threadId"), + message = input.json.decodeFromJsonElement( + com.openmausbot.companion.core.Message.serializer(), + objectValue.required("message"), + ), + ) + "thread" -> Frame.Thread( + threadId = objectValue.requiredString("threadId"), + activeLeafId = objectValue["activeLeafId"]?.jsonPrimitive?.contentOrNull, + ) + "bot" -> Frame.Bot(input.json.decodeFromJsonElement( + com.openmausbot.companion.core.Bot.serializer(), + objectValue.required("bot"), + )) + "bot.deleted" -> Frame.BotDeleted(objectValue.requiredString("botId")) + "group" -> Frame.Room(input.json.decodeFromJsonElement( + com.openmausbot.companion.core.Room.serializer(), + objectValue.required("group"), + )) + "group.deleted" -> Frame.RoomDeleted(objectValue.requiredString("groupId")) + "notify" -> Frame.Notify(input.json.decodeFromJsonElement( + NotificationFrame.serializer(), + objectValue.required("notification"), + )) + "screen" -> Frame.Screen( + botId = objectValue.requiredString("botId"), + png = objectValue.requiredString("png"), + mime = objectValue["mime"]?.jsonPrimitive?.contentOrNull ?: "image/png", + ) + "computer" -> Frame.Computer( + botId = objectValue.requiredString("botId"), + state = objectValue["state"]?.jsonPrimitive?.contentOrNull ?: "", + ) + "config" -> Frame.Config + "runtime" -> Frame.Runtime(input.json.decodeFromJsonElement( + RuntimeEvent.serializer(), + objectValue.required("event"), + )) + else -> Frame.Unknown(kind) + } + } + + override fun serialize(encoder: Encoder, value: Frame) { + val output = encoder as? JsonEncoder + ?: throw SerializationException("Frame can only be encoded as JSON") + output.encodeJsonElement(value.toJsonObject(output)) + } +} + +@Serializable(with = StreamFrameSerializer::class) +data class StreamFrame(val frame: Frame, val seq: Int? = null) + +object StreamFrameSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("StreamFrame") + + override fun deserialize(decoder: Decoder): StreamFrame { + val input = decoder as? JsonDecoder + ?: throw SerializationException("StreamFrame can only be decoded from JSON") + val objectValue = input.decodeJsonElement().jsonObject + return StreamFrame( + frame = input.json.decodeFromJsonElement(FrameSerializer, objectValue), + seq = objectValue["seq"]?.jsonPrimitive?.intOrNull, + ) + } + + override fun serialize(encoder: Encoder, value: StreamFrame) { + val output = encoder as? JsonEncoder + ?: throw SerializationException("StreamFrame can only be encoded as JSON") + val frameObject = value.frame.toJsonObject(output) + output.encodeJsonElement(buildJsonObject { + frameObject.forEach { (key, element) -> put(key, element) } + value.seq?.let { put("seq", it) } + }) + } +} + +private fun JsonObject.required(name: String) = this[name] + ?: throw SerializationException("Frame is missing required field '$name'") + +private fun JsonObject.requiredString(name: String): String = + required(name).jsonPrimitive.contentOrNull + ?: throw SerializationException("Frame field '$name' must be a string") + +private fun Frame.toJsonObject(output: JsonEncoder): JsonObject = buildJsonObject { + when (this@toJsonObject) { + is Frame.Hello -> { + put("kind", "hello") + put("cursor", cursor) + put("resumed", resumed) + } + is Frame.Message -> { + put("kind", "message") + put("threadId", threadId) + put("message", output.json.encodeToJsonElement(com.openmausbot.companion.core.Message.serializer(), message)) + } + is Frame.MessagePatch -> { + put("kind", "message.patch") + put("threadId", threadId) + put("message", output.json.encodeToJsonElement(com.openmausbot.companion.core.Message.serializer(), message)) + } + is Frame.Thread -> { + put("kind", "thread") + put("threadId", threadId) + activeLeafId?.let { put("activeLeafId", it) } + } + is Frame.Bot -> { + put("kind", "bot") + put("bot", output.json.encodeToJsonElement(com.openmausbot.companion.core.Bot.serializer(), bot)) + } + is Frame.BotDeleted -> { + put("kind", "bot.deleted") + put("botId", botId) + } + is Frame.Room -> { + put("kind", "group") + put("group", output.json.encodeToJsonElement(com.openmausbot.companion.core.Room.serializer(), room)) + } + is Frame.RoomDeleted -> { + put("kind", "group.deleted") + put("groupId", groupId) + } + is Frame.Notify -> { + put("kind", "notify") + put("notification", output.json.encodeToJsonElement(NotificationFrame.serializer(), notification)) + } + is Frame.Screen -> { + put("kind", "screen") + put("botId", botId) + put("png", png) + put("mime", mime) + } + is Frame.Computer -> { + put("kind", "computer") + put("botId", botId) + put("state", state) + } + Frame.Config -> put("kind", "config") + is Frame.Runtime -> { + put("kind", "runtime") + put("event", output.json.encodeToJsonElement(RuntimeEvent.serializer(), event)) + } + is Frame.Unknown -> put("kind", kind) + } +} diff --git a/android/core/src/main/kotlin/com/openmausbot/companion/core/Markdown.kt b/android/core/src/main/kotlin/com/openmausbot/companion/core/Markdown.kt new file mode 100644 index 000000000..de8cd8f77 --- /dev/null +++ b/android/core/src/main/kotlin/com/openmausbot/companion/core/Markdown.kt @@ -0,0 +1,110 @@ +package com.openmausbot.companion.core + +sealed interface MarkdownBlock { + data class Paragraph(val text: String) : MarkdownBlock + data class Bullet(val indent: Int, val text: String) : MarkdownBlock + data class Ordered(val indent: Int, val number: Int, val text: String) : MarkdownBlock + data class Heading(val level: Int, val text: String) : MarkdownBlock + data class Code(val language: String?, val text: String) : MarkdownBlock + data class Quote(val text: String) : MarkdownBlock + data object Rule : MarkdownBlock +} + +object Markdown { + fun blocks(source: String): List { + val blocks = mutableListOf() + val paragraph = mutableListOf() + + fun flushParagraph() { + if (paragraph.isEmpty()) return + blocks += MarkdownBlock.Paragraph(paragraph.joinToString(" ")) + paragraph.clear() + } + + val lines = source + .replace("\r\n", "\n") + .replace("\r", "\n") + .split('\n') + var index = 0 + while (index < lines.size) { + val line = lines[index++] + val trimmed = line.trim() + + if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) { + flushParagraph() + val marker = trimmed.take(3) + val language = trimmed.drop(3).trim().ifEmpty { null } + val body = mutableListOf() + while (index < lines.size) { + val next = lines[index++] + if (next.trim().startsWith(marker)) break + body += next + } + blocks += MarkdownBlock.Code(language, body.joinToString("\n")) + continue + } + + if (trimmed.isEmpty()) { + flushParagraph() + continue + } + + if (trimmed.length >= 3 && trimmed.first() in "-*_") { + if (trimmed.all { it == trimmed.first() }) { + flushParagraph() + blocks += MarkdownBlock.Rule + continue + } + } + + heading(trimmed)?.let { + flushParagraph() + blocks += it + continue + } + + if (trimmed.startsWith('>')) { + flushParagraph() + blocks += MarkdownBlock.Quote(trimmed.drop(1).trim()) + continue + } + + listItem(line)?.let { + flushParagraph() + blocks += it + continue + } + + paragraph += trimmed + } + flushParagraph() + return blocks + } + + private fun heading(trimmed: String): MarkdownBlock.Heading? { + val hashes = trimmed.takeWhile { it == '#' }.length + if (hashes !in 1..6) return null + val rest = trimmed.drop(hashes) + if (!rest.startsWith(' ')) return null + return MarkdownBlock.Heading(hashes, rest.trim()) + } + + private fun listItem(line: String): MarkdownBlock? { + val leading = line.takeWhile { it == ' ' || it == '\t' }.length + val indent = (leading / 2).coerceAtMost(4) + val trimmed = line.trim() + + listOf("- ", "* ", "+ ").firstOrNull(trimmed::startsWith)?.let { + return MarkdownBlock.Bullet(indent, trimmed.drop(2)) + } + + val digits = trimmed.takeWhile(Char::isDigit) + if (digits.isNotEmpty() && digits.length <= 9) { + val rest = trimmed.drop(digits.length) + if (rest.startsWith(". ") || rest.startsWith(") ")) { + return MarkdownBlock.Ordered(indent, digits.toIntOrNull() ?: 1, rest.drop(2)) + } + } + return null + } +} diff --git a/android/core/src/main/kotlin/com/openmausbot/companion/core/Models.kt b/android/core/src/main/kotlin/com/openmausbot/companion/core/Models.kt new file mode 100644 index 000000000..5d4712003 --- /dev/null +++ b/android/core/src/main/kotlin/com/openmausbot/companion/core/Models.kt @@ -0,0 +1,355 @@ +package com.openmausbot.companion.core + +import java.net.URI +import java.util.Base64 +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.put + +/** The one JSON configuration used for every sidecar payload. */ +val CompanionJson = Json { + ignoreUnknownKeys = true +} + +@Serializable +data class OptionCard( + val title: String, + val subtitle: String, + val options: List, + val answered: String? = null, + val dismissed: Boolean? = null, + val requestId: String? = null, + val tool: String? = null, + val held: String? = null, + val allowKey: String? = null, +) { + val isPending: Boolean get() = requestId != null && answered == null && dismissed != true + val isPermission: Boolean get() = tool != null +} + +@Serializable +data class ToolActivity( + val name: String, + val ok: Boolean? = null, + val spoken: String? = null, + val setup: Boolean? = null, +) + +@Serializable +data class Sender( + val botId: String, + val name: String, + val color: String, +) + +@Serializable +data class Reaction(val emoji: String, val by: String) + +@Serializable +data class CommChip( + val groupId: String, + val withBotId: String, + val withName: String, + val withColor: String, +) + +@Serializable +data class Message( + val id: String, + val role: Role, + val kind: Kind, + val at: Double, + val text: String? = null, + val card: OptionCard? = null, + val tool: ToolActivity? = null, + val parentId: String? = null, + val from: Sender? = null, + val reactions: List? = null, + val comm: CommChip? = null, + val hasImage: Boolean? = null, + val png: String? = null, + val mime: String? = null, +) { + @Serializable(with = MessageKindSerializer::class) + enum class Kind { TEXT, OPTIONS, ACTIVITY, SCREEN, UNKNOWN } + + @Serializable(with = MessageRoleSerializer::class) + enum class Role { BOT, USER } +} + +object MessageKindSerializer : KSerializer { + override val descriptor = PrimitiveSerialDescriptor("Message.Kind", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): Message.Kind = when (decoder.decodeString()) { + "text" -> Message.Kind.TEXT + "options" -> Message.Kind.OPTIONS + "activity" -> Message.Kind.ACTIVITY + "screen" -> Message.Kind.SCREEN + else -> Message.Kind.UNKNOWN + } + + override fun serialize(encoder: Encoder, value: Message.Kind) { + encoder.encodeString(value.name.lowercase()) + } +} + +object MessageRoleSerializer : KSerializer { + override val descriptor = PrimitiveSerialDescriptor("Message.Role", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): Message.Role = when (decoder.decodeString()) { + "user" -> Message.Role.USER + else -> Message.Role.BOT + } + + override fun serialize(encoder: Encoder, value: Message.Role) { + encoder.encodeString(value.name.lowercase()) + } +} + +@Serializable +data class ModelSelection(val instanceId: String, val model: String) + +@Serializable +data class BotTask(val threadId: String, val title: String, val createdAt: Double) + +@Serializable +data class Bot( + val id: String, + val threadId: String, + val name: String, + val title: String, + val description: String, + val notifications: Boolean, + val color: String, + val unread: Boolean, + val modelSelection: ModelSelection, + val createdAt: Double, + val busy: Boolean? = null, + val pinned: Boolean? = null, + val hidden: Boolean? = null, + val chiefOfStaff: Boolean? = null, + val autoApprove: Boolean? = null, + val alwaysAllow: List? = null, + val computer: String? = null, + val cloudBackend: String? = null, + val speakReplies: Boolean? = null, + val voice: String? = null, + val mascotExpression: String? = null, + val tasks: List? = null, + val messages: List? = null, + val activeLeafId: String? = null, + val hasMore: Boolean? = null, +) + +@Serializable +data class GroupResponder(val kind: String, val botId: String? = null) + +@Serializable +data class Room( + val id: String, + val threadId: String, + val name: String, + val memberIds: List, + val defaultResponder: GroupResponder, + val bulletin: String, + val unread: Boolean, + val createdAt: Double, + val dm: Boolean? = null, + val busyBotId: String? = null, + val messages: List? = null, + val hasMore: Boolean? = null, +) + +@Serializable(with = FleetSerializer::class) +data class Fleet(val bots: List, val groups: List) + +object FleetSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Fleet") + + override fun deserialize(decoder: Decoder): Fleet { + val input = decoder as? JsonDecoder + ?: throw SerializationException("Fleet can only be decoded from JSON") + val objectValue = input.decodeJsonElement().jsonObject + + fun lossyArray(name: String, decode: (kotlinx.serialization.json.JsonElement) -> T): List { + val element = objectValue[name] ?: return emptyList() + if (element is JsonNull) return emptyList() + val array = element as? JsonArray + ?: throw SerializationException("Fleet.$name must be an array") + return array.mapNotNull { runCatching { decode(it) }.getOrNull() } + } + + return Fleet( + bots = lossyArray("bots") { input.json.decodeFromJsonElement(Bot.serializer(), it) }, + groups = lossyArray("groups") { input.json.decodeFromJsonElement(Room.serializer(), it) }, + ) + } + + override fun serialize(encoder: Encoder, value: Fleet) { + val output = encoder as? JsonEncoder + ?: throw SerializationException("Fleet can only be encoded as JSON") + output.encodeJsonElement(buildJsonObject { + put("bots", JsonArray(value.bots.map { output.json.encodeToJsonElement(Bot.serializer(), it) })) + put("groups", JsonArray(value.groups.map { output.json.encodeToJsonElement(Room.serializer(), it) })) + }) + } +} + +@Serializable +data class ThreadPage(val messages: List, val hasMore: Boolean? = null) + +@Serializable +data class SearchHit( + val threadId: String, + val messageId: String, + val at: Double, + val role: Message.Role, + val kind: Message.Kind, + val snippet: String, + val matchStart: Int, + val matchLength: Int, + val botId: String? = null, + val groupId: String? = null, + val name: String, + val task: String? = null, + val onActivePath: Boolean, +) { + val id: String get() = "$threadId:$messageId" +} + +data class TranscriptExport(val data: ByteArray, val filename: String, val contentType: String) + +@Serializable +data class PairedDevice( + val id: String, + val name: String, + val createdAt: Double, + val lastSeenAt: Double, +) + +@Serializable +data class PairResponse( + val token: String, + val device: PairedDevice, + val serverName: String, + val hosts: List? = null, +) + +@Serializable(with = CloudDesktopSessionSerializer::class) +data class CloudDesktopSession(val url: URI) + +object CloudDesktopSessionSerializer : KSerializer { + @Serializable + private data class Wire(@SerialName("joinUrl") val joinUrl: String) + + override val descriptor: SerialDescriptor = Wire.serializer().descriptor + + override fun deserialize(decoder: Decoder): CloudDesktopSession { + val raw = Wire.serializer().deserialize(decoder).joinUrl + val parsed = runCatching { URI(raw) }.getOrNull() + if (parsed == null || !parsed.scheme.equals("https", ignoreCase = true) || parsed.host.isNullOrEmpty()) { + throw SerializationException("Cloud desktop URL must be HTTPS with a host") + } + return CloudDesktopSession(parsed) + } + + override fun serialize(encoder: Encoder, value: CloudDesktopSession) { + Wire.serializer().serialize(encoder, Wire(value.url.toASCIIString())) + } +} + +@Serializable +data class ProviderSnapshot( + val state: String, + val reason: String? = null, + val authenticated: Boolean? = null, + val version: String? = null, +) { + val isAvailable: Boolean get() = state == "available" +} + +@Serializable +data class ModelOption(val id: String, val label: String) + +@Serializable +data class ModelCatalog( + @SerialName("default") val defaultModel: String, + val options: List, +) + +@Serializable +data class Instance( + val instanceId: String, + val driverKind: String, + val displayName: String? = null, + val snapshot: ProviderSnapshot, + val models: ModelCatalog, +) { + val id: String get() = instanceId +} + +@Serializable +data class InstanceList(val instances: List) + +@Serializable +data class ConfigFlag( + val configured: Boolean, + val apiKeyConfigured: Boolean? = null, + val ready: Boolean? = null, + val voice: String? = null, +) + +@Serializable +data class Profile(val name: String, val email: String) + +@Serializable +data class ConfigStatus( + val composio: ConfigFlag? = null, + val box: ConfigFlag? = null, + val tts: ConfigFlag? = null, + val profile: Profile? = null, +) + +@Serializable +data class APIErrorBody(val error: String) + +data class ScreenFrame(val png: String, val mime: String) { + val data: ByteArray? + get() = try { + Base64.getDecoder().decode(png) + } catch (_: IllegalArgumentException) { + null + } +} + +@Serializable +data class CreatedBot(val bot: Bot) + +@Serializable +internal data class SearchResponse(val hits: List) + +@Serializable +internal data class MessageResponse(val message: Message) + +@Serializable +internal data class ActiveBranchResponse(val activeLeafId: String) + +@Serializable +internal data class BotResponse(val bot: Bot) diff --git a/android/core/src/main/kotlin/com/openmausbot/companion/core/Sse.kt b/android/core/src/main/kotlin/com/openmausbot/companion/core/Sse.kt new file mode 100644 index 000000000..c1593da86 --- /dev/null +++ b/android/core/src/main/kotlin/com/openmausbot/companion/core/Sse.kt @@ -0,0 +1,118 @@ +package com.openmausbot.companion.core + +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.serialization.decodeFromString +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response + +data class SSEEvent(val id: String? = null, val data: String) + +/** A line-oriented parser whose empty input line is the SSE event terminator. */ +class SSEParser { + private val fields = mutableListOf>() + + fun line(raw: String): SSEEvent? { + val line = raw.removeSuffix("\r") + if (line.isEmpty()) { + val event = event(fields) + fields.clear() + return event + } + if (line.startsWith(':')) return null + val colon = line.indexOf(':') + if (colon < 0) return null + var value = line.substring(colon + 1) + if (value.startsWith(' ')) value = value.drop(1) + fields += line.substring(0, colon) to value + return null + } + + /** Discards an event that never received its terminating blank line. */ + fun reset() { + fields.clear() + } + + companion object { + internal fun event(fields: List>): SSEEvent? { + var id: String? = null + val data = mutableListOf() + fields.forEach { (name, value) -> + when (name) { + "id" -> id = value + "data" -> data += value + } + } + return data.takeIf { it.isNotEmpty() }?.let { SSEEvent(id, it.joinToString("\n")) } + } + } +} + +/** + * Reads an OkHttp response as raw bytes so consecutive newlines remain visible + * to [SSEParser]. Malformed JSON drops one frame; unknown frame kinds decode as + * [Frame.Unknown] and keep the stream alive. + */ +fun eventStream( + request: Request, + client: OkHttpClient, +): Flow = callbackFlow { + val call = client.newCall(request) + val responseRef = AtomicReference(null) + val reader = launch(Dispatchers.IO) { + try { + val response = call.execute() + responseRef.set(response) + if (response.code != 200) throw APIError.Status(response.code) + val body = response.body ?: throw APIError.Transport("The computer sent an empty event stream.") + val input = body.byteStream() + val parser = SSEParser() + val line = ByteArrayOutputStream() + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + + while (isActive) { + val count = input.read(buffer) + if (count < 0) break + for (index in 0 until count) { + val byte = buffer[index] + if (byte.toInt() != 0x0A) { + line.write(byte.toInt()) + continue + } + val text = line.toByteArray().toString(Charsets.UTF_8) + line.reset() + val event = parser.line(text) ?: continue + val frame = runCatching { + CompanionJson.decodeFromString(event.data) + }.getOrNull() ?: continue + send(frame) + } + } + parser.reset() + close() + } catch (_: CancellationException) { + // A collector leaving the flow deliberately tears down the call. + } catch (error: APIError) { + close(error) + } catch (error: IOException) { + close(APIError.Transport(error.message ?: "Could not reach the computer.", error)) + } finally { + responseRef.getAndSet(null)?.close() + } + } + + awaitClose { + responseRef.getAndSet(null)?.close() + call.cancel() + reader.cancel() + } +} diff --git a/android/core/src/main/kotlin/com/openmausbot/companion/core/Store.kt b/android/core/src/main/kotlin/com/openmausbot/companion/core/Store.kt new file mode 100644 index 000000000..4c7a156b8 --- /dev/null +++ b/android/core/src/main/kotlin/com/openmausbot/companion/core/Store.kt @@ -0,0 +1,259 @@ +package com.openmausbot.companion.core + +data class PendingApproval(val threadId: String, val message: Message) + +data class CompanionState( + val bots: List = emptyList(), + val rooms: List = emptyList(), + val messages: Map> = emptyMap(), + val hasMore: Map = emptyMap(), + val cursor: String? = null, + val notifications: List = emptyList(), + val streaming: Map = emptyMap(), + val reasoning: Map = emptyMap(), + val screens: Map = emptyMap(), +) { + fun transcript(threadId: String): List = messages[threadId].orEmpty() + + fun visibleTranscript(threadId: String): List { + val all = transcript(threadId) + val leafId = botForThread(threadId)?.activeLeafId ?: return all + val byId = all.associateBy(Message::id) + var current = byId[leafId] ?: return all + val visible = mutableListOf() + val visited = mutableSetOf() + while (visited.add(current.id)) { + visible += current + val parentId = current.parentId ?: break + current = byId[parentId] ?: break + } + return visible.asReversed() + } + + fun bot(id: String): Bot? = bots.firstOrNull { it.id == id } + fun botForThread(threadId: String): Bot? = bots.firstOrNull { it.threadId == threadId } + fun roomForThread(threadId: String): Room? = rooms.firstOrNull { it.threadId == threadId } + + val pendingApprovals: List + get() = (bots.map(Bot::threadId) + rooms.map(Room::threadId)) + .flatMap { threadId -> + visibleTranscript(threadId) + .filter { it.card?.isPending == true } + .map { PendingApproval(threadId, it) } + } + .sortedByDescending { it.message.at } + + val unreadCount: Int + get() = bots.count { it.unread && it.hidden != true } + rooms.count(Room::unread) + + fun hydrate(fleet: Fleet): CompanionState { + val hydratedMessages = buildMap { + fleet.bots.forEach { put(it.threadId, it.messages.orEmpty()) } + fleet.groups.forEach { put(it.threadId, it.messages.orEmpty()) } + } + val hydratedHasMore = buildMap { + fleet.bots.forEach { put(it.threadId, it.hasMore ?: false) } + fleet.groups.forEach { put(it.threadId, it.hasMore ?: false) } + } + return copy( + bots = fleet.bots, + rooms = fleet.groups, + messages = hydratedMessages, + hasMore = hydratedHasMore, + ) + } + + fun prepend(page: ThreadPage, threadId: String): CompanionState { + val existing = messages[threadId].orEmpty() + val known = existing.mapTo(mutableSetOf(), Message::id) + return copy( + messages = messages + (threadId to (page.messages.filterNot { it.id in known } + existing)), + hasMore = hasMore + (threadId to (page.hasMore ?: false)), + ) + } + + fun merge(page: ThreadPage, threadId: String): CompanionState { + val byId = transcript(threadId).associateByTo(linkedMapOf(), Message::id) + page.messages.forEach { byId[it.id] = it } + val merged = byId.values.sortedWith(compareBy { it.at }.thenBy { it.id }) + return copy( + messages = messages + (threadId to merged), + hasMore = page.hasMore?.let { hasMore + (threadId to it) } ?: hasMore, + ) + } + + fun versions(message: Message, threadId: String): List { + if (message.role != Message.Role.USER || message.kind != Message.Kind.TEXT) return emptyList() + return transcript(threadId) + .filter { + it.role == Message.Role.USER && it.kind == Message.Kind.TEXT && it.parentId == message.parentId + } + .sortedWith(compareBy { it.at }.thenBy { it.id }) + } + + fun apply(streamFrame: StreamFrame): CompanionState = apply(streamFrame.frame) + + fun apply(frame: Frame): CompanionState = when (frame) { + is Frame.Hello -> this + + is Frame.Message -> { + var result = copy( + messages = append(messages, frame.threadId, frame.message), + bots = bots.map { + if (it.threadId == frame.threadId) it.copy(activeLeafId = frame.message.id) else it + }, + ) + if (frame.message.role == Message.Role.BOT && frame.message.kind == Message.Kind.TEXT) { + result = result.clearStream(frame.threadId) + } + result + } + + is Frame.MessagePatch -> { + val existing = transcript(frame.threadId) + val index = existing.indexOfFirst { it.id == frame.message.id } + val patched = if (index >= 0) { + existing.toMutableList().also { it[index] = frame.message } + } else { + append(messages, frame.threadId, frame.message).getValue(frame.threadId) + } + copy(messages = messages + (frame.threadId to patched)) + } + + is Frame.Thread -> copy( + bots = bots.map { + if (it.threadId == frame.threadId) it.copy(activeLeafId = frame.activeLeafId) else it + }, + ).clearStream(frame.threadId) + + is Frame.Bot -> applyBot(frame.bot) + is Frame.BotDeleted -> deleteBot(frame.botId) + is Frame.Room -> applyRoom(frame.room) + is Frame.RoomDeleted -> deleteRoom(frame.groupId) + + is Frame.Notify -> copy(notifications = (notifications + frame.notification).takeLast(100)) + is Frame.Runtime -> applyRuntime(frame.event) + is Frame.Screen -> copy(screens = screens + (frame.botId to ScreenFrame(frame.png, frame.mime))) + + is Frame.Computer, Frame.Config, is Frame.Unknown -> this + } + + fun clearScreen(botId: String): CompanionState = copy(screens = screens - botId) + + fun clearStream(threadId: String): CompanionState = copy( + streaming = streaming - threadId, + reasoning = reasoning - threadId, + ) + + fun resetCursor(cursor: String): CompanionState = copy(cursor = cursor) + + fun advance(seq: Int?): CompanionState { + val current = cursor ?: return this + if (seq == null || ':' !in current) return this + return copy(cursor = "${current.substringBefore(':')}:$seq") + } + + private fun applyBot(bot: Bot): CompanionState { + val index = bots.indexOfFirst { it.id == bot.id } + if (index < 0) { + val nextMessages = if (messages.containsKey(bot.threadId)) { + messages + } else { + messages + (bot.threadId to bot.messages.orEmpty()) + } + return copy(bots = bots + bot, messages = nextMessages) + } + + val previous = bots[index] + if (bot.messages == null) { + val merged = bot.copy( + messages = previous.messages, + activeLeafId = bot.activeLeafId ?: previous.activeLeafId, + ) + return copy(bots = bots.replacing(index, merged)) + } + + var result = copy( + bots = bots.replacing(index, bot.copy(messages = bot.messages)), + messages = messages + (bot.threadId to bot.messages), + hasMore = hasMore + (bot.threadId to (bot.hasMore ?: false)), + ).clearStream(previous.threadId) + if (previous.threadId != bot.threadId) result = result.clearStream(bot.threadId) + return result + } + + private fun deleteBot(botId: String): CompanionState { + val bot = bots.firstOrNull { it.id == botId } ?: return this + return copy( + bots = bots.filterNot { it.id == botId }, + messages = messages - bot.threadId, + hasMore = hasMore - bot.threadId, + streaming = streaming - bot.threadId, + reasoning = reasoning - bot.threadId, + screens = screens - botId, + ) + } + + private fun applyRoom(room: Room): CompanionState { + val index = rooms.indexOfFirst { it.id == room.id } + if (index < 0) { + val nextMessages = if (messages.containsKey(room.threadId)) { + messages + } else { + messages + (room.threadId to room.messages.orEmpty()) + } + return copy(rooms = rooms + room, messages = nextMessages) + } + return copy(rooms = rooms.replacing(index, room.copy(messages = rooms[index].messages))) + } + + private fun deleteRoom(groupId: String): CompanionState { + val room = rooms.firstOrNull { it.id == groupId } ?: return this + return copy( + rooms = rooms.filterNot { it.id == groupId }, + messages = messages - room.threadId, + hasMore = hasMore - room.threadId, + streaming = streaming - room.threadId, + reasoning = reasoning - room.threadId, + ) + } + + private fun applyRuntime(event: RuntimeEvent): CompanionState { + return when (event.type) { + "content.delta" -> { + val delta = event.delta + if (delta.isNullOrEmpty()) { + this + } else { + when (event.streamKind) { + "assistant_text" -> copy( + streaming = streaming + (event.threadId to (streaming[event.threadId].orEmpty() + delta)), + ) + "reasoning_text" -> copy( + reasoning = reasoning + (event.threadId to (reasoning[event.threadId].orEmpty() + delta)), + ) + else -> this + } + } + } + "turn.completed", "turn.failed", "turn.aborted" -> clearStream(event.threadId) + else -> this + } + } + + private companion object { + fun append( + messages: Map>, + threadId: String, + message: Message, + ): Map> { + val thread = messages[threadId].orEmpty() + val index = thread.indexOfFirst { it.id == message.id } + val next = if (index >= 0) thread.replacing(index, message) else thread + message + return messages + (threadId to next) + } + + fun List.replacing(index: Int, value: T): List = + toMutableList().also { it[index] = value } + } +} diff --git a/android/core/src/test/kotlin/com/openmausbot/companion/core/ClientTest.kt b/android/core/src/test/kotlin/com/openmausbot/companion/core/ClientTest.kt new file mode 100644 index 000000000..a92b0aeaa --- /dev/null +++ b/android/core/src/test/kotlin/com/openmausbot/companion/core/ClientTest.kt @@ -0,0 +1,223 @@ +package com.openmausbot.companion.core + +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ClientTest { + private lateinit var server: MockWebServer + private lateinit var connection: Connection + private lateinit var client: CompanionClient + + @BeforeTest + fun setUp() { + server = MockWebServer() + server.start() + connection = requireNotNull(Connection.parse(server.url("/").toString())) + client = CompanionClient(connection, "device-token") + } + + @AfterTest + fun tearDown() { + server.shutdown() + } + + @Test + fun pairingUsesTheRightCredentialFieldAndNoAuthorization() = runBlocking { + server.enqueue(json(fixtureText("pair-response"))) + val older = CompanionClient.pair(connection, "004209", "Ada's phone") + assertTrue(older.token.startsWith("omb_")) + server.takeRequest().let { request -> + assertEquals("POST", request.method) + assertEquals("/api/pair", request.path) + assertEquals(null, request.getHeader("Authorization")) + assertEquals( + mapOf("code" to "004209", "deviceName" to "Ada's phone"), + stringBody(request.body.readUtf8()), + ) + } + + val token = "omb_pair_" + "a".repeat(43) + server.enqueue(json(fixtureText("pair-response"))) + CompanionClient.pair(connection, token, "Ada's phone") + val body = stringBody(server.takeRequest().body.readUtf8()) + assertEquals(token, body["credential"]) + assertFalse("code" in body) + } + + @Test + fun readingCallsMatchTheAllowlist() = runBlocking { + server.enqueue(json("""{"ok":true}""")) + server.enqueue(json(fixtureText("bots-paged"))) + server.enqueue(json(fixtureText("thread-page"))) + server.enqueue(json(fixtureText("thread-page"))) + server.enqueue(json("""{"hits":[]}""")) + server.enqueue(MockResponse() + .setResponseCode(200) + .setHeader("Content-Disposition", "attachment; filename=chat.md") + .setHeader("Content-Type", "text/markdown") + .setBody("transcript")) + server.enqueue(json(fixtureText("instances"))) + server.enqueue(json(fixtureText("config"))) + server.enqueue(MockResponse().setResponseCode(200).setBody("pixels")) + + assertEquals(true, client.health()["ok"]?.jsonPrimitive?.content?.toBoolean()) + assertTrue(client.fleet().bots.isNotEmpty()) + assertEquals(2, client.messages("t1", before = "m0").messages.size) + assertEquals(2, client.messagesAround("t1", "m4").messages.size) + assertTrue(client.search("needle").isEmpty()) + val exported = client.export("t1", "md") + assertEquals("chat.md", exported.filename) + assertEquals("text/markdown", exported.contentType) + assertTrue(client.instances().isNotEmpty()) + assertEquals("Ada Lovelace", client.config().profile?.name) + assertEquals("pixels", client.image("t1", "m1").toString(Charsets.UTF_8)) + + val requests = List(9) { server.takeRequest() } + assertEquals( + listOf( + "GET /api/health", + "GET /api/bots?messages=50", + "GET /api/threads/t1/messages?limit=50&before=m0", + "GET /api/threads/t1/messages?limit=50&around=m4", + "GET /api/search?q=needle&limit=40", + "GET /api/threads/t1/export?format=md", + "GET /api/instances", + "GET /api/config", + "GET /api/threads/t1/messages/m1/image", + ), + requests.map { "${it.method} ${it.path}" }, + ) + requests.forEach { assertEquals("Bearer device-token", it.getHeader("Authorization")) } + } + + @Test + fun actionCallsMatchTheAllowlist() = runBlocking { + val bot = botJson() + val message = fixtureText("options-card") + listOf( + """{"bot":$bot}""", // create bot + "{}", // bot message + "{}", // room message + "{}", // respond + "{}", // always allow + """{"message":$message}""", // reaction + "{}", // edit + """{"activeLeafId":"m2"}""", // branch + """{"bot":$bot}""", // create task + """{"bot":$bot}""", // switch task + "{}", // rename task + """{"bot":$bot}""", // delete task + "{}", // interrupt + """{"joinUrl":"https://desktop.example/session/fresh"}""", // cloud desktop + "{}", // bot read + "{}", // room read + ).forEach { server.enqueue(json(it)) } + + client.createBot() + client.sendToBot("b1", "hello") + client.sendToRoom("g1", "hello room") + client.respond("t1", "r1", "answer", "Yes") + client.alwaysAllow("b1", "Bash:git") + client.toggleReaction("t1", "m1", "👍") + client.edit("b1", "m1", "retry") + assertEquals("m2", client.setActiveBranch("b1", "m2")) + client.createTask("b1", "Next") + client.switchTask("b1", "t2") + client.renameTask("b1", "t2", "Renamed") + client.deleteTask("b1", "t2") + client.interrupt("b1") + assertEquals("https://desktop.example/session/fresh", client.cloudDesktop("b1").url.toString()) + client.markBotRead("b1") + client.markRoomRead("g1") + + val requests = List(16) { server.takeRequest() } + assertEquals( + listOf( + "POST /api/bots", + "POST /api/bots/b1/messages", + "POST /api/groups/g1/messages", + "POST /api/threads/t1/respond", + "POST /api/bots/b1/always-allow", + "POST /api/threads/t1/messages/m1/reactions", + "POST /api/bots/b1/messages/m1/edit", + "POST /api/bots/b1/active-branch", + "POST /api/bots/b1/tasks", + "POST /api/bots/b1/tasks/t2", + "PATCH /api/bots/b1/tasks/t2", + "DELETE /api/bots/b1/tasks/t2", + "POST /api/bots/b1/interrupt", + "POST /api/bots/b1/computer/join", + "POST /api/bots/b1/read", + "POST /api/groups/g1/read", + ), + requests.map { "${it.method} ${it.path}" }, + ) + assertEquals(mapOf("text" to "hello"), stringBody(requests[1].body.readUtf8())) + assertEquals(mapOf("text" to "hello room"), stringBody(requests[2].body.readUtf8())) + assertEquals( + mapOf("requestId" to "r1", "behavior" to "answer", "message" to "Yes"), + stringBody(requests[3].body.readUtf8()), + ) + assertEquals(mapOf("allowKey" to "Bash:git"), stringBody(requests[4].body.readUtf8())) + assertEquals(mapOf("emoji" to "👍"), stringBody(requests[5].body.readUtf8())) + assertEquals(mapOf("text" to "retry"), stringBody(requests[6].body.readUtf8())) + assertEquals(mapOf("messageId" to "m2"), stringBody(requests[7].body.readUtf8())) + assertEquals(mapOf("title" to "Next"), stringBody(requests[8].body.readUtf8())) + assertEquals(mapOf("title" to "Renamed"), stringBody(requests[10].body.readUtf8())) + requests.forEach { assertEquals("Bearer device-token", it.getHeader("Authorization")) } + } + + @Test + fun eventRequestCarriesResumeCursorAndScreenChoice() = runBlocking { + server.enqueue(MockResponse().setResponseCode(200).setBody( + "data: {\"kind\":\"hello\",\"cursor\":\"stream:7\",\"resumed\":true}\n\n", + )) + val frames = client.events("stream:6", screens = true).take(1).toList() + assertEquals(1, frames.size) + val request = server.takeRequest() + assertEquals("GET", request.method) + assertEquals("on", request.requestUrl?.queryParameter("screens")) + assertEquals("stream:6", request.requestUrl?.queryParameter("since")) + assertEquals("text/event-stream", request.getHeader("Accept")) + assertEquals("Bearer device-token", request.getHeader("Authorization")) + } + + @Test + fun statusErrorsCarryTheHarnessMessageAndUnauthorizedMeaning() = runBlocking { + server.enqueue(json("""{"error":"The bot is occupied."}""", 409)) + val busy = assertFailsWith { client.createBot() } + assertEquals(409, busy.code) + assertEquals("The bot is occupied.", busy.message) + assertFalse(busy.isUnauthorized) + + server.enqueue(json(fixtureText("unauthorized"), 401)) + val unauthorized = assertFailsWith { client.fleet() } + assertTrue(unauthorized.isUnauthorized) + } + + private fun json(body: String, code: Int = 200) = MockResponse() + .setResponseCode(code) + .setHeader("Content-Type", "application/json") + .setBody(body) + + private fun stringBody(raw: String): Map = + CompanionJson.parseToJsonElement(raw).jsonObject.mapValues { it.value.jsonPrimitive.content } + + private fun botJson(): String { + val root = CompanionJson.parseToJsonElement(fixtureText("bots-full")).jsonObject + return root.getValue("bots").toString().removePrefix("[").removeSuffix("]") + } +} diff --git a/android/core/src/test/kotlin/com/openmausbot/companion/core/ConnectionTest.kt b/android/core/src/test/kotlin/com/openmausbot/companion/core/ConnectionTest.kt new file mode 100644 index 000000000..6c1db7b9d --- /dev/null +++ b/android/core/src/test/kotlin/com/openmausbot/companion/core/ConnectionTest.kt @@ -0,0 +1,249 @@ +package com.openmausbot.companion.core + +import java.net.Inet6Address +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.NetworkInterface +import java.net.Socket +import java.net.SocketAddress +import java.net.SocketException +import java.net.URI +import java.util.Collections +import java.util.concurrent.atomic.AtomicReference +import javax.net.SocketFactory +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.SerializationException +import kotlinx.serialization.decodeFromString +import okhttp3.Call +import okhttp3.Dns +import okhttp3.EventListener +import okhttp3.OkHttpClient +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class ConnectionTest { + @Test + fun parsesHostnamesAndPorts() { + val implicit = Connection.parse("macbook.tailnet.ts.net") + assertEquals("macbook.tailnet.ts.net", implicit?.host) + assertEquals(8810, implicit?.port) + val explicit = Connection.parse("http://192.168.1.42:9910/") + assertEquals("192.168.1.42", explicit?.host) + assertEquals(9910, explicit?.port) + } + + @Test + fun parsesIpv6WithAndWithoutAnExplicitPort() { + val bare = Connection.parse("2001:db8::1") + assertEquals("[2001:db8::1]", bare?.host) + assertEquals(8810, bare?.port) + assertEquals("http://[2001:db8::1]:8810", bare?.baseUrl.toString()) + val explicit = Connection.parse("[2001:db8::1]:9910") + assertEquals("[2001:db8::1]", explicit?.host) + assertEquals(9910, explicit?.port) + assertEquals("http://[2001:db8::1]:9910", explicit?.baseUrl.toString()) + } + + @Test + fun retainsTheScopeZoneOnLinkLocalIpv6() { + val connection = Connection.parse("[fe80::1%en0]:8810") + assertEquals("[fe80::1%en0]", connection?.host) + assertEquals("http://[fe80::1%25en0]:8810", connection?.baseUrl.toString()) + } + + @Test + fun zonedIpv6UsesScopedAddressOnTheRealOkHttpConnectPath() = runBlocking { + val networkInterface = assertNotNull( + Collections.list(NetworkInterface.getNetworkInterfaces()).firstOrNull { candidate -> + Collections.list(candidate.inetAddresses).any { it is Inet6Address } + }, + "the JVM must expose an IPv6-capable interface", + ) + var fallbackCalled = false + val fallback = object : Dns { + override fun lookup(hostname: String) = emptyList().also { + fallbackCalled = true + } + } + val connection = assertNotNull(Connection.parse("[fe80::1%${networkInterface.name}]:8810")) + val endpoint = assertNotNull(connection.httpEndpoint(fallback)) + assertEquals(SCOPED_IPV6_HTTP_HOST, endpoint.baseUrl.host) + + val route = RecordingRouteListener() + val sockets = RecordingSocketFactory() + val okHttp = OkHttpClient.Builder() + .dns(fallback) + .eventListener(route) + .socketFactory(sockets) + .build() + assertFailsWith { + CompanionClient(connection, null, okHttp).health() + } + + assertEquals(SCOPED_IPV6_HTTP_HOST, route.dnsHost.get()) + val resolved = assertNotNull(route.dnsAddresses.get()?.single() as? Inet6Address) + assertEquals(networkInterface.index, resolved.scopeId) + assertEquals(networkInterface.name, resolved.scopedInterface?.name) + val connectTarget = assertNotNull(sockets.connectTarget.get()) + assertEquals(resolved, connectTarget.address) + assertFalse(fallbackCalled, "the scoped literal must not fall through to ordinary DNS") + } + + @Test + fun olderSavedIpv6ConnectionIsNormalizedWhenUsed() { + val saved = CompanionJson.decodeFromString( + """{"id":"saved","name":"Mac","host":"::1","port":8810}""", + ) + assertEquals("http://[::1]:8810", saved.baseUrl.toString()) + } + + @Test + fun rejectsAmbiguousOrUnsafeAddresses() { + assertNull(Connection.parse("host:not-a-port")) + assertNull(Connection.parse("[::1]:not-a-port")) + assertNull(Connection.parse("[::1]:70000")) + assertNull(Connection.parse("host/path")) + assertNull(Connection.parse("host name")) + } + + @Test + fun parsesADesktopPairingInvite() { + val token = "omb_pair_" + "a".repeat(43) + val invite = PairingInvite.parse( + URI("openmausbot://pair?address=macbook.tail1234.ts.net%3A8810&token=$token&code=004209&name=Milind%27s%20Mac"), + )!! + assertEquals("macbook.tail1234.ts.net", invite.connection.host) + assertEquals(8810, invite.connection.port) + assertEquals("Milind's Mac", invite.connection.name) + assertEquals(token, invite.credential) + } + + @Test + fun literalPlusInPairingInviteNameIsPreserved() { + val invite = PairingInvite.parse( + URI("openmausbot://pair?address=mac.local&code=004209&name=Ada%27s+Mac"), + ) + assertEquals("Ada's+Mac", invite?.connection?.name) + } + + @Test + fun parsesAnOlderCodeOnlyPairingInvite() { + val invite = PairingInvite.parse(URI("openmausbot://pair?address=mac.local&code=004209")) + assertEquals("004209", invite?.credential) + } + + @Test + fun carriesFallbackHostsFromTheInvite() { + val invite = PairingInvite.parse(URI( + "openmausbot://pair?address=macbook.tail1234.ts.net%3A8810&code=004209" + + "&hosts=macbook.tail1234.ts.net,192.168.1.42,openmausbot-aa.local", + ))!! + assertEquals( + listOf("macbook.tail1234.ts.net", "192.168.1.42", "openmausbot-aa.local"), + invite.connection.hosts, + ) + } + + @Test + fun dropsUnusableFallbackHostsWithoutRefusingTheInvite() { + val invite = PairingInvite.parse(URI( + "openmausbot://pair?address=mac.local&code=004209&hosts=%20192.168.1.42%20,,bad%2Fslash,has%20space", + ))!! + assertEquals(listOf("192.168.1.42"), invite.connection.hosts) + val empty = PairingInvite.parse( + URI("openmausbot://pair?address=mac.local&code=004209&hosts=bad%2Fslash"), + ) + assertNull(empty?.connection?.hosts) + } + + @Test + fun savedConnectionWithoutFallbacksStillDecodes() { + val saved = CompanionJson.decodeFromString( + """{"id":"saved","name":"Mac","host":"mac.tail1234.ts.net","port":8810}""", + ) + assertNull(saved.hosts) + assertEquals(listOf("mac.tail1234.ts.net"), saved.orderedHosts) + } + + @Test + fun pairResponseWithAndWithoutHostsDecodes() { + val older = CompanionJson.decodeFromString( + """{"token":"omb_x","device":{"id":"d","name":"p","createdAt":1,"lastSeenAt":1},"serverName":"Mac"}""", + ) + assertNull(older.hosts) + val newer = CompanionJson.decodeFromString( + """{"token":"omb_x","device":{"id":"d","name":"p","createdAt":1,"lastSeenAt":1},"serverName":"Mac","hosts":["a.ts.net","192.168.1.42"]}""", + ) + assertEquals(listOf("a.ts.net", "192.168.1.42"), newer.hosts) + } + + @Test + fun rejectsUntrustedOrMalformedPairingInvites() { + listOf( + "https://example.com/pair?address=mac.local&code=123456", + "openmausbot://pair?address=mac.local&code=12345", + "openmausbot://pair?address=mac.local&token=weak", + "openmausbot://pair?address=mac.local&token=weak&code=123456", + "openmausbot://pair?address=host%2Fpath&code=123456", + "openmausbot://pair?address=one.local&address=two.local&code=123456", + ).forEach { assertNull(PairingInvite.parse(URI(it)), it) } + } + + @Test + fun acceptsOnlyAnHttpsCloudDesktopSession() { + val valid = CompanionJson.decodeFromString( + """{"joinUrl":"https://desktop.example/session/fresh","state":"ready"}""", + ) + assertEquals("https://desktop.example/session/fresh", valid.url.toString()) + listOf( + "http://desktop.example/session", + "javascript:alert(1)", + "not a URL", + "https:///missing-host", + ).forEach { value -> + assertFailsWith { + CompanionJson.decodeFromString("""{"joinUrl":"$value"}""") + } + } + } + + private class RecordingRouteListener : EventListener() { + val dnsHost = AtomicReference() + val dnsAddresses = AtomicReference>() + + override fun dnsStart(call: Call, domainName: String) { + dnsHost.set(domainName) + } + + override fun dnsEnd(call: Call, domainName: String, inetAddressList: List) { + dnsAddresses.set(inetAddressList) + } + } + + private class RecordingSocketFactory : SocketFactory() { + val connectTarget = AtomicReference() + + override fun createSocket(): Socket = object : Socket() { + override fun connect(endpoint: SocketAddress, timeout: Int) { + connectTarget.set(endpoint as InetSocketAddress) + throw SocketException("connect target recorded") + } + } + + override fun createSocket(host: String, port: Int): Socket = unsupported() + override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket = unsupported() + override fun createSocket(host: InetAddress, port: Int): Socket = unsupported() + override fun createSocket( + address: InetAddress, + port: Int, + localAddress: InetAddress, + localPort: Int, + ): Socket = unsupported() + + private fun unsupported(): Nothing = error("OkHttp must use createSocket() before connect") + } +} diff --git a/android/core/src/test/kotlin/com/openmausbot/companion/core/DecodingTest.kt b/android/core/src/test/kotlin/com/openmausbot/companion/core/DecodingTest.kt new file mode 100644 index 000000000..d2ac99a06 --- /dev/null +++ b/android/core/src/test/kotlin/com/openmausbot/companion/core/DecodingTest.kt @@ -0,0 +1,235 @@ +package com.openmausbot.companion.core + +import kotlinx.serialization.SerializationException +import kotlinx.serialization.decodeFromString +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DecodingTest { + @Test + fun decodesThePagedFleet() { + val fleet = decodeFixture("bots-paged") + assertTrue(fleet.bots.isNotEmpty()) + val bot = fleet.bots.first() + assertTrue(bot.id.isNotEmpty()) + assertTrue(bot.threadId.isNotEmpty()) + assertTrue(bot.name.isNotEmpty()) + assertNotNull(bot.messages) + val room = fleet.groups.first() + assertEquals(3, room.messages?.size) + assertEquals(true, room.hasMore) + } + + @Test + fun decodesTheFullFleetToo() { + val fleet = decodeFixture("bots-full") + assertTrue(fleet.bots.isNotEmpty()) + assertNull(fleet.bots.first().hasMore) + } + + @Test + fun decodesTheCloudBackendAndItsAbsence() { + val fleet = CompanionJson.decodeFromString( + """{"bots":[ + {"id":"b1","threadId":"t1","name":"Scout","title":"","description":"","notifications":true,"color":"green","unread":false,"modelSelection":{"instanceId":"i1","model":"m1"},"createdAt":1,"computer":"cloud","cloudBackend":"vps"}, + {"id":"b2","threadId":"t2","name":"Rio","title":"","description":"","notifications":true,"color":"blue","unread":false,"modelSelection":{"instanceId":"i1","model":"m1"},"createdAt":2,"computer":"cloud"} + ],"groups":[]}""", + ) + assertEquals("vps", fleet.bots.first().cloudBackend) + assertNull(fleet.bots.last().cloudBackend) + } + + @Test + fun oneMalformedBotOrRoomDoesNotHideTheRestOfTheFleet() { + val fleet = CompanionJson.decodeFromString( + """{"bots":[ + {"id":"broken","threadId":42}, + {"id":"good","threadId":"t1","name":"Scout","title":"","description":"","notifications":true,"color":"green","unread":false,"modelSelection":{"instanceId":"i1","model":"m1"},"createdAt":1} + ],"groups":[ + {"id":"broken-room","threadId":42}, + {"id":"good-room","threadId":"rt1","name":"Room","memberIds":[],"defaultResponder":{"kind":"mentions"},"bulletin":"","unread":false,"createdAt":1} + ]}""", + ) + assertEquals(listOf("good"), fleet.bots.map(Bot::id)) + assertEquals(listOf("good-room"), fleet.groups.map(Room::id)) + } + + @Test + fun neverDecodesProviderSessionCursors() { + listOf("bots-full", "bots-paged", "sse-frames").forEach { name -> + assertFalse("resumeCursors" in fixtureText(name), "$name carries provider session cursors") + } + } + + @Test + fun decodesAThreadPage() { + val page = decodeFixture("thread-page") + assertEquals(2, page.messages.size) + assertEquals(true, page.hasMore) + assertEquals(page.messages.map(Message::at).sorted(), page.messages.map(Message::at)) + } + + @Test + fun decodesAnOptionsCard() { + val message = decodeFixture("options-card") + assertEquals(Message.Kind.OPTIONS, message.kind) + assertEquals(Message.Role.BOT, message.role) + val card = assertNotNull(message.card) + assertTrue(card.options.isNotEmpty()) + assertFalse(card.isPending) + assertFalse(card.isPermission) + } + + @Test + fun pendingApprovalIsActionableAndAnsweredOrDismissedIsNot() { + val message = CompanionJson.decodeFromString( + """{"id":"m1","role":"bot","kind":"options","at":1786742413762, + "card":{"title":"Approval needed","subtitle":"rm -rf ./build","options":["Allow","Deny"],"requestId":"req-1","tool":"Bash","allowKey":"Bash:rm"}}""", + ) + val card = assertNotNull(message.card) + assertTrue(card.isPending) + assertTrue(card.isPermission) + assertEquals("Bash:rm", card.allowKey) + assertFalse(card.copy(answered = "Allow").isPending) + assertFalse(card.copy(dismissed = true).isPending) + } + + @Test + fun decodesAMessageThatGainedAnUnknownField() { + val message = CompanionJson.decodeFromString( + """{"id":"m2","role":"user","kind":"text","at":1,"text":"hi","somethingNew":{"a":1}}""", + ) + assertEquals("hi", message.text) + } + + @Test + fun decodesThePairResponse() { + val paired = decodeFixture("pair-response") + assertTrue(paired.token.startsWith("omb_")) + assertEquals("Ada's iPhone", paired.device.name) + assertTrue(paired.serverName.isNotEmpty()) + } + + @Test + fun decodesTheHarnessErrorBodies() { + assertTrue(decodeFixture("unauthorized").error.contains("pair")) + assertTrue(decodeFixture("forbidden").error.isNotEmpty()) + assertTrue(decodeFixture("pair-rejected").error.isNotEmpty()) + } + + @Test + fun decodesInstancesAndConfig() { + val instance = decodeFixture("instances").instances.first() + assertTrue(instance.instanceId.isNotEmpty()) + assertTrue(instance.driverKind.isNotEmpty()) + val config = decodeFixture("config") + assertEquals("Ada Lovelace", config.profile?.name) + assertEquals(false, config.box?.configured) + } + + @Test + fun decodesEveryCapturedFrame() { + val frames = decodeFixture>("sse-frames") + assertTrue(frames.isNotEmpty()) + val kinds = frames.map { streamFrame -> + when (val frame = streamFrame.frame) { + is Frame.Hello -> { + assertTrue(':' in frame.cursor) + assertFalse(frame.resumed) + assertNull(streamFrame.seq) + "hello" + } + is Frame.Message -> { + assertTrue(frame.threadId.isNotEmpty()) + assertTrue(frame.message.id.isNotEmpty()) + assertNotNull(streamFrame.seq) + "message" + } + is Frame.Bot -> { + assertTrue(frame.bot.id.isNotEmpty()) + assertNull(frame.bot.messages) + "bot" + } + is Frame.Unknown -> error("unhandled frame kind in fixtures: ${frame.kind}") + else -> "other" + } + } + assertTrue("hello" in kinds) + assertTrue("message" in kinds) + assertTrue("bot" in kinds) + } + + @Test + fun unknownFrameKindIsAbsorbedRatherThanThrown() { + val stream = CompanionJson.decodeFromString( + """{"kind":"routine.run","run":{"id":"r1"},"seq":9}""", + ) + assertEquals(9, stream.seq) + assertEquals(Frame.Unknown("routine.run"), stream.frame) + } + + @Test + fun decodesANotifyFrame() { + val stream = CompanionJson.decodeFromString( + """{"kind":"notify","seq":12,"notification":{"kind":"approval","botId":"b1","botName":"Scout","threadId":"t1","title":"Scout needs approval","body":"rm -rf ./build"}}""", + ) + val notification = (stream.frame as Frame.Notify).notification + assertTrue(notification.isBlocking) + assertEquals("t1", notification.threadId) + assertEquals("t1", stream.frame.threadId) + } + + @Test + fun unknownMessageKindDecodesAndKeepsItsText() { + val message = CompanionJson.decodeFromString( + """{"id":"m1","role":"bot","kind":"webhook","at":1,"text":"Stripe fired"}""", + ) + assertEquals(Message.Kind.UNKNOWN, message.kind) + assertEquals("Stripe fired", message.text) + } + + @Test + fun unknownRoleIsNotAttributedToTheUser() { + val message = CompanionJson.decodeFromString( + """{"id":"m1","role":"system","kind":"text","at":1,"text":"hello"}""", + ) + assertEquals(Message.Role.BOT, message.role) + } + + @Test + fun oneUnknownMessageDoesNotSinkThePage() { + val page = CompanionJson.decodeFromString( + """{"messages":[ + {"id":"m1","role":"user","kind":"text","at":1,"text":"go"}, + {"id":"m2","role":"bot","kind":"something-new","at":2,"text":"working"}, + {"id":"m3","role":"bot","kind":"text","at":3,"text":"done"} + ],"hasMore":false}""", + ) + assertEquals(listOf(Message.Kind.TEXT, Message.Kind.UNKNOWN, Message.Kind.TEXT), page.messages.map(Message::kind)) + assertEquals(listOf("m1", "m2", "m3"), page.messages.map(Message::id)) + } + + @Test + fun unknownMessageArrivesOverTheStream() { + val frame = CompanionJson.decodeFromString( + """{"kind":"message","seq":3,"threadId":"t1","message":{"id":"m9","role":"bot","kind":"routine.run","at":9,"text":"ran"}}""", + ).frame as Frame.Message + assertEquals("t1", frame.threadId) + assertEquals(Message.Kind.UNKNOWN, frame.message.kind) + assertEquals("ran", frame.message.text) + } + + @Test + fun messageKindRemainsRequired() { + assertFailsWith { + CompanionJson.decodeFromString( + """{"id":"m1","role":"bot","at":1,"text":"missing discriminator"}""", + ) + } + } +} diff --git a/android/core/src/test/kotlin/com/openmausbot/companion/core/EventStreamTest.kt b/android/core/src/test/kotlin/com/openmausbot/companion/core/EventStreamTest.kt new file mode 100644 index 000000000..bd3a348e4 --- /dev/null +++ b/android/core/src/test/kotlin/com/openmausbot/companion/core/EventStreamTest.kt @@ -0,0 +1,134 @@ +package com.openmausbot.companion.core + +import java.util.concurrent.CountDownLatch +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import okhttp3.MediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody +import okio.Buffer +import okio.BufferedSource +import okio.Source +import okio.Timeout +import okio.buffer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class EventStreamTest { + private val request = Request.Builder().url("http://127.0.0.1:8810/api/events").build() + + @Test + fun deliversEveryFrameAsItArrives() = runBlocking { + val body = ChunkedBody(listOf( + "data: {\"kind\":\"hello\",\"cursor\":\"abc12345:0\",\"resumed\":false}\n\n", + ": keepalive\n\n", + "id: abc12345:1\ndata: {\"kind\":\"bot\",\"seq\":1,\"bot\":{\"id\":\"b1\",\"threadId\":\"t1\",\"name\":\"Scout\",\"title\":\"\",\"description\":\"\",\"notifications\":true,\"color\":\"green\",\"unread\":false,\"modelSelection\":{\"instanceId\":\"i\",\"model\":\"m\"},\"createdAt\":1}}\n\n", + "id: abc12345:2\ndata: {\"kind\":\"message\",\"seq\":2,\"threadId\":\"t1\",\"message\":{\"id\":\"m1\",\"role\":\"user\",\"kind\":\"text\",\"at\":1,\"text\":\"hi\"}}\n\n", + )) + val frames = eventStream(request, client(body)).take(3).toList() + assertEquals(3, frames.size) + assertIs(frames[0].frame) + assertIs(frames[1].frame) + assertIs(frames[2].frame) + assertEquals(2, frames[2].seq) + } + + @Test + fun survivesAFrameSplitAcrossReads() = runBlocking { + val body = ChunkedBody(listOf( + "data: {\"kind\":\"hel", + "lo\",\"cursor\":\"abc12345:0\",\"resumed\":true}", + "\n\n", + )) + val frames = eventStream(request, client(body)).take(1).toList() + val hello = assertIs(frames.single().frame) + assertEquals("abc12345:0", hello.cursor) + assertTrue(hello.resumed) + } + + @Test + fun reportsUnauthorizedStreamRatherThanEndingQuietly() = runBlocking { + val error = assertFailsWith { + eventStream(request, client(ChunkedBody(listOf("{\"error\":\"pair this device\"}")), 401)).toList() + } + assertTrue(error.isUnauthorized) + } + + @Test + fun cancellingConsumerTearsDownTheRequest() = runBlocking { + val body = ChunkedBody( + chunks = listOf("data: {\"kind\":\"hello\",\"cursor\":\"abc12345:0\",\"resumed\":false}\n\n"), + blockAtEnd = true, + ) + val frames = withTimeout(2_000) { + eventStream(request, client(body)).take(1).toList() + } + assertEquals(1, frames.size) + assertTrue(body.closed.get(), "leaving the flow should close its response body") + } + + @Test + fun malformedFrameIsDroppedWithoutEndingTheStream() = runBlocking { + val body = ChunkedBody(listOf( + "data: {not-json}\n\n", + "data: {\"kind\":\"config\",\"seq\":2}\n\n", + )) + val frames = eventStream(request, client(body)).toList() + assertEquals(1, frames.size) + assertEquals(Frame.Config, frames.single().frame) + } + + private fun client(body: ResponseBody, status: Int = 200): OkHttpClient = OkHttpClient.Builder() + .addInterceptor { chain -> + Response.Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(status) + .message(if (status == 200) "OK" else "Unauthorized") + .header("Content-Type", "text/event-stream") + .body(body) + .build() + } + .build() + + private class ChunkedBody( + chunks: List, + private val blockAtEnd: Boolean = false, + ) : ResponseBody() { + val closed = AtomicBoolean(false) + private val release = CountDownLatch(1) + private val bytes = chunks.map { it.toByteArray() } + private var index = 0 + private val stream: BufferedSource = object : Source { + override fun read(sink: Buffer, byteCount: Long): Long { + if (index < bytes.size) { + val next = bytes[index++] + sink.write(next) + return next.size.toLong() + } + if (blockAtEnd && !closed.get()) release.await() + return -1 + } + + override fun timeout(): Timeout = Timeout.NONE + + override fun close() { + closed.set(true) + release.countDown() + } + }.buffer() + + override fun contentType(): MediaType? = null + override fun contentLength(): Long = -1 + override fun source(): BufferedSource = stream + } +} diff --git a/android/core/src/test/kotlin/com/openmausbot/companion/core/FailoverTest.kt b/android/core/src/test/kotlin/com/openmausbot/companion/core/FailoverTest.kt new file mode 100644 index 000000000..b22808cc7 --- /dev/null +++ b/android/core/src/test/kotlin/com/openmausbot/companion/core/FailoverTest.kt @@ -0,0 +1,169 @@ +package com.openmausbot.companion.core + +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import javax.net.ssl.SSLException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class FailoverTest { + @Test + fun walksCandidatesInOrderAndWraps() { + val rotation = CandidateRotation(listOf("mac.tail1234.ts.net", "192.168.1.42", "openmausbot-aa.local")) + assertEquals("mac.tail1234.ts.net", rotation.current) + assertEquals("192.168.1.42", rotation.advance()) + assertEquals("openmausbot-aa.local", rotation.advance()) + assertEquals("mac.tail1234.ts.net", rotation.advance()) + } + + @Test + fun promotesTheWorkingCandidateToTheFront() { + val rotation = CandidateRotation(listOf("mac.tail1234.ts.net", "192.168.1.42", "openmausbot-aa.local")) + rotation.advance() + assertEquals( + listOf("192.168.1.42", "mac.tail1234.ts.net", "openmausbot-aa.local"), + rotation.promoted(), + ) + } + + @Test + fun promotionWithoutAWalkChangesNothing() { + val rotation = CandidateRotation(listOf("mac.tail1234.ts.net", "192.168.1.42")) + assertEquals(listOf("mac.tail1234.ts.net", "192.168.1.42"), rotation.promoted()) + } + + @Test + fun survivesAnEmptyCandidateList() { + val rotation = CandidateRotation(emptyList()) + assertEquals("", rotation.current) + assertEquals("", rotation.advance()) + assertEquals(emptyList(), rotation.promoted()) + } + + @Test + fun rotatesOnAddressFailuresAndNothingElse() { + listOf( + ConnectionFailure.CANNOT_FIND_HOST, + ConnectionFailure.CANNOT_CONNECT_TO_HOST, + ConnectionFailure.TIMED_OUT, + ConnectionFailure.SECURE_CONNECTION_FAILED, + ).forEach { assertTrue(ConnectionAdvice.shouldTryAnotherHost(it)) } + listOf( + ConnectionFailure.NOT_CONNECTED_TO_INTERNET, + ConnectionFailure.CANCELLED, + ConnectionFailure.NETWORK_CONNECTION_LOST, + ).forEach { assertFalse(ConnectionAdvice.shouldTryAnotherHost(it)) } + + assertTrue(ConnectionAdvice.shouldTryAnotherHost(UnknownHostException())) + assertTrue(ConnectionAdvice.shouldTryAnotherHost(ConnectException())) + assertTrue(ConnectionAdvice.shouldTryAnotherHost(SocketTimeoutException())) + assertTrue(ConnectionAdvice.shouldTryAnotherHost(SSLException("TLS"))) + assertTrue(ConnectionAdvice.shouldTryAnotherHost(APIError.Transport("wrapped", UnknownHostException()))) + assertFalse(ConnectionAdvice.shouldTryAnotherHost(APIError.Status(401))) + } + + @Test + fun unresolvedHostNamesTheTailnetPossibility() { + val message = ConnectionAdvice.message( + ConnectionFailure.CANNOT_FIND_HOST, + "mac.tail1234.ts.net", + 8810, + ) + assertTrue(message.contains("mac.tail1234.ts.net")) + assertTrue(message.contains("tailnet")) + assertTrue(message.contains("retrying automatically")) + } + + @Test + fun refusedConnectionPointsAtTheCompanionToggle() { + val message = ConnectionAdvice.message( + ConnectionFailure.CANNOT_CONNECT_TO_HOST, + "192.168.1.42", + 8810, + ) + assertTrue(message.contains("port 8810")) + assertTrue(message.contains("Settings → Companion")) + } + + @Test + fun timeoutBlamesTheRouteNotTheApp() { + val message = ConnectionAdvice.message(ConnectionFailure.TIMED_OUT, "192.168.1.42", 8810) + assertTrue(message.contains("No route")) + assertTrue(message.contains("firewall")) + } + + @Test + fun offlineSaysOffline() { + assertTrue( + ConnectionAdvice.message(ConnectionFailure.NOT_CONNECTED_TO_INTERNET, "x", 8810) + .contains("You're offline."), + ) + } + + @Test + fun adviceNamesTheCandidateBeingTriedNext() { + val message = ConnectionAdvice.message( + ConnectionFailure.CANNOT_FIND_HOST, + "mac.tail1234.ts.net", + 8810, + tryingNext = "192.168.1.42", + ) + assertTrue(message.contains("Trying 192.168.1.42 next.")) + } + + @Test + fun orderedHostsLeadsWithStoredHostAndDeduplicates() { + val connection = Connection( + name = "Mac", + host = "192.168.1.42", + port = 8810, + hosts = listOf("mac.tail1234.ts.net", "192.168.1.42", "openmausbot-aa.local"), + ) + assertEquals( + listOf("192.168.1.42", "mac.tail1234.ts.net", "openmausbot-aa.local"), + connection.orderedHosts, + ) + } + + @Test + fun orderedHostsFallsBackToSingleStoredHost() { + val connection = Connection(name = "Mac", host = "mac.tail1234.ts.net", port = 8810) + assertEquals(listOf("mac.tail1234.ts.net"), connection.orderedHosts) + } + + @Test + fun dialingSwapsHostWithoutTouchingStoredOrder() { + val connection = Connection( + name = "Mac", + host = "mac.tail1234.ts.net", + port = 8810, + hosts = listOf("mac.tail1234.ts.net", "192.168.1.42"), + ) + val dialed = connection.dialing("192.168.1.42") + assertEquals("192.168.1.42", dialed.host) + assertEquals("http://192.168.1.42:8810", dialed.baseUrl.toString()) + assertEquals(connection.hosts, dialed.hosts) + assertEquals(connection.id, dialed.id) + } + + @Test + fun promoteReordersAndKeepsEveryCandidate() { + val connection = Connection( + name = "Mac", + host = "mac.tail1234.ts.net", + port = 8810, + hosts = listOf("mac.tail1234.ts.net", "192.168.1.42", "openmausbot-aa.local"), + ).promoting("192.168.1.42") + assertEquals("192.168.1.42", connection.host) + assertEquals( + listOf("192.168.1.42", "mac.tail1234.ts.net", "openmausbot-aa.local"), + connection.hosts, + ) + val typed = connection.promoting("10.0.0.7") + assertEquals("10.0.0.7", typed.hosts?.first()) + assertEquals(4, typed.hosts?.size) + } +} diff --git a/android/core/src/test/kotlin/com/openmausbot/companion/core/FixtureSupport.kt b/android/core/src/test/kotlin/com/openmausbot/companion/core/FixtureSupport.kt new file mode 100644 index 000000000..77798063c --- /dev/null +++ b/android/core/src/test/kotlin/com/openmausbot/companion/core/FixtureSupport.kt @@ -0,0 +1,14 @@ +package com.openmausbot.companion.core + +import kotlinx.serialization.decodeFromString + +internal fun fixtureBytes(name: String): ByteArray = + checkNotNull(object {}.javaClass.classLoader.getResourceAsStream("$name.json")) { + "missing fixture $name.json — run scripts/capture-companion-fixtures.mjs" + }.use { it.readBytes() } + +internal fun fixtureText(name: String): String = fixtureBytes(name).toString(Charsets.UTF_8) + +internal inline fun decodeFixture(name: String): T = + CompanionJson.decodeFromString(fixtureText(name)) + diff --git a/android/core/src/test/kotlin/com/openmausbot/companion/core/MarkdownTest.kt b/android/core/src/test/kotlin/com/openmausbot/companion/core/MarkdownTest.kt new file mode 100644 index 000000000..4f06a3d5e --- /dev/null +++ b/android/core/src/test/kotlin/com/openmausbot/companion/core/MarkdownTest.kt @@ -0,0 +1,184 @@ +package com.openmausbot.companion.core + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class MarkdownTest { + @Test + fun plainTextIsOneParagraph() { + assertEquals(listOf(MarkdownBlock.Paragraph("just a reply")), Markdown.blocks("just a reply")) + } + + @Test + fun emptyInputProducesNothing() { + assertEquals(emptyList(), Markdown.blocks("")) + assertEquals(emptyList(), Markdown.blocks("\n\n \n")) + } + + @Test + fun softBreaksBecomeSpaces() { + assertEquals( + listOf(MarkdownBlock.Paragraph("one line and its continuation")), + Markdown.blocks("one line\nand its continuation"), + ) + } + + @Test + fun blankLineSeparatesParagraphs() { + assertEquals( + listOf(MarkdownBlock.Paragraph("first"), MarkdownBlock.Paragraph("second")), + Markdown.blocks("first\n\nsecond"), + ) + } + + @Test + fun headingLevels() { + assertEquals(listOf(MarkdownBlock.Heading(1, "Title")), Markdown.blocks("# Title")) + assertEquals(listOf(MarkdownBlock.Heading(3, "Deeper")), Markdown.blocks("### Deeper")) + } + + @Test + fun hashWithoutSpaceIsNotAHeading() { + assertEquals(listOf(MarkdownBlock.Paragraph("#hashtag")), Markdown.blocks("#hashtag")) + assertEquals(listOf(MarkdownBlock.Paragraph("####### seven")), Markdown.blocks("####### seven")) + } + + @Test + fun bulletMarkers() { + assertEquals( + listOf( + MarkdownBlock.Bullet(0, "one"), + MarkdownBlock.Bullet(0, "two"), + MarkdownBlock.Bullet(0, "three"), + ), + Markdown.blocks("- one\n* two\n+ three"), + ) + } + + @Test + fun nestedBulletsCountIndent() { + assertEquals( + listOf( + MarkdownBlock.Bullet(0, "top"), + MarkdownBlock.Bullet(1, "nested"), + MarkdownBlock.Bullet(2, "deeper"), + ), + Markdown.blocks("- top\n - nested\n - deeper"), + ) + } + + @Test + fun orderedListsKeepTheirNumbers() { + assertEquals( + listOf( + MarkdownBlock.Ordered(0, 1, "first"), + MarkdownBlock.Ordered(0, 2, "second"), + MarkdownBlock.Ordered(0, 10, "tenth"), + ), + Markdown.blocks("1. first\n2. second\n10) tenth"), + ) + } + + @Test + fun numberWithoutDelimiterIsProse() { + assertEquals(listOf(MarkdownBlock.Paragraph("2026 was the year")), Markdown.blocks("2026 was the year")) + assertEquals(listOf(MarkdownBlock.Paragraph("3.14 is pi")), Markdown.blocks("3.14 is pi")) + } + + @Test + fun inlineSyntaxSurvivesTheSplit() { + assertEquals( + listOf(MarkdownBlock.Bullet(0, "**bold** and `code` and [link](https://x.test)")), + Markdown.blocks("- **bold** and `code` and [link](https://x.test)"), + ) + } + + @Test + fun fencedCodeKeepsLanguageAndWhitespace() { + assertEquals( + listOf(MarkdownBlock.Code("swift", "let x = 1\n indented")), + Markdown.blocks("```swift\nlet x = 1\n indented\n```"), + ) + } + + @Test + fun fenceWithoutLanguage() { + assertEquals(listOf(MarkdownBlock.Code(null, "plain")), Markdown.blocks("```\nplain\n```")) + } + + @Test + fun unclosedFenceRunsToTheEnd() { + assertEquals( + listOf(MarkdownBlock.Paragraph("here:"), MarkdownBlock.Code("py", "print(1)")), + Markdown.blocks("here:\n```py\nprint(1)"), + ) + } + + @Test + fun fenceContentIsNotReparsed() { + assertEquals( + listOf(MarkdownBlock.Code(null, "# not a heading\n- not a bullet")), + Markdown.blocks("```\n# not a heading\n- not a bullet\n```"), + ) + } + + @Test + fun quote() { + assertEquals(listOf(MarkdownBlock.Quote("quoted")), Markdown.blocks("> quoted")) + } + + @Test + fun horizontalRules() { + assertEquals(listOf(MarkdownBlock.Rule), Markdown.blocks("---")) + assertEquals(listOf(MarkdownBlock.Rule), Markdown.blocks("***")) + assertEquals(listOf(MarkdownBlock.Rule), Markdown.blocks("___")) + } + + @Test + fun ruleNeedsThreeAndNothingElse() { + assertEquals(listOf(MarkdownBlock.Paragraph("--")), Markdown.blocks("--")) + assertEquals(listOf(MarkdownBlock.Paragraph("-- dashes --")), Markdown.blocks("-- dashes --")) + } + + @Test + fun crlfDoesNotCreatePhantomBlocks() { + assertEquals( + listOf(MarkdownBlock.Paragraph("one two")), + Markdown.blocks("one\r\ntwo"), + ) + assertEquals( + listOf(MarkdownBlock.Code(null, "one\ntwo")), + Markdown.blocks("```\r\none\r\ntwo\r\n```"), + ) + } + + @Test + fun partialInputAlwaysRendersSomething() { + listOf("#", "# ", "# Head", "- ", "- it", "**bo", "```", "```sw\nlet", "[link](htt").forEach { + assertTrue(Markdown.blocks(it).isNotEmpty(), "dropped everything for $it") + } + } + + @Test + fun noPrefixOfAReplyLosesCharacters() { + val reply = "# Result\n\nRan **two** checks:\n\n- `pnpm test` passed\n- `pnpm lint` passed\n\n```sh\npnpm test\n```\n\n> nothing else to report" + for (length in 1..reply.length) { + val partial = reply.take(length) + val rendered = Markdown.blocks(partial).joinToString(separator = "", transform = ::text) + val sent = partial.filter { !it.isWhitespace() && it !in "#->`*_" } + val shown = rendered.filter { !it.isWhitespace() && it !in "#->`*_" } + assertEquals(sent, shown, "lost content at $length characters") + } + } + + private fun text(block: MarkdownBlock): String = when (block) { + is MarkdownBlock.Paragraph -> block.text + is MarkdownBlock.Bullet -> block.text + is MarkdownBlock.Ordered -> block.number.toString() + block.text + is MarkdownBlock.Heading -> block.text + is MarkdownBlock.Code -> block.language.orEmpty() + block.text + is MarkdownBlock.Quote -> block.text + MarkdownBlock.Rule -> "" + } +} diff --git a/android/core/src/test/kotlin/com/openmausbot/companion/core/SseTest.kt b/android/core/src/test/kotlin/com/openmausbot/companion/core/SseTest.kt new file mode 100644 index 000000000..6abc140f0 --- /dev/null +++ b/android/core/src/test/kotlin/com/openmausbot/companion/core/SseTest.kt @@ -0,0 +1,101 @@ +package com.openmausbot.companion.core + +import kotlinx.serialization.decodeFromString +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SseTest { + private fun events(lines: List): List { + val parser = SSEParser() + return lines.mapNotNull(parser::line) + } + + @Test + fun readsOneFrameTheHarnessActuallySends() { + val parsed = events(listOf("id: 778d5d30:4", """data: {"kind":"bot","seq":4}""", "")) + assertEquals(1, parsed.size) + assertEquals("778d5d30:4", parsed[0].id) + assertEquals("""{"kind":"bot","seq":4}""", parsed[0].data) + } + + @Test + fun swallowsKeepaliveComments() { + assertTrue(events(listOf(": keepalive", "")).isEmpty()) + assertEquals(1, events(listOf(": keepalive", "", "data: {}", "")).size) + } + + @Test + fun emitsNothingUntilTheBlankLineArrives() { + val parser = SSEParser() + assertNull(parser.line("id: s:1")) + assertNull(parser.line("data: {}")) + assertNotNull(parser.line("")) + } + + @Test + fun joinsMultipleDataLines() { + val parsed = events(listOf("data: {", "data: \"kind\": \"hello\"", "data: }", "")) + assertEquals("{\n\"kind\": \"hello\"\n}", parsed.first().data) + } + + @Test + fun handlesOptionalSpaceAndCarriageReturns() { + assertEquals("tight", events(listOf("data:tight", "")).first().data) + assertEquals(" padded", events(listOf("data: padded", "")).first().data) + assertEquals("crlf", events(listOf("data: crlf\r", "\r")).first().data) + } + + @Test + fun ignoresUnusedFieldsAndBlocksWithoutData() { + assertTrue(events(listOf("event: ping", "retry: 3000", "")).isEmpty()) + assertTrue(events(listOf("", "", "")).isEmpty()) + assertTrue(events(listOf("garbage-with-no-colon", "")).isEmpty()) + } + + @Test + fun fieldsDoNotLeakIntoTheNextEvent() { + val parser = SSEParser() + parser.line("id: s:1") + parser.line("data: first") + val first = parser.line("") + parser.line("data: second") + val second = parser.line("") + assertEquals("s:1", first?.id) + assertEquals("second", second?.data) + assertNull(second?.id) + } + + @Test + fun resetDiscardsAnIncompleteEventAtEof() { + val parser = SSEParser() + parser.line("id: s:1") + parser.line("data: incomplete") + parser.reset() + assertNull(parser.line("")) + } + + @Test + fun fullStreamDecodesIntoFrames() { + val lines = listOf( + """data: {"kind":"hello","cursor":"abc12345:0","resumed":false}""", "", + ": keepalive", "", + "id: abc12345:1", + """data: {"kind":"message","threadId":"t1","seq":1,"message":{"id":"m1","role":"user","kind":"text","at":1}}""", + "", + ) + val frames = events(lines).mapNotNull { + runCatching { CompanionJson.decodeFromString(it.data) }.getOrNull() + } + assertEquals(2, frames.size) + val hello = assertIs(frames[0].frame) + assertEquals("abc12345:0", hello.cursor) + assertFalse(hello.resumed) + assertIs(frames[1].frame) + assertEquals(1, frames[1].seq) + } +} diff --git a/android/core/src/test/kotlin/com/openmausbot/companion/core/StoreTest.kt b/android/core/src/test/kotlin/com/openmausbot/companion/core/StoreTest.kt new file mode 100644 index 000000000..44de701c2 --- /dev/null +++ b/android/core/src/test/kotlin/com/openmausbot/companion/core/StoreTest.kt @@ -0,0 +1,397 @@ +package com.openmausbot.companion.core + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class StoreTest { + private fun fleet(): Fleet = decodeFixture("bots-paged") + private fun message(id: String, at: Double = 1.0, text: String = "hello") = Message( + id = id, + role = Message.Role.USER, + kind = Message.Kind.TEXT, + at = at, + text = text, + ) + private fun hydrated(): CompanionState = CompanionState().hydrate(fleet()) + + @Test + fun hydrateIndexesEveryThread() { + val state = hydrated() + assertTrue(state.bots.isNotEmpty()) + state.bots.forEach { assertNotNull(state.messages[it.threadId]) } + state.rooms.forEach { + assertEquals(it.messages?.size, state.transcript(it.threadId).size) + assertEquals(it.hasMore, state.hasMore[it.threadId]) + } + } + + @Test + fun applyIsPureAndLeavesThePreviousStateUntouched() { + val before = hydrated() + val threadId = before.bots.first().threadId + val after = before.apply(Frame.Message(threadId, message("new"))) + assertFalse(before.transcript(threadId).any { it.id == "new" }) + assertTrue(after.transcript(threadId).any { it.id == "new" }) + } + + @Test + fun appendsAndPatchesInPlace() { + var state = hydrated() + val threadId = state.bots.first().threadId + val before = state.transcript(threadId).size + state = state.apply(Frame.Message(threadId, message("new-1"))) + assertEquals(before + 1, state.transcript(threadId).size) + state = state.apply(Frame.MessagePatch(threadId, message("new-1", text = "edited"))) + assertEquals(before + 1, state.transcript(threadId).size) + assertEquals("edited", state.transcript(threadId).last().text) + } + + @Test + fun patchForAnUnseenMessageAppendsIt() { + val state = CompanionState().apply(Frame.MessagePatch("t1", message("missed"))) + assertEquals(listOf("missed"), state.transcript("t1").map(Message::id)) + } + + @Test + fun replayedMessageDoesNotAppearTwice() { + var state = hydrated() + val threadId = state.bots.first().threadId + val before = state.transcript(threadId).size + state = state.apply(Frame.Message(threadId, message("dupe"))) + state = state.apply(Frame.Message(threadId, message("dupe"))) + assertEquals(before + 1, state.transcript(threadId).size) + } + + @Test + fun scrollbackPrependsWithoutDuplicating() { + val state = CompanionState(messages = mapOf("t1" to listOf(message("c"), message("d")))) + .prepend(ThreadPage(listOf(message("a"), message("b"), message("c")), true), "t1") + assertEquals(listOf("a", "b", "c", "d"), state.transcript("t1").map(Message::id)) + assertEquals(true, state.hasMore["t1"]) + } + + @Test + fun searchWindowMergesAndOrdersWithoutDuplicating() { + val state = CompanionState(messages = mapOf( + "t1" to listOf(message("d", 4.0), message("e", 5.0)), + )).merge( + ThreadPage(listOf(message("b", 2.0), message("c", 3.0), message("d", 4.0)), true), + "t1", + ) + assertEquals(listOf("b", "c", "d", "e"), state.transcript("t1").map(Message::id)) + assertEquals(true, state.hasMore["t1"]) + } + + @Test + fun botFrameMergesRatherThanWipingTranscript() { + var state = hydrated() + val bot = state.bots.first() + state = state.apply(Frame.Message(bot.threadId, message("keep-me"))) + val count = state.transcript(bot.threadId).size + state = state.apply(Frame.Bot(bot.copy(messages = null, busy = true, unread = true))) + assertEquals(true, state.bot(bot.id)?.busy) + assertEquals(count, state.transcript(bot.threadId).size) + assertNotNull(state.bot(bot.id)?.messages) + } + + @Test + fun taskSwitchReplacesTheActiveTranscript() { + var state = hydrated() + val bot = state.bots.first() + state = state.apply(Frame.Message(bot.threadId, message("old-tail"))) + state = state.apply(Frame.Bot(bot.copy( + threadId = "another-task", + messages = listOf(message("new-root", text = "new task")), + activeLeafId = "new-root", + ))) + assertEquals("another-task", state.bot(bot.id)?.threadId) + assertEquals(listOf("new-root"), state.transcript("another-task").map(Message::id)) + assertFalse(state.transcript("another-task").any { it.id == "old-tail" }) + } + + @Test + fun roomFrameNeverWipesTranscript() { + val hydrated = hydrated() + val room = hydrated.rooms.first() + val existing = hydrated.transcript(room.threadId) + val state = hydrated.apply(Frame.Room(room.copy(messages = null, unread = true))) + assertEquals(existing, state.transcript(room.threadId)) + assertEquals(true, state.roomForThread(room.threadId)?.unread) + } + + @Test + fun visibleTranscriptFollowsTheActiveBranch() { + val hydrated = hydrated() + val bot = hydrated.bots.first() + val root = message("root") + val first = message("first", 2.0).copy(parentId = root.id) + val fork = message("fork", 3.0).copy(parentId = root.id) + val tail = message("tail", 4.0).copy(parentId = fork.id) + val state = hydrated.copy(messages = hydrated.messages + (bot.threadId to listOf(root, first, fork, tail))) + .apply(Frame.Thread(bot.threadId, tail.id)) + assertEquals(listOf("root", "fork", "tail"), state.visibleTranscript(bot.threadId).map(Message::id)) + } + + @Test + fun versionsAreUserMessagesWithTheSameParent() { + val root = message("root") + val first = message("first", 2.0).copy(parentId = root.id) + val second = message("second", 3.0).copy(parentId = root.id) + val reply = message("reply", 4.0).copy(role = Message.Role.BOT, parentId = root.id) + val state = CompanionState(messages = mapOf("t1" to listOf(root, second, reply, first))) + assertEquals(listOf("first", "second"), state.versions(first, "t1").map(Message::id)) + } + + @Test + fun messageAppendMovesLeafAndBranchSwitchClearsLiveText() { + var state = hydrated() + val bot = state.bots.first() + state = state.apply(Frame.Runtime(RuntimeEvent( + "content.delta", bot.threadId, "old branch", "assistant_text", + ))) + state = state.apply(Frame.Thread(bot.threadId, "other")) + assertNull(state.streaming[bot.threadId]) + state = state.apply(Frame.Message(bot.threadId, message("latest"))) + assertEquals("latest", state.bot(bot.id)?.activeLeafId) + } + + @Test + fun deletingABotDropsTranscriptStreamAndScreen() { + var state = hydrated() + val bot = state.bots.first() + state = state.apply(Frame.Runtime(RuntimeEvent( + "content.delta", bot.threadId, "partial", "assistant_text", + ))) + state = state.apply(Frame.Screen(bot.id, "AAAA", "image/png")) + state = state.apply(Frame.BotDeleted(bot.id)) + assertNull(state.bot(bot.id)) + assertTrue(state.transcript(bot.threadId).isEmpty()) + assertNull(state.hasMore[bot.threadId]) + assertNull(state.streaming[bot.threadId]) + assertNull(state.screens[bot.id]) + } + + @Test + fun deletingARoomDropsTranscriptAndStream() { + var state = hydrated() + val room = state.rooms.first() + state = state.apply(Frame.Runtime(RuntimeEvent( + "content.delta", room.threadId, "partial", "assistant_text", + ))) + state = state.apply(Frame.RoomDeleted(room.id)) + assertNull(state.roomForThread(room.threadId)) + assertTrue(state.transcript(room.threadId).isEmpty()) + assertNull(state.hasMore[room.threadId]) + assertNull(state.streaming[room.threadId]) + } + + @Test + fun unknownBotFrameAddsIt() { + val bot = hydrated().bots.first().copy(id = "brand-new", threadId = "brand-new-thread") + val state = CompanionState().apply(Frame.Bot(bot)) + assertEquals(1, state.bots.size) + assertNotNull(state.messages["brand-new-thread"]) + } + + @Test + fun pendingApprovalsAreUnansweredOnesNewestFirst() { + val hydrated = hydrated() + val firstThread = hydrated.bots.first().threadId + val secondThread = hydrated.rooms.first().threadId + fun card(id: String, at: Double, requestId: String?, answered: String? = null) = Message( + id = id, + role = Message.Role.BOT, + kind = Message.Kind.OPTIONS, + at = at, + card = OptionCard( + title = "Approval needed", + subtitle = "rm -rf ./build", + options = listOf("Allow", "Deny"), + answered = answered, + requestId = requestId, + tool = "Bash", + allowKey = "Bash:rm", + ), + ) + val state = hydrated.copy(messages = hydrated.messages + mapOf( + firstThread to listOf( + card("old", 1.0, "r1"), + card("answered", 2.0, "r2", "Allow"), + card("history", 3.0, null), + ), + secondThread to listOf(card("new", 9.0, "r3")), + )) + assertEquals(listOf("new", "old"), state.pendingApprovals.map { it.message.id }) + assertEquals(secondThread, state.pendingApprovals.first().threadId) + } + + @Test + fun unreadCountCountsVisibleUnreadBotsAndUnreadRooms() { + val hydrated = hydrated() + val visibleUnread = hydrated.bots.first().copy(unread = true, hidden = false) + val hiddenUnread = visibleUnread.copy(id = "hidden", threadId = "hidden-thread", hidden = true) + val read = visibleUnread.copy(id = "read", threadId = "read-thread", unread = false) + val unreadRoom = hydrated.rooms.first().copy(unread = true) + assertEquals( + 2, + hydrated.copy(bots = listOf(visibleUnread, hiddenUnread, read), rooms = listOf(unreadRoom)).unreadCount, + ) + } + + @Test + fun cursorFollowsStreamAndKeepsStreamId() { + var state = CompanionState().apply(Frame.Hello("abc12345:7", true)) + assertNull(state.cursor) + state = state.resetCursor("abc12345:7") + assertEquals("abc12345:7", state.cursor) + state = state.advance(8) + assertEquals("abc12345:8", state.cursor) + state = state.advance(null) + assertEquals("abc12345:8", state.cursor) + } + + @Test + fun advancingBeforeAnyHelloDoesNothing() { + assertNull(CompanionState().advance(4).cursor) + } + + @Test + fun notificationsCollectInOrder() { + val approval = NotificationFrame( + "approval", "b1", "Scout", "t1", "Scout needs approval", "rm -rf", + ) + val done = NotificationFrame("done", "b1", "Scout", "t1", "Scout finished", "pushed") + val state = CompanionState().apply(Frame.Notify(approval)).apply(Frame.Notify(done)) + assertEquals(2, state.notifications.size) + assertTrue(state.notifications[0].isBlocking) + assertFalse(state.notifications[1].isBlocking) + } + + @Test + fun notificationsKeepOnlyARecentWindow() { + var state = CompanionState() + repeat(120) { index -> + state = state.apply(Frame.Notify(NotificationFrame( + "done", "b1", "Scout", "t1", "Done $index", "body", + ))) + } + assertEquals(100, state.notifications.size) + assertEquals("Done 20", state.notifications.first().title) + } + + @Test + fun framesThisClientIgnoresAreHarmless() { + var state = hydrated() + val before = state.bots.size + state = state.apply(Frame.Screen("b1", "AAAA", "image/png")) + state = state.apply(Frame.Computer("b1", "provisioning")) + state = state.apply(Frame.Config) + state = state.apply(Frame.Runtime(RuntimeEvent("content.delta", "t1", "hi", "assistant_text"))) + state = state.apply(Frame.Unknown("routine.run")) + assertEquals(before, state.bots.size) + } +} + +class StreamingTest { + private fun delta(text: String, thread: String = "t1", kind: String = "assistant_text") = + Frame.Runtime(RuntimeEvent("content.delta", thread, text, kind)) + + @Test + fun deltasAccumulateIntoLiveText() { + val state = CompanionState().apply(delta("Hel")).apply(delta("lo, ")).apply(delta("world")) + assertEquals("Hello, world", state.streaming["t1"]) + } + + @Test + fun reasoningIsKeptApartFromAnswer() { + val state = CompanionState().apply(delta("thinking…", kind = "reasoning_text")).apply(delta("the answer")) + assertEquals("thinking…", state.reasoning["t1"]) + assertEquals("the answer", state.streaming["t1"]) + } + + @Test + fun unknownStreamKindIsDroppedRatherThanGuessedAt() { + val state = CompanionState().apply(delta("???", kind = "some_future_kind")) + assertNull(state.streaming["t1"]) + assertNull(state.reasoning["t1"]) + } + + @Test + fun settledReplyReplacesLiveText() { + var state = CompanionState().apply(delta("partial answer")) + assertNotNull(state.streaming["t1"]) + state = state.apply(Frame.Message("t1", Message( + "m1", Message.Role.BOT, Message.Kind.TEXT, 1.0, "partial answer, completed", + ))) + assertNull(state.streaming["t1"]) + assertEquals(1, state.transcript("t1").size) + } + + @Test + fun onlySettledBotTextReplyClearsIt() { + var state = CompanionState().apply(delta("mid-answer")) + state = state.apply(Frame.Message("t1", Message( + "u1", Message.Role.USER, Message.Kind.TEXT, 1.0, "another question", + ))) + state = state.apply(Frame.Message("t1", Message( + "a1", Message.Role.BOT, Message.Kind.ACTIVITY, 2.0, + ))) + assertEquals("mid-answer", state.streaming["t1"]) + } + + @Test + fun turnEndingClearsEvenWithoutSettledMessage() { + listOf("turn.completed", "turn.failed", "turn.aborted").forEach { ending -> + val state = CompanionState().apply(delta("half a sentence")).apply( + Frame.Runtime(RuntimeEvent(ending, "t1")), + ) + assertNull(state.streaming["t1"], "$ending should end the live bubble") + assertNull(state.reasoning["t1"]) + } + } + + @Test + fun threadsStreamIndependently() { + val state = CompanionState() + .apply(delta("for one", "t1")) + .apply(delta("for two", "t2")) + .apply(Frame.Runtime(RuntimeEvent("turn.completed", "t1"))) + assertNull(state.streaming["t1"]) + assertEquals("for two", state.streaming["t2"]) + } +} + +class ScreenTest { + private fun frame(png: String, bot: String = "b1") = Frame.Screen(bot, png, "image/png") + + @Test + fun onlyNewestFrameIsKept() { + val state = CompanionState().apply(frame("AAAA")).apply(frame("BBBB")).apply(frame("CCCC")) + assertEquals("CCCC", state.screens["b1"]?.png) + assertEquals(1, state.screens.size) + } + + @Test + fun botsAreTrackedSeparately() { + val state = CompanionState().apply(frame("one", "b1")).apply(frame("two", "b2")) + assertEquals("one", state.screens["b1"]?.png) + assertEquals("two", state.screens["b2"]?.png) + } + + @Test + fun closingPanelForgetsFrame() { + val state = CompanionState().apply(frame("stale")).clearScreen("b1") + assertNull(state.screens["b1"]) + } + + @Test + fun badBase64DecodesToNullRatherThanCrashing() { + assertContentEquals("hello".toByteArray(), ScreenFrame("aGVsbG8=", "image/png").data) + assertNull(ScreenFrame("not base64 at all!!", "image/png").data) + } +} diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..f8e1ee3125fe0768e9a76ee977ac089eb657005e GIT binary patch literal 45633 zcma&NV|1n6wyqu9PQ|uu+csuwn-$x(T~Woh?Nr6KUD3(A)@l1Yd+oj6Z_U=8`RAE` z#vE6_`?!1WLs1443=Ieh3JM4ai0JG2|2{}S&_HrxszP*9^5P7#QX*pVDq?D?;6T8C z{bWO1$9at%!*8ax*TT&F99vwf1Ls+3lklsb|bC`H`~Q z_w}*E9P=Wq;PYlGYhZ^lt#N97bt5aZ#mQcOr~h^B;R>f-b0gf{y(;VA{noAt`RZzU z7vQWD{%|q!urW2j0Z&%ChtL(^9m` zgaU%|B;V#N_?%iPvu0PVkX=1m9=*SEGt-Lp#&Jh%rz6EJXlV^O5B5YfM5j{PCeElx z8sipzw8d=wVhFK+@mgrWyA)Sv3BJq=+q+cL@=wuH$2;LjY z^{&+X4*HFA0{QvlM_V4PTQjIdd;d|2YuN;s|bi!@<)r-G%TuOCHz$O(_-K z)5in&6uNN<0UfwY=K>d;cL{{WK2FR|NihJMN0Q4X+(1lE)$kY?T$7UWleIU`i zQG#X-&&m-8x^(;n@o}$@vPMYRoq~|FqC~CU3MnoiifD{(CwAGd%X#kFHq#4~%_a!{ zeX{XXDT#(DvX7NtAs7S}2ZuiZ>gtd;tCR7E)3{J^`~#Vd**9qz%~JRFAiZf{zt|Dr zvQw!)n7fNUn_gH`o9?8W8t_%x6~=y*`r46bjj(t{YU*qfqd}J}*mkgUfsXTI>Uxl6 z)Fj>#RMy{`wINIR;{_-!xGLgVaTfNJ2-)%YUfO&X5z&3^E#4?k-_|Yv$`fpgYkvnA%E{CiV zP|-zAf8+1@R`sT{rSE#)-nuU7Pwr-z>0_+CLQT|3vc-R22ExKT4ym@Gj77j$aTVns zp4Kri#Ml?t7*n(;>nkxKdhOU9Qbwz%*#i9_%K<`m4T{3aPbQ?J(Mo`6E5cDdbAk%X z+4bN%E#a(&ZXe{G#V!2Nt+^L$msKVHP z|APpBhq7knz(O2yY)$$VyI_Xg4UIC*$!i7qQG~KEZnO@Q1i89@4ZKW*3^Wh?o?zSkfPxdhnTxlO!3tAqe_ zuEqHVcAk3uQIFTpP~C{d$?>7yt3G3Fo>syXTus>o0tJdFpQWC27hDiwC%O09i|xCq z@H6l|+maB;%CYQIChyhu;PVYz9e&5a@EEQs3$DS6dLIS+;N@I0)V}%B`jdYv;JDck zd|xxp(I?aedivE7*19hesoa-@Xm$^EHbbVmh$2^W-&aTejsyc$i+}A#n2W*&0Qt`5 zJS!2A|LVV;L!(*x2N)GjJC;b1RB_f(#D&g_-};a*|BTRvfdIX}Gau<;uCylMNC;UG zzL((>6KQBQ01wr%7u9qI2HLEDY!>XisIKb#6=F?pAz)!_JX}w|>1V>X^QkMdFi@Jr z`1N*V4xUl{qvECHoF?#lXuO#Dg2#gh|AU$Wc=nuIbmVPBEGd(R#&Z`TP9*o%?%#ob zWN%ByU+55yBNfjMjkJnBjT!cVDi}+PR3N&H(f8$d^Pu;A_WV*{)c2Q{IiE7&LPsd4 z!rvkUf{sco_WNSIdW+btM#O+4n`JiceH6%`7pDV zRqJ@lj=Dt(e-Gkz$b!c2>b)H$lf(fuAPdIsLSe(dZ4E~9+Ge!{3j~>nS%r)eQZ;Iq ztWGpp=2Ptc!LK_TQ8cgJXUlU5mRu|7F2{eu*;a>_5S<;bus=t*IXcfzJRPv4xIs;s zt2<&}OM>KxkTxa=dFMfNr42=DL~I}6+_{`HT_YJBiWkpVZND1Diad~Yr*Fuq{zljr z*_+jXk=qVBdwlQkYuIrB4GG*#voba$?h*u0uRNL+87-?AjzG2X_R9mzQ7BJEawutObr|ey~%in>6k%A`K*`pb-|DF5m})!`b=~osoiW2)IFh?_y9y<3Cix_ znvC=bjBX1J820!%%9FaB@v?hAsd05e@w$^ZAvtUp*=Bi+Owkl?rLa6F#yl{s+?563 zmn2 zV95%gySAJ$L!Vvk4kx!n@mo`3Mfi`2lXUkBmd%)u)7C?Pa;oK~zUQ#p0u{a|&0;zNO#9a4`v^3df90X#~l_k$q7n&L5 z?TszF842~g+}tgUP}UG?ObLCE1(Js_$e>XS7m%o7j@@VdxePtg)w{i5an+xK95r?s zDeEhgMO-2$H?@0{p-!4NJ)}zP+3LzZB?FVap)ObHV6wp}Lrxvz$cjBND1T6ln$EfJ zZRPeR2lP}K0p8x`ahxB??Ud;i7$Y5X!5}qBFS+Zp=P^#)08nQi_HuJcN$0=x;2s53 zwoH}He9BlKT4GdWfWt)@o@$4zN$B@5gVIN~aHtwIhh{O$uHiMgYl=&Vd$w#B2 zRv+xK3>4E{!)+LXA2#*K6H~HpovXAQeXV(^Pd%G_>ro0(4_@`{2Ag(+8{9pqJ>Co$ zRRV(oX;nD+Jel_2^BlNO=cQP8q*G#~R3PTERUxvug_C4T3qwb9MQE|^{5(H*nt`fn z^%*p-RwkAhT6(r>E@5w8FaB)Q<{#`H9fTdc6QBuSr9D-x!Tb9f?wI=M{^$cB5@1;0 z+yLHh?3^c-Qte@JI<SW`$bs5Vv9!yWjJD%oY z8Cdc$a(LLy@tB2)+rUCt&0$&+;&?f~W6+3Xk3g zy9L�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 literal 0 HcmV?d00001 diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..23449a2b5 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 000000000..adff685a0 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 000000000..e509b2dd8 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 000000000..ca79eb2ad --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,20 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} + +dependencyResolutionManagement { + repositories { + mavenCentral() + } +} + +rootProject.name = "openmausbot-android" +include(":core") + From 3c0bcabb3de6640fa1984aa941e05518756da26e Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Fri, 21 Aug 2026 17:18:00 -0300 Subject: [PATCH 2/6] android/core: the Session, its storage, and failover The stateful half of the port: the Session that folds frames into state, the storage that outlives a process, and the failover that keeps a paired computer reachable when its address moves. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SkWZ3y7iKQMjH6tHNFBte7 --- android/build.gradle.kts | 3 +- android/core/build.gradle.kts | 8 +- .../com/openmausbot/companion/core/Chat.kt | 86 ++ .../openmausbot/companion/core/Failover.kt | 61 +- .../com/openmausbot/companion/core/Session.kt | 798 ++++++++++++++++++ .../companion/core/SessionStorage.kt | 60 ++ .../companion/core/ChatSummaryTest.kt | 132 +++ .../openmausbot/companion/core/SessionTest.kt | 778 +++++++++++++++++ .../gradle/wrapper/gradle-wrapper.properties | 2 +- android/settings.gradle.kts | 5 +- 10 files changed, 1922 insertions(+), 11 deletions(-) create mode 100644 android/core/src/main/kotlin/com/openmausbot/companion/core/Chat.kt create mode 100644 android/core/src/main/kotlin/com/openmausbot/companion/core/Session.kt create mode 100644 android/core/src/main/kotlin/com/openmausbot/companion/core/SessionStorage.kt create mode 100644 android/core/src/test/kotlin/com/openmausbot/companion/core/ChatSummaryTest.kt create mode 100644 android/core/src/test/kotlin/com/openmausbot/companion/core/SessionTest.kt diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 3c728f63b..b95dcfd64 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,5 +1,6 @@ plugins { kotlin("jvm") version "2.2.21" apply false kotlin("plugin.serialization") version "2.2.21" apply false + kotlin("plugin.compose") version "2.2.21" apply false + id("com.android.application") version "9.3.1" apply false } - diff --git a/android/core/build.gradle.kts b/android/core/build.gradle.kts index 0b852ccdb..0e7fe366f 100644 --- a/android/core/build.gradle.kts +++ b/android/core/build.gradle.kts @@ -13,12 +13,14 @@ kotlin { } dependencies { - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") - implementation("com.squareup.okhttp3:okhttp:4.12.0") + // api: :app consumes CompanionJson / StateFlow / Session types directly + api("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") + api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") + api("com.squareup.okhttp3:okhttp:4.12.0") testImplementation(kotlin("test")) testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") } sourceSets.test { diff --git a/android/core/src/main/kotlin/com/openmausbot/companion/core/Chat.kt b/android/core/src/main/kotlin/com/openmausbot/companion/core/Chat.kt new file mode 100644 index 000000000..7048ae7ec --- /dev/null +++ b/android/core/src/main/kotlin/com/openmausbot/companion/core/Chat.kt @@ -0,0 +1,86 @@ +package com.openmausbot.companion.core + +/** A chat is a bot or a room. They share a thread, which is what every message is keyed by. */ +sealed class Chat { + abstract val id: String + abstract val threadId: String + abstract val name: String + abstract val subtitle: String + abstract val unread: Boolean + abstract val busy: Boolean + abstract val color: String + + data class BotChat(val bot: Bot) : Chat() { + override val id: String get() = bot.id + override val threadId: String get() = bot.threadId + override val name: String get() = bot.name + override val subtitle: String get() = bot.title + override val unread: Boolean get() = bot.unread + override val busy: Boolean get() = bot.busy == true + override val color: String get() = bot.color + } + + data class RoomChat(val room: Room) : Chat() { + override val id: String get() = room.id + override val threadId: String get() = room.threadId + override val name: String get() = room.name + override val subtitle: String get() = "${room.memberIds.size} bots" + override val unread: Boolean get() = room.unread + override val busy: Boolean get() = room.busyBotId != null + override val color: String get() = "blue" + } +} + +/** + * A chat plus the two things a roster row shows that the record itself does not carry: + * the preview line, and when the thread last moved. + */ +data class ChatSummary( + val chat: Chat, + val preview: String, + val lastActivity: Double, + val pinned: Boolean, +) { + val id: String get() = chat.id +} + +/** + * Everything worth showing in the chat list: pinned first, then unread, then most + * recently active. Hidden bots stay hidden. Rooms never pin. + */ +val CompanionState.chatSummaries: List + get() { + val chats = bots.filter { it.hidden != true }.map { Chat.BotChat(it) } + + rooms.map { Chat.RoomChat(it) } + return chats + .map { chat -> + val last = visibleTranscript(chat.threadId).lastOrNull() + ChatSummary( + chat = chat, + preview = previewOf(last), + lastActivity = last?.at ?: 0.0, + pinned = pinned(chat), + ) + } + .sortedWith( + compareByDescending { it.pinned } + .thenByDescending { it.chat.unread } + .thenByDescending { it.lastActivity }, + ) + } + +private fun pinned(chat: Chat): Boolean = when (chat) { + is Chat.BotChat -> chat.bot.pinned == true + is Chat.RoomChat -> false +} + +private fun previewOf(last: Message?): String { + if (last == null) return "" + return when (last.kind) { + Message.Kind.TEXT -> last.text.orEmpty() + Message.Kind.OPTIONS -> if (last.card?.isPending == true) "Waiting on you" else last.card?.title.orEmpty() + Message.Kind.ACTIVITY -> last.tool?.name.orEmpty() + Message.Kind.SCREEN -> "Screenshot" + Message.Kind.UNKNOWN -> last.text.orEmpty() + } +} diff --git a/android/core/src/main/kotlin/com/openmausbot/companion/core/Failover.kt b/android/core/src/main/kotlin/com/openmausbot/companion/core/Failover.kt index eac6a47a6..10b8ec56b 100644 --- a/android/core/src/main/kotlin/com/openmausbot/companion/core/Failover.kt +++ b/android/core/src/main/kotlin/com/openmausbot/companion/core/Failover.kt @@ -4,6 +4,7 @@ import java.net.ConnectException import java.net.SocketTimeoutException import java.net.UnknownHostException import javax.net.ssl.SSLException +import kotlin.coroutines.cancellation.CancellationException class CandidateRotation(val hosts: List) { private var index = 0 @@ -42,10 +43,48 @@ object ConnectionAdvice { ConnectionFailure.SECURE_CONNECTION_FAILED, ) - fun shouldTryAnotherHost(error: Throwable): Boolean = when (error) { - is APIError.Transport -> error.cause?.let(::shouldTryAnotherHost) ?: false - is UnknownHostException, is ConnectException, is SocketTimeoutException, is SSLException -> true - else -> false + fun shouldTryAnotherHost(error: Throwable): Boolean = + shouldTryAnotherHost(classify(error)) + + /** Map a transport failure to the URLError-shaped categories Session walks on. */ + fun classify(error: Throwable): ConnectionFailure { + val chain = generateSequence(error) { it.cause }.toList() + for (candidate in chain) { + when (candidate) { + is CancellationException -> return ConnectionFailure.CANCELLED + is UnknownHostException -> return ConnectionFailure.CANNOT_FIND_HOST + is ConnectException -> return ConnectionFailure.CANNOT_CONNECT_TO_HOST + is SocketTimeoutException -> return ConnectionFailure.TIMED_OUT + is SSLException -> return ConnectionFailure.SECURE_CONNECTION_FAILED + is java.net.NoRouteToHostException -> return ConnectionFailure.TIMED_OUT + is java.net.SocketException -> { + val detail = candidate.message.orEmpty().lowercase() + if ("network is unreachable" in detail || "no route" in detail) { + return ConnectionFailure.TIMED_OUT + } + if ("connection refused" in detail) { + return ConnectionFailure.CANNOT_CONNECT_TO_HOST + } + if ("reset" in detail || "broken pipe" in detail || "connection abort" in detail) { + return ConnectionFailure.NETWORK_CONNECTION_LOST + } + } + } + } + val detail = chain.joinToString(" ") { it.message.orEmpty() }.lowercase() + return when { + "unable to resolve host" in detail || "unknown host" in detail -> + ConnectionFailure.CANNOT_FIND_HOST + "failed to connect" in detail || "connection refused" in detail -> + ConnectionFailure.CANNOT_CONNECT_TO_HOST + "timeout" in detail || "timed out" in detail -> + ConnectionFailure.TIMED_OUT + "cleartext" in detail || "ssl" in detail || "tls" in detail -> + ConnectionFailure.SECURE_CONNECTION_FAILED + "offline" in detail || "no address associated" in detail -> + ConnectionFailure.NOT_CONNECTED_TO_INTERNET + else -> ConnectionFailure.OTHER + } } fun message( @@ -67,4 +106,18 @@ object ConnectionAdvice { val fallback = tryingNext?.let { " Trying $it next." }.orEmpty() return advice + fallback + " The app keeps retrying automatically." } + + fun message( + error: Throwable, + host: String, + port: Int, + tryingNext: String? = null, + ): String { + val failure = classify(error) + return if (failure == ConnectionFailure.OTHER) { + error.message?.takeIf { it.isNotBlank() } ?: message(failure, host, port, tryingNext) + } else { + message(failure, host, port, tryingNext) + } + } } diff --git a/android/core/src/main/kotlin/com/openmausbot/companion/core/Session.kt b/android/core/src/main/kotlin/com/openmausbot/companion/core/Session.kt new file mode 100644 index 000000000..e53d4da4f --- /dev/null +++ b/android/core/src/main/kotlin/com/openmausbot/companion/core/Session.kt @@ -0,0 +1,798 @@ +package com.openmausbot.companion.core + +import java.net.URI +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.OkHttpClient + +/** + * Stream lifecycle, pairing restore, and actions — port of `ios/App/Session.swift`. + * + * Pure JVM: storage, device name, notifications, and HTTP are injected so the + * state machine can be unit-tested with virtual time (`kotlinx-coroutines-test`). + */ +class Session( + private val scope: CoroutineScope, + private val connectionStore: ConnectionStore, + private val tokenStore: TokenStore, + private val deviceNameProvider: () -> String, + private val notificationSink: NotificationSink = NoOpNotificationSink, + private val httpClient: OkHttpClient = OkHttpClient(), + private val clientFactory: (Connection, String?) -> CompanionClient = { connection, token -> + CompanionClient(connection, token, httpClient) + }, + private val pairFn: suspend (Connection, String, String) -> PairResponse = { connection, credential, deviceName -> + CompanionClient.pair(connection, credential, deviceName, httpClient) + }, + /** Test seam: override the SSE Flow without subclassing [CompanionClient]. */ + private val eventsFn: (CompanionClient, String?, Boolean) -> Flow = { client, since, screens -> + client.events(since, screens) + }, + /** Test seam: override fleet hydrate. */ + private val hydrateFn: suspend (CompanionClient, Int?) -> Fleet = { client, messages -> + client.fleet(messages) + }, +) { + sealed interface Status { + data object Unpaired : Status + data object Connecting : Status + data object Live : Status + data object Unauthorized : Status + data class Offline(val message: String) : Status + } + + private val _state = MutableStateFlow(CompanionState()) + val state: StateFlow = _state.asStateFlow() + + private val _connection = MutableStateFlow(null) + val connection: StateFlow = _connection.asStateFlow() + + private val _status = MutableStateFlow(Status.Unpaired) + val status: StateFlow = _status.asStateFlow() + + private val _actionError = MutableStateFlow(null) + var actionError: String? + get() = _actionError.value + set(value) { _actionError.value = value } + val actionErrorFlow: StateFlow = _actionError.asStateFlow() + + private val _focusedMessageId = MutableStateFlow(null) + val focusedMessageId: StateFlow = _focusedMessageId.asStateFlow() + + private val _pairingInvite = MutableStateFlow(null) + val pairingInvite: StateFlow = _pairingInvite.asStateFlow() + + private var client: CompanionClient? = null + private var token: String? = null + private var rotation = CandidateRotation(emptyList()) + private var streamJob: Job? = null + private var streamGeneration = 0 + private var reconnectDelaySeconds: Long = 0 + private var screenWatchers = 0 + private var restorePending = false + private val gate = Mutex() + private val restored = CompletableDeferred() + /** QR credentials that already failed a redeem — never replay (§6). */ + private val spentQrCredentials = mutableSetOf() + + init { + scope.launch { + try { + restore() + } finally { + restored.complete(Unit) + } + } + } + + /** Wait until the launch-time restore attempt has finished (tests / connect). */ + suspend fun awaitRestored() { + restored.await() + } + + /** Rebuild the last connection at launch — three outcomes match iOS Keychain restore. */ + private suspend fun restore() = gate.withLock { + restoreLocked() + } + + /** + * Redeem a one-time pairing credential. Persists only the long-lived device + * token + connection — never the QR credential/code. + * + * Rejects when a pairing already exists (including a locked-token restore) + * or when another [pair] is already in the redeem+persist critical section — + * concurrent callers must not silently replace a pairing. + * + * High-entropy QR credentials are burned at the point of no return (before + * the redeem request) so a successful remote redeem followed by a save + * failure, or cancellation after the request may have been sent, cannot + * leave a spent QR reusable. Six-digit codes remain retryable. + */ + suspend fun pair(connection: Connection, credential: String) { + awaitRestored() + gate.withLock { + if (isPairedLocked()) { + _actionError.value = ALREADY_PAIRED_MESSAGE + throw AlreadyPairedException() + } + if (isQrCredential(credential) && credential in spentQrCredentials) { + _actionError.value = SPENT_QR_MESSAGE + clearInviteIfCredential(credential) + throw SpentPairingCredentialException() + } + + // Point of no return for high-entropy QR: once redeem runs the + // server may consume it regardless of later save/cancel outcomes. + val qr = isQrCredential(credential) + if (qr) burnQrCredential(credential) + + val deviceName = deviceNameProvider() + val paired = try { + pairFn(connection, credential, deviceName) + } catch (error: Throwable) { + if (error is kotlinx.coroutines.CancellationException) throw error + _actionError.value = if (qr) qrFailureMessage(error) else error.message + throw error + } + var stored = connection + if (paired.serverName.isNotEmpty()) stored = stored.copy(name = paired.serverName) + if (!paired.hosts.isNullOrEmpty()) stored = stored.copy(hosts = paired.hosts) + stored = stored.promoting(stored.host) + + try { + tokenStore.save(stored.id, paired.token) + connectionStore.save(stored) + } catch (error: Throwable) { + if (error is kotlinx.coroutines.CancellationException) throw error + _actionError.value = if (qr) qrFailureMessage(error) else error.message + throw error + } + + _connection.value = stored + token = paired.token + rotation = CandidateRotation(stored.orderedHosts) + client = clientFactory(stored, paired.token) + _state.value = CompanionState() + restorePending = false + _pairingInvite.value = null + } + connect() + } + + suspend fun pair(invite: PairingInvite) = pair(invite.connection, invite.credential) + + /** + * Accept a deep-link invite only after restore has settled and only while + * unpaired. Cold-start links wait for restore so they cannot overwrite a + * pairing that is still loading. + */ + fun receivePairingURL(url: String) { + if (restored.isCompleted) { + acceptPairingURL(url) + return + } + scope.launch { + restored.await() + acceptPairingURL(url) + } + } + + fun receivePairingURI(uri: URI) = receivePairingURL(uri.toString()) + + fun consumePairingInvite() { + _pairingInvite.value = null + } + + private fun acceptPairingURL(url: String) { + if (isPairedLocked()) { + _actionError.value = ALREADY_PAIRED_MESSAGE + return + } + val invite = PairingInvite.parse(url) + if (invite == null) { + _actionError.value = "That pairing invitation is not valid. Start pairing again on your computer." + return + } + if (isQrCredential(invite.credential) && invite.credential in spentQrCredentials) { + _actionError.value = SPENT_QR_MESSAGE + return + } + _pairingInvite.value = invite + } + + /** True when a computer is already bound — including locked-token restore. */ + private fun isPairedLocked(): Boolean = + _connection.value != null || + token != null || + client != null || + restorePending || + _status.value !is Status.Unpaired + + private fun burnQrCredential(credential: String) { + spentQrCredentials += credential + clearInviteIfCredential(credential) + } + + private fun clearInviteIfCredential(credential: String) { + val current = _pairingInvite.value + if (current?.credential == credential) { + _pairingInvite.value = null + } + } + + private fun qrFailureMessage(error: Throwable): String { + val base = error.message?.takeIf { it.isNotBlank() } ?: "Pairing failed." + return "$base Start pairing again on your computer and rescan the new QR code." + } + + fun signOut() { + streamJob?.cancel() + streamJob = null + scope.launch { + gate.withLock { + restorePending = false + _connection.value?.id?.let { tokenStore.remove(it) } + connectionStore.clear() + _connection.value = null + client = null + token = null + rotation = CandidateRotation(emptyList()) + _state.value = CompanionState() + notificationSink.setBadge(0) + _status.value = Status.Unpaired + } + } + } + + /** Suspending unpair for tests / callers that need completion. */ + suspend fun signOutAndAwait() { + streamJob?.cancel() + streamJob = null + gate.withLock { + restorePending = false + _connection.value?.id?.let { tokenStore.remove(it) } + connectionStore.clear() + _connection.value = null + client = null + token = null + rotation = CandidateRotation(emptyList()) + _state.value = CompanionState() + notificationSink.setBadge(0) + _status.value = Status.Unpaired + } + } + + /** Called when the app comes to the front, and once at launch. */ + fun connect() { + scope.launch { + restored.await() + val generation = gate.withLock { + if (client == null && restorePending) { + restoreLocked() + } + if (client == null || streamJob != null) return@withLock null + reconnectDelaySeconds = 0 + streamGeneration += 1 + streamGeneration + } ?: return@launch + val job = scope.launch { + try { + runStream() + } finally { + gate.withLock { + if (streamGeneration == generation) { + streamJob = null + } + } + } + } + gate.withLock { streamJob = job } + } + } + + private suspend fun restoreLocked() { + restorePending = false + val saved = connectionStore.load() ?: return + when (val stored = tokenStore.read(saved.id)) { + is TokenStore.ReadResult.Unavailable -> { + _connection.value = saved + restorePending = true + _status.value = Status.Offline( + if (stored.locked) { + "Unlock this phone to reach your computer." + } else { + stored.message + }, + ) + } + TokenStore.ReadResult.Missing -> Unit + is TokenStore.ReadResult.Found -> { + _connection.value = saved + token = stored.token + rotation = CandidateRotation(saved.orderedHosts) + client = clientFactory(saved, stored.token) + _status.value = Status.Connecting + } + } + } + + /** + * Pull-to-refresh: reopen the stream and wait until status leaves connecting + * or 10s — so the spinner means what it appears to mean. + * + * Restarts under the session mutex (not a fire-and-forget enqueue) so a + * caller on any dispatcher observes Connecting before the wait loop runs. + */ + suspend fun refresh() { + awaitRestored() + gate.withLock { + if (client == null && restorePending) { + restoreLocked() + } + if (client == null) return@withLock + streamJob?.cancel() + streamJob = null + reconnectDelaySeconds = 0 + streamGeneration += 1 + val generation = streamGeneration + _status.value = Status.Connecting + val job = scope.launch { + try { + runStream() + } finally { + gate.withLock { + if (streamGeneration == generation) { + streamJob = null + } + } + } + } + streamJob = job + } + withTimeoutOrNull(10_000) { + while (_status.value is Status.Connecting && currentCoroutineContext().isActive) { + delay(120) + } + } + } + + fun watchScreen(ofBotId: String) { + scope.launch { + gate.withLock { + screenWatchers += 1 + if (screenWatchers == 1) restartStreamLocked() + } + } + } + + fun stopWatchingScreen(ofBotId: String) { + scope.launch { + gate.withLock { + screenWatchers = maxOf(0, screenWatchers - 1) + if (screenWatchers == 0) { + _state.update { it.clearScreen(ofBotId) } + restartStreamLocked() + } + } + } + } + + private fun restartStream() { + scope.launch { + gate.withLock { restartStreamLocked() } + } + } + + private fun restartStreamLocked() { + if (streamJob == null) return + streamJob?.cancel() + streamJob = null + if (client == null) return + reconnectDelaySeconds = 0 + streamGeneration += 1 + val generation = streamGeneration + // Launch without holding the mutex across runStream — the job handle is + // published immediately so a concurrent connect() sees it. + val job = scope.launch { + try { + runStream() + } finally { + gate.withLock { + if (streamGeneration == generation) { + streamJob = null + } + } + } + } + streamJob = job + } + + /** Called when the app leaves the screen — deliberate disconnect so the cursor is known. */ + fun disconnect() { + streamJob?.cancel() + streamJob = null + } + + private suspend fun runStream() { + while (currentCoroutineContext().isActive) { + val activeClient = client ?: return + _status.value = Status.Connecting + try { + eventsFn(activeClient, _state.value.cursor, screenWatchers > 0) + .collect { frame -> + currentCoroutineContext().ensureActive() + reconnectDelaySeconds = 0 + + when (val payload = frame.frame) { + is Frame.Hello -> { + if (!payload.resumed) { + hydrate() + _state.update { it.resetCursor(payload.cursor) } + } + _status.value = Status.Live + promoteWorkingHost() + } + else -> { + _state.update { it.apply(frame) } + if (payload is Frame.Notify) { + notificationSink.deliver(payload.notification, frame.seq) + } + notificationSink.setBadge(_state.value.unreadCount) + _state.update { it.advance(frame.seq) } + } + } + } + // Clean stream end — harness went away + _status.value = Status.Offline("Lost the connection.") + } catch (error: Throwable) { + if (!currentCoroutineContext().isActive || error is kotlinx.coroutines.CancellationException) { + return + } + val apiError = error as? APIError + if (apiError?.isUnauthorized == true) { + _status.value = Status.Unauthorized + return + } + _status.value = Status.Offline(failureMessage(error)) + } + + if (!currentCoroutineContext().isActive) return + reconnectDelaySeconds = if (reconnectDelaySeconds == 0L) 1L else minOf(reconnectDelaySeconds * 2, 15L) + delay(reconnectDelaySeconds * 1_000) + } + } + + private suspend fun hydrate() { + val activeClient = client ?: return + val fleet = hydrateFn(activeClient, 50) + _state.update { it.hydrate(fleet) } + notificationSink.setBadge(_state.value.unreadCount) + } + + private fun failureMessage(error: Throwable): String { + val connection = _connection.value + ?: return error.message?.takeIf { it.isNotBlank() } ?: "Could not reach the computer." + val failure = ConnectionAdvice.classify(error) + val failed = rotation.current.ifEmpty { connection.host } + var next: String? = null + if (ConnectionAdvice.shouldTryAnotherHost(failure) && rotation.count > 1) { + val candidate = rotation.advance() + val activeToken = token + if (activeToken != null) { + client = clientFactory(connection.dialing(candidate), activeToken) + } + next = candidate + } + return if (failure == ConnectionFailure.OTHER) { + error.message?.takeIf { it.isNotBlank() } + ?: ConnectionAdvice.message(failure, failed, connection.port, next) + } else { + ConnectionAdvice.message(failure, failed, connection.port, next) + } + } + + private suspend fun promoteWorkingHost() { + val winner = rotation.current + val updated = _connection.value ?: return + if (winner.isEmpty() || updated.host == Connection.urlHost(winner)) return + val promoted = updated.promoting(winner) + _connection.value = promoted + connectionStore.save(promoted) + } + + /** Replace the stored address by hand, keeping the pairing and its token. */ + fun updateAddress(text: String): Boolean { + val parsed = Connection.parse(text) ?: return false + val current = _connection.value ?: return false + val updated = current.copy(port = parsed.port).promoting(parsed.host) + scope.launch { + gate.withLock { + _connection.value = updated + connectionStore.save(updated) + rotation = CandidateRotation(updated.orderedHosts) + val activeToken = token + if (activeToken != null) { + client = clientFactory(updated, activeToken) + } + restartStreamLocked() + } + } + return true + } + + suspend fun updateAddressAndAwait(text: String): Boolean { + val parsed = Connection.parse(text) ?: return false + val current = _connection.value ?: return false + val updated = current.copy(port = parsed.port).promoting(parsed.host) + gate.withLock { + _connection.value = updated + connectionStore.save(updated) + rotation = CandidateRotation(updated.orderedHosts) + val activeToken = token + if (activeToken != null) { + client = clientFactory(updated, activeToken) + } + restartStreamLocked() + } + return true + } + + // MARK: - Actions + + suspend fun send(text: String, to: Chat) { + perform { + when (to) { + is Chat.BotChat -> it.sendToBot(to.bot.id, text) + is Chat.RoomChat -> it.sendToRoom(to.room.id, text) + } + } + } + + suspend fun answer(threadId: String, card: OptionCard, choice: String) { + val requestId = card.requestId ?: return + perform { + if (card.isPermission) { + it.respond( + threadId = threadId, + requestId = requestId, + behavior = if (choice.equals("allow", ignoreCase = true)) "allow" else "deny", + ) + } else { + it.respond( + threadId = threadId, + requestId = requestId, + behavior = "answer", + message = choice, + ) + } + } + } + + suspend fun alwaysAllow(bot: Bot, card: OptionCard) { + val key = card.allowKey ?: return + perform { it.alwaysAllow(bot.id, key) } + } + + suspend fun createBot(): Bot? { + val activeClient = client ?: return null + return try { + val bot = activeClient.createBot() + _state.update { it.apply(Frame.Bot(bot)) } + bot + } catch (error: Throwable) { + _actionError.value = error.message + null + } + } + + suspend fun interrupt(bot: Bot) { + perform { it.interrupt(bot.id) } + } + + suspend fun cloudDesktop(forBot: Bot): URI { + val activeClient = client ?: throw APIError.Transport("This computer is offline.") + return try { + activeClient.cloudDesktop(forBot.id).url + } catch (error: APIError) { + if (error.isUnauthorized) _status.value = Status.Unauthorized + throw error + } + } + + suspend fun markRead(chat: Chat) { + perform(quietly = true) { + when (chat) { + is Chat.BotChat -> it.markBotRead(chat.bot.id) + is Chat.RoomChat -> it.markRoomRead(chat.room.id) + } + } + } + + suspend fun loadOlder(threadId: String) { + val activeClient = client ?: return + val oldest = _state.value.transcript(threadId).firstOrNull() ?: return + try { + val page = activeClient.messages(threadId, before = oldest.id, limit = 50) + _state.update { it.prepend(page, threadId) } + } catch (error: Throwable) { + _actionError.value = error.message + } + } + + suspend fun image(threadId: String, messageId: String): ByteArray? = + try { + client?.image(threadId, messageId) + } catch (_: Throwable) { + null + } + + suspend fun search(query: String): List { + val trimmed = query.trim() + if (trimmed.length < 2) return emptyList() + val activeClient = client ?: return emptyList() + return try { + activeClient.search(trimmed) + } catch (error: Throwable) { + _actionError.value = error.message + emptyList() + } + } + + suspend fun open(hit: SearchHit): Chat? { + val activeClient = client ?: return null + return try { + val botId = hit.botId + if (botId != null) { + var bot = _state.value.bot(botId) ?: return null + if (bot.threadId != hit.threadId) { + bot = activeClient.switchTask(bot.id, hit.threadId) + _state.update { it.apply(Frame.Bot(bot)) } + } + if (!hit.onActivePath) { + val leaf = activeClient.setActiveBranch(bot.id, hit.messageId) + _state.update { it.apply(Frame.Thread(hit.threadId, leaf)) } + } + val page = activeClient.messagesAround(hit.threadId, hit.messageId) + _state.update { it.merge(page, hit.threadId) } + _focusedMessageId.value = hit.messageId + return _state.value.bot(bot.id)?.let { Chat.BotChat(it) } + } + val groupId = hit.groupId + if (groupId != null) { + val room = _state.value.rooms.firstOrNull { it.id == groupId } ?: return null + val page = activeClient.messagesAround(hit.threadId, hit.messageId) + _state.update { it.merge(page, hit.threadId) } + _focusedMessageId.value = hit.messageId + return Chat.RoomChat(room) + } + null + } catch (error: Throwable) { + _actionError.value = error.message + null + } + } + + fun consumeFocus(messageId: String) { + if (_focusedMessageId.value == messageId) _focusedMessageId.value = null + } + + suspend fun createTask(forBot: Bot, title: String?) { + val activeClient = client ?: return + try { + _state.update { it.apply(Frame.Bot(activeClient.createTask(forBot.id, title))) } + } catch (error: Throwable) { + _actionError.value = error.message + } + } + + suspend fun switchTask(task: BotTask, forBot: Bot) { + if (task.threadId == forBot.threadId) return + val activeClient = client ?: return + try { + _state.update { it.apply(Frame.Bot(activeClient.switchTask(forBot.id, task.threadId))) } + } catch (error: Throwable) { + _actionError.value = error.message + } + } + + suspend fun renameTask(task: BotTask, forBot: Bot, title: String) { + val activeClient = client ?: return + try { + activeClient.renameTask(forBot.id, task.threadId, title) + refresh() + } catch (error: Throwable) { + _actionError.value = error.message + } + } + + suspend fun deleteTask(task: BotTask, forBot: Bot) { + val activeClient = client ?: return + try { + _state.update { it.apply(Frame.Bot(activeClient.deleteTask(forBot.id, task.threadId))) } + } catch (error: Throwable) { + _actionError.value = error.message + } + } + + suspend fun react(to: Message, inThreadId: String, emoji: String) { + val activeClient = client ?: return + try { + val patched = activeClient.toggleReaction(inThreadId, to.id, emoji) + _state.update { it.apply(Frame.MessagePatch(inThreadId, patched)) } + } catch (error: Throwable) { + _actionError.value = error.message + } + } + + suspend fun edit(message: Message, forBot: Bot, text: String) { + perform { it.edit(forBot.id, message.id, text) } + } + + suspend fun switchVersion(to: Message, forBot: Bot) { + val activeClient = client ?: return + try { + val leaf = activeClient.setActiveBranch(forBot.id, to.id) + _state.update { it.apply(Frame.Thread(forBot.threadId, leaf)) } + } catch (error: Throwable) { + _actionError.value = error.message + } + } + + suspend fun export(threadId: String, format: String): ExportedTranscript? { + val activeClient = client ?: return null + return try { + val exported = activeClient.export(threadId, format) + ExportedTranscript(exported.data, exported.filename, exported.contentType) + } catch (error: Throwable) { + _actionError.value = error.message + null + } + } + + private suspend fun perform(quietly: Boolean = false, body: suspend (CompanionClient) -> Unit) { + val activeClient = client ?: return + try { + body(activeClient) + } catch (error: APIError) { + if (error.isUnauthorized) { + _status.value = Status.Unauthorized + } else if (!quietly) { + _actionError.value = error.message + } + } catch (error: Throwable) { + if (!quietly) _actionError.value = error.message + } + } + + companion object { + const val ALREADY_PAIRED_MESSAGE = + "This phone is already paired. Unpair it in Settings before connecting it to another computer." + const val SPENT_QR_MESSAGE = + "That pairing code was already used. Start pairing again on your computer and rescan the new QR code." + + /** High-entropy QR token — distinct from a retryable six-digit code. */ + fun isQrCredential(credential: String): Boolean = + credential.startsWith("omb_pair_") || + !(credential.length == 6 && credential.all { it in '0'..'9' }) + } +} + +/** Thrown by [Session.pair] when a computer is already bound. */ +class AlreadyPairedException : IllegalStateException(Session.ALREADY_PAIRED_MESSAGE) + +/** Thrown when a burned QR credential is presented again. */ +class SpentPairingCredentialException : IllegalStateException(Session.SPENT_QR_MESSAGE) diff --git a/android/core/src/main/kotlin/com/openmausbot/companion/core/SessionStorage.kt b/android/core/src/main/kotlin/com/openmausbot/companion/core/SessionStorage.kt new file mode 100644 index 000000000..3b7955038 --- /dev/null +++ b/android/core/src/main/kotlin/com/openmausbot/companion/core/SessionStorage.kt @@ -0,0 +1,60 @@ +package com.openmausbot.companion.core + +/** + * Connection record store — UserDefaults analogue. Safe to back up; holds no token. + */ +interface ConnectionStore { + suspend fun load(): Connection? + suspend fun save(connection: Connection) + suspend fun clear() +} + +/** + * Device-token store — Keychain analogue. Must not appear in any backup path. + * + * Distinguishes "no token" from "cannot read yet" the same way iOS Keychain does: + * a locked/unavailable store is offline, never unpaired. + */ +interface TokenStore { + sealed class ReadResult { + data class Found(val token: String) : ReadResult() + data object Missing : ReadResult() + data class Unavailable(val locked: Boolean, val message: String) : ReadResult() + } + + suspend fun save(connectionId: String, token: String) + suspend fun read(connectionId: String): ReadResult + suspend fun remove(connectionId: String) +} + +/** Local notification surface fed by live/replayed notify frames. */ +interface NotificationSink { + fun deliver(notification: NotificationFrame, sequence: Int?) + fun setBadge(count: Int) +} + +object NoOpNotificationSink : NotificationSink { + override fun deliver(notification: NotificationFrame, sequence: Int?) = Unit + override fun setBadge(count: Int) = Unit +} + +data class ExportedTranscript( + val data: ByteArray, + val filename: String, + val contentType: String, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ExportedTranscript) return false + return filename == other.filename && + contentType == other.contentType && + data.contentEquals(other.data) + } + + override fun hashCode(): Int { + var result = data.contentHashCode() + result = 31 * result + filename.hashCode() + result = 31 * result + contentType.hashCode() + return result + } +} diff --git a/android/core/src/test/kotlin/com/openmausbot/companion/core/ChatSummaryTest.kt b/android/core/src/test/kotlin/com/openmausbot/companion/core/ChatSummaryTest.kt new file mode 100644 index 000000000..a8ca4918c --- /dev/null +++ b/android/core/src/test/kotlin/com/openmausbot/companion/core/ChatSummaryTest.kt @@ -0,0 +1,132 @@ +package com.openmausbot.companion.core + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ChatSummaryTest { + @Test + fun sortsPinnedThenUnreadThenActivityAndHidesHiddenBots() { + val state = CompanionState( + bots = listOf( + sampleBot("hidden", "th", hidden = true, unread = true, pinned = true), + sampleBot("pinned", "tp", pinned = true, unread = false, at = 10.0), + sampleBot("unread", "tu", pinned = false, unread = true, at = 5.0), + sampleBot("old", "to", pinned = false, unread = false, at = 1.0), + sampleBot("new", "tn", pinned = false, unread = false, at = 20.0), + ), + rooms = listOf( + Room( + id = "r1", + threadId = "tr", + name = "Room", + memberIds = listOf("a", "b"), + defaultResponder = GroupResponder("bot", "a"), + bulletin = "", + unread = true, + createdAt = 1.0, + ), + ), + messages = mapOf( + "tp" to listOf(text("m1", 10.0, "pinned preview")), + "tu" to listOf(text("m2", 5.0, "unread preview")), + "to" to listOf(text("m3", 1.0, "old preview")), + "tn" to listOf(text("m4", 20.0, "new preview")), + "tr" to listOf( + Message( + id = "m5", + role = Message.Role.BOT, + kind = Message.Kind.OPTIONS, + at = 15.0, + card = OptionCard( + title = "Allow shell?", + subtitle = "", + options = listOf("Allow", "Deny"), + requestId = "req", + ), + ), + ), + ), + ) + + val summaries = state.chatSummaries + // pinned → unread (by activity: room@15 before bot@5) → read (new@20 before old@1) + assertEquals( + listOf("pinned", "r1", "unread", "new", "old"), + summaries.map { it.id }, + ) + assertEquals("Waiting on you", summaries.first { it.id == "r1" }.preview) + assertEquals(false, summaries.first { it.id == "r1" }.pinned) + assertTrue(summaries.none { it.id == "hidden" }) + } + + @Test + fun previewRulesMatchIos() { + val textState = CompanionState( + bots = listOf(sampleBot("b", "t")), + messages = mapOf("t" to listOf(text("m", 1.0, "hello"))), + ) + assertEquals("hello", textState.chatSummaries.single().preview) + + val activity = CompanionState( + bots = listOf(sampleBot("b", "t")), + messages = mapOf( + "t" to listOf( + Message( + id = "m", + role = Message.Role.BOT, + kind = Message.Kind.ACTIVITY, + at = 1.0, + tool = ToolActivity(name = "Bash"), + ), + ), + ), + ) + assertEquals("Bash", activity.chatSummaries.single().preview) + + val screen = CompanionState( + bots = listOf(sampleBot("b", "t")), + messages = mapOf( + "t" to listOf( + Message( + id = "m", + role = Message.Role.BOT, + kind = Message.Kind.SCREEN, + at = 1.0, + ), + ), + ), + ) + assertEquals("Screenshot", screen.chatSummaries.single().preview) + } +} + +private fun sampleBot( + id: String, + threadId: String, + hidden: Boolean? = null, + unread: Boolean = false, + pinned: Boolean? = null, + at: Double = 0.0, +) = Bot( + id = id, + threadId = threadId, + name = id, + title = "role", + description = "", + notifications = true, + color = "green", + unread = unread, + modelSelection = ModelSelection("i", "m"), + createdAt = at, + pinned = pinned, + hidden = hidden, +) + +private fun text(id: String, at: Double, body: String) = Message( + id = id, + role = Message.Role.USER, + kind = Message.Kind.TEXT, + at = at, + text = body, +) diff --git a/android/core/src/test/kotlin/com/openmausbot/companion/core/SessionTest.kt b/android/core/src/test/kotlin/com/openmausbot/companion/core/SessionTest.kt new file mode 100644 index 000000000..7f4be72f2 --- /dev/null +++ b/android/core/src/test/kotlin/com/openmausbot/companion/core/SessionTest.kt @@ -0,0 +1,778 @@ +package com.openmausbot.companion.core + +import java.net.ConnectException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield + +@OptIn(ExperimentalCoroutinesApi::class) +class SessionTest { + @Test + fun restoreWithMissingConnectionStaysUnpaired() = runTest { + val session = session() + session.awaitRestored() + assertEquals(Session.Status.Unpaired, session.status.value) + assertNull(session.connection.value) + } + + @Test + fun lockedTokenIsOfflineNotUnpaired() = runTest { + val connection = Connection(id = "c1", name = "Mac", host = "192.168.1.2", port = 8810) + val tokens = FakeTokenStore().apply { + unavailable["c1"] = TokenStore.ReadResult.Unavailable(locked = true, message = "locked") + } + val session = session( + connectionStore = FakeConnectionStore(connection), + tokenStore = tokens, + events = { _, _ -> emptyFlow() }, + ) + session.awaitRestored() + assertEquals(connection, session.connection.value) + val status = assertIs(session.status.value) + assertTrue(status.message.contains("Unlock")) + } + + @Test + fun pairPersistsTokenAndConnectionNeverCredential() = runTest { + val connections = FakeConnectionStore() + val tokens = FakeTokenStore() + var pairedCredential: String? = null + val session = session( + connectionStore = connections, + tokenStore = tokens, + pairFn = { _, credential, deviceName -> + pairedCredential = credential + assertEquals("Pixel", deviceName) + PairResponse( + token = "device-token-abc", + device = PairedDevice("d1", "Pixel", 1.0, 1.0), + serverName = "Ada's Mac", + hosts = listOf("mac.ts.net", "192.168.1.2"), + ) + }, + events = { _, _ -> emptyFlow() }, + ) + session.awaitRestored() + + session.pair( + Connection(name = "invite", host = "192.168.1.2", port = 8810), + "omb_pair_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLM", + ) + advanceUntilIdle() + + assertEquals("omb_pair_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLM", pairedCredential) + assertEquals("device-token-abc", tokens.saved[connections.saved!!.id]) + assertEquals("Ada's Mac", connections.saved!!.name) + assertTrue(tokens.saved.values.none { it.startsWith("omb_pair_") }) + } + + @Test + fun alreadyPairedRejectsNewInvite() = runTest { + val connection = Connection(id = "c1", name = "Mac", host = "192.168.1.2", port = 8810) + val tokens = FakeTokenStore().apply { saved["c1"] = "tok" } + val session = session( + connectionStore = FakeConnectionStore(connection), + tokenStore = tokens, + events = { _, _ -> emptyFlow() }, + ) + session.awaitRestored() + session.receivePairingURL("openmausbot://pair?address=10.0.0.1:8810&code=123456") + assertNull(session.pairingInvite.value) + assertTrue(session.actionError!!.contains("already paired")) + } + + @Test + fun pairItselfRejectsWhenAlreadyPaired() = runTest { + val connection = Connection(id = "c1", name = "Mac", host = "192.168.1.2", port = 8810) + val tokens = FakeTokenStore().apply { saved["c1"] = "tok" } + var pairCalls = 0 + val session = session( + connectionStore = FakeConnectionStore(connection), + tokenStore = tokens, + pairFn = { _, _, _ -> + pairCalls++ + error("should not redeem") + }, + events = { _, _ -> emptyFlow() }, + ) + session.awaitRestored() + assertFailsWith { + session.pair(Connection(name = "other", host = "10.0.0.1", port = 8810), "123456") + } + assertEquals(0, pairCalls) + assertEquals("tok", tokens.saved["c1"]) + assertTrue(session.actionError!!.contains("already paired")) + } + + @Test + fun coldStartDeepLinkWaitsForRestoreBeforeAccepting() = runTest { + val connection = Connection(id = "c1", name = "Mac", host = "192.168.1.2", port = 8810) + val restoreGate = CompletableDeferred() + val connections = object : ConnectionStore { + override suspend fun load(): Connection? { + restoreGate.await() + return connection + } + override suspend fun save(connection: Connection) = Unit + override suspend fun clear() = Unit + } + val session = session( + connectionStore = connections, + tokenStore = FakeTokenStore().apply { saved["c1"] = "tok" }, + events = { _, _ -> emptyFlow() }, + ) + // Deep link arrives while restore is still suspended. + session.receivePairingURL( + "openmausbot://pair?address=10.0.0.1:8810&token=omb_pair_" + "a".repeat(43), + ) + runCurrent() + assertNull(session.pairingInvite.value) + + restoreGate.complete(Unit) + session.awaitRestored() + advanceUntilIdle() + assertNull(session.pairingInvite.value) + assertTrue(session.actionError!!.contains("already paired")) + } + + @Test + fun failedQrRedeemBurnsInviteAndRejectsReplay() = runTest { + val qr = "omb_pair_" + "b".repeat(43) + var attempts = 0 + val session = session( + pairFn = { _, _, _ -> + attempts++ + throw APIError.Transport("redeem failed") + }, + events = { _, _ -> emptyFlow() }, + ) + session.awaitRestored() + session.receivePairingURL("openmausbot://pair?address=192.168.1.2:8810&token=$qr&code=123456") + assertEquals(qr, session.pairingInvite.value?.credential) + + assertFailsWith { + session.pair(session.pairingInvite.value!!) + } + assertNull(session.pairingInvite.value) + assertTrue(session.actionError!!.contains("rescan the new QR code")) + + assertFailsWith { + session.pair(Connection(name = "Mac", host = "192.168.1.2", port = 8810), qr) + } + assertEquals(1, attempts) + + session.receivePairingURL("openmausbot://pair?address=192.168.1.2:8810&token=$qr&code=123456") + assertNull(session.pairingInvite.value) + assertTrue(session.actionError!!.contains("already used") || session.actionError!!.contains("rescan")) + } + + @Test + fun failedCodeRedeemRemainsRetryable() = runTest { + var attempts = 0 + val session = session( + pairFn = { _, _, _ -> + attempts++ + if (attempts == 1) throw APIError.Transport("wrong code") + PairResponse( + token = "device-token", + device = PairedDevice("d1", "Pixel", 1.0, 1.0), + serverName = "Mac", + hosts = null, + ) + }, + events = { _, _ -> emptyFlow() }, + ) + session.awaitRestored() + assertFailsWith { + session.pair(Connection(name = "Mac", host = "192.168.1.2", port = 8810), "123456") + } + session.pair(Connection(name = "Mac", host = "192.168.1.2", port = 8810), "123456") + advanceUntilIdle() + assertEquals(2, attempts) + assertTrue(session.connection.value != null) + } + + @Test + fun concurrentPairOnlyOneWins() = runTest { + val connections = FakeConnectionStore() + val tokens = FakeTokenStore() + val firstEntered = CompletableDeferred() + val releaseFirst = CompletableDeferred() + var pairCalls = 0 + val session = session( + connectionStore = connections, + tokenStore = tokens, + pairFn = { _, credential, _ -> + pairCalls++ + if (pairCalls == 1) { + firstEntered.complete(Unit) + releaseFirst.await() + } + PairResponse( + token = "tok-$credential", + device = PairedDevice("d1", "Pixel", 1.0, 1.0), + serverName = "Mac-$credential", + hosts = null, + ) + }, + events = { _, _ -> emptyFlow() }, + ) + session.awaitRestored() + + var firstResult: Result? = null + var secondResult: Result? = null + val first = launch { + firstResult = runCatching { + session.pair(Connection(name = "a", host = "10.0.0.1", port = 8810), "111111") + } + } + firstEntered.await() + val second = launch { + secondResult = runCatching { + session.pair(Connection(name = "b", host = "10.0.0.2", port = 8810), "222222") + } + } + runCurrent() + assertTrue(second.isActive) + releaseFirst.complete(Unit) + advanceUntilIdle() + first.join() + second.join() + + assertEquals(1, pairCalls) + assertTrue(firstResult!!.isSuccess) + assertTrue(secondResult!!.exceptionOrNull() is AlreadyPairedException) + assertEquals("tok-111111", tokens.saved.values.single()) + assertEquals("Mac-111111", connections.saved!!.name) + assertTrue(session.actionError!!.contains("already paired")) + } + + @Test + fun qrBurnedWhenSaveFailsAfterSuccessfulRedeem() = runTest { + val qr = "omb_pair_" + "c".repeat(43) + var attempts = 0 + val tokens = object : TokenStore by FakeTokenStore() { + override suspend fun save(connectionId: String, token: String) { + error("disk full") + } + } + val session = session( + tokenStore = tokens, + pairFn = { _, _, _ -> + attempts++ + PairResponse( + token = "device-token", + device = PairedDevice("d1", "Pixel", 1.0, 1.0), + serverName = "Mac", + hosts = null, + ) + }, + events = { _, _ -> emptyFlow() }, + ) + session.awaitRestored() + + assertFailsWith { + session.pair(Connection(name = "Mac", host = "192.168.1.2", port = 8810), qr) + } + assertNull(session.connection.value) + assertTrue(session.actionError!!.contains("rescan the new QR code")) + + assertFailsWith { + session.pair(Connection(name = "Mac", host = "192.168.1.2", port = 8810), qr) + } + assertEquals(1, attempts) + } + + @Test + fun qrBurnedWhenCancelledAfterRedeemStarts() = runTest { + val qr = "omb_pair_" + "d".repeat(43) + var attempts = 0 + val redeemStarted = CompletableDeferred() + val blockRedeem = CompletableDeferred() + val session = session( + pairFn = { _, _, _ -> + attempts++ + redeemStarted.complete(Unit) + blockRedeem.await() + PairResponse( + token = "device-token", + device = PairedDevice("d1", "Pixel", 1.0, 1.0), + serverName = "Mac", + hosts = null, + ) + }, + events = { _, _ -> emptyFlow() }, + ) + session.awaitRestored() + + val job = launch { + session.pair(Connection(name = "Mac", host = "192.168.1.2", port = 8810), qr) + } + redeemStarted.await() + job.cancel() + advanceUntilIdle() + assertTrue(job.isCancelled) + assertNull(session.connection.value) + + assertFailsWith { + session.pair(Connection(name = "Mac", host = "192.168.1.2", port = 8810), qr) + } + assertEquals(1, attempts) + } + + @Test + fun unpairClearsLocalStateOnly() = runTest { + val connection = Connection(id = "c1", name = "Mac", host = "192.168.1.2", port = 8810) + val connections = FakeConnectionStore(connection) + val tokens = FakeTokenStore().apply { saved["c1"] = "tok" } + val session = session( + connectionStore = connections, + tokenStore = tokens, + events = { _, _ -> emptyFlow() }, + ) + session.awaitRestored() + session.signOutAndAwait() + assertEquals(Session.Status.Unpaired, session.status.value) + assertNull(connections.saved) + assertTrue(tokens.saved.isEmpty()) + } + + @Test + fun helloNotResumedHydratesBeforeCommittingCursor() = runTest { + var hydrateCalls = 0 + var opens = 0 + val hang = MutableSharedFlow(extraBufferCapacity = 1) + val session = session( + connectionStore = FakeConnectionStore( + Connection(id = "c1", name = "Mac", host = "127.0.0.1", port = 8810), + ), + tokenStore = FakeTokenStore().apply { saved["c1"] = "tok" }, + events = { _, _ -> + opens++ + hang + }, + hydrate = { + hydrateCalls++ + Fleet( + bots = listOf(sampleBot(id = "b1", threadId = "t1")), + groups = emptyList(), + ) + }, + ) + session.awaitRestored() + session.connect() + runCurrent() + assertEquals(1, opens) + hang.tryEmit(StreamFrame(Frame.Hello(cursor = "stream:7", resumed = false), seq = 0)) + runCurrent() + yield() + runCurrent() + + assertEquals(1, hydrateCalls) + assertEquals("stream:7", session.state.value.cursor) + assertEquals(1, session.state.value.bots.size) + assertEquals(Session.Status.Live, session.status.value) + } + + @Test + fun disconnectMidHydrateDoesNotCommitCursorAndReconnectRequestsGap() = runTest { + val hang = MutableSharedFlow(extraBufferCapacity = 1) + val hydrateStarted = CompletableDeferred() + val hydrateRelease = CompletableDeferred() + val opens = mutableListOf() + val session = session( + connectionStore = FakeConnectionStore( + Connection(id = "c1", name = "Mac", host = "127.0.0.1", port = 8810), + ), + tokenStore = FakeTokenStore().apply { saved["c1"] = "tok" }, + events = { since, _ -> + opens += since + hang + }, + hydrate = { + hydrateStarted.complete(Unit) + hydrateRelease.await() + Fleet( + bots = listOf(sampleBot(id = "b1", threadId = "t1")), + groups = emptyList(), + ) + }, + ) + session.awaitRestored() + session.connect() + runCurrent() + hang.tryEmit(StreamFrame(Frame.Hello(cursor = "stream:9", resumed = false), seq = 0)) + runCurrent() + hydrateStarted.await() + assertNull(session.state.value.cursor) + + session.disconnect() + runCurrent() + // Unblock any cancelled waiter without committing through a successful hydrate. + hydrateRelease.cancel() + assertNull(session.state.value.cursor) + assertEquals(listOf(null), opens) + + session.connect() + runCurrent() + assertEquals(listOf(null, null), opens) + assertNull(session.state.value.cursor) + } + + @Test + fun screenWatcherTurnsScreensOnAndLastCloseClears() = runTest { + val hang = MutableSharedFlow(extraBufferCapacity = 4) + val opens = mutableListOf>() + val session = session( + connectionStore = FakeConnectionStore( + Connection(id = "c1", name = "Mac", host = "127.0.0.1", port = 8810), + ), + tokenStore = FakeTokenStore().apply { saved["c1"] = "tok" }, + events = { since, screens -> + opens += since to screens + hang + }, + hydrate = { + Fleet( + bots = listOf(sampleBot(id = "b1", threadId = "t1")), + groups = emptyList(), + ) + }, + ) + session.awaitRestored() + session.connect() + runCurrent() + // Cold hello commits the cursor so a later screens reconnect can prove + // the gap request keeps `since=` rather than resetting. + hang.tryEmit(StreamFrame(Frame.Hello(cursor = "stream:1", resumed = false), seq = 0)) + runCurrent() + yield() + runCurrent() + hang.tryEmit( + StreamFrame( + Frame.Screen(botId = "b1", png = "AA==", mime = "image/png"), + seq = 2, + ), + ) + runCurrent() + assertEquals(listOf>(null to false), opens) + assertTrue(session.state.value.screens.containsKey("b1")) + // Screen frame advances the committed hello cursor — reconnect must + // request this gap, not reset to null. + assertEquals("stream:2", session.state.value.cursor) + + session.watchScreen("b1") + advanceUntilIdle() + runCurrent() + assertEquals( + listOf>(null to false, "stream:2" to true), + opens, + ) + + session.watchScreen("b1") + advanceUntilIdle() + assertEquals(2, opens.size) + + session.stopWatchingScreen("b1") + advanceUntilIdle() + assertEquals(2, opens.size) + assertTrue(session.state.value.screens.containsKey("b1")) + + session.stopWatchingScreen("b1") + advanceUntilIdle() + runCurrent() + assertEquals( + listOf>( + null to false, + "stream:2" to true, + "stream:2" to false, + ), + opens, + ) + assertTrue(session.state.value.screens.isEmpty()) + } + + @Test + fun cleanStreamEndBacksOffOneTwoFourCappedAtFifteen() = runTest { + var opens = 0 + val session = session( + connectionStore = FakeConnectionStore( + Connection(id = "c1", name = "Mac", host = "127.0.0.1", port = 8810), + ), + tokenStore = FakeTokenStore().apply { saved["c1"] = "tok" }, + events = { _, _ -> + opens++ + emptyFlow() // clean end immediately + }, + ) + session.awaitRestored() + session.connect() + runCurrent() + assertEquals(1, opens) + assertEquals(Session.Status.Offline("Lost the connection."), session.status.value) + + advanceTimeBy(1_000) + runCurrent() + assertEquals(2, opens) + + advanceTimeBy(2_000) + runCurrent() + assertEquals(3, opens) + + advanceTimeBy(4_000) + runCurrent() + assertEquals(4, opens) + + advanceTimeBy(8_000) + runCurrent() + assertEquals(5, opens) + + advanceTimeBy(15_000) + runCurrent() + assertEquals(6, opens) + } + + @Test + fun unauthorizedDoesNotRetry() = runTest { + var opens = 0 + val session = session( + connectionStore = FakeConnectionStore( + Connection(id = "c1", name = "Mac", host = "127.0.0.1", port = 8810), + ), + tokenStore = FakeTokenStore().apply { saved["c1"] = "tok" }, + events = { _, _ -> + opens++ + flow { throw APIError.Status(401, "revoked") } + }, + ) + session.awaitRestored() + session.connect() + advanceUntilIdle() + advanceTimeBy(60_000) + advanceUntilIdle() + assertEquals(Session.Status.Unauthorized, session.status.value) + assertEquals(1, opens) + } + + @Test + fun deliberateDisconnectIsNotRetried() = runTest { + val hang = MutableSharedFlow(extraBufferCapacity = 1) + var opens = 0 + val session = session( + connectionStore = FakeConnectionStore( + Connection(id = "c1", name = "Mac", host = "127.0.0.1", port = 8810), + ), + tokenStore = FakeTokenStore().apply { saved["c1"] = "tok" }, + events = { _, _ -> + opens++ + hang + }, + ) + session.awaitRestored() + session.connect() + runCurrent() + hang.tryEmit(StreamFrame(Frame.Hello(cursor = "s:1", resumed = true), seq = 1)) + runCurrent() + assertEquals(Session.Status.Live, session.status.value) + + session.disconnect() + advanceTimeBy(60_000) + advanceUntilIdle() + assertEquals(1, opens) + } + + @Test + fun refreshWaitsUntilLeavingConnectingOrTenSeconds() = runTest { + val hang = MutableSharedFlow() + val session = session( + connectionStore = FakeConnectionStore( + Connection(id = "c1", name = "Mac", host = "127.0.0.1", port = 8810), + ), + tokenStore = FakeTokenStore().apply { saved["c1"] = "tok" }, + events = { _, _ -> hang }, + ) + session.awaitRestored() + val job = launch { session.refresh() } + runCurrent() + assertEquals(Session.Status.Connecting, session.status.value) + advanceTimeBy(9_999) + assertTrue(job.isActive) + advanceTimeBy(2) + advanceUntilIdle() + assertTrue(job.isCompleted) + } + + @Test + fun refreshWhileLiveRestartsAndWaitsForSettlement() = runTest { + val hang = MutableSharedFlow(extraBufferCapacity = 2) + var opens = 0 + val session = session( + connectionStore = FakeConnectionStore( + Connection(id = "c1", name = "Mac", host = "127.0.0.1", port = 8810), + ), + tokenStore = FakeTokenStore().apply { saved["c1"] = "tok" }, + events = { _, _ -> + opens++ + hang + }, + ) + session.awaitRestored() + session.connect() + runCurrent() + hang.tryEmit(StreamFrame(Frame.Hello(cursor = "s:1", resumed = true), seq = 1)) + runCurrent() + assertEquals(Session.Status.Live, session.status.value) + assertEquals(1, opens) + + val job = launch { session.refresh() } + runCurrent() + assertEquals(Session.Status.Connecting, session.status.value) + assertTrue(job.isActive) + assertEquals(2, opens) + + hang.tryEmit(StreamFrame(Frame.Hello(cursor = "s:1", resumed = true), seq = 2)) + runCurrent() + advanceUntilIdle() + assertEquals(Session.Status.Live, session.status.value) + assertTrue(job.isCompleted) + } + + @Test + fun notifyFramesUseDedupeContractViaSink() = runTest { + val notifications = RecordingNotifications() + val hang = MutableSharedFlow(extraBufferCapacity = 2) + val session = session( + connectionStore = FakeConnectionStore( + Connection(id = "c1", name = "Mac", host = "127.0.0.1", port = 8810), + ), + tokenStore = FakeTokenStore().apply { saved["c1"] = "tok" }, + notifications = notifications, + events = { _, _ -> hang }, + ) + session.awaitRestored() + session.connect() + runCurrent() + hang.tryEmit(StreamFrame(Frame.Hello(cursor = "s:1", resumed = true), seq = 1)) + hang.tryEmit( + StreamFrame( + Frame.Notify( + NotificationFrame( + kind = "approval", + botId = "b1", + botName = "Scout", + threadId = "t1", + title = "Allow?", + body = "rm -rf", + ), + ), + seq = 42, + ), + ) + runCurrent() + assertEquals(1, notifications.delivered.size) + assertEquals(42, notifications.delivered.single().second) + } + + @Test + fun addressFailureWalksCandidates() { + val failure = ConnectionAdvice.classify(ConnectException("refused")) + assertEquals(ConnectionFailure.CANNOT_CONNECT_TO_HOST, failure) + assertTrue(ConnectionAdvice.shouldTryAnotherHost(failure)) + val message = ConnectionAdvice.message(failure, "192.168.1.2", 8810, tryingNext = "mac.ts.net") + assertTrue(message.contains("Trying mac.ts.net next.")) + assertTrue(message.contains("port 8810")) + } + + private fun kotlinx.coroutines.test.TestScope.session( + connectionStore: ConnectionStore = FakeConnectionStore(), + tokenStore: TokenStore = FakeTokenStore(), + pairFn: suspend (Connection, String, String) -> PairResponse = { _, _, _ -> error("pair not expected") }, + events: (String?, Boolean) -> Flow = { _, _ -> emptyFlow() }, + hydrate: suspend () -> Fleet = { Fleet(emptyList(), emptyList()) }, + notifications: NotificationSink = RecordingNotifications(), + ): Session = Session( + scope = backgroundScope, + connectionStore = connectionStore, + tokenStore = tokenStore, + deviceNameProvider = { "Pixel" }, + notificationSink = notifications, + clientFactory = { connection, token -> CompanionClient(connection, token) }, + pairFn = pairFn, + eventsFn = { _, since, screens -> events(since, screens) }, + hydrateFn = { _, _ -> hydrate() }, + ) +} + +private fun sampleBot(id: String, threadId: String) = Bot( + id = id, + threadId = threadId, + name = "Scout", + title = "coder", + description = "", + notifications = true, + color = "green", + unread = false, + modelSelection = ModelSelection("i", "m"), + createdAt = 1.0, +) + +private class FakeConnectionStore( + initial: Connection? = null, +) : ConnectionStore { + var saved: Connection? = initial + override suspend fun load(): Connection? = saved + override suspend fun save(connection: Connection) { + saved = connection + } + override suspend fun clear() { + saved = null + } +} + +private class FakeTokenStore : TokenStore { + val saved = linkedMapOf() + val unavailable = linkedMapOf() + + override suspend fun save(connectionId: String, token: String) { + saved[connectionId] = token + unavailable.remove(connectionId) + } + + override suspend fun read(connectionId: String): TokenStore.ReadResult { + unavailable[connectionId]?.let { return it } + val token = saved[connectionId] ?: return TokenStore.ReadResult.Missing + return TokenStore.ReadResult.Found(token) + } + + override suspend fun remove(connectionId: String) { + saved.remove(connectionId) + unavailable.remove(connectionId) + } +} + +private class RecordingNotifications : NotificationSink { + val delivered = mutableListOf>() + var lastBadge = 0 + private set + override fun deliver(notification: NotificationFrame, sequence: Int?) { + delivered += notification to sequence + } + override fun setBadge(count: Int) { + lastBadge = count + } +} diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 23449a2b5..1a704683a 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index ca79eb2ad..9a1dc31ea 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -1,7 +1,8 @@ pluginManagement { repositories { - gradlePluginPortal() + google() mavenCentral() + gradlePluginPortal() } } @@ -11,10 +12,10 @@ plugins { dependencyResolutionManagement { repositories { + google() mavenCentral() } } rootProject.name = "openmausbot-android" include(":core") - From 7aa59ae393508d91a2fee77eebe15f415590d36c Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Fri, 21 Aug 2026 17:18:00 -0300 Subject: [PATCH 3/6] =?UTF-8?q?android/core:=20close=20the=20upstream=20de?= =?UTF-8?q?lta=20=E2=80=94=20group=20creation,=20card=20preview,=20host=20?= =?UTF-8?q?zones?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream moved on since the port's base; this closes the core-level half of the delta against ios/ at d487882. POST /api/groups reaches the phone: CreatedRoom, Client.createRoom and Session.createRoom, folding the created room in locally rather than waiting for the broadcast, and mirroring Swift's CharacterSet.whitespaces so a name of only spaces or a tab is omitted and the harness names the room after its first member — while a newline-only name is sent raw, as iOS does. A pending card's question is now the roster preview, since the row already says "waiting on you" beside it; and urlHost drops an interface zone from non-IPv6 hosts, keeping the scope on link-local IPv6 and the synthetic-host dialing path intact. Implemented by Codex; strictly reviewed by Grok (2 rounds: isBlank() is not CharacterSet.whitespaces — LF and CR must survive the trim). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SkWZ3y7iKQMjH6tHNFBte7 --- .../com/openmausbot/companion/core/Chat.kt | 5 +- .../com/openmausbot/companion/core/Client.kt | 15 ++++ .../openmausbot/companion/core/Connection.kt | 2 +- .../com/openmausbot/companion/core/Models.kt | 3 + .../com/openmausbot/companion/core/Session.kt | 12 ++++ .../companion/core/ChatSummaryTest.kt | 70 ++++++++++++++++++- .../openmausbot/companion/core/ClientTest.kt | 51 ++++++++++++++ .../companion/core/ConnectionTest.kt | 25 +++++++ .../openmausbot/companion/core/SessionTest.kt | 49 +++++++++++++ 9 files changed, 229 insertions(+), 3 deletions(-) diff --git a/android/core/src/main/kotlin/com/openmausbot/companion/core/Chat.kt b/android/core/src/main/kotlin/com/openmausbot/companion/core/Chat.kt index 7048ae7ec..93d6e42bb 100644 --- a/android/core/src/main/kotlin/com/openmausbot/companion/core/Chat.kt +++ b/android/core/src/main/kotlin/com/openmausbot/companion/core/Chat.kt @@ -78,7 +78,10 @@ private fun previewOf(last: Message?): String { if (last == null) return "" return when (last.kind) { Message.Kind.TEXT -> last.text.orEmpty() - Message.Kind.OPTIONS -> if (last.card?.isPending == true) "Waiting on you" else last.card?.title.orEmpty() + Message.Kind.OPTIONS -> { + val card = last.card ?: return "" + if (card.isPending && card.subtitle.isNotEmpty()) card.subtitle else card.title + } Message.Kind.ACTIVITY -> last.tool?.name.orEmpty() Message.Kind.SCREEN -> "Screenshot" Message.Kind.UNKNOWN -> last.text.orEmpty() diff --git a/android/core/src/main/kotlin/com/openmausbot/companion/core/Client.kt b/android/core/src/main/kotlin/com/openmausbot/companion/core/Client.kt index 35d6be95d..24fe87806 100644 --- a/android/core/src/main/kotlin/com/openmausbot/companion/core/Client.kt +++ b/android/core/src/main/kotlin/com/openmausbot/companion/core/Client.kt @@ -12,7 +12,9 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.serialization.SerializationException import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import okhttp3.Call @@ -171,6 +173,19 @@ class CompanionClient( suspend fun createBot(): Bot = send(makeRequest("POST", "/api/bots")).bot + suspend fun createRoom(name: String?, memberIds: List): Room { + val body = buildJsonObject { + put("memberIds", JsonArray(memberIds.map(::JsonPrimitive))) + name?.let { value -> + val trimmed = value.trim { character -> + character == '\t' || character.category == CharCategory.SPACE_SEPARATOR + } + if (trimmed.isNotEmpty()) put("name", value) + } + } + return send(makeRequest("POST", "/api/groups", body = body)).group + } + suspend fun sendToBot(botId: String, text: String) { sendUnit(makeRequest("POST", "/api/bots/$botId/messages", body = jsonBody("text" to text))) } diff --git a/android/core/src/main/kotlin/com/openmausbot/companion/core/Connection.kt b/android/core/src/main/kotlin/com/openmausbot/companion/core/Connection.kt index adfdfc21e..1c031a967 100644 --- a/android/core/src/main/kotlin/com/openmausbot/companion/core/Connection.kt +++ b/android/core/src/main/kotlin/com/openmausbot/companion/core/Connection.kt @@ -47,7 +47,7 @@ data class Connection( } else { host } - return if (':' in bare) "[$bare]" else bare + return if (':' in bare) "[$bare]" else bare.substringBefore('%') } fun parse(text: String, defaultPort: Int = 8810): Connection? { diff --git a/android/core/src/main/kotlin/com/openmausbot/companion/core/Models.kt b/android/core/src/main/kotlin/com/openmausbot/companion/core/Models.kt index 5d4712003..1148c547c 100644 --- a/android/core/src/main/kotlin/com/openmausbot/companion/core/Models.kt +++ b/android/core/src/main/kotlin/com/openmausbot/companion/core/Models.kt @@ -342,6 +342,9 @@ data class ScreenFrame(val png: String, val mime: String) { @Serializable data class CreatedBot(val bot: Bot) +@Serializable +data class CreatedRoom(val group: Room) + @Serializable internal data class SearchResponse(val hits: List) diff --git a/android/core/src/main/kotlin/com/openmausbot/companion/core/Session.kt b/android/core/src/main/kotlin/com/openmausbot/companion/core/Session.kt index e53d4da4f..cbb374b1b 100644 --- a/android/core/src/main/kotlin/com/openmausbot/companion/core/Session.kt +++ b/android/core/src/main/kotlin/com/openmausbot/companion/core/Session.kt @@ -599,6 +599,18 @@ class Session( } } + suspend fun createRoom(name: String?, memberIds: List): Room? { + val activeClient = client ?: return null + return try { + val room = activeClient.createRoom(name, memberIds) + _state.update { it.apply(Frame.Room(room)) } + room + } catch (error: Throwable) { + _actionError.value = error.message + null + } + } + suspend fun interrupt(bot: Bot) { perform { it.interrupt(bot.id) } } diff --git a/android/core/src/test/kotlin/com/openmausbot/companion/core/ChatSummaryTest.kt b/android/core/src/test/kotlin/com/openmausbot/companion/core/ChatSummaryTest.kt index a8ca4918c..19e6d2a12 100644 --- a/android/core/src/test/kotlin/com/openmausbot/companion/core/ChatSummaryTest.kt +++ b/android/core/src/test/kotlin/com/openmausbot/companion/core/ChatSummaryTest.kt @@ -55,11 +55,63 @@ class ChatSummaryTest { listOf("pinned", "r1", "unread", "new", "old"), summaries.map { it.id }, ) - assertEquals("Waiting on you", summaries.first { it.id == "r1" }.preview) + assertEquals("Allow shell?", summaries.first { it.id == "r1" }.preview) assertEquals(false, summaries.first { it.id == "r1" }.pinned) assertTrue(summaries.none { it.id == "hidden" }) } + @Test + fun optionPreviewUsesThePendingQuestionOtherwiseTheTitle() { + assertEquals( + "Run the shell command?", + optionPreview(OptionCard( + title = "Allow shell?", + subtitle = "Run the shell command?", + options = listOf("Allow", "Deny"), + requestId = "req", + )), + ) + assertEquals( + "Allow shell?", + optionPreview(OptionCard( + title = "Allow shell?", + subtitle = "", + options = listOf("Allow", "Deny"), + requestId = "req", + )), + ) + assertEquals( + " ", + optionPreview(OptionCard( + title = "Allow shell?", + subtitle = " ", + options = listOf("Allow", "Deny"), + requestId = "req", + )), + ) + assertEquals( + "Choose a mode", + optionPreview(OptionCard( + title = "Choose a mode", + subtitle = "Which mode should run?", + options = listOf("Fast", "Safe"), + answered = "Safe", + requestId = "req", + )), + ) + assertEquals( + "Dismissed question", + optionPreview(OptionCard( + title = "Dismissed question", + subtitle = "Question details", + options = listOf("Allow", "Deny"), + dismissed = true, + requestId = "req", + )), + ) + assertEquals("", optionPreview(null)) + } + @Test fun previewRulesMatchIos() { val textState = CompanionState( @@ -130,3 +182,19 @@ private fun text(id: String, at: Double, body: String) = Message( at = at, text = body, ) + +private fun optionPreview(card: OptionCard?): String { + val state = CompanionState( + bots = listOf(sampleBot("b", "t")), + messages = mapOf( + "t" to listOf(Message( + id = "m", + role = Message.Role.BOT, + kind = Message.Kind.OPTIONS, + at = 1.0, + card = card, + )), + ), + ) + return state.chatSummaries.single().preview +} diff --git a/android/core/src/test/kotlin/com/openmausbot/companion/core/ClientTest.kt b/android/core/src/test/kotlin/com/openmausbot/companion/core/ClientTest.kt index a92b0aeaa..38e56b625 100644 --- a/android/core/src/test/kotlin/com/openmausbot/companion/core/ClientTest.kt +++ b/android/core/src/test/kotlin/com/openmausbot/companion/core/ClientTest.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.toList import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import okhttp3.mockwebserver.MockResponse @@ -103,6 +104,45 @@ class ClientTest { requests.forEach { assertEquals("Bearer device-token", it.getHeader("Authorization")) } } + @Test + fun createRoomSendsMembersAndMirrorsIosWhitespaceNameRules() = runBlocking { + val cases = listOf>( + "Launch Team" to true, + null to false, + "" to false, + " " to false, + "\t" to false, + " \t\n" to true, + "\n" to true, + "\r" to true, + ) + repeat(cases.size) { server.enqueue(json("""{"group":${roomJson()}}""")) } + + cases.forEachIndexed { index, (name, includesName) -> + val memberIds = if (index == 0) listOf("b1", "b2") else listOf("b$index") + val room = client.createRoom(name, memberIds) + if (index == 0) { + assertEquals("g-new", room.id) + assertEquals("Launch Team", room.name) + assertEquals(listOf("b1", "b2"), room.memberIds) + } + + val request = server.takeRequest() + assertEquals("POST", request.method) + assertEquals("/api/groups", request.path) + val body = CompanionJson.parseToJsonElement(request.body.readUtf8()).jsonObject + assertEquals( + memberIds, + body.getValue("memberIds").jsonArray.map { it.jsonPrimitive.content }, + ) + if (includesName) { + assertEquals(name, body.getValue("name").jsonPrimitive.content) + } else { + assertFalse("name" in body) + } + } + } + @Test fun actionCallsMatchTheAllowlist() = runBlocking { val bot = botJson() @@ -220,4 +260,15 @@ class ClientTest { val root = CompanionJson.parseToJsonElement(fixtureText("bots-full")).jsonObject return root.getValue("bots").toString().removePrefix("[").removeSuffix("]") } + + private fun roomJson(): String = """{ + "id":"g-new", + "threadId":"t-new", + "name":"Launch Team", + "memberIds":["b1","b2"], + "defaultResponder":{"kind":"mentions"}, + "bulletin":"", + "unread":false, + "createdAt":3 + }""".trimIndent() } diff --git a/android/core/src/test/kotlin/com/openmausbot/companion/core/ConnectionTest.kt b/android/core/src/test/kotlin/com/openmausbot/companion/core/ConnectionTest.kt index 6c1db7b9d..390aefb40 100644 --- a/android/core/src/test/kotlin/com/openmausbot/companion/core/ConnectionTest.kt +++ b/android/core/src/test/kotlin/com/openmausbot/companion/core/ConnectionTest.kt @@ -36,6 +36,31 @@ class ConnectionTest { assertEquals(9910, explicit?.port) } + @Test + fun normalizesZonesOnlyForNonIpv6Hosts() { + assertEquals("", Connection.urlHost("")) + assertEquals("", Connection.urlHost("%")) + assertEquals("192.168.1.3", Connection.urlHost("192.168.1.3%en0")) + assertEquals("mac.local", Connection.urlHost("mac.local%en0")) + assertEquals("[fe80::1%en0]", Connection.urlHost("fe80::1%en0")) + assertEquals("[fe80::1%en0]", Connection.urlHost("[fe80::1%en0]")) + assertEquals("192.168.1.3", Connection.urlHost("192.168.1.3")) + assertEquals("mac.local", Connection.urlHost("mac.local")) + } + + @Test + fun parsersNormalizeNonIpv6ZoneSuffixes() { + val connection = Connection.parse("192.168.1.3%en0:9910") + assertEquals("192.168.1.3", connection?.host) + assertEquals(9910, connection?.port) + + val invite = PairingInvite.parse( + URI("openmausbot://pair?address=192.168.1.3%25en0%3A9910&code=004209"), + ) + assertEquals("192.168.1.3", invite?.connection?.host) + assertEquals(9910, invite?.connection?.port) + } + @Test fun parsesIpv6WithAndWithoutAnExplicitPort() { val bare = Connection.parse("2001:db8::1") diff --git a/android/core/src/test/kotlin/com/openmausbot/companion/core/SessionTest.kt b/android/core/src/test/kotlin/com/openmausbot/companion/core/SessionTest.kt index 7f4be72f2..be51c7436 100644 --- a/android/core/src/test/kotlin/com/openmausbot/companion/core/SessionTest.kt +++ b/android/core/src/test/kotlin/com/openmausbot/companion/core/SessionTest.kt @@ -19,6 +19,8 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.yield +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer @OptIn(ExperimentalCoroutinesApi::class) class SessionTest { @@ -352,6 +354,42 @@ class SessionTest { assertTrue(tokens.saved.isEmpty()) } + @Test + fun createRoomFoldsTheResultAndSurfacesFailure() = runTest { + val server = MockWebServer() + server.start() + try { + val connection = requireNotNull(Connection.parse(server.url("/").toString())) + val tokens = FakeTokenStore().apply { saved[connection.id] = "tok" } + val session = session( + connectionStore = FakeConnectionStore(connection), + tokenStore = tokens, + ) + session.awaitRestored() + + server.enqueue(MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody("""{"group":${roomJson()}}""")) + val room = session.createRoom("Launch Team", listOf("b1", "b2")) + + assertEquals("g-new", room?.id) + assertEquals(room, session.state.value.rooms.single()) + assertEquals(emptyList(), session.state.value.transcript("t-new")) + assertNull(session.actionError) + + server.enqueue(MockResponse() + .setResponseCode(403) + .setHeader("Content-Type", "application/json") + .setBody("""{"error":"Room creation is not allowed."}""")) + assertNull(session.createRoom(null, listOf("b1"))) + assertEquals("Room creation is not allowed.", session.actionError) + assertEquals(listOf("g-new"), session.state.value.rooms.map(Room::id)) + } finally { + server.shutdown() + } + } + @Test fun helloNotResumedHydratesBeforeCommittingCursor() = runTest { var hydrateCalls = 0 @@ -716,6 +754,17 @@ class SessionTest { eventsFn = { _, since, screens -> events(since, screens) }, hydrateFn = { _, _ -> hydrate() }, ) + + private fun roomJson(): String = """{ + "id":"g-new", + "threadId":"t-new", + "name":"Launch Team", + "memberIds":["b1","b2"], + "defaultResponder":{"kind":"mentions"}, + "bulletin":"", + "unread":false, + "createdAt":3 + }""".trimIndent() } private fun sampleBot(id: String, threadId: String) = Bot( From dc2f2e60cf66f1dc7e441651fca442bb4c310ca1 Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Fri, 21 Aug 2026 17:18:00 -0300 Subject: [PATCH 4/6] android/core: answer what the card offered, and follow the bot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream fixed two companion bugs this port had faithfully mirrored (#312, #313), and the fix landed in the shared core, so the port follows. A permission card's answer no longer comes from comparing the button's text to "allow". The one refusal is Deny — trimmed and case-insensitively — and every other option the card offered means allow, so a provider that says Approve, Yes or Allow once is no longer answered with a denial. When the provider offers its own Always allow, choosing it records the standing grant against the card's key rather than only answering. Navigation stops being a thread. A chat target carries the owner — a bot or a room — alongside the thread it asked for, so deleting the open task follows the bot to whichever task the desktop moved it to, while a bot that is really gone still closes the chat. The same target reads a notification, which now keeps the botId it used to discard: with routines minting a fresh task per run, opening the exact task stopped being an edge case. The old threadId overload stays for one pass, deprecated and documented, until the Compose call sites move to the Chat form. Implemented by Codex; strictly reviewed by Grok (approved with no defects; the transitional overload is marked so it cannot become API). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SkWZ3y7iKQMjH6tHNFBte7 --- .../com/openmausbot/companion/core/Chat.kt | 25 ++ .../com/openmausbot/companion/core/Models.kt | 33 ++ .../com/openmausbot/companion/core/Session.kt | 103 +++++- .../companion/core/ChatTargetTest.kt | 110 ++++++ .../companion/core/DecodingTest.kt | 50 +++ .../companion/core/SessionP1Test.kt | 338 ++++++++++++++++++ 6 files changed, 645 insertions(+), 14 deletions(-) create mode 100644 android/core/src/test/kotlin/com/openmausbot/companion/core/ChatTargetTest.kt create mode 100644 android/core/src/test/kotlin/com/openmausbot/companion/core/SessionP1Test.kt diff --git a/android/core/src/main/kotlin/com/openmausbot/companion/core/Chat.kt b/android/core/src/main/kotlin/com/openmausbot/companion/core/Chat.kt index 93d6e42bb..fd5c4c893 100644 --- a/android/core/src/main/kotlin/com/openmausbot/companion/core/Chat.kt +++ b/android/core/src/main/kotlin/com/openmausbot/companion/core/Chat.kt @@ -31,6 +31,31 @@ sealed class Chat { } } +sealed interface ChatTarget { + val threadId: String + + data class Bot( + val botId: String, + override val threadId: String, + ) : ChatTarget + + data class Room( + val roomId: String, + override val threadId: String, + ) : ChatTarget +} + +val Chat.target: ChatTarget + get() = when (this) { + is Chat.BotChat -> ChatTarget.Bot(bot.id, threadId) + is Chat.RoomChat -> ChatTarget.Room(room.id, threadId) + } + +fun CompanionState.chat(target: ChatTarget): Chat? = when (target) { + is ChatTarget.Bot -> bot(target.botId)?.let(Chat::BotChat) + is ChatTarget.Room -> rooms.firstOrNull { it.id == target.roomId }?.let(Chat::RoomChat) +} + /** * A chat plus the two things a roster row shows that the record itself does not carry: * the preview line, and when the thread last moved. diff --git a/android/core/src/main/kotlin/com/openmausbot/companion/core/Models.kt b/android/core/src/main/kotlin/com/openmausbot/companion/core/Models.kt index 1148c547c..786f73d1b 100644 --- a/android/core/src/main/kotlin/com/openmausbot/companion/core/Models.kt +++ b/android/core/src/main/kotlin/com/openmausbot/companion/core/Models.kt @@ -42,6 +42,39 @@ data class OptionCard( ) { val isPending: Boolean get() = requestId != null && answered == null && dismissed != true val isPermission: Boolean get() = tool != null + + fun responseBehavior(choice: String): String = responseBehavior(choice, isPermission) + + fun shouldRememberPermission(choice: String): Boolean = + isPermission && allowKey != null && choice.trim().equals("Always allow", ignoreCase = true) + + companion object { + fun responseBehavior(choice: String, isPermission: Boolean): String = when { + !isPermission -> "answer" + isRefusal(choice) -> "deny" + else -> "allow" + } + + fun isRefusal(choice: String): Boolean = choice.trim().equals("Deny", ignoreCase = true) + } +} + +@ConsistentCopyVisibility +data class NotificationTarget private constructor( + val botId: String, + val threadId: String, +) { + fun requiresTaskSwitch(activeThreadId: String): Boolean = threadId != activeThreadId + + companion object { + fun from(botId: String?, threadId: String?): NotificationTarget? { + if (botId.isNullOrBlank() || threadId.isNullOrBlank()) return null + return NotificationTarget(botId, threadId) + } + + fun from(payload: Map): NotificationTarget? = + from(payload["botId"], payload["threadId"]) + } } @Serializable diff --git a/android/core/src/main/kotlin/com/openmausbot/companion/core/Session.kt b/android/core/src/main/kotlin/com/openmausbot/companion/core/Session.kt index cbb374b1b..2f71d871a 100644 --- a/android/core/src/main/kotlin/com/openmausbot/companion/core/Session.kt +++ b/android/core/src/main/kotlin/com/openmausbot/companion/core/Session.kt @@ -85,6 +85,7 @@ class Session( private var screenWatchers = 0 private var restorePending = false private val gate = Mutex() + private val notificationGate = Mutex() private val restored = CompletableDeferred() /** QR credentials that already failed a redeem — never replay (§6). */ private val spentQrCredentials = mutableSetOf() @@ -562,23 +563,52 @@ class Session( } } + suspend fun answer( + chat: Chat, + card: OptionCard, + choice: String, + rememberingPermission: Boolean = true, + ) { + val requestId = card.requestId ?: return + if ( + rememberingPermission && + card.shouldRememberPermission(choice) && + chat is Chat.BotChat + ) { + alwaysAllow(chat.bot, card) + } + answer(chat.threadId, requestId, choice, card.isPermission) + } + + /** + * Answers [card] in [threadId] using the card's permission-aware response behavior. + * + * This overload cannot persist a standing permission grant because it has no [Chat], and thus no + * bot. Call sites must migrate to the [answer] overload that accepts a [Chat]. + */ + @Deprecated( + message = "Use the answer(Chat, OptionCard, String) overload so standing grants can be persisted.", + level = DeprecationLevel.WARNING, + ) suspend fun answer(threadId: String, card: OptionCard, choice: String) { val requestId = card.requestId ?: return + answer(threadId, requestId, choice, card.isPermission) + } + + suspend fun answer( + threadId: String, + requestId: String, + choice: String, + isPermission: Boolean, + ) { perform { - if (card.isPermission) { - it.respond( - threadId = threadId, - requestId = requestId, - behavior = if (choice.equals("allow", ignoreCase = true)) "allow" else "deny", - ) - } else { - it.respond( - threadId = threadId, - requestId = requestId, - behavior = "answer", - message = choice, - ) - } + val behavior = OptionCard.responseBehavior(choice, isPermission) + it.respond( + threadId = threadId, + requestId = requestId, + behavior = behavior, + message = choice.takeIf { behavior == "answer" }, + ) } } @@ -698,6 +728,51 @@ class Session( } } + suspend fun openNotification(target: NotificationTarget): Chat? { + awaitRestored() + return notificationGate.withLock { + val activeClient = client + if (activeClient == null) { + _actionError.value = "Pair this phone with your computer to open that task." + return@withLock null + } + + try { + _state.value.roomForThread(target.threadId)?.let { + return@withLock Chat.RoomChat(it) + } + + var bot = _state.value.bot(target.botId) + if (bot == null) { + val fleet = hydrateFn(activeClient, 50) + _state.update { it.hydrate(fleet) } + notificationSink.setBadge(_state.value.unreadCount) + _state.value.roomForThread(target.threadId)?.let { + return@withLock Chat.RoomChat(it) + } + bot = _state.value.bot(target.botId) + } + + var selected = bot + ?: throw APIError.Status(404, "That agent no longer exists.") + if (target.requiresTaskSwitch(selected.threadId)) { + try { + selected = activeClient.switchTask(selected.id, target.threadId) + _state.update { it.apply(Frame.Bot(selected)) } + } catch (error: Throwable) { + if (error is kotlinx.coroutines.CancellationException) throw error + // The requested task can disappear between notification delivery and the tap. + } + } + Chat.BotChat(selected) + } catch (error: Throwable) { + if (error is kotlinx.coroutines.CancellationException) throw error + _actionError.value = error.message + null + } + } + } + fun consumeFocus(messageId: String) { if (_focusedMessageId.value == messageId) _focusedMessageId.value = null } diff --git a/android/core/src/test/kotlin/com/openmausbot/companion/core/ChatTargetTest.kt b/android/core/src/test/kotlin/com/openmausbot/companion/core/ChatTargetTest.kt new file mode 100644 index 000000000..c7b1d3d62 --- /dev/null +++ b/android/core/src/test/kotlin/com/openmausbot/companion/core/ChatTargetTest.kt @@ -0,0 +1,110 @@ +package com.openmausbot.companion.core + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull + +class ChatTargetTest { + @Test + fun createTaskFollowsTheSameBotToTheCreatedTask() { + val original = bot("b1", "task-1", "task-1") + val target = Chat.BotChat(original).target + val state = CompanionState(bots = listOf(original)).apply( + Frame.Bot(bot("b1", "task-2", "task-1", "task-2")), + ) + + assertEquals("task-1", target.threadId) + assertEquals("task-2", assertIs(state.chat(target)).threadId) + } + + @Test + fun switchTaskFollowsTheSameBotToItsNewActiveTask() { + val original = bot("b1", "task-1", "task-1", "task-2") + val target = Chat.BotChat(original).target + val state = CompanionState(bots = listOf(original)).apply( + Frame.Bot(bot("b1", "task-2", "task-1", "task-2")), + ) + + assertEquals("task-2", assertIs(state.chat(target)).threadId) + } + + @Test + fun deletingAnInactiveTaskKeepsTheActiveTask() { + val original = bot("b1", "task-1", "task-1", "task-2") + val target = Chat.BotChat(original).target + val state = CompanionState(bots = listOf(original)).apply( + Frame.Bot(bot("b1", "task-1", "task-1")), + ) + + assertEquals("task-1", assertIs(state.chat(target)).threadId) + } + + @Test + fun deletingTheActiveTaskFollowsTheDesktopSelectedTask() { + val original = bot("b1", "task-2", "task-1", "task-2") + val target = Chat.BotChat(original).target + val state = CompanionState(bots = listOf(original)).apply( + Frame.Bot(bot("b1", "task-1", "task-1")), + ) + + assertEquals("task-2", target.threadId) + assertEquals("task-1", assertIs(state.chat(target)).threadId) + } + + @Test + fun removingTheBotClosesItsStableTarget() { + val original = bot("b1", "task-1", "task-1") + val target = Chat.BotChat(original).target + val state = CompanionState(bots = listOf(original)).apply(Frame.BotDeleted("b1")) + + assertNull(state.chat(target)) + } + + @Test + fun botTargetNeverChoosesAnotherBotWithTheSameRequestedThread() { + val owner = bot("owner", "active-owner", "shared-thread", "active-owner") + val other = bot("other", "active-other", "shared-thread", "active-other") + val target = ChatTarget.Bot(owner.id, "shared-thread") + val state = CompanionState(bots = listOf(other, owner)) + + assertEquals("owner", assertIs(state.chat(target)).id) + } + + @Test + fun roomTargetUsesItsStableRoomId() { + val owner = room("owner", "room-thread") + val other = room("other", "room-thread") + val target = Chat.RoomChat(owner).target + val state = CompanionState(rooms = listOf(other, owner)) + + assertEquals("owner", assertIs(state.chat(target)).id) + } + + private fun bot(id: String, active: String, vararg tasks: String): Bot = Bot( + id = id, + threadId = active, + name = id, + title = "", + description = "", + notifications = true, + color = "green", + unread = false, + modelSelection = ModelSelection("instance", "model"), + createdAt = 1.0, + tasks = tasks.mapIndexed { index, threadId -> + BotTask(threadId, "Task ${index + 1}", index.toDouble()) + }, + ) + + private fun room(id: String, threadId: String): Room = Room( + id = id, + threadId = threadId, + name = id, + memberIds = emptyList(), + defaultResponder = GroupResponder("mentions"), + bulletin = "", + unread = false, + createdAt = 1.0, + ) +} diff --git a/android/core/src/test/kotlin/com/openmausbot/companion/core/DecodingTest.kt b/android/core/src/test/kotlin/com/openmausbot/companion/core/DecodingTest.kt index d2ac99a06..3a7d73570 100644 --- a/android/core/src/test/kotlin/com/openmausbot/companion/core/DecodingTest.kt +++ b/android/core/src/test/kotlin/com/openmausbot/companion/core/DecodingTest.kt @@ -95,10 +95,60 @@ class DecodingTest { assertTrue(card.isPending) assertTrue(card.isPermission) assertEquals("Bash:rm", card.allowKey) + assertEquals("allow", card.responseBehavior("Allow")) + assertEquals("allow", card.responseBehavior("Approve")) + assertEquals("allow", card.responseBehavior("Yes")) + assertEquals("allow", card.responseBehavior("Always allow")) + assertEquals("deny", card.responseBehavior("Deny")) + assertEquals("deny", card.responseBehavior(" \tdeny \r\n")) + assertTrue(OptionCard.isRefusal("\nDeNy\t")) + assertTrue(card.shouldRememberPermission(" \nAlways allow\t")) + assertFalse(card.shouldRememberPermission("Allow")) + assertFalse(card.shouldRememberPermission(" deny ")) assertFalse(card.copy(answered = "Allow").isPending) assertFalse(card.copy(dismissed = true).isPending) } + @Test + fun questionSendsItsLiteralChoiceAsAnAnswer() { + val card = assertNotNull(decodeFixture("options-card").card) + assertFalse(card.isPermission) + assertEquals("answer", card.responseBehavior("Anything")) + assertEquals("answer", OptionCard.responseBehavior("\nDeny\t", isPermission = false)) + assertFalse(card.shouldRememberPermission("Always allow")) + } + + @Test + fun standingGrantRequiresPermissionAndProviderKey() { + val base = OptionCard( + title = "Approval needed", + subtitle = "git push", + options = listOf("Always allow", "Deny"), + requestId = "req-1", + ) + assertFalse(base.shouldRememberPermission("Always allow")) + assertFalse(base.copy(allowKey = "Bash:git").shouldRememberPermission("Always allow")) + assertTrue( + base.copy(tool = "Bash", allowKey = "Bash:git") + .shouldRememberPermission("Always allow"), + ) + } + + @Test + fun notificationTargetRequiresBothExactIds() { + assertEquals( + NotificationTarget.from("bot-1", "detached-task-2"), + NotificationTarget.from(mapOf("botId" to "bot-1", "threadId" to "detached-task-2")), + ) + assertNull(NotificationTarget.from(mapOf("botId" to "bot-1"))) + assertNull(NotificationTarget.from(mapOf("threadId" to "task-1"))) + assertNull(NotificationTarget.from(" ", "task-1")) + assertNull(NotificationTarget.from("bot-1", "\n\t")) + val detached = assertNotNull(NotificationTarget.from("bot-1", "task-2")) + assertTrue(detached.requiresTaskSwitch("task-1")) + assertFalse(detached.requiresTaskSwitch("task-2")) + } + @Test fun decodesAMessageThatGainedAnUnknownField() { val message = CompanionJson.decodeFromString( diff --git a/android/core/src/test/kotlin/com/openmausbot/companion/core/SessionP1Test.kt b/android/core/src/test/kotlin/com/openmausbot/companion/core/SessionP1Test.kt new file mode 100644 index 000000000..28807ce20 --- /dev/null +++ b/android/core/src/test/kotlin/com/openmausbot/companion/core/SessionP1Test.kt @@ -0,0 +1,338 @@ +package com.openmausbot.companion.core + +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest + +@OptIn(ExperimentalCoroutinesApi::class) +class SessionP1Test { + private lateinit var server: MockWebServer + + @BeforeTest + fun setUp() { + server = MockWebServer() + server.start() + } + + @AfterTest + fun tearDown() { + server.shutdown() + } + + @Test + fun permissionAnswersUseTheSwiftBehaviorForOfferedProviderChoices() = runTest { + val session = session() + val chat = Chat.BotChat(bot("b1", "task-1", "task-1")) + val choices = listOf("Approve", "Yes", "Always allow", "Deny", " \ndeny\t") + val card = permissionCard(options = choices, allowKey = "Bash:git") + repeat(choices.size) { server.enqueue(json("{}")) } + + choices.forEach { choice -> + session.answer(chat, card, choice, rememberingPermission = false) + } + + val requests = List(5) { server.takeRequest() } + val bodies = requests.map(::body) + assertEquals( + listOf("allow", "allow", "allow", "deny", "deny"), + bodies.map { it.getValue("behavior") }, + ) + assertEquals( + List(5) { setOf("requestId", "behavior") }, + bodies.map { it.keys }, + ) + assertEquals(List(5) { "/api/threads/task-1/respond" }, requests.map { it.path }) + } + + @Test + fun questionAnswersSendOnlyTheLiteralOptionsTheCardOffered() = runTest { + val session = session() + val chat = Chat.RoomChat(room("room-1", "room-thread")) + val offered = listOf("Ship it", "Not yet\n") + val card = OptionCard( + title = "Release?", + subtitle = "Choose", + options = offered, + requestId = "question-1", + ) + repeat(offered.size) { server.enqueue(json("{}")) } + + session.answer(chat, card, "Ship it") + session.answer(chat, card, "Not yet\n") + + val sent = List(2) { body(server.takeRequest()) } + assertEquals(listOf("answer", "answer"), sent.map { it.getValue("behavior") }) + assertEquals(listOf("Ship it", "Not yet\n"), sent.map { it.getValue("message") }) + assertTrue(sent.all { it.keys == setOf("requestId", "behavior", "message") }) + } + + @Test + fun failedStandingGrantUsesTheProviderKeyOnceAndStillAnswersOnce() = runTest { + val session = session() + val chat = Chat.BotChat(bot("b1", "task-1", "task-1")) + val card = permissionCard( + options = listOf("Always allow", "Deny"), + allowKey = "Bash:git push", + ) + server.enqueue(json("""{"error":"Could not save grant."}""", code = 500)) + server.enqueue(json("{}")) + + session.answer(chat, card, "Always allow") + + val grant = server.takeRequest() + val answer = server.takeRequest() + assertEquals("/api/bots/b1/always-allow", grant.path) + assertEquals(mapOf("allowKey" to "Bash:git push"), body(grant)) + assertEquals("/api/threads/task-1/respond", answer.path) + assertEquals( + mapOf("requestId" to "request-1", "behavior" to "allow"), + body(answer), + ) + assertEquals(2, server.requestCount) + assertEquals("Could not save grant.", session.actionError) + } + + @Test + fun missingKeyRoomAndDisabledMemoryNeverInventAStandingGrant() = runTest { + val session = session() + val bot = Chat.BotChat(bot("b1", "task-1", "task-1")) + val room = Chat.RoomChat(room("room-1", "room-thread")) + repeat(3) { server.enqueue(json("{}")) } + + session.answer( + bot, + permissionCard(options = listOf("Always allow", "Deny"), allowKey = null), + "Always allow", + ) + session.answer( + bot, + permissionCard(options = listOf("Always allow", "Deny"), allowKey = "Bash:git"), + "Always allow", + rememberingPermission = false, + ) + session.answer( + room, + permissionCard(options = listOf("Always allow", "Deny"), allowKey = "Bash:git"), + "Always allow", + ) + + val requests = List(3) { server.takeRequest() } + assertEquals( + listOf( + "/api/threads/task-1/respond", + "/api/threads/task-1/respond", + "/api/threads/room-thread/respond", + ), + requests.map { it.path }, + ) + assertTrue(requests.all { body(it)["behavior"] == "allow" }) + assertEquals(3, server.requestCount) + } + + @Test + fun taskActionsApplyDesktopBotsAndTheStableTargetFollowsEveryTransition() = runTest { + val initial = bot("b1", "task-1", "task-1", "old-inactive") + val created = bot("b1", "task-2", "task-1", "old-inactive", "task-2") + val switched = bot("b1", "task-1", "task-1", "old-inactive", "task-2") + val inactiveDeleted = bot("b1", "task-1", "task-1", "task-2") + val activeDeleted = bot("b1", "task-2", "task-2") + val session = session { Fleet(listOf(initial), emptyList()) } + val stableTarget = assertIs( + session.openNotification(target("b1", "task-1")), + ).target + listOf(created, switched, inactiveDeleted, activeDeleted).forEach { returned -> + server.enqueue(json("""{"bot":${CompanionJson.encodeToString(returned)}}""")) + } + + session.createTask(initial, null) + assertEquals("task-2", assertIs(session.state.value.chat(stableTarget)).threadId) + + session.switchTask(BotTask("task-1", "Task 1", 1.0), created) + assertEquals("task-1", assertIs(session.state.value.chat(stableTarget)).threadId) + + session.deleteTask(BotTask("old-inactive", "Old", 0.0), switched) + assertEquals("task-1", assertIs(session.state.value.chat(stableTarget)).threadId) + + session.deleteTask(BotTask("task-1", "Task 1", 1.0), inactiveDeleted) + assertEquals("task-2", assertIs(session.state.value.chat(stableTarget)).threadId) + assertEquals( + listOf( + "POST /api/bots/b1/tasks", + "POST /api/bots/b1/tasks/task-1", + "DELETE /api/bots/b1/tasks/old-inactive", + "DELETE /api/bots/b1/tasks/task-1", + ), + List(4) { server.takeRequest().let { "${it.method} ${it.path}" } }, + ) + } + + @Test + fun roomNotificationHydratesAndPrefersTheRoomThread() = runTest { + var hydrates = 0 + val asker = bot("asker", "bot-task", "bot-task") + val room = room("room-1", "room-thread") + val session = session { + hydrates++ + Fleet(listOf(asker), listOf(room)) + } + + val opened = session.openNotification(target("asker", "room-thread")) + + assertEquals("room-1", assertIs(opened).id) + assertEquals(1, hydrates) + assertEquals(0, server.requestCount) + } + + @Test + fun activeTaskNotificationHydratesWithoutSwitching() = runTest { + var hydrates = 0 + val session = session { + hydrates++ + Fleet(listOf(bot("b1", "task-1", "task-1", "task-2")), emptyList()) + } + + val opened = session.openNotification(target("b1", "task-1")) + + assertEquals("task-1", assertIs(opened).threadId) + assertEquals(1, hydrates) + assertEquals(0, server.requestCount) + } + + @Test + fun inactiveTaskNotificationSwitchesOnceAndRepeatedOpenIsIdempotent() = runTest { + var hydrates = 0 + val switched = bot("b1", "task-2", "task-1", "task-2") + val session = session { + hydrates++ + Fleet(listOf(bot("b1", "task-1", "task-1", "task-2")), emptyList()) + } + server.enqueue(json("""{"bot":${CompanionJson.encodeToString(switched)}}""")) + val target = target("b1", "task-2") + + val first = session.openNotification(target) + val second = session.openNotification(target) + + assertEquals("task-2", assertIs(first).threadId) + assertEquals("task-2", assertIs(second).threadId) + assertEquals("task-2", session.state.value.bot("b1")?.threadId) + assertEquals(1, hydrates) + assertEquals(1, server.requestCount) + assertEquals("POST /api/bots/b1/tasks/task-2", server.takeRequest().let { "${it.method} ${it.path}" }) + } + + @Test + fun deletedTaskNotificationFallsBackToTheBotsCurrentChat() = runTest { + val session = session { + Fleet(listOf(bot("b1", "task-1", "task-1")), emptyList()) + } + server.enqueue(json("""{"error":"Task not found."}""", code = 404)) + + val opened = session.openNotification(target("b1", "deleted-task")) + + assertEquals("task-1", assertIs(opened).threadId) + assertNull(session.actionError) + assertEquals(1, server.requestCount) + assertEquals("/api/bots/b1/tasks/deleted-task", server.takeRequest().path) + } + + @Test + fun deletedBotNotificationDoesNotChooseAnotherBotsMatchingTask() = runTest { + val session = session { + Fleet( + listOf(bot("other", "other-active", "deleted-task", "other-active")), + emptyList(), + ) + } + + val opened = session.openNotification(target("deleted", "deleted-task")) + + assertNull(opened) + assertEquals("That agent no longer exists.", session.actionError) + assertEquals(0, server.requestCount) + } + + private suspend fun TestScope.session( + hydrate: suspend () -> Fleet = { Fleet(emptyList(), emptyList()) }, + ): Session { + val connection = requireNotNull(Connection.parse(server.url("/").toString())) + return Session( + scope = backgroundScope, + connectionStore = object : ConnectionStore { + override suspend fun load(): Connection = connection + override suspend fun save(connection: Connection) = Unit + override suspend fun clear() = Unit + }, + tokenStore = object : TokenStore { + override suspend fun save(connectionId: String, token: String) = Unit + override suspend fun read(connectionId: String): TokenStore.ReadResult = + TokenStore.ReadResult.Found("device-token") + override suspend fun remove(connectionId: String) = Unit + }, + deviceNameProvider = { "Pixel" }, + eventsFn = { _, _, _ -> emptyFlow() }, + hydrateFn = { _, _ -> hydrate() }, + ).also { it.awaitRestored() } + } + + private fun target(botId: String, threadId: String): NotificationTarget = + requireNotNull(NotificationTarget.from(botId, threadId)) + + private fun permissionCard(options: List, allowKey: String?): OptionCard = OptionCard( + title = "Approval needed", + subtitle = "git push", + options = options, + requestId = "request-1", + tool = "Bash", + allowKey = allowKey, + ) + + private fun bot(id: String, active: String, vararg tasks: String): Bot = Bot( + id = id, + threadId = active, + name = id, + title = "", + description = "", + notifications = true, + color = "green", + unread = false, + modelSelection = ModelSelection("instance", "model"), + createdAt = 1.0, + tasks = tasks.mapIndexed { index, threadId -> + BotTask(threadId, "Task ${index + 1}", index.toDouble()) + }, + ) + + private fun room(id: String, threadId: String): Room = Room( + id = id, + threadId = threadId, + name = id, + memberIds = emptyList(), + defaultResponder = GroupResponder("mentions"), + bulletin = "", + unread = false, + createdAt = 1.0, + ) + + private fun json(body: String, code: Int = 200): MockResponse = MockResponse() + .setResponseCode(code) + .setHeader("Content-Type", "application/json") + .setBody(body) + + private fun body(request: RecordedRequest): Map = + CompanionJson.parseToJsonElement(request.body.readUtf8()).jsonObject + .mapValues { it.value.jsonPrimitive.content } +} From f83422240df04c8c60830c10dc61cbd863e75383 Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Fri, 21 Aug 2026 17:18:00 -0300 Subject: [PATCH 5/6] android/core: a typed restore state, so routing never reads copy Whether a saved credential is still being restored was only knowable by matching the sentence shown to the user, which is display text: it can be reworded or localised, and it is not even written for every unavailable token. A typed state says so directly. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SkWZ3y7iKQMjH6tHNFBte7 --- .../com/openmausbot/companion/core/Session.kt | 36 ++++++++++----- .../openmausbot/companion/core/SessionTest.kt | 44 ++++++++++++++++++- 2 files changed, 68 insertions(+), 12 deletions(-) diff --git a/android/core/src/main/kotlin/com/openmausbot/companion/core/Session.kt b/android/core/src/main/kotlin/com/openmausbot/companion/core/Session.kt index 2f71d871a..5b7e2321c 100644 --- a/android/core/src/main/kotlin/com/openmausbot/companion/core/Session.kt +++ b/android/core/src/main/kotlin/com/openmausbot/companion/core/Session.kt @@ -55,6 +55,12 @@ class Session( data class Offline(val message: String) : Status } + sealed interface RestoreState { + data object Pending : RestoreState + data object Ready : RestoreState + data object Unpaired : RestoreState + } + private val _state = MutableStateFlow(CompanionState()) val state: StateFlow = _state.asStateFlow() @@ -64,6 +70,9 @@ class Session( private val _status = MutableStateFlow(Status.Unpaired) val status: StateFlow = _status.asStateFlow() + private val _restoreState = MutableStateFlow(RestoreState.Pending) + val restoreState: StateFlow = _restoreState.asStateFlow() + private val _actionError = MutableStateFlow(null) var actionError: String? get() = _actionError.value @@ -83,7 +92,6 @@ class Session( private var streamGeneration = 0 private var reconnectDelaySeconds: Long = 0 private var screenWatchers = 0 - private var restorePending = false private val gate = Mutex() private val notificationGate = Mutex() private val restored = CompletableDeferred() @@ -168,7 +176,7 @@ class Session( rotation = CandidateRotation(stored.orderedHosts) client = clientFactory(stored, paired.token) _state.value = CompanionState() - restorePending = false + _restoreState.value = RestoreState.Ready _pairingInvite.value = null } connect() @@ -220,7 +228,7 @@ class Session( _connection.value != null || token != null || client != null || - restorePending || + _restoreState.value is RestoreState.Pending || _status.value !is Status.Unpaired private fun burnQrCredential(credential: String) { @@ -245,7 +253,7 @@ class Session( streamJob = null scope.launch { gate.withLock { - restorePending = false + _restoreState.value = RestoreState.Unpaired _connection.value?.id?.let { tokenStore.remove(it) } connectionStore.clear() _connection.value = null @@ -264,7 +272,7 @@ class Session( streamJob?.cancel() streamJob = null gate.withLock { - restorePending = false + _restoreState.value = RestoreState.Unpaired _connection.value?.id?.let { tokenStore.remove(it) } connectionStore.clear() _connection.value = null @@ -282,7 +290,7 @@ class Session( scope.launch { restored.await() val generation = gate.withLock { - if (client == null && restorePending) { + if (client == null && _restoreState.value is RestoreState.Pending) { restoreLocked() } if (client == null || streamJob != null) return@withLock null @@ -306,12 +314,15 @@ class Session( } private suspend fun restoreLocked() { - restorePending = false - val saved = connectionStore.load() ?: return + val saved = connectionStore.load() + if (saved == null) { + _restoreState.value = RestoreState.Unpaired + return + } when (val stored = tokenStore.read(saved.id)) { is TokenStore.ReadResult.Unavailable -> { _connection.value = saved - restorePending = true + _restoreState.value = RestoreState.Pending _status.value = Status.Offline( if (stored.locked) { "Unlock this phone to reach your computer." @@ -320,12 +331,15 @@ class Session( }, ) } - TokenStore.ReadResult.Missing -> Unit + TokenStore.ReadResult.Missing -> { + _restoreState.value = RestoreState.Unpaired + } is TokenStore.ReadResult.Found -> { _connection.value = saved token = stored.token rotation = CandidateRotation(saved.orderedHosts) client = clientFactory(saved, stored.token) + _restoreState.value = RestoreState.Ready _status.value = Status.Connecting } } @@ -341,7 +355,7 @@ class Session( suspend fun refresh() { awaitRestored() gate.withLock { - if (client == null && restorePending) { + if (client == null && _restoreState.value is RestoreState.Pending) { restoreLocked() } if (client == null) return@withLock diff --git a/android/core/src/test/kotlin/com/openmausbot/companion/core/SessionTest.kt b/android/core/src/test/kotlin/com/openmausbot/companion/core/SessionTest.kt index be51c7436..aae8519af 100644 --- a/android/core/src/test/kotlin/com/openmausbot/companion/core/SessionTest.kt +++ b/android/core/src/test/kotlin/com/openmausbot/companion/core/SessionTest.kt @@ -29,6 +29,7 @@ class SessionTest { val session = session() session.awaitRestored() assertEquals(Session.Status.Unpaired, session.status.value) + assertEquals(Session.RestoreState.Unpaired, session.restoreState.value) assertNull(session.connection.value) } @@ -45,8 +46,49 @@ class SessionTest { ) session.awaitRestored() assertEquals(connection, session.connection.value) + assertEquals(Session.RestoreState.Pending, session.restoreState.value) val status = assertIs(session.status.value) - assertTrue(status.message.contains("Unlock")) + assertEquals("Unlock this phone to reach your computer.", status.message) + } + + @Test + fun unavailableTokenErrorRemainsPendingWithoutLockedCopy() = runTest { + val connection = Connection(id = "c1", name = "Mac", host = "192.168.1.2", port = 8810) + val tokens = FakeTokenStore().apply { + unavailable["c1"] = TokenStore.ReadResult.Unavailable( + locked = false, + message = "Secure storage is temporarily unavailable.", + ) + } + val session = session( + connectionStore = FakeConnectionStore(connection), + tokenStore = tokens, + events = { _, _ -> emptyFlow() }, + ) + + session.awaitRestored() + + assertEquals(connection, session.connection.value) + assertEquals(Session.RestoreState.Pending, session.restoreState.value) + assertEquals( + "Secure storage is temporarily unavailable.", + assertIs(session.status.value).message, + ) + } + + @Test + fun restoredTokenIsReady() = runTest { + val connection = Connection(id = "c1", name = "Mac", host = "192.168.1.2", port = 8810) + val session = session( + connectionStore = FakeConnectionStore(connection), + tokenStore = FakeTokenStore().apply { saved["c1"] = "device-token" }, + events = { _, _ -> emptyFlow() }, + ) + + session.awaitRestored() + + assertEquals(Session.RestoreState.Ready, session.restoreState.value) + assertEquals(Session.Status.Connecting, session.status.value) } @Test From 742e9dbecc53878509285db834b37202a52c4595 Mon Sep 17 00:00:00 2001 From: Kesley DEV Date: Fri, 21 Aug 2026 17:18:00 -0300 Subject: [PATCH 6/6] android/core: profiles, avatars, a voice, and routines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The companion's allowlist widened, so the phone learns the routes behind four things iOS gained: a bot's persistent profile — identity, avatar, notification and voice preferences, and nothing outside that paired-safe surface; generating or uploading an avatar, image-only and capped where the harness caps it; the voices a bot can speak in and a preview of one, with the workspace key staying on the desktop where it belongs; and routines, which mint ordinary tasks from an agent that already exists. Decoding is pinned to the same iOS fixtures the rest of :core reads, so the two ports cannot drift on the wire. One asymmetry is mirrored rather than fixed: iOS accepts .jpg but refuses .jpeg and uppercase extensions, while the allowlist regex takes both. The shared validation and the harness agree with iOS — it is the allowlist that is a step looser — so the port follows iOS, and the divergence is worth raising upstream rather than papering over here. Implemented by Codex; strictly reviewed by Grok (approved with no defects; he traced the extension rule to shared/bot-avatar.ts and the attachment reader to confirm which side is the outlier). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SkWZ3y7iKQMjH6tHNFBte7 --- .../com/openmausbot/companion/core/Client.kt | 149 ++++++++- .../com/openmausbot/companion/core/Models.kt | 282 +++++++++++++++++ .../com/openmausbot/companion/core/Session.kt | 165 ++++++++++ .../companion/core/DecodingTest.kt | 39 +++ .../companion/core/ProfileClientTest.kt | 292 ++++++++++++++++++ .../core/ProfileRoutinePolicyTest.kt | 93 ++++++ .../companion/core/RoutineClientTest.kt | 179 +++++++++++ .../companion/core/SessionP2Test.kt | 158 ++++++++++ 8 files changed, 1353 insertions(+), 4 deletions(-) create mode 100644 android/core/src/test/kotlin/com/openmausbot/companion/core/ProfileClientTest.kt create mode 100644 android/core/src/test/kotlin/com/openmausbot/companion/core/ProfileRoutinePolicyTest.kt create mode 100644 android/core/src/test/kotlin/com/openmausbot/companion/core/RoutineClientTest.kt create mode 100644 android/core/src/test/kotlin/com/openmausbot/companion/core/SessionP2Test.kt diff --git a/android/core/src/main/kotlin/com/openmausbot/companion/core/Client.kt b/android/core/src/main/kotlin/com/openmausbot/companion/core/Client.kt index 24fe87806..4fc70495c 100644 --- a/android/core/src/main/kotlin/com/openmausbot/companion/core/Client.kt +++ b/android/core/src/main/kotlin/com/openmausbot/companion/core/Client.kt @@ -12,10 +12,13 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.serialization.SerializationException import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.put import okhttp3.Call import okhttp3.Callback @@ -108,6 +111,14 @@ class CompanionClient( .writeTimeout(ACTION_TIMEOUT_SECONDS, TimeUnit.SECONDS) .build() + private val avatarGenerationClient = baseClient.newBuilder() + .dns(endpoint?.dns ?: baseClient.dns) + .callTimeout(AVATAR_GENERATION_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .connectTimeout(AVATAR_GENERATION_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(AVATAR_GENERATION_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .writeTimeout(AVATAR_GENERATION_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build() + suspend fun health(): JsonObject = send(makeRequest("GET", "/api/health")) suspend fun fleet(messages: Int? = 50): Fleet = send(makeRequest( @@ -171,8 +182,91 @@ class CompanionClient( return raw.data } + suspend fun avatar(path: String): ByteArray { + if (!validAvatarPath(path)) throw APIError.BadUrl + val raw = perform(makeRequest("GET", path)) + check(raw) + return raw.data + } + + suspend fun voices(): List = send( + makeRequest("GET", "/api/tts/voices"), + ).voices + + suspend fun routines(): RoutinesResponse = send(makeRequest("GET", "/api/routines")) + suspend fun createBot(): Bot = send(makeRequest("POST", "/api/bots")).bot + suspend fun updateProfile(botId: String, patch: BotProfilePatch): Bot { + val body = CompanionJson.encodeToJsonElement(BotProfilePatch.serializer(), patch).jsonObject + return send( + makeRequest("PATCH", "/api/bots/$botId/profile", body = body), + ).bot + } + + suspend fun uploadAvatar(data: ByteArray, mime: String): String { + if (mime !in AVATAR_MIME_TYPES || data.size > AVATAR_MAX_BYTES) { + throw APIError.Transport("Choose a PNG, JPEG, GIF, or WebP image up to 10 MB.") + } + val saved = send(makeRequest( + "POST", + "/api/attachments", + rawBody = data.toRequestBody(mime.toMediaType()), + )) + val name = saved.path.substringAfterLast('/') + if (name.isEmpty() || '/' in name) { + throw APIError.Transport("The uploaded image could not be used.") + } + return "/api/attachments/$name" + } + + suspend fun generateAvatar(botId: String, prompt: String): Bot = + send( + makeRequest( + "POST", + "/api/bots/$botId/avatar/generate", + body = jsonBody("prompt" to prompt.take(400)), + ), + avatarGenerationClient, + ).bot + + suspend fun previewVoice(text: String, voiceId: String): ByteArray { + val raw = perform(makeRequest( + "POST", + "/api/tts/speak", + body = jsonBody("text" to text.take(500), "voiceId" to voiceId), + )) + check(raw) + return raw.data + } + + suspend fun createRoutine(input: RoutineInput): Routine { + requireSupported(input.schedule) + return send( + makeRequest("POST", "/api/routines", body = routineBody(input)), + ).routine + } + + suspend fun updateRoutine(id: String, input: RoutineInput): Routine { + requireSupported(input.schedule) + return send( + makeRequest("PATCH", "/api/routines/$id", body = routineBody(input)), + ).routine + } + + suspend fun setRoutineEnabled(id: String, enabled: Boolean): Routine = + send( + makeRequest("PATCH", "/api/routines/$id", body = buildJsonObject { put("enabled", enabled) }), + ).routine + + suspend fun runRoutine(id: String): RoutineRun = send( + makeRequest("POST", "/api/routines/$id/run"), + ).run + + suspend fun deleteRoutine(id: String) { + sendUnit(makeRequest("DELETE", "/api/routines/$id")) + } + suspend fun createRoom(name: String?, memberIds: List): Room { val body = buildJsonObject { put("memberIds", JsonArray(memberIds.map(::JsonPrimitive))) @@ -289,12 +383,15 @@ class CompanionClient( path: String, query: List> = emptyList(), body: JsonObject? = null, + rawBody: RequestBody? = null, ): Request { + require(body == null || rawBody == null) val base = endpoint?.baseUrl ?: throw APIError.BadUrl val url = base.newBuilder().encodedPath(path).apply { query.forEach { (name, value) -> addQueryParameter(name, value) } }.build() val requestBody = when { + rawBody != null -> rawBody body != null -> CompanionJson.encodeToString(JsonObject.serializer(), body) .toRequestBody(JSON_MEDIA_TYPE) method == "POST" || method == "PATCH" -> EMPTY_BODY @@ -307,8 +404,11 @@ class CompanionClient( .build() } - private suspend inline fun send(request: Request): T { - val raw = perform(request) + private suspend inline fun send( + request: Request, + requestClient: OkHttpClient = actionClient, + ): T { + val raw = perform(request, requestClient) check(raw) return try { CompanionJson.decodeFromString(raw.data.toString(Charsets.UTF_8)) @@ -322,8 +422,11 @@ class CompanionClient( check(raw) } - private suspend fun perform(request: Request): RawResponse = suspendCancellableCoroutine { continuation -> - val call = actionClient.newCall(request) + private suspend fun perform( + request: Request, + requestClient: OkHttpClient = actionClient, + ): RawResponse = suspendCancellableCoroutine { continuation -> + val call = requestClient.newCall(request) continuation.invokeOnCancellation { call.cancel() } call.enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { @@ -367,10 +470,48 @@ class CompanionClient( companion object { private const val ACTION_TIMEOUT_SECONDS = 20L + private const val AVATAR_GENERATION_TIMEOUT_SECONDS = 150L private const val STREAM_IDLE_TIMEOUT_SECONDS = 90L + private const val AVATAR_MAX_BYTES = 10 * 1_024 * 1_024 + private val AVATAR_MIME_TYPES = setOf("image/png", "image/jpeg", "image/gif", "image/webp") private val JSON_MEDIA_TYPE = "application/json".toMediaType() private val EMPTY_BODY: RequestBody = ByteArray(0).toRequestBody(null) + private fun validAvatarPath(path: String): Boolean { + val prefix = "/api/attachments/" + if (!path.startsWith(prefix)) return false + val name = path.removePrefix(prefix) + val dot = name.lastIndexOf('.') + if (dot <= 0) return false + val stem = name.substring(0, dot) + val extension = name.substring(dot + 1) + return stem.all { it in '0'..'9' || it in 'A'..'Z' || it in 'a'..'z' || it == '-' } && + extension in setOf("png", "jpg", "gif", "webp") + } + + private fun requireSupported(schedule: RoutineSchedule) { + if (schedule.type == RoutineSchedule.Kind.UNKNOWN) { + throw APIError.Transport("Choose a supported schedule before saving this routine.") + } + } + + private fun routineBody(input: RoutineInput): JsonObject = buildJsonObject { + put("name", input.name) + put("prompt", input.prompt) + put("botId", input.botId) + put("runOn", input.runOn) + input.enabled?.let { put("enabled", it) } + put("schedule", buildJsonObject { + put("type", input.schedule.type.name.lowercase()) + input.schedule.at?.let { put("at", it) } + input.schedule.time?.let { put("time", it) } + input.schedule.weekdays?.let { days -> + put("weekdays", JsonArray(days.map(::JsonPrimitive))) + } + }) + put("durationMinutes", input.durationMinutes) + } + suspend fun pair( connection: Connection, credential: String, diff --git a/android/core/src/main/kotlin/com/openmausbot/companion/core/Models.kt b/android/core/src/main/kotlin/com/openmausbot/companion/core/Models.kt index 786f73d1b..9cbf87f81 100644 --- a/android/core/src/main/kotlin/com/openmausbot/companion/core/Models.kt +++ b/android/core/src/main/kotlin/com/openmausbot/companion/core/Models.kt @@ -10,13 +10,18 @@ import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonEncoder import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.encodeToJsonElement @@ -174,6 +179,8 @@ data class Bot( val unread: Boolean, val modelSelection: ModelSelection, val createdAt: Double, + val avatarUrl: String? = null, + val avatarCrop: AvatarCrop? = null, val busy: Boolean? = null, val pinned: Boolean? = null, val hidden: Boolean? = null, @@ -191,6 +198,23 @@ data class Bot( val hasMore: Boolean? = null, ) +@Serializable(with = AvatarCropSerializer::class) +enum class AvatarCrop { MASCOT, CIRCLE, ROUNDED, SQUARE } + +object AvatarCropSerializer : KSerializer { + override val descriptor = PrimitiveSerialDescriptor("AvatarCrop", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): AvatarCrop { + val raw = decoder.decodeString() + return AvatarCrop.entries.firstOrNull { it.name.lowercase() == raw } + ?: AvatarCrop.MASCOT + } + + override fun serialize(encoder: Encoder, value: AvatarCrop) { + encoder.encodeString(value.name.lowercase()) + } +} + @Serializable data class GroupResponder(val kind: String, val botId: String? = null) @@ -357,9 +381,249 @@ data class ConfigStatus( val composio: ConfigFlag? = null, val box: ConfigFlag? = null, val tts: ConfigFlag? = null, + val imageGen: ConfigFlag? = null, val profile: Profile? = null, +) { + val isTTSConfigured: Boolean + get() = tts?.configured == true || tts?.apiKeyConfigured == true + + val hasWorkspaceDefaultVoice: Boolean + get() = !tts?.voice.isNullOrBlank() + + fun canSpeak(agentVoice: String?): Boolean = + isTTSConfigured && (!agentVoice.isNullOrBlank() || hasWorkspaceDefaultVoice) +} + +@Serializable(with = BotProfilePatchSerializer::class) +data class BotProfilePatch( + val name: String? = null, + val title: String? = null, + val description: String? = null, + val notifications: Boolean? = null, + val avatarUrl: AvatarURL? = null, + val avatarCrop: AvatarCrop? = null, + val voice: String? = null, + val speakReplies: Boolean? = null, +) { + sealed interface AvatarURL { + data class Set(val path: String) : AvatarURL + data object Clear : AvatarURL + } +} + +object BotProfilePatchSerializer : KSerializer { + private val fieldNames = setOf( + "name", + "title", + "description", + "notifications", + "avatarUrl", + "avatarCrop", + "voice", + "speakReplies", + ) + + override val descriptor = buildClassSerialDescriptor("BotProfilePatch") { + element("name", isOptional = true) + element("title", isOptional = true) + element("description", isOptional = true) + element("notifications", isOptional = true) + element("avatarUrl", isOptional = true) + element("avatarCrop", isOptional = true) + element("voice", isOptional = true) + element("speakReplies", isOptional = true) + } + + override fun serialize(encoder: Encoder, value: BotProfilePatch) { + val output = encoder as? JsonEncoder + ?: throw SerializationException("BotProfilePatch can only be encoded as JSON") + output.encodeJsonElement(buildJsonObject { + value.name?.let { put("name", it) } + value.title?.let { put("title", it) } + value.description?.let { put("description", it) } + value.notifications?.let { put("notifications", it) } + when (val avatarUrl = value.avatarUrl) { + is BotProfilePatch.AvatarURL.Set -> put("avatarUrl", avatarUrl.path) + BotProfilePatch.AvatarURL.Clear -> put("avatarUrl", JsonNull) + null -> Unit + } + value.avatarCrop?.let { put("avatarCrop", it.name.lowercase()) } + value.voice?.let { put("voice", it) } + value.speakReplies?.let { put("speakReplies", it) } + }) + } + + override fun deserialize(decoder: Decoder): BotProfilePatch { + val input = decoder as? JsonDecoder + ?: throw SerializationException("BotProfilePatch can only be decoded from JSON") + val value = input.decodeJsonElement().jsonObject + val unknown = value.keys - fieldNames + if (unknown.isNotEmpty()) { + throw SerializationException("Unsupported profile field: ${unknown.first()}") + } + + fun string(name: String): String? { + val element = value[name] ?: return null + if (element is JsonNull) throw SerializationException("$name must be a string") + val primitive = element as? JsonPrimitive + ?: throw SerializationException("$name must be a string") + if (!primitive.isString) throw SerializationException("$name must be a string") + return primitive.content + } + + fun boolean(name: String): Boolean? { + val element = value[name] ?: return null + val primitive = element as? JsonPrimitive + ?: throw SerializationException("$name must be true or false") + return primitive.booleanOrNull + ?: throw SerializationException("$name must be true or false") + } + + val avatarUrl = when (val element = value["avatarUrl"]) { + null -> null + JsonNull -> BotProfilePatch.AvatarURL.Clear + else -> BotProfilePatch.AvatarURL.Set(string("avatarUrl")!!) + } + val crop = string("avatarCrop")?.let { raw -> + AvatarCrop.entries.firstOrNull { it.name.lowercase() == raw } + ?: AvatarCrop.MASCOT + } + return BotProfilePatch( + name = string("name"), + title = string("title"), + description = string("description"), + notifications = boolean("notifications"), + avatarUrl = avatarUrl, + avatarCrop = crop, + voice = string("voice"), + speakReplies = boolean("speakReplies"), + ) + } +} + +@Serializable +data class Voice( + val id: String, + val label: String, + val description: String? = null, ) +@Serializable +data class RoutineSchedule( + val type: Kind, + val at: Double? = null, + val time: String? = null, + val weekdays: List? = null, +) { + @Serializable(with = RoutineScheduleKindSerializer::class) + enum class Kind { ONCE, DAILY, UNKNOWN } + + companion object { + fun once(atMillis: Double): RoutineSchedule = RoutineSchedule(Kind.ONCE, at = atMillis) + + fun daily(time: String, weekdays: List): RoutineSchedule = + RoutineSchedule(Kind.DAILY, time = time, weekdays = weekdays) + } +} + +object RoutineScheduleKindSerializer : KSerializer { + override val descriptor = PrimitiveSerialDescriptor("RoutineSchedule.Kind", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): RoutineSchedule.Kind = when (decoder.decodeString()) { + "once" -> RoutineSchedule.Kind.ONCE + "daily" -> RoutineSchedule.Kind.DAILY + else -> RoutineSchedule.Kind.UNKNOWN + } + + override fun serialize(encoder: Encoder, value: RoutineSchedule.Kind) { + encoder.encodeString(value.name.lowercase()) + } +} + +@Serializable +data class Routine( + val id: String, + val name: String, + val prompt: String, + val botId: String, + val runOn: String, + val enabled: Boolean, + val schedule: RoutineSchedule, + val durationMinutes: Int, + val nextRunAt: Double? = null, + val createdAt: Double, + val updatedAt: Double, +) { + val runLocation: RoutineRunLocation + get() = RoutineRunLocation.entries.firstOrNull { it.wireValue == runOn } + ?: RoutineRunLocation.MAUS + + fun canToggle(atMillis: Double = System.currentTimeMillis().toDouble()): Boolean = when (schedule.type) { + RoutineSchedule.Kind.DAILY -> true + RoutineSchedule.Kind.ONCE -> (schedule.at ?: Double.NEGATIVE_INFINITY) > atMillis + RoutineSchedule.Kind.UNKNOWN -> false + } +} + +@Serializable +data class RoutineRun( + val id: String, + val routineId: String, + val routineName: String, + val prompt: String? = null, + val durationMinutes: Int? = null, + val botId: String, + val runOn: String, + val scheduledFor: Double, + val status: String, + val manual: Boolean, + val triggerSource: String? = null, + val threadId: String? = null, + val startedAt: Double? = null, + val finishedAt: Double? = null, + val output: String? = null, + val error: String? = null, + val createdAt: Double, + val seenAt: Double? = null, +) + +@Serializable +data class RoutineInput( + val name: String, + val prompt: String, + val botId: String, + val runOn: String = "maus", + val enabled: Boolean? = null, + val schedule: RoutineSchedule, + val durationMinutes: Int = 30, +) + +@Serializable +enum class RoutineRunLocation(val wireValue: String) { + @SerialName("maus") + MAUS("maus"), + + @SerialName("cloud") + CLOUD("cloud"), +} + +data class RoutineRunAvailability( + val cloudConfigured: Boolean, + val cloudInstanceAvailable: Boolean, +) { + constructor(config: ConfigStatus?, instances: List) : this( + cloudConfigured = config?.box?.configured == true, + cloudInstanceAvailable = instances.any { + it.driverKind == "boxAgent" && it.snapshot.isAvailable + }, + ) + + val cloudReady: Boolean get() = cloudConfigured && cloudInstanceAvailable + + fun canSelect(location: RoutineRunLocation, preserving: RoutineRunLocation): Boolean = + location == RoutineRunLocation.MAUS || cloudReady || preserving == RoutineRunLocation.CLOUD +} + @Serializable data class APIErrorBody(val error: String) @@ -389,3 +653,21 @@ internal data class ActiveBranchResponse(val activeLeafId: String) @Serializable internal data class BotResponse(val bot: Bot) + +@Serializable +internal data class VoiceListResponse(val voices: List, val error: String? = null) + +@Serializable +internal data class AttachmentResponse(val path: String, val mime: String, val bytes: Int) + +@Serializable +internal data class GeneratedAvatarResponse(val avatarUrl: String, val bot: Bot) + +@Serializable +data class RoutinesResponse(val routines: List, val runs: List) + +@Serializable +internal data class RoutineResponse(val routine: Routine) + +@Serializable +internal data class RoutineRunResponse(val run: RoutineRun) diff --git a/android/core/src/main/kotlin/com/openmausbot/companion/core/Session.kt b/android/core/src/main/kotlin/com/openmausbot/companion/core/Session.kt index 5b7e2321c..11fb34b3d 100644 --- a/android/core/src/main/kotlin/com/openmausbot/companion/core/Session.kt +++ b/android/core/src/main/kotlin/com/openmausbot/companion/core/Session.kt @@ -4,6 +4,8 @@ import java.net.URI import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive @@ -829,6 +831,169 @@ class Session( } } + suspend fun updateProfile(patch: BotProfilePatch, forBot: Bot): Bot? { + val activeClient = client ?: return null + return try { + val updated = activeClient.updateProfile(forBot.id, patch) + currentCoroutineContext().ensureActive() + _state.update { it.apply(Frame.Bot(updated)) } + updated + } catch (error: Throwable) { + if (error is kotlinx.coroutines.CancellationException) throw error + _actionError.value = error.message + null + } + } + + suspend fun uploadAvatar( + data: ByteArray, + mime: String, + forBot: Bot, + crop: AvatarCrop, + ): Bot? { + val activeClient = client ?: return null + return try { + val avatarUrl = activeClient.uploadAvatar(data, mime) + currentCoroutineContext().ensureActive() + val current = _state.value.bot(forBot.id) ?: forBot + updateProfile( + BotProfilePatch( + avatarUrl = BotProfilePatch.AvatarURL.Set(avatarUrl), + avatarCrop = crop, + ), + current, + ) + } catch (error: Throwable) { + if (error is kotlinx.coroutines.CancellationException) throw error + _actionError.value = error.message + null + } + } + + suspend fun generateAvatar(prompt: String, forBot: Bot): Bot? { + val activeClient = client ?: return null + return try { + val updated = activeClient.generateAvatar(forBot.id, prompt) + currentCoroutineContext().ensureActive() + _state.update { it.apply(Frame.Bot(updated)) } + updated + } catch (error: Throwable) { + if (error is kotlinx.coroutines.CancellationException) throw error + _actionError.value = error.message + null + } + } + + suspend fun avatarData(forBot: Bot): ByteArray? { + val path = forBot.avatarUrl ?: return null + return try { + client?.avatar(path) + } catch (error: Throwable) { + if (error is kotlinx.coroutines.CancellationException) throw error + null + } + } + + suspend fun voiceOptions(): List { + val activeClient = client ?: return emptyList() + return try { + activeClient.voices() + } catch (error: Throwable) { + if (error is kotlinx.coroutines.CancellationException) throw error + _actionError.value = error.message + emptyList() + } + } + + suspend fun previewVoice(voiceId: String, forBot: Bot): ByteArray? { + val activeClient = client ?: return null + return try { + activeClient.previewVoice("Hello, I'm ${forBot.name}.", voiceId) + } catch (error: Throwable) { + if (error is kotlinx.coroutines.CancellationException) throw error + _actionError.value = error.message + null + } + } + + suspend fun configStatus(): ConfigStatus? = try { + client?.config() + } catch (error: Throwable) { + if (error is kotlinx.coroutines.CancellationException) throw error + null + } + + suspend fun loadRoutines(): RoutinesResponse { + val activeClient = client ?: return RoutinesResponse(emptyList(), emptyList()) + return try { + activeClient.routines() + } catch (error: Throwable) { + if (error is kotlinx.coroutines.CancellationException) throw error + _actionError.value = error.message + RoutinesResponse(emptyList(), emptyList()) + } + } + + suspend fun loadRoutineRunAvailability(): RoutineRunAvailability? { + val activeClient = client ?: return null + return try { + coroutineScope { + val config = async { activeClient.config() } + val instances = async { activeClient.instances() } + RoutineRunAvailability(config.await(), instances.await()) + } + } catch (error: Throwable) { + if (error is kotlinx.coroutines.CancellationException) throw error + _actionError.value = error.message + null + } + } + + suspend fun saveRoutine(input: RoutineInput, id: String?): Routine? { + val activeClient = client ?: return null + return try { + if (id == null) activeClient.createRoutine(input) else activeClient.updateRoutine(id, input) + } catch (error: Throwable) { + if (error is kotlinx.coroutines.CancellationException) throw error + _actionError.value = error.message + null + } + } + + suspend fun setRoutineEnabled(routine: Routine, enabled: Boolean): Routine? { + val activeClient = client ?: return null + return try { + activeClient.setRoutineEnabled(routine.id, enabled) + } catch (error: Throwable) { + if (error is kotlinx.coroutines.CancellationException) throw error + _actionError.value = error.message + null + } + } + + suspend fun runRoutine(routine: Routine): RoutineRun? { + val activeClient = client ?: return null + return try { + activeClient.runRoutine(routine.id) + } catch (error: Throwable) { + if (error is kotlinx.coroutines.CancellationException) throw error + _actionError.value = error.message + null + } + } + + suspend fun deleteRoutine(routine: Routine): Boolean { + val activeClient = client ?: return false + return try { + activeClient.deleteRoutine(routine.id) + true + } catch (error: Throwable) { + if (error is kotlinx.coroutines.CancellationException) throw error + _actionError.value = error.message + false + } + } + suspend fun react(to: Message, inThreadId: String, emoji: String) { val activeClient = client ?: return try { diff --git a/android/core/src/test/kotlin/com/openmausbot/companion/core/DecodingTest.kt b/android/core/src/test/kotlin/com/openmausbot/companion/core/DecodingTest.kt index 3a7d73570..5424ffdd0 100644 --- a/android/core/src/test/kotlin/com/openmausbot/companion/core/DecodingTest.kt +++ b/android/core/src/test/kotlin/com/openmausbot/companion/core/DecodingTest.kt @@ -32,6 +32,45 @@ class DecodingTest { assertNull(fleet.bots.first().hasMore) } + @Test + fun oldAndNewAvatarProfilesDecodeTogether() { + val oldBot = decodeFixture("bots-full").bots.first() + assertNull(oldBot.avatarUrl) + assertNull(oldBot.avatarCrop) + + val newBot = decodeFixture("bot-avatar-profile").bots.first() + assertEquals( + "/api/attachments/123e4567-e89b-12d3-a456-426614174000.webp", + newBot.avatarUrl, + ) + assertEquals(AvatarCrop.ROUNDED, newBot.avatarCrop) + assertEquals("voice-1", newBot.voice) + assertEquals(true, newBot.speakReplies) + } + + @Test + fun futureAvatarCropFallsBackWithoutDroppingTheBot() { + listOf("hexagon", "ROUNDED").forEach { futureValue -> + val fixture = fixtureText("bot-avatar-profile") + .replace("\"avatarCrop\":\"rounded\"", "\"avatarCrop\":\"$futureValue\"") + val fleet = CompanionJson.decodeFromString(fixture) + + assertEquals(1, fleet.bots.size) + assertEquals(AvatarCrop.MASCOT, fleet.bots.first().avatarCrop) + } + } + + @Test + fun futureRoutineScheduleKindRemainsVisibleAsUnknown() { + val schedule = CompanionJson.decodeFromString( + """{"type":"weekly","time":"09:00","weekdays":[1]}""", + ) + + assertEquals(RoutineSchedule.Kind.UNKNOWN, schedule.type) + assertEquals("09:00", schedule.time) + assertEquals(listOf(1), schedule.weekdays) + } + @Test fun decodesTheCloudBackendAndItsAbsence() { val fleet = CompanionJson.decodeFromString( diff --git a/android/core/src/test/kotlin/com/openmausbot/companion/core/ProfileClientTest.kt b/android/core/src/test/kotlin/com/openmausbot/companion/core/ProfileClientTest.kt new file mode 100644 index 000000000..a2ddb6c23 --- /dev/null +++ b/android/core/src/test/kotlin/com/openmausbot/companion/core/ProfileClientTest.kt @@ -0,0 +1,292 @@ +package com.openmausbot.companion.core + +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.SerializationException +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class ProfileClientTest { + private lateinit var server: MockWebServer + private lateinit var connection: Connection + private lateinit var client: CompanionClient + + @BeforeTest + fun setUp() { + server = MockWebServer() + server.start() + connection = requireNotNull(Connection.parse(server.url("/").toString())) + client = CompanionClient(connection, "paired-token") + } + + @AfterTest + fun tearDown() { + server.shutdown() + } + + @Test + fun profilePatchPreservesServerLimitsWithoutClientTruncation() = runBlocking { + val name = "n".repeat(100) + val title = "t".repeat(200) + val description = "d".repeat(4_000) + val voice = "v".repeat(200) + server.enqueue(json(botResponse())) + + client.updateProfile( + "avatar-bot", + BotProfilePatch(name = name, title = title, description = description, voice = voice), + ) + + val request = server.takeRequest() + assertEquals("PATCH", request.method) + assertEquals("/api/bots/avatar-bot/profile", request.path) + val body = CompanionJson.parseToJsonElement(request.body.readUtf8()).jsonObject + assertEquals(name, body.getValue("name").jsonPrimitive.content) + assertEquals(title, body.getValue("title").jsonPrimitive.content) + assertEquals(description, body.getValue("description").jsonPrimitive.content) + assertEquals(voice, body.getValue("voice").jsonPrimitive.content) + } + + @Test + fun profilePatchPreservesAnExplicitEmptyWorkspaceDefaultVoice() { + val body = CompanionJson.parseToJsonElement( + CompanionJson.encodeToString(BotProfilePatch(voice = "")), + ).jsonObject + + assertEquals(setOf("voice"), body.keys) + assertEquals("", body.getValue("voice").jsonPrimitive.content) + } + + @Test + fun profilePatchOmitsUnsetFieldsAndCanSendAnEmptyPayload() = runBlocking { + server.enqueue(json(botResponse())) + client.updateProfile("avatar-bot", BotProfilePatch()) + + val request = server.takeRequest() + assertEquals("{}", request.body.readUtf8()) + assertEquals("application/json; charset=utf-8", request.getHeader("Content-Type")) + assertEquals("Bearer paired-token", request.getHeader("Authorization")) + } + + @Test + fun profilePatchIsClosedToThePairedSafeSurface() { + val encoded = CompanionJson.encodeToString( + BotProfilePatch( + name = "Scout", + title = "Researcher", + description = "Finds evidence.", + notifications = false, + avatarUrl = BotProfilePatch.AvatarURL.Set( + "/api/attachments/123e4567-e89b-12d3-a456-426614174000.webp", + ), + avatarCrop = AvatarCrop.ROUNDED, + voice = "voice-1", + speakReplies = true, + ), + ) + assertEquals( + setOf( + "name", + "title", + "description", + "notifications", + "avatarUrl", + "avatarCrop", + "voice", + "speakReplies", + ), + CompanionJson.parseToJsonElement(encoded).jsonObject.keys, + ) + assertFailsWith { + CompanionJson.decodeFromString("""{"color":"red"}""") + } + } + + @Test + fun profileClientSendsOnlyFieldsOwnedByTheAction() = runBlocking { + server.enqueue(json(botResponse())) + + client.updateProfile( + "avatar-bot", + BotProfilePatch(avatarCrop = AvatarCrop.ROUNDED), + ) + + val body = CompanionJson.parseToJsonElement(server.takeRequest().body.readUtf8()).jsonObject + assertEquals(setOf("avatarCrop"), body.keys) + assertEquals("rounded", body.getValue("avatarCrop").jsonPrimitive.content) + } + + @Test + fun profileClientEncodesAnExplicitAvatarClearAsNull() = runBlocking { + server.enqueue(json(botResponse())) + + client.updateProfile( + "avatar-bot", + BotProfilePatch( + avatarUrl = BotProfilePatch.AvatarURL.Clear, + avatarCrop = AvatarCrop.MASCOT, + ), + ) + + val body = CompanionJson.parseToJsonElement(server.takeRequest().body.readUtf8()).jsonObject + assertEquals(setOf("avatarCrop", "avatarUrl"), body.keys) + assertEquals(JsonNull, body["avatarUrl"]) + assertEquals("mascot", body.getValue("avatarCrop").jsonPrimitive.content) + } + + @Test + fun avatarFetchAcceptsOnlyThePathsAcceptedByIos() = runBlocking { + val valid = listOf( + "/api/attachments/a.png", + "/api/attachments/ABC-123.jpg", + "/api/attachments/a.gif", + "/api/attachments/a.webp", + ) + valid.forEach { server.enqueue(MockResponse().setResponseCode(200).setBody("pixels")) } + + valid.forEach { path -> + assertEquals("pixels", client.avatar(path).toString(Charsets.UTF_8)) + val request = server.takeRequest() + assertEquals("GET", request.method) + assertEquals(path, request.path) + assertEquals("Bearer paired-token", request.getHeader("Authorization")) + } + + listOf( + "/api/attachments/a.jpeg", + "/api/attachments/a.JPG", + "/api/attachments/a_b.png", + "/api/attachments/a.b.png", + "/api/attachments/nested/a.png", + "/api/attachments/../a.png", + "https://tracker.example/a.png", + ).forEach { path -> + assertFailsWith { client.avatar(path) } + } + assertEquals(valid.size, server.requestCount) + } + + @Test + fun avatarUploadIsRawImageOnlyAndCappedAtTenMegabytes() = runBlocking { + val limit = 10 * 1_024 * 1_024 + server.enqueue(json( + """{"path":"/tmp/attachments/generated.png","mime":"image/png","bytes":$limit}""", + code = 201, + )) + + assertEquals( + "/api/attachments/generated.png", + client.uploadAvatar(ByteArray(limit), "image/png"), + ) + val request = server.takeRequest() + assertEquals("POST", request.method) + assertEquals("/api/attachments", request.path) + assertEquals("image/png", request.getHeader("Content-Type")) + assertEquals(limit.toLong(), request.bodySize) + + assertFailsWith { + client.uploadAvatar(ByteArray(1), "text/plain") + } + assertFailsWith { + client.uploadAvatar(ByteArray(limit + 1), "image/jpeg") + } + assertEquals(1, server.requestCount) + } + + @Test + fun avatarUploadAcceptsTheFourIosImageMimeTypes() = runBlocking { + val mimeTypes = listOf("image/png", "image/jpeg", "image/gif", "image/webp") + mimeTypes.forEachIndexed { index, mime -> + server.enqueue(json( + """{"path":"/tmp/attachments/generated-$index.png","mime":"$mime","bytes":1}""", + code = 201, + )) + } + + mimeTypes.forEachIndexed { index, mime -> + assertEquals( + "/api/attachments/generated-$index.png", + client.uploadAvatar(byteArrayOf(index.toByte()), mime), + ) + assertEquals(mime, server.takeRequest().getHeader("Content-Type")) + } + } + + @Test + fun avatarGenerationTruncatesThePromptAndOutlivesTheServerTimeout() = runBlocking { + var observedReadTimeoutMillis = 0 + val timedClient = CompanionClient( + connection, + "paired-token", + OkHttpClient.Builder().addInterceptor { chain -> + observedReadTimeoutMillis = chain.readTimeoutMillis() + chain.proceed(chain.request()) + }.build(), + ) + server.enqueue(json(generatedAvatarResponse(), code = 201)) + + timedClient.generateAvatar("avatar-bot", "p".repeat(401)) + + val request = server.takeRequest() + assertEquals("POST", request.method) + assertEquals("/api/bots/avatar-bot/avatar/generate", request.path) + val body = CompanionJson.parseToJsonElement(request.body.readUtf8()).jsonObject + assertEquals("p".repeat(400), body.getValue("prompt").jsonPrimitive.content) + assertTrue(observedReadTimeoutMillis > 120_000) + } + + @Test + fun voiceRoutesReturnOnlyLabelsOrAudioAndBoundThePreviewText() = runBlocking { + server.enqueue(json( + """{"voices":[{"id":"voice-1","label":"Warm","description":"Calm"}]}""", + )) + server.enqueue(MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "audio/mpeg") + .setBody("audio-bytes")) + + val voices = client.voices() + assertEquals(listOf(Voice("voice-1", "Warm", "Calm")), voices) + assertContentEquals( + "audio-bytes".toByteArray(), + client.previewVoice("x".repeat(501), "voice-1"), + ) + + val listRequest = server.takeRequest() + assertEquals("GET", listRequest.method) + assertEquals("/api/tts/voices", listRequest.path) + val speakRequest = server.takeRequest() + assertEquals("POST", speakRequest.method) + assertEquals("/api/tts/speak", speakRequest.path) + val body = CompanionJson.parseToJsonElement(speakRequest.body.readUtf8()).jsonObject + assertEquals(setOf("text", "voiceId"), body.keys) + assertEquals("x".repeat(500), body.getValue("text").jsonPrimitive.content) + assertEquals("voice-1", body.getValue("voiceId").jsonPrimitive.content) + } + + private fun json(body: String, code: Int = 200): MockResponse = MockResponse() + .setResponseCode(code) + .setHeader("Content-Type", "application/json") + .setBody(body) + + private fun botJson(): String = CompanionJson.parseToJsonElement( + fixtureText("bot-avatar-profile"), + ).jsonObject.getValue("bots").toString().removePrefix("[").removeSuffix("]") + + private fun botResponse(): String = """{"bot":${botJson()}}""" + + private fun generatedAvatarResponse(): String = + """{"avatarUrl":"/api/attachments/123e4567-e89b-12d3-a456-426614174000.webp","bot":${botJson()}}""" +} diff --git a/android/core/src/test/kotlin/com/openmausbot/companion/core/ProfileRoutinePolicyTest.kt b/android/core/src/test/kotlin/com/openmausbot/companion/core/ProfileRoutinePolicyTest.kt new file mode 100644 index 000000000..9557dea67 --- /dev/null +++ b/android/core/src/test/kotlin/com/openmausbot/companion/core/ProfileRoutinePolicyTest.kt @@ -0,0 +1,93 @@ +package com.openmausbot.companion.core + +import kotlinx.serialization.decodeFromString +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ProfileRoutinePolicyTest { + @Test + fun onlyFutureOneTimeRoutinesCanToggle() { + val now = 2_000_000.0 + + assertTrue(routine(RoutineSchedule.daily("09:00", listOf(1))).canToggle(now)) + assertTrue(routine(RoutineSchedule.once(now + 1_000)).canToggle(now)) + assertFalse(routine(RoutineSchedule.once(now)).canToggle(now)) + assertFalse(routine(RoutineSchedule.once(now - 1_000)).canToggle(now)) + assertFalse( + routine(RoutineSchedule(RoutineSchedule.Kind.UNKNOWN, at = now + 1_000)).canToggle(now), + "an unsupported kind stays non-toggleable even when it carries a future at field", + ) + } + + @Test + fun cloudRunAvailabilityMatchesDesktopRequirements() { + val configured = decodeConfig("""{"box":{"configured":true}}""") + val unconfigured = decodeConfig("""{"box":{"configured":false}}""") + val available = decodeInstances("available") + val unavailable = decodeInstances("unavailable") + + assertFalse(RoutineRunAvailability(unconfigured, available).cloudReady) + assertFalse(RoutineRunAvailability(configured, unavailable).cloudReady) + + val ready = RoutineRunAvailability(configured, available) + assertTrue(ready.cloudReady) + assertTrue(ready.canSelect(RoutineRunLocation.CLOUD, preserving = RoutineRunLocation.MAUS)) + + val offline = RoutineRunAvailability(configured, unavailable) + assertFalse(offline.canSelect(RoutineRunLocation.CLOUD, preserving = RoutineRunLocation.MAUS)) + assertTrue( + offline.canSelect(RoutineRunLocation.CLOUD, preserving = RoutineRunLocation.CLOUD), + "an existing cloud routine must not silently move", + ) + assertTrue(offline.canSelect(RoutineRunLocation.MAUS, preserving = RoutineRunLocation.CLOUD)) + } + + @Test + fun agentVoiceWorksWithoutANonexistentWorkspaceDefault() { + val keyOnly = decodeConfig( + """{"tts":{"configured":true,"ready":false,"voice":""}}""", + ) + assertTrue(keyOnly.isTTSConfigured) + assertFalse(keyOnly.hasWorkspaceDefaultVoice) + assertFalse(keyOnly.canSpeak(agentVoice = null)) + assertTrue(keyOnly.canSpeak(agentVoice = "agent-voice")) + + val withDefault = decodeConfig( + """{"tts":{"configured":true,"ready":true,"voice":"workspace-voice"}}""", + ) + assertTrue(withDefault.hasWorkspaceDefaultVoice) + assertTrue(withDefault.canSpeak(agentVoice = null)) + } + + @Test + fun decodesImageGenerationStatus() { + val config = decodeConfig("""{"imageGen":{"configured":true}}""") + + assertTrue(config.imageGen?.configured == true) + } + + private fun routine(schedule: RoutineSchedule): Routine = Routine( + id = "routine-1", + name = "Brief", + prompt = "Summarize", + botId = "bot-1", + runOn = "maus", + enabled = false, + schedule = schedule, + durationMinutes = 30, + nextRunAt = null, + createdAt = 1.0, + updatedAt = 1.0, + ) + + private fun decodeConfig(json: String): ConfigStatus = CompanionJson.decodeFromString(json) + + private fun decodeInstances(state: String): List = CompanionJson.decodeFromString( + """{"instances":[{ + "instanceId":"box-1","driverKind":"boxAgent", + "snapshot":{"state":"$state"}, + "models":{"default":"model-1","options":[]} + }]}""", + ).instances +} diff --git a/android/core/src/test/kotlin/com/openmausbot/companion/core/RoutineClientTest.kt b/android/core/src/test/kotlin/com/openmausbot/companion/core/RoutineClientTest.kt new file mode 100644 index 000000000..972e28976 --- /dev/null +++ b/android/core/src/test/kotlin/com/openmausbot/companion/core/RoutineClientTest.kt @@ -0,0 +1,179 @@ +package com.openmausbot.companion.core + +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse + +class RoutineClientTest { + private lateinit var server: MockWebServer + private lateinit var client: CompanionClient + + @BeforeTest + fun setUp() { + server = MockWebServer() + server.start() + val connection = requireNotNull(Connection.parse(server.url("/").toString())) + client = CompanionClient(connection, "paired-token") + } + + @AfterTest + fun tearDown() { + server.shutdown() + } + + @Test + fun routineCallsMatchThePairedAllowlistAndWireBodies() = runBlocking { + val routine = routineJson() + val run = routineRunJson() + listOf( + """{"routines":[$routine],"runs":[$run]}""", + """{"routine":$routine}""", + """{"routine":$routine}""", + """{"routine":$routine}""", + """{"run":$run}""", + "{}", + ).forEach { server.enqueue(json(it)) } + + val listed = client.routines() + assertEquals(listOf("routine-1"), listed.routines.map(Routine::id)) + assertEquals(listOf("run-1"), listed.runs.map(RoutineRun::id)) + + val exactName = "n".repeat(80) + val exactPrompt = "p".repeat(20_000) + client.createRoutine( + RoutineInput( + name = exactName, + prompt = exactPrompt, + botId = "bot-1", + schedule = RoutineSchedule.once(2_000_000.0), + durationMinutes = 15, + ), + ) + client.updateRoutine( + "routine-1", + RoutineInput( + name = exactName, + prompt = exactPrompt, + botId = "bot-1", + runOn = "cloud", + enabled = false, + schedule = RoutineSchedule.daily("08:05", listOf(1, 2, 3, 4, 5)), + durationMinutes = 240, + ), + ) + client.setRoutineEnabled("routine-1", true) + client.runRoutine("routine-1") + client.deleteRoutine("routine-1") + + val requests = List(6) { server.takeRequest() } + assertEquals( + listOf( + "GET /api/routines", + "POST /api/routines", + "PATCH /api/routines/routine-1", + "PATCH /api/routines/routine-1", + "POST /api/routines/routine-1/run", + "DELETE /api/routines/routine-1", + ), + requests.map { "${it.method} ${it.path}" }, + ) + requests.forEach { assertEquals("Bearer paired-token", it.getHeader("Authorization")) } + + val create = CompanionJson.parseToJsonElement(requests[1].body.readUtf8()).jsonObject + assertEquals( + setOf("name", "prompt", "botId", "runOn", "schedule", "durationMinutes"), + create.keys, + ) + assertEquals(exactName, create.getValue("name").jsonPrimitive.content) + assertEquals(exactPrompt, create.getValue("prompt").jsonPrimitive.content) + assertEquals("maus", create.getValue("runOn").jsonPrimitive.content) + assertEquals(15, create.getValue("durationMinutes").jsonPrimitive.content.toInt()) + assertEquals( + mapOf("type" to "once", "at" to "2000000.0"), + create.getValue("schedule").jsonObject.mapValues { it.value.jsonPrimitive.content }, + ) + + val update = CompanionJson.parseToJsonElement(requests[2].body.readUtf8()).jsonObject + assertEquals("cloud", update.getValue("runOn").jsonPrimitive.content) + assertFalse(update.getValue("enabled").jsonPrimitive.content.toBoolean()) + assertEquals(240, update.getValue("durationMinutes").jsonPrimitive.content.toInt()) + val schedule = update.getValue("schedule").jsonObject + assertEquals("daily", schedule.getValue("type").jsonPrimitive.content) + assertEquals("08:05", schedule.getValue("time").jsonPrimitive.content) + assertEquals( + listOf(1, 2, 3, 4, 5), + schedule.getValue("weekdays").jsonArray.map { it.jsonPrimitive.content.toInt() }, + ) + + assertEquals( + mapOf("enabled" to "true"), + CompanionJson.parseToJsonElement(requests[3].body.readUtf8()) + .jsonObject.mapValues { it.value.jsonPrimitive.content }, + ) + assertEquals(0, requests[4].bodySize) + assertEquals(0, requests[5].bodySize) + + assertFailsWith { + client.createRoutine( + RoutineInput( + name = "Future schedule", + prompt = "Do work", + botId = "bot-1", + schedule = RoutineSchedule( + type = RoutineSchedule.Kind.UNKNOWN, + time = "09:00", + weekdays = listOf(1), + ), + ), + ) + } + assertEquals(6, server.requestCount) + } + + private fun json(body: String): MockResponse = MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(body) + + private fun routineJson(): String = """{ + "id":"routine-1", + "name":"Brief", + "prompt":"Summarize", + "botId":"bot-1", + "runOn":"maus", + "enabled":true, + "schedule":{"type":"once","at":2000000}, + "durationMinutes":30, + "nextRunAt":2000000, + "createdAt":1, + "updatedAt":2 + }""".trimIndent() + + private fun routineRunJson(): String = """{ + "id":"run-1", + "routineId":"routine-1", + "routineName":"Brief", + "prompt":"Summarize", + "durationMinutes":30, + "botId":"bot-1", + "runOn":"maus", + "scheduledFor":2000000, + "status":"completed", + "manual":true, + "triggerSource":"manual", + "threadId":"task-1", + "startedAt":2000001, + "finishedAt":2000002, + "output":"Done", + "createdAt":2000000 + }""".trimIndent() +} diff --git a/android/core/src/test/kotlin/com/openmausbot/companion/core/SessionP2Test.kt b/android/core/src/test/kotlin/com/openmausbot/companion/core/SessionP2Test.kt new file mode 100644 index 000000000..c2cab6fb8 --- /dev/null +++ b/android/core/src/test/kotlin/com/openmausbot/companion/core/SessionP2Test.kt @@ -0,0 +1,158 @@ +package com.openmausbot.companion.core + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.jsonObject +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class SessionP2Test { + @Test + fun updateProfileFoldsTheReturnedBotIntoSessionState() = runTest { + val server = MockWebServer() + server.start() + try { + val connection = requireNotNull(Connection.parse(server.url("/").toString())) + .copy(id = "connection-1") + val session = session(connection) + session.awaitRestored() + val original = decodeFixture("bot-avatar-profile").bots.first() + val updatedJson = fixtureBotJson().replace( + "\"name\":\"Scout\"", + "\"name\":\"Mobile Scout\"", + ) + server.enqueue(json("""{"bot":$updatedJson}""")) + + val updated = session.updateProfile( + BotProfilePatch(name = "Mobile Scout"), + forBot = original, + ) + + assertEquals("Mobile Scout", updated?.name) + assertEquals("Mobile Scout", session.state.value.bot(original.id)?.name) + val request = server.takeRequest() + assertEquals("PATCH", request.method) + assertEquals("/api/bots/avatar-bot/profile", request.path) + assertEquals( + setOf("name"), + CompanionJson.parseToJsonElement(request.body.readUtf8()).jsonObject.keys, + ) + } finally { + server.shutdown() + } + } + + @Test + fun routineSessionMethodsLoadSaveToggleRunAndDelete() = runTest { + val server = MockWebServer() + server.start() + try { + val connection = requireNotNull(Connection.parse(server.url("/").toString())) + .copy(id = "connection-1") + val session = session(connection) + session.awaitRestored() + val routineJson = routineJson() + val runJson = routineRunJson() + listOf( + """{"routines":[$routineJson],"runs":[$runJson]}""", + """{"routine":$routineJson}""", + """{"routine":$routineJson}""", + """{"routine":$routineJson}""", + """{"run":$runJson}""", + "{}", + ).forEach { server.enqueue(json(it)) } + + val loaded = session.loadRoutines() + val routine = assertNotNull(loaded.routines.firstOrNull()) + val input = RoutineInput( + name = "Brief", + prompt = "Summarize", + botId = "bot-1", + schedule = RoutineSchedule.once(2_000_000.0), + ) + assertNotNull(session.saveRoutine(input, id = null)) + assertNotNull(session.saveRoutine(input, id = routine.id)) + assertNotNull(session.setRoutineEnabled(routine, enabled = false)) + assertEquals("task-1", session.runRoutine(routine)?.threadId) + assertTrue(session.deleteRoutine(routine)) + + assertEquals( + listOf( + "GET /api/routines", + "POST /api/routines", + "PATCH /api/routines/routine-1", + "PATCH /api/routines/routine-1", + "POST /api/routines/routine-1/run", + "DELETE /api/routines/routine-1", + ), + List(6) { server.takeRequest() }.map { "${it.method} ${it.path}" }, + ) + } finally { + server.shutdown() + } + } + + private fun kotlinx.coroutines.test.TestScope.session(connection: Connection): Session = Session( + scope = backgroundScope, + connectionStore = P2ConnectionStore(connection), + tokenStore = P2TokenStore(connection.id, "paired-token"), + deviceNameProvider = { "Pixel" }, + eventsFn = { _, _, _ -> emptyFlow() }, + ) + + private fun json(body: String): MockResponse = MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(body) + + private fun fixtureBotJson(): String = CompanionJson.parseToJsonElement( + fixtureText("bot-avatar-profile"), + ).jsonObject.getValue("bots").toString().removePrefix("[").removeSuffix("]") + + private fun routineJson(): String = """{ + "id":"routine-1","name":"Brief","prompt":"Summarize","botId":"bot-1", + "runOn":"maus","enabled":true,"schedule":{"type":"once","at":2000000}, + "durationMinutes":30,"nextRunAt":2000000,"createdAt":1,"updatedAt":2 + }""".trimIndent() + + private fun routineRunJson(): String = """{ + "id":"run-1","routineId":"routine-1","routineName":"Brief","botId":"bot-1", + "runOn":"maus","scheduledFor":2000000,"status":"completed","manual":true, + "triggerSource":"manual","threadId":"task-1","createdAt":2000000 + }""".trimIndent() +} + +private class P2ConnectionStore(initial: Connection) : ConnectionStore { + private var connection: Connection? = initial + + override suspend fun load(): Connection? = connection + + override suspend fun save(connection: Connection) { + this.connection = connection + } + + override suspend fun clear() { + connection = null + } +} + +private class P2TokenStore(connectionId: String, token: String) : TokenStore { + private val values = mutableMapOf(connectionId to token) + + override suspend fun save(connectionId: String, token: String) { + values[connectionId] = token + } + + override suspend fun read(connectionId: String): TokenStore.ReadResult = + values[connectionId]?.let(TokenStore.ReadResult::Found) ?: TokenStore.ReadResult.Missing + + override suspend fun remove(connectionId: String) { + values.remove(connectionId) + } +}