Skip to content

Commit 56aeff5

Browse files
committed
Close Android app parity gaps
1 parent 027bfd3 commit 56aeff5

9 files changed

Lines changed: 1281 additions & 59 deletions

File tree

android_dashboard/app/src/main/java/sh/sandboxed/dashboard/data/AppContainer.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.SharingStarted
88
import kotlinx.coroutines.flow.StateFlow
99
import kotlinx.coroutines.flow.stateIn
1010
import sh.sandboxed.dashboard.data.api.ApiService
11+
import sh.sandboxed.dashboard.data.api.DesktopStreamSocket
1112
import sh.sandboxed.dashboard.data.api.Net
1213
import sh.sandboxed.dashboard.data.api.SseClient
1314
import sh.sandboxed.dashboard.data.api.TerminalSocket
@@ -25,5 +26,6 @@ class AppContainer(context: Context) {
2526
val api: ApiService = ApiService(Net.httpClient) { snapshot() }
2627
val sse: SseClient = SseClient(api, Net.streamingClient)
2728
val terminal: TerminalSocket = TerminalSocket(Net.streamingClient) { snapshot() }
29+
val desktop: DesktopStreamSocket = DesktopStreamSocket(Net.streamingClient) { snapshot() }
2830
val fido: FidoChannel = FidoChannel(sse, api, scope, cached).also { it.start() }
2931
}

