| 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 |