返回 CodeWhale
PetSharedClient.kt
根目录 / pet / android / src / main / kotlin / codewhale / pet / PetSharedClient.kt
1 package codewhale.pet
2
3 import java.net.HttpURLConnection
4 import java.net.URL
5 import java.io.File
6 import java.util.UUID
7 import android.os.SystemClock
8 import org.json.JSONObject
9
10 /** Local authenticated view. Android reaches the owner through an explicitly
11 * established adb reverse/secure loopback tunnel; no LAN listener or world. */
12 class PetSharedClient(private val descriptor: File) {
13 private val client = UUID.randomUUID().toString()
14 private var sequence = 0L
15 private var pending: JSONObject? = null
16 private var frame: JSONObject? = null
17 private var changed = 0L
18 private var lease = 0L
19 var identity = ""; private set
20 var granted = false; private set
21 var message = "Join the shared pet from More."; private set
22 private fun connection(): JSONObject {
23 require(descriptor.isFile && descriptor.length() in 1..4096) { "Join shared pet from More and select its connection.json." }
24 return JSONObject(descriptor.readText()).also(::validateConnection)
25 }
26 private fun request(path: String, body: JSONObject? = null): JSONObject {
27 val d = connection()
28 val http = URL("http://127.0.0.1:${d.getInt("port")}$path").openConnection() as HttpURLConnection
29 try {
30 http.instanceFollowRedirects = false; http.connectTimeout = 800; http.readTimeout = 1500
31 http.setRequestProperty("Authorization", "Bearer ${d.getString("token")}")
32 if (body != null) { http.requestMethod = "POST"; http.doOutput = true; http.setRequestProperty("Content-Type", "application/json"); http.outputStream.use { it.write(body.toString().toByteArray()) } }
33 val code = http.responseCode
34 val bound = (if (path == "/v1/export") 64 else 8) * 1024 * 1024
35 val bytes = (if (code == 200) http.inputStream else http.errorStream)?.use { it.readPetBytes(bound + 1) } ?: error("Shared pet unavailable.")
36 require(bytes.size <= bound) { "Shared pet response exceeds its bound." }
37 val result = JSONObject(String(bytes, Charsets.UTF_8))
38 if (code != 200) {
39 val error = result.optString("error", "Shared pet unavailable.")
40 if (path == "/v1/action" && code == 409 && !error.contains("storage")) pending = null
41 error(error)
42 }
43 return result
44 } finally { http.disconnect() }
45 }
46 fun poll(still: Boolean, sound: Boolean): PetScene {
47 val next = request("/v1/frame")
48 require(next.getInt("version") == 1 && next.getString("identity") == connection().getString("identity")) { "Shared identity changed; select its connection again." }
49 if (frame?.optString("epoch") != next.getString("epoch") || frame?.optLong("tick") != next.getLong("tick")) changed = SystemClock.elapsedRealtime()
50 frame = next; identity = "${next.getString("identity")} · tick ${next.getLong("tick")} · ${next.getString("digest")}"
51 val fresh = SystemClock.elapsedRealtime() - changed < 800
52 message = if (fresh && next.getBoolean("producerConnected")) "Following ${next.getString("source")}" else "${next.getString("source")} · telemetry unobserved"
53 if (!next.getBoolean("storageAvailable")) message += " · storage unavailable; previous recording kept"
54 if (next.getBoolean("audioUnavailable")) granted = false
55 if (SystemClock.elapsedRealtime() - lease >= 500) setSound(sound && fresh)
56 flush()
57 val pose = if (still) next.getJSONObject("still") else next
58 val points = pose.getJSONArray("points"); require(points.length() == 980)
59 val dots = FloatArray(1960) { i -> points.getJSONArray(i / 2).getDouble(i % 2).also { require(it.isFinite() && kotlin.math.abs(it) <= 8) }.toFloat() }
60 val state = PetNativeCore.decodeState(pose.getJSONObject("state")); val style = PetNativeCore.decodeStyle(pose.getJSONObject("style"))
61 if (!fresh || !next.getBoolean("producerConnected")) { style.hollow = true }
62 val appearance = next.optJSONObject("appearance")?.let { a ->
63 fun color(key: String): List<Int> { val c=a.getJSONArray(key); require(c.length()==3); return (0..2).map { c.getInt(it).also { n -> require(n in 0..255) } } }
64 PetAppearance(color("background"), color("backgroundTop"), a.getDouble("dotScale").toFloat().coerceIn(.65f,1.8f), a.getDouble("glow").toFloat().coerceIn(0f,1f), a.getBoolean("environment"))
65 }
66 return PetScene(next.getDouble("timeMs"), state, style, dots, next.getString("behaviour"), next.getString("needs"),
67 next.getDouble("surface").toFloat(), if (still) 0f else next.getDouble("caustic").toFloat(), null, 0, next.getString("digest"), appearance,
68 next.optJSONObject("activity")?.takeIf { it.optBoolean("observed") && fresh }?.let { a ->
69 a.getString("label").take(96) + (if (a.isNull("tool")) "" else " · " + a.getString("tool").take(96)) +
70 (if (a.optInt("parallel") > 0) " · ${a.getInt("parallel")} parallel agents" else "")
71 })
72 }
73 fun interact(food: Boolean) {
74 val f = frame ?: return
75 if (pending != null || SystemClock.elapsedRealtime() - changed >= 800) return
76 pending = JSONObject().put("identity", f.getString("identity")).put("client", client).put("seq", sequence + 1)
77 .put("source_revision", f.getLong("sourceRevision")).put("action", JSONObject().put("kind", "interact").put("food", food).put("x", .2).put("y", -.15))
78 flush()
79 }
80 private fun flush() { pending?.let { request("/v1/action", it); sequence++; pending = null } }
81 fun setSound(enabled: Boolean) { granted = request("/v1/audio", JSONObject().put("client", client).put("enabled", enabled)).getBoolean("granted"); lease = SystemClock.elapsedRealtime() }
82 fun detach() { if (granted) runCatching { setSound(false) }; granted = false }
83 fun export(): ByteArray = request("/v1/export").toString().toByteArray(Charsets.UTF_8)
84 companion object {
85 fun validateConnection(d: JSONObject) {
86 require(d.getInt("version") == 1 && d.getInt("port") in 1..65535 && d.getString("token").matches(Regex("[a-f0-9]{64}"))) { "Invalid shared pet connection." }
87 UUID.fromString(d.getString("identity"))
88 }
89 }
90 }
91
92 internal fun java.io.InputStream.readPetBytes(limit: Int): ByteArray {
93 val out = java.io.ByteArrayOutputStream(); val buffer = ByteArray(16384)
94 while (out.size() < limit) {
95 val count = read(buffer, 0, minOf(buffer.size, limit - out.size()))
96 if (count < 0) break
97 if (count > 0) out.write(buffer, 0, count)
98 }
99 return out.toByteArray()
100 }
101
101 lines Plain Text