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..b95dcfd64 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +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 new file mode 100644 index 000000000..0e7fe366f --- /dev/null +++ b/android/core/build.gradle.kts @@ -0,0 +1,32 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + kotlin("jvm") + kotlin("plugin.serialization") +} + +kotlin { + jvmToolchain(17) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +dependencies { + // 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 { + resources.srcDir(rootProject.projectDir.resolve("../ios/Tests/CompanionCoreTests/Fixtures")) +} + +tasks.test { + useJUnitPlatform() +} 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..fd5c4c893 --- /dev/null +++ b/android/core/src/main/kotlin/com/openmausbot/companion/core/Chat.kt @@ -0,0 +1,114 @@ +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" + } +} + +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. + */ +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 -> { + 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 new file mode 100644 index 000000000..4fc70495c --- /dev/null +++ b/android/core/src/main/kotlin/com/openmausbot/companion/core/Client.kt @@ -0,0 +1,538 @@ +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.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 +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() + + 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( + 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 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))) + 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))) + } + + 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, + 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 + 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, + requestClient: OkHttpClient = actionClient, + ): T { + val raw = perform(request, requestClient) + 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, + 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) { + 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 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, + 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..1c031a967 --- /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.substringBefore('%') + } + + 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..10b8ec56b --- /dev/null +++ b/android/core/src/main/kotlin/com/openmausbot/companion/core/Failover.kt @@ -0,0 +1,123 @@ +package com.openmausbot.companion.core + +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 + + 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 = + 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( + 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." + } + + 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/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..9cbf87f81 --- /dev/null +++ b/android/core/src/main/kotlin/com/openmausbot/companion/core/Models.kt @@ -0,0 +1,673 @@ +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.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 +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 + + 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 +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 avatarUrl: String? = null, + val avatarCrop: AvatarCrop? = null, + 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(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) + +@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 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) + +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 +data class CreatedRoom(val group: Room) + +@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) + +@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 new file mode 100644 index 000000000..11fb34b3d --- /dev/null +++ b/android/core/src/main/kotlin/com/openmausbot/companion/core/Session.kt @@ -0,0 +1,1064 @@ +package com.openmausbot.companion.core + +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 +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 + } + + 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() + + private val _connection = MutableStateFlow(null) + val connection: StateFlow = _connection.asStateFlow() + + 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 + 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 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() + + 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() + _restoreState.value = RestoreState.Ready + _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 || + _restoreState.value is RestoreState.Pending || + _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 { + _restoreState.value = RestoreState.Unpaired + _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 { + _restoreState.value = RestoreState.Unpaired + _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 && _restoreState.value is RestoreState.Pending) { + 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() { + 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 + _restoreState.value = RestoreState.Pending + _status.value = Status.Offline( + if (stored.locked) { + "Unlock this phone to reach your computer." + } else { + stored.message + }, + ) + } + 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 + } + } + } + + /** + * 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 && _restoreState.value is RestoreState.Pending) { + 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( + 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 { + val behavior = OptionCard.responseBehavior(choice, isPermission) + it.respond( + threadId = threadId, + requestId = requestId, + behavior = behavior, + message = choice.takeIf { behavior == "answer" }, + ) + } + } + + 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 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) } + } + + 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 + } + } + + 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 + } + + 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 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 { + 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/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/ChatSummaryTest.kt b/android/core/src/test/kotlin/com/openmausbot/companion/core/ChatSummaryTest.kt new file mode 100644 index 000000000..19e6d2a12 --- /dev/null +++ b/android/core/src/test/kotlin/com/openmausbot/companion/core/ChatSummaryTest.kt @@ -0,0 +1,200 @@ +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("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( + 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, +) + +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/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/ClientTest.kt b/android/core/src/test/kotlin/com/openmausbot/companion/core/ClientTest.kt new file mode 100644 index 000000000..38e56b625 --- /dev/null +++ b/android/core/src/test/kotlin/com/openmausbot/companion/core/ClientTest.kt @@ -0,0 +1,274 @@ +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.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 +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 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() + 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("]") + } + + 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 new file mode 100644 index 000000000..390aefb40 --- /dev/null +++ b/android/core/src/test/kotlin/com/openmausbot/companion/core/ConnectionTest.kt @@ -0,0 +1,274 @@ +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 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") + 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..5424ffdd0 --- /dev/null +++ b/android/core/src/test/kotlin/com/openmausbot/companion/core/DecodingTest.kt @@ -0,0 +1,324 @@ +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 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( + """{"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) + 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( + """{"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/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/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 } +} 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) + } +} 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..aae8519af --- /dev/null +++ b/android/core/src/test/kotlin/com/openmausbot/companion/core/SessionTest.kt @@ -0,0 +1,869 @@ +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 +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer + +@OptIn(ExperimentalCoroutinesApi::class) +class SessionTest { + @Test + fun restoreWithMissingConnectionStaysUnpaired() = runTest { + val session = session() + session.awaitRestored() + assertEquals(Session.Status.Unpaired, session.status.value) + assertEquals(Session.RestoreState.Unpaired, session.restoreState.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) + assertEquals(Session.RestoreState.Pending, session.restoreState.value) + val status = assertIs(session.status.value) + 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 + 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 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 + 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 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( + 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/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 000000000..f8e1ee312 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..1a704683a --- /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.5.0-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..9a1dc31ea --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,21 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} + +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "openmausbot-android" +include(":core")