android_dashboard/app/src/main/java/sh/sandboxed/dashboard/data/Models.kt

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -385,20 +385,25 @@ object ToolUiParser {
385385
fun parse(name: String, args: kotlinx.serialization.json.JsonElement?): ToolUiContent {
386386
val obj = (args as? JsonObject) ?: return ToolUiContent.Unknown(name, args?.toString() ?: "")
387387
return when (name) {
388-
"ui_data_table" -> ToolUiContent.DataTable(
388+
"ui_dataTable", "ui_data_table" -> ToolUiContent.DataTable(
389389
title = obj["title"]?.let { textOf(it) },
390390
columns = (obj["columns"] as? kotlinx.serialization.json.JsonArray).orEmpty().map {
391-
val c = (it as? JsonObject) ?: return@map TableColumn("", "")
392-
TableColumn(
393-
id = textOf(c["id"]) ?: "",
394-
label = textOf(c["label"]) ?: textOf(c["id"]) ?: "",
395-
)
391+
val c = it as? JsonObject
392+
if (c == null) {
393+
val id = textOf(it) ?: ""
394+
TableColumn(id = id, label = id)
395+
} else {
396+
TableColumn(
397+
id = textOf(c["id"]) ?: "",
398+
label = textOf(c["label"]) ?: textOf(c["id"]) ?: "",
399+
)
400+
}
396401
},
397402
rows = (obj["rows"] as? kotlinx.serialization.json.JsonArray).orEmpty().mapNotNull {
398403
(it as? JsonObject)?.mapValues { (_, v) -> textOf(v) ?: v.toString() }
399404
},
400405
)
401-
"ui_option_list" -> ToolUiContent.OptionList(
406+
"ui_optionList", "ui_option_list" -> ToolUiContent.OptionList(
402407
options = (obj["options"] as? kotlinx.serialization.json.JsonArray).orEmpty().mapNotNull {
403408
val o = (it as? JsonObject) ?: return@mapNotNull null
404409
UiOption(
@@ -408,20 +413,20 @@ object ToolUiParser {
408413
disabled = textOf(o["disabled"]) == "true",
409414
)
410415
},
411-
multiSelect = textOf(obj["selectionMode"]) == "multi",
416+
multiSelect = textOf(obj["selectionMode"]) == "multi" || textOf(obj["multiple"]) == "true",
412417
)
413418
"ui_progress" -> ToolUiContent.Progress(
414419
title = textOf(obj["title"]),
415420
current = textOf(obj["current"])?.toIntOrNull() ?: 0,
416421
total = textOf(obj["total"])?.toIntOrNull() ?: 0,
417422
status = textOf(obj["status"]),
418423
)
419-
"ui_alert" -> ToolUiContent.Alert(
424+
"ui_alert", "ui_notification" -> ToolUiContent.Alert(
420425
title = textOf(obj["title"]) ?: "",
421426
message = textOf(obj["message"]),
422427
severity = textOf(obj["type"]) ?: "info",
423428
)
424-
"ui_code_block" -> ToolUiContent.CodeBlock(
429+
"ui_codeBlock", "ui_code", "ui_code_block" -> ToolUiContent.CodeBlock(
425430
title = textOf(obj["title"]),
426431
language = textOf(obj["language"]),
427432
code = textOf(obj["code"]) ?: "",

android_dashboard/app/src/main/java/sh/sandboxed/dashboard/data/api/ApiService.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ class ApiService(
119119
getList("/api/control/missions", mapOf("limit" to limit.toString(), "offset" to offset.toString()))
120120

121121
suspend fun getMission(id: String): Mission = getJson("/api/control/missions/$id")
122+
suspend fun childMissions(parentId: String): List<Mission> =
123+
listMissions(limit = 200).filter { it.parentMissionId == parentId }
122124
suspend fun currentMission(): Mission? = runCatching { getJson<Mission>("/api/control/missions/current") }.getOrNull()
123125
suspend fun createMission(req: CreateMissionRequest): Mission = postJson("/api/control/missions", req)
124126
suspend fun loadMission(id: String): Mission = postEmpty("/api/control/missions/$id/load")
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package sh.sandboxed.dashboard.data.api
2+
3+
import android.graphics.Bitmap
4+
import android.graphics.BitmapFactory
5+
import kotlinx.coroutines.channels.awaitClose
6+
import kotlinx.coroutines.flow.Flow
7+
import kotlinx.coroutines.flow.callbackFlow
8+
import okhttp3.HttpUrl.Companion.toHttpUrl
9+
import okhttp3.OkHttpClient
10+
import okhttp3.Request
11+
import okhttp3.Response
12+
import okhttp3.WebSocket
13+
import okhttp3.WebSocketListener
14+
import org.json.JSONObject
15+
import sh.sandboxed.dashboard.data.AppSettings
16+
17+
sealed class DesktopStreamEvent {
18+
data object Connected : DesktopStreamEvent()
19+
data class Frame(val bitmap: Bitmap) : DesktopStreamEvent()
20+
data class Error(val message: String) : DesktopStreamEvent()
21+
data class Closed(val reason: String?) : DesktopStreamEvent()
22+
}
23+
24+
class DesktopStreamSocket(
25+
private val client: OkHttpClient,
26+
private val provider: () -> AppSettings,
27+
) {
28+
@Volatile private var ws: WebSocket? = null
29+
30+
fun connect(display: String, fps: Int, quality: Int): Flow<DesktopStreamEvent> = callbackFlow {
31+
val s = provider()
32+
val baseUrl = s.baseUrl.trimEnd('/').ifBlank { error("Server URL not configured") }
33+
val wsBase = baseUrl.replaceFirst("https://", "wss://").replaceFirst("http://", "ws://")
34+
val url = "$wsBase/api/desktop/stream".toHttpUrl().newBuilder()
35+
.addQueryParameter("display", display)
36+
.addQueryParameter("fps", fps.coerceIn(1, 30).toString())
37+
.addQueryParameter("quality", quality.coerceIn(10, 100).toString())
38+
.build()
39+
val protocols = listOfNotNull(
40+
"sandboxed",
41+
s.jwtToken?.let { "jwt.$it" },
42+
).joinToString(", ")
43+
val req = Request.Builder()
44+
.url(url)
45+
.apply { if (protocols.isNotEmpty()) header("Sec-WebSocket-Protocol", protocols) }
46+
.build()
47+
48+
val socket = client.newWebSocket(req, object : WebSocketListener() {
49+
override fun onOpen(webSocket: WebSocket, response: Response) {
50+
this@DesktopStreamSocket.ws = webSocket
51+
trySend(DesktopStreamEvent.Connected)
52+
}
53+
54+
override fun onMessage(webSocket: WebSocket, text: String) {
55+
trySend(DesktopStreamEvent.Error(text))
56+
}
57+
58+
override fun onMessage(webSocket: WebSocket, bytes: okio.ByteString) {
59+
val data = bytes.toByteArray()
60+
val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size)
61+
if (bitmap == null) {
62+
trySend(DesktopStreamEvent.Error("Could not decode desktop frame"))
63+
} else {
64+
trySend(DesktopStreamEvent.Frame(bitmap))
65+
}
66+
}
67+
68+
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
69+
webSocket.close(code, reason)
70+
}
71+
72+
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
73+
this@DesktopStreamSocket.ws = null
74+
trySend(DesktopStreamEvent.Closed(reason))
75+
close()
76+
}
77+
78+
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
79+
this@DesktopStreamSocket.ws = null
80+
trySend(DesktopStreamEvent.Error(t.message ?: "Desktop stream failed"))
81+
close(t)
82+
}
83+
})
84+
85+
awaitClose {
86+
socket.close(1000, "client closing")
87+
this@DesktopStreamSocket.ws = null
88+
}
89+
}
90+
91+
fun pause() {
92+
ws?.send("""{"t":"pause"}""")
93+
}
94+
95+
fun resume() {
96+
ws?.send("""{"t":"resume"}""")
97+
}
98+
99+
fun setFps(fps: Int) {
100+
ws?.send("""{"t":"fps","fps":${fps.coerceIn(1, 30)}}""")
101+
}
102+
103+
fun setQuality(quality: Int) {
104+
ws?.send("""{"t":"quality","quality":${quality.coerceIn(10, 100)}}""")
105+
}
106+
107+
fun click(x: Int, y: Int, double: Boolean = false) {
108+
ws?.send("""{"t":"click","x":$x,"y":$y,"button":"left","double":$double}""")
109+
}
110+
111+
fun scroll(x: Int, y: Int, amount: Int) {
112+
ws?.send("""{"t":"scroll","x":$x,"y":$y,"amount":$amount}""")
113+
}
114+
115+
fun typeText(text: String) {
116+
if (text.isBlank()) return
117+
ws?.send("""{"t":"type","text":${JSONObject.quote(text)}}""")
118+
}
119+
120+
fun key(key: String) {
121+
if (key.isBlank()) return
122+
ws?.send("""{"t":"key","key":${JSONObject.quote(key)}}""")
123+
}
124+
}

0 commit comments

Comments
 (0)