返回 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 // Validate every rendered field of both poses before this frame can
50 // refresh freshness, lease sound or flush a pending action. org.json
51 // parses "NaN"/"Infinity" strings through getDouble, so check finiteness.
52 val epoch = next.getString("epoch"); UUID.fromString(epoch)
53 val tick = next.getLong("tick"); val timeMs = next.getDouble("timeMs")
54 val surface = next.getDouble("surface"); val caustic = next.getDouble("caustic")
55 require(tick >= 0 && timeMs.isFinite() && timeMs >= 0 && surface.isFinite() && caustic.isFinite()
56 && next.getString("digest").length == 16 && next.getString("behaviour") in BEHAVIOURS && next.getString("needs") in NEEDS) { "Invalid shared pet frame." }
57 val poses = listOf(next, next.getJSONObject("still")).map { pose ->
58 val points = pose.getJSONArray("points"); require(points.length() == 980) { "Invalid shared pet frame." }
59 val dots = FloatArray(1960) { i -> points.getJSONArray(i / 2).also { require(it.length() == 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 require(validState(state) && validStyle(style)) { "Invalid shared pet frame." }
62 Triple(dots, state, style)
63 }
64 val appearance = next.optJSONObject("appearance")?.let { a ->
65 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) } } }
66 val dotScale = a.getDouble("dotScale"); val glow = a.getDouble("glow"); require(dotScale.isFinite() && glow.isFinite())
67 PetAppearance(color("background"), color("backgroundTop"), dotScale.toFloat().coerceIn(.65f,1.8f), glow.toFloat().coerceIn(0f,1f), a.getBoolean("environment"))
68 }
69 if (frame?.optString("epoch") != epoch || frame?.optLong("tick") != tick) changed = SystemClock.elapsedRealtime()
70 frame = next; identity = "${next.getString("identity")} · tick $tick · ${next.getString("digest")}"
71 val fresh = SystemClock.elapsedRealtime() - changed < 800
72 message = if (fresh && next.getBoolean("producerConnected")) "Following ${next.getString("source")}" else "${next.getString("source")} · telemetry unobserved"
73 if (!next.getBoolean("storageAvailable")) message += " · storage unavailable; previous recording kept"
74 if (next.getBoolean("audioUnavailable")) granted = false
75 if (SystemClock.elapsedRealtime() - lease >= 500) setSound(sound && fresh)
76 flush()
77 val (dots, state, style) = poses[if (still) 1 else 0]
78 if (!fresh || !next.getBoolean("producerConnected")) { style.hollow = true }
79 return PetScene(timeMs, state, style, dots, next.getString("behaviour"), next.getString("needs"),
80 surface.toFloat(), if (still) 0f else caustic.toFloat(), null, 0, next.getString("digest"), appearance,
81 next.optJSONObject("activity")?.takeIf { it.optBoolean("observed") && fresh }?.let { a ->
82 a.getString("label").take(96) + (if (a.isNull("tool")) "" else " · " + a.getString("tool").take(96)) +
83 (if (a.optInt("parallel") > 0) " · ${a.getInt("parallel")} parallel agents" else "")
84 })
85 }
86 fun interact(food: Boolean) {
87 val f = frame ?: return
88 if (pending != null || SystemClock.elapsedRealtime() - changed >= 800) return
89 pending = JSONObject().put("identity", f.getString("identity")).put("client", client).put("seq", sequence + 1)
90 .put("source_revision", f.getLong("sourceRevision")).put("action", JSONObject().put("kind", "interact").put("food", food).put("x", .2).put("y", -.15))
91 flush()
92 }
93 private fun flush() { pending?.let { request("/v1/action", it); sequence++; pending = null } }
94 fun setSound(enabled: Boolean) { granted = request("/v1/audio", JSONObject().put("client", client).put("enabled", enabled)).getBoolean("granted"); lease = SystemClock.elapsedRealtime() }
95 fun detach() { if (granted) runCatching { setSound(false) }; granted = false }
96 fun export(): ByteArray = request("/v1/export").toString().toByteArray(Charsets.UTF_8)
97 companion object {
98 private val BEHAVIOURS = setOf("swim", "dive", "roll", "breathe", "drift", "doze", "wake")
99 private val NEEDS = setOf("none", "orient", "approach", "call")
100 /** The shared core's validatePetState bounds. */
101 private fun validState(s: PetState) = listOf(s.activity, s.coherence, s.attention, s.observed, s.lit).all { it.isFinite() && it in 0.0..1.0 }
102 && listOf(s.roamX, s.roamY, s.flip).all { it.isFinite() && kotlin.math.abs(it) <= 1.0 } && CHANNELS.any { it.key == s.channel }
103 private fun validStyle(f: Frame) = listOf(f.r, f.g, f.b).all { it.isFinite() && it in 0.0..255.0 } && f.alpha.isFinite() && f.alpha in 0.0..1.0
104 && f.work.isFinite() && CHANNELS.any { it.key == f.channel }
105 fun validateConnection(d: JSONObject) {
106 require(d.getInt("version") == 1 && d.getInt("port") in 1..65535 && d.getString("token").matches(Regex("[a-f0-9]{64}"))) { "Invalid shared pet connection." }
107 UUID.fromString(d.getString("identity"))
108 }
109 }
110 }
111
112 internal fun java.io.InputStream.readPetBytes(limit: Int): ByteArray {
113 val out = java.io.ByteArrayOutputStream(); val buffer = ByteArray(16384)
114 while (out.size() < limit) {
115 val count = read(buffer, 0, minOf(buffer.size, limit - out.size()))
116 if (count < 0) break
117 if (count > 0) out.write(buffer, 0, count)
118 }
119 return out.toByteArray()
120 }
121
121 lines Plain Text