RawCastClient.kt
Each line, with the release that last changed it.
| 0.5.0 | 1 | package dev.stave.cast.raw |
| 2 | ||
| 3 | import kotlinx.coroutines.* | |
| 4 | import kotlinx.coroutines.flow.* | |
| 5 | import org.json.JSONObject | |
| 6 | import java.io.* | |
| 7 | import java.net.InetSocketAddress | |
| 8 | import javax.net.ssl.SSLContext | |
| 9 | import javax.net.ssl.SSLSocket | |
| 10 | import javax.net.ssl.TrustManager | |
| 11 | import javax.net.ssl.X509TrustManager | |
| 12 | ||
| 13 | /** | |
| 14 | * Raw Cast protocol client for observing Chromecast sessions without the Cast SDK. | |
| 15 | * | |
| 16 | * Connects via TLS to port 8009 on any Chromecast/Chromecast Audio/group leader, | |
| 17 | * discovers the active media session, and streams [MediaStatus] updates. Transport | |
| 18 | * commands (play/pause/stop/seek/next/previous) are sent as JSON over the media | |
| 19 | * namespace. | |
| 20 | * | |
| 21 | * This bypasses Google Play Services entirely — works on Shield TV, Fire TV, libre | |
| 22 | * builds, or any device that can open a TLS socket. | |
| 23 | * | |
| 24 | * Protocol references: | |
| 25 | * - github.com/thibauts/node-castv2 | |
| 26 | * - github.com/home-assistant-libs/pychromecast | |
| 27 | * - chromium.googlesource.com/.../cast_channel.proto | |
| 28 | */ | |
| 29 | class RawCastClient( | |
| 30 | val host: String, | |
| 31 | private val port: Int = 8009, | |
| 32 | ) { | |
| 33 | companion object { | |
| 34 | // Cast protocol namespaces | |
| 35 | private const val NS_CONNECTION = "urn:x-cast:com.google.cast.tp.connection" | |
| 36 | private const val NS_HEARTBEAT = "urn:x-cast:com.google.cast.tp.heartbeat" | |
| 37 | private const val NS_RECEIVER = "urn:x-cast:com.google.cast.receiver" | |
| 38 | private const val NS_MEDIA = "urn:x-cast:com.google.cast.media" | |
| 39 | ||
| 40 | private const val SENDER_ID = "sender-earhorn" | |
| 41 | private const val RECEIVER_ID = "receiver-0" | |
| 42 | ||
| 43 | private const val HEARTBEAT_INTERVAL_MS = 5_000L | |
| 44 | private const val CONNECT_TIMEOUT_MS = 10_000 | |
| 45 | } | |
| 46 | ||
| 47 | data class MediaStatus( | |
| 48 | val title: String? = null, | |
| 49 | val artist: String? = null, | |
| 50 | val album: String? = null, | |
| 51 | val albumArtUrl: String? = null, | |
| 52 | val contentId: String? = null, | |
| 53 | val duration: Double = 0.0, | |
| 54 | val currentTime: Double = 0.0, | |
| 55 | val playerState: String = "UNKNOWN", // PLAYING, PAUSED, IDLE, BUFFERING | |
| 56 | val mediaSessionId: Int = 0, | |
| 57 | /** Receiver's supportedMediaCommands bitmask (Cast constants: | |
| 58 | * 1=PAUSE, 2=SEEK, 64=QUEUE_NEXT, 128=QUEUE_PREV, 256=SHUFFLE, | |
| 59 | * 1024|2048=REPEAT). 0 = not yet reported. */ | |
| 60 | val supportedMediaCommands: Long = 0L, | |
| 61 | /** BUFFERED or LIVE; "" until reported. */ | |
| 62 | val streamType: String = "", | |
| 63 | /** The receiver's queue repeat mode: REPEAT_OFF, REPEAT_ALL, | |
| 64 | * REPEAT_SINGLE, REPEAT_ALL_AND_SHUFFLE; "" until reported. Read so | |
| 65 | * the Shuffle/Repeat controls can show and change the HOUSE's mode | |
| 66 | * while observing (I.15, design decision 2026-09-02). */ | |
| 67 | val repeatMode: String = "", | |
| 68 | val volume: Double = 1.0, | |
| 69 | val isMuted: Boolean = false, | |
| 70 | /** The receiver-side queue item ID of the currently playing item. | |
| 71 | * Cross-referenced against [queueItems] to derive the queue index. */ | |
| 72 | val currentItemId: Int = 0, | |
| 73 | /** Advances by one for every MEDIA_STATUS message the receiver sends, | |
| 74 | * and for nothing else. RECEIVER_STATUS (device volume) copies this | |
| 75 | * object with the same value. The observer keys "is this a new | |
| 76 | * playback report?" on this number, not on object identity — a | |
| 77 | * volume report used to count as a new status with an unchanged | |
| 78 | * position and trip the freeze detector (review N.1, 2026-09-02). */ | |
| 79 | val statusSeq: Long = 0L, | |
| 80 | ) | |
| 81 | ||
| 82 | /** One entry of the receiver-side queue, fetched via QUEUE_GET_ITEM_IDS / | |
| 83 | * QUEUE_GET_ITEMS. This is the same mechanism Google's own MediaQueue | |
| 84 | * class uses to populate sender-side queue views — the receiver is the | |
| 85 | * source of truth and any connected sender/observer may read it. */ | |
| 86 | data class QueueItem( | |
| 87 | val itemId: Int, | |
| 88 | val contentId: String?, | |
| 89 | val title: String?, | |
| 90 | val artist: String?, | |
| 91 | val album: String?, | |
| 92 | val albumArtUrl: String?, | |
| 93 | val durationSeconds: Double, | |
| 94 | ) | |
| 95 | ||
| 96 | private val _mediaStatus = MutableStateFlow(MediaStatus()) | |
| 97 | val mediaStatus: StateFlow<MediaStatus> = _mediaStatus.asStateFlow() | |
| 98 | ||
| 99 | /** The receiver's queue in playback order. Populated after connect once | |
| 100 | * a media session is discovered; refreshed automatically on QUEUE_CHANGE | |
| 101 | * broadcasts (fired when the sender edits the queue) and on media-session | |
| 102 | * change (new load). Emits progressively as item batches arrive. */ | |
| 103 | private val _queueItems = MutableStateFlow<List<QueueItem>>(emptyList()) | |
| 104 | val queueItems: StateFlow<List<QueueItem>> = _queueItems.asStateFlow() | |
| 105 | ||
| 106 | // Queue-fetch bookkeeping. Guarded by [queueLock]: the read loop | |
| 107 | // mutates from its handlers, but disconnect()/clearQueueState() can | |
| 108 | // arrive from the UI thread mid-iteration — an unsynchronized HashMap | |
| 109 | // there is a ConcurrentModificationException crash. | |
| 110 | private val queueLock = Any() | |
| 111 | private var queueItemIdOrder: List<Int> = emptyList() | |
| 112 | private val queueItemBuf = mutableMapOf<Int, QueueItem>() | |
| 113 | private var queueRefetchJob: Job? = null | |
| 114 | ||
| 115 | // Last-received timestamps — lets observers gate their periodic pulls | |
| 116 | // on staleness instead of hammering the receiver on a fixed tick. | |
| 117 | @Volatile private var lastMediaStatusAt = 0L | |
| 118 | @Volatile private var lastReceiverStatusAt = 0L | |
| 119 | fun mediaStatusAgeMs(): Long = System.currentTimeMillis() - lastMediaStatusAt | |
| 120 | fun receiverStatusAgeMs(): Long = System.currentTimeMillis() - lastReceiverStatusAt | |
| 121 | ||
| 122 | private val _connected = MutableStateFlow(false) | |
| 123 | val connected: StateFlow<Boolean> = _connected.asStateFlow() | |
| 124 | ||
| 125 | private var socket: SSLSocket? = null | |
| 126 | private var output: OutputStream? = null | |
| 127 | private var input: InputStream? = null | |
| 128 | private var scope: CoroutineScope? = null | |
| 129 | private var transportId: String? = null | |
| 130 | private var requestId = 1 | |
| 131 | ||
| 132 | // ── Connect / Disconnect ───────────────────────────────────── | |
| 133 | ||
| 134 | suspend fun connect() = withContext(Dispatchers.IO) { | |
| 135 | disconnect() | |
| 136 | val sc = CoroutineScope(SupervisorJob() + Dispatchers.IO) | |
| 137 | scope = sc | |
| 138 | ||
| 139 | android.util.Log.i("EarhornCast", "connecting to $host:$port") | |
| 140 | // TLS with permissive trust (Chromecast uses self-signed certs) | |
| 141 | val sslContext = SSLContext.getInstance("TLS") | |
| 142 | sslContext.init(null, arrayOf<TrustManager>(PermissiveTrustManager), null) | |
| 143 | val factory = sslContext.socketFactory | |
| 144 | ||
| 145 | val sock = factory.createSocket() as SSLSocket | |
| 146 | sock.connect(InetSocketAddress(host, port), CONNECT_TIMEOUT_MS) | |
| 147 | // The handshake had no timeout: a receiver that accepts and stalls | |
| 148 | // pinned this thread until it let go (I.13). Bound it; a real | |
| 149 | // handshake on the LAN completes in well under a second. Reset to | |
| 150 | // no timeout afterwards — the read loop blocks on purpose. | |
| 151 | sock.soTimeout = CONNECT_TIMEOUT_MS | |
| 152 | sock.startHandshake() | |
| 153 | sock.soTimeout = 0 | |
| 154 | socket = sock | |
| 155 | output = BufferedOutputStream(sock.outputStream) | |
| 156 | input = BufferedInputStream(sock.inputStream) | |
| 157 | _connected.value = true | |
| 158 | android.util.Log.i("EarhornCast", "TLS handshake OK — sending CONNECT + GET_STATUS") | |
| 159 | ||
| 160 | // Start reader + heartbeat | |
| 161 | sc.launch { readLoop() } | |
| 162 | sc.launch { heartbeatLoop() } | |
| 163 | ||
| 164 | // Connect to receiver platform | |
| 165 | sendJson(RECEIVER_ID, NS_CONNECTION, """{"type":"CONNECT","origin":{}}""") | |
| 166 | // Get receiver status to discover active app/session | |
| 167 | sendJson(RECEIVER_ID, NS_RECEIVER, | |
| 168 | """{"type":"GET_STATUS","requestId":${nextRequestId()}}""") | |
| 169 | } | |
| 170 | ||
| 171 | fun disconnect() { | |
| 172 | if (_connected.value || socket != null) { | |
| 173 | android.util.Log.i("EarhornCast", "disconnecting from $host:$port") | |
| 174 | } | |
| 175 | scope?.cancel() | |
| 176 | scope = null | |
| 177 | runCatching { socket?.close() } | |
| 178 | socket = null | |
| 179 | output = null | |
| 180 | input = null | |
| 181 | transportId = null | |
| 182 | _connected.value = false | |
| 183 | clearQueueState() | |
| 184 | } | |
| 185 | ||
| 186 | // ── Transport commands ─────────────────────────────────────── | |
| 187 | ||
| 188 | suspend fun play() = sendMediaCommand("PLAY") | |
| 189 | suspend fun pause() = sendMediaCommand("PAUSE") | |
| 190 | suspend fun stop() = sendMediaCommand("STOP") | |
| 191 | ||
| 192 | suspend fun seek(positionSeconds: Double) { | |
| 193 | val tid = transportId ?: return | |
| 194 | val msid = _mediaStatus.value.mediaSessionId | |
| 195 | if (msid == 0) return | |
| 196 | sendJson(tid, NS_MEDIA, | |
| 197 | """{"type":"SEEK","mediaSessionId":$msid,"currentTime":$positionSeconds,"requestId":${nextRequestId()}}""") | |
| 198 | } | |
| 199 | ||
| 200 | suspend fun next() { | |
| 201 | val tid = transportId ?: return | |
| 202 | val msid = _mediaStatus.value.mediaSessionId | |
| 203 | if (msid == 0) return | |
| 204 | sendJson(tid, NS_MEDIA, | |
| 205 | """{"type":"QUEUE_NEXT","mediaSessionId":$msid,"requestId":${nextRequestId()}}""") | |
| 206 | } | |
| 207 | ||
| 208 | suspend fun previous() { | |
| 209 | val tid = transportId ?: return | |
| 210 | val msid = _mediaStatus.value.mediaSessionId | |
| 211 | if (msid == 0) return | |
| 212 | sendJson(tid, NS_MEDIA, | |
| 213 | """{"type":"QUEUE_PREV","mediaSessionId":$msid,"requestId":${nextRequestId()}}""") | |
| 214 | } | |
| 215 | ||
| 216 | suspend fun setVolume(level: Double) { | |
| 217 | sendJson(RECEIVER_ID, NS_RECEIVER, | |
| 218 | """{"type":"SET_VOLUME","volume":{"level":$level},"requestId":${nextRequestId()}}""") | |
| 219 | // Optimistic echo: reflect the target immediately so the local UI | |
| 220 | // (and any localVol re-sync) never reads a stale level mid-gesture. | |
| 221 | // The receiver's RECEIVER_STATUS broadcast confirms shortly after | |
| 222 | // and corrects if the device clamped the value. | |
| 223 | _mediaStatus.value = _mediaStatus.value.copy(volume = level.coerceIn(0.0, 1.0)) | |
| 224 | } | |
| 225 | ||
| 226 | /** Re-request receiver status (device volume, running app). The receiver | |
| 227 | * broadcasts RECEIVER_STATUS on every volume change from any sender or | |
| 228 | * hardware control, but a periodic pull guards against missed | |
| 229 | * broadcasts so multi-device observers stay in sync. */ | |
| 230 | suspend fun requestReceiverStatus() { | |
| 231 | sendJson(RECEIVER_ID, NS_RECEIVER, | |
| 232 | """{"type":"GET_STATUS","requestId":${nextRequestId()}}""") | |
| 233 | } | |
| 234 | ||
| 235 | /** Re-request current media status from the Chromecast. Called periodically | |
| 236 | * to catch any MEDIA_STATUS broadcasts that were missed. */ | |
| 237 | suspend fun requestMediaStatus() { | |
| 238 | val tid = transportId ?: return | |
| 239 | sendJson(tid, NS_MEDIA, | |
| 240 | """{"type":"GET_STATUS","requestId":${nextRequestId()}}""") | |
| 241 | } | |
| 242 | ||
| 243 | // ── Queue introspection ────────────────────────────────────── | |
| 244 | // Two-step fetch, mirroring the Cast SDK's MediaQueue: first ask for | |
| 245 | // the ordered list of item IDs, then fetch item metadata in batches. | |
| 246 | ||
| 247 | /** Ask the receiver for the ordered queue item IDs. The response | |
| 248 | * arrives as a QUEUE_ITEM_IDS message handled in [handleMessage], | |
| 249 | * which then fires batched QUEUE_GET_ITEMS requests. */ | |
| 250 | suspend fun requestQueueItemIds() { | |
| 251 | val tid = transportId ?: return | |
| 252 | val msid = _mediaStatus.value.mediaSessionId | |
| 253 | if (msid == 0) return | |
| 254 | sendJson(tid, NS_MEDIA, | |
| 255 | """{"type":"QUEUE_GET_ITEM_IDS","mediaSessionId":$msid,"requestId":${nextRequestId()}}""") | |
| 256 | } | |
| 257 | ||
| 258 | private suspend fun requestQueueItemsBatch(ids: List<Int>) { | |
| 259 | if (ids.isEmpty()) return | |
| 260 | val tid = transportId ?: return | |
| 261 | val msid = _mediaStatus.value.mediaSessionId | |
| 262 | if (msid == 0) return | |
| 263 | sendJson(tid, NS_MEDIA, | |
| 264 | """{"type":"QUEUE_GET_ITEMS","mediaSessionId":$msid,"itemIds":${ids.joinToString(",", "[", "]")},"requestId":${nextRequestId()}}""") | |
| 265 | } | |
| 266 | ||
| 267 | /** Jump playback to a specific queue item. QUEUE_UPDATE with a | |
| 268 | * currentItemId changes position without altering queue contents, | |
| 269 | * so the sender's local queue copy stays in sync (its index tracker | |
| 270 | * follows via onQueueStatusUpdated). */ | |
| 271 | /** Set the receiver queue's repeat mode (Cast QUEUE_UPDATE.repeatMode). */ | |
| 272 | suspend fun setRepeatMode(mode: String) { | |
| 273 | val tid = transportId ?: return | |
| 274 | val msid = _mediaStatus.value.mediaSessionId | |
| 275 | if (msid == 0) return | |
| 276 | sendJson(tid, NS_MEDIA, | |
| 277 | """{"type":"QUEUE_UPDATE","mediaSessionId":$msid,"repeatMode":"$mode","requestId":${nextRequestId()}}""") | |
| 278 | } | |
| 279 | ||
| 280 | /** Shuffle the receiver queue (Cast QUEUE_UPDATE.shuffle). */ | |
| 281 | suspend fun setShuffle(on: Boolean) { | |
| 282 | val tid = transportId ?: return | |
| 283 | val msid = _mediaStatus.value.mediaSessionId | |
| 284 | if (msid == 0) return | |
| 285 | sendJson(tid, NS_MEDIA, | |
| 286 | """{"type":"QUEUE_UPDATE","mediaSessionId":$msid,"shuffle":$on,"requestId":${nextRequestId()}}""") | |
| 287 | } | |
| 288 | ||
| 289 | suspend fun queueJump(itemId: Int) { | |
| 290 | val tid = transportId ?: return | |
| 291 | val msid = _mediaStatus.value.mediaSessionId | |
| 292 | if (msid == 0) return | |
| 293 | sendJson(tid, NS_MEDIA, | |
| 294 | """{"type":"QUEUE_UPDATE","mediaSessionId":$msid,"currentItemId":$itemId,"requestId":${nextRequestId()}}""") | |
| 295 | } | |
| 296 | ||
| 297 | private suspend fun sendMediaCommand(type: String) { | |
| 298 | // Every mutating command is logged WITH ITS CALL SITE. If any screen | |
| 299 | // ever kills a session, this line is the proof — and its absence at | |
| 300 | // the moment a cast dies is proof Earhorn did not send the kill. | |
| 301 | android.util.Log.i("EarhornCast", | |
| 302 | "OUTBOUND MEDIA COMMAND: $type to $host", Throwable("call site")) | |
| 303 | val tid = transportId ?: return | |
| 304 | val msid = _mediaStatus.value.mediaSessionId | |
| 305 | if (msid == 0) return | |
| 306 | sendJson(tid, NS_MEDIA, | |
| 307 | """{"type":"$type","mediaSessionId":$msid,"requestId":${nextRequestId()}}""") | |
| 308 | } | |
| 309 | ||
| 310 | // ── Message I/O ───────────────────────────────────────────── | |
| 311 | ||
| 312 | private suspend fun sendJson( | |
| 313 | destinationId: String, | |
| 314 | namespace: String, | |
| 315 | json: String, | |
| 316 | ) = withContext(Dispatchers.IO) { | |
| 317 | val msg = CastMessageCodec.encode( | |
| 318 | sourceId = SENDER_ID, | |
| 319 | destinationId = destinationId, | |
| 320 | namespace = namespace, | |
| 321 | payloadUtf8 = json, | |
| 322 | ) | |
| 323 | val lenBuf = ByteArray(4) | |
| 324 | lenBuf[0] = (msg.size shr 24 and 0xFF).toByte() | |
| 325 | lenBuf[1] = (msg.size shr 16 and 0xFF).toByte() | |
| 326 | lenBuf[2] = (msg.size shr 8 and 0xFF).toByte() | |
| 327 | lenBuf[3] = (msg.size and 0xFF).toByte() | |
| 328 | val out = output ?: return@withContext | |
| 329 | // Every public command (play/pause/volume/queue/…) funnels through | |
| 330 | // here, and callers launch them from viewModelScope / UI scopes. | |
| 331 | // A write on a dead socket (CCA rebooted, network drop) used to | |
| 332 | // throw IOException out of this suspend fun → unhandled coroutine | |
| 333 | // exception → APP CRASH, landing the user back on the Browse | |
| 334 | // start-destination. Commands are fire-and-forget over an | |
| 335 | // unreliable transport: swallow the failure, flip connected=false | |
| 336 | // so the observer's supervisor can start reconnecting. | |
| 337 | try { | |
| 338 | synchronized(out) { | |
| 339 | out.write(lenBuf) | |
| 340 | out.write(msg) | |
| 341 | out.flush() | |
| 342 | } | |
| 343 | } catch (e: java.io.IOException) { | |
| 344 | android.util.Log.w("EarhornCast", | |
| 345 | "send failed (${e.message}) — marking connection lost") | |
| 346 | _connected.value = false | |
| 347 | } | |
| 348 | } | |
| 349 | ||
| 350 | private suspend fun readLoop() { | |
| 351 | try { | |
| 352 | val inp = input ?: return | |
| 353 | val lenBuf = ByteArray(4) | |
| 354 | while (currentCoroutineContext().isActive) { | |
| 355 | // Read 4-byte length prefix | |
| 356 | readFully(inp, lenBuf) | |
| 357 | val len = (lenBuf[0].toInt() and 0xFF shl 24) or | |
| 358 | (lenBuf[1].toInt() and 0xFF shl 16) or | |
| 359 | (lenBuf[2].toInt() and 0xFF shl 8) or | |
| 360 | (lenBuf[3].toInt() and 0xFF) | |
| 361 | if (len <= 0 || len > 1_000_000) { | |
| 362 | // The stream is desynchronised; skipping the prefix | |
| 363 | // would parse payload bytes as the next length. Drop the | |
| 364 | // connection and let the supervisor reconnect (I.14). | |
| 365 | android.util.Log.w("EarhornCast", "bad length prefix $len — marking connection lost") | |
| 366 | _connected.value = false | |
| 367 | return | |
| 368 | } | |
| 369 | ||
| 370 | val msgBuf = ByteArray(len) | |
| 371 | readFully(inp, msgBuf) | |
| 372 | ||
| 373 | val decoded = CastMessageCodec.decode(msgBuf) ?: continue | |
| 374 | handleMessage(decoded) | |
| 375 | } | |
| 376 | } catch (e: IOException) { | |
| 377 | // Connection lost | |
| 378 | _connected.value = false | |
| 379 | } catch (t: Throwable) { | |
| 380 | // Anything else — a payload the codec's guards let through, an | |
| 381 | // unexpected type — used to kill this coroutine silently while | |
| 382 | // the heartbeat kept the socket "connected": a mute socket of | |
| 383 | // the screen's own making (review N.2, 2026-09-02). Treat it | |
| 384 | // as a lost connection so the supervisor reconnects at once. | |
| 385 | android.util.Log.w("EarhornCast", "read loop died (${t.javaClass.simpleName}: ${t.message}) — marking connection lost") | |
| 386 | _connected.value = false | |
| 387 | } | |
| 388 | } | |
| 389 | ||
| 390 | private fun readFully(inp: InputStream, buf: ByteArray) { | |
| 391 | var offset = 0 | |
| 392 | while (offset < buf.size) { | |
| 393 | val n = inp.read(buf, offset, buf.size - offset) | |
| 394 | if (n < 0) throw IOException("End of stream") | |
| 395 | offset += n | |
| 396 | } | |
| 397 | } | |
| 398 | ||
| 399 | private suspend fun heartbeatLoop() { | |
| 400 | while (currentCoroutineContext().isActive) { | |
| 401 | delay(HEARTBEAT_INTERVAL_MS) | |
| 402 | runCatching { | |
| 403 | sendJson(RECEIVER_ID, NS_HEARTBEAT, """{"type":"PING"}""") | |
| 404 | } | |
| 405 | } | |
| 406 | } | |
| 407 | ||
| 408 | // ── Message handling ──────────────────────────────────────── | |
| 409 | ||
| 410 | private suspend fun handleMessage(msg: CastMessageCodec.DecodedMessage) { | |
| 411 | val json = runCatching { JSONObject(msg.payloadUtf8) }.getOrNull() | |
| 412 | ?: return | |
| 413 | val type = json.optString("type", "") | |
| 414 | ||
| 415 | // Diagnostic — skip heartbeat to keep logcat readable, log everything else | |
| 416 | // with a truncated payload preview. Filter on tag "EarhornCast" in | |
| 417 | // logcat to see the receiver's message flow during a session. Useful | |
| 418 | // when a device gets stuck on "Waiting for…" — the logs will show | |
| 419 | // whether MEDIA_STATUS is arriving at all, and what it contains. | |
| 420 | // Release logging: message TYPE only. Full-payload wire logging was | |
| 421 | // the diag build's instrument (retired 2026-08-29 after the leader | |
| 422 | // investigation got its specimens); type lines keep session | |
| 423 | // narratives readable without the volume. | |
| 424 | if (msg.namespace != NS_HEARTBEAT && type != "MEDIA_STATUS" && type != "RECEIVER_STATUS") { | |
| 425 | android.util.Log.i("EarhornCast", | |
| 426 | "rx ns=${msg.namespace.removePrefix("urn:x-cast:com.google.cast.")} type=$type") | |
| 427 | } | |
| 428 | // One compact line per media status: the receiver's own claim of | |
| 429 | // state and position, straight off the wire — the forced-read | |
| 430 | // record for stale-position and zombie investigations. This line | |
| 431 | // was wrongly cut in the 0.4.3 trim; its absence cost the group | |
| 432 | // its screen-side instrument for the Classical KC incident. | |
| 433 | if (type == "MEDIA_STATUS") { | |
| 434 | val st = _mediaStatus.value | |
| 435 | android.util.Log.i("EarhornCast", | |
| 436 | "status: state=${st.playerState} pos=${st.currentTime}s session=${st.mediaSessionId}") | |
| 437 | } | |
| 438 | ||
| 439 | when (msg.namespace) { | |
| 440 | NS_HEARTBEAT -> { | |
| 441 | if (type == "PING") { | |
| 442 | sendJson(msg.sourceId, NS_HEARTBEAT, """{"type":"PONG"}""") | |
| 443 | } | |
| 444 | } | |
| 445 | ||
| 446 | NS_RECEIVER -> { | |
| 447 | if (type == "RECEIVER_STATUS") { | |
| 448 | handleReceiverStatus(json) | |
| 449 | } | |
| 450 | } | |
| 451 | ||
| 452 | NS_MEDIA -> { | |
| 453 | when (type) { | |
| 454 | "MEDIA_STATUS" -> handleMediaStatus(json) | |
| 455 | "QUEUE_ITEM_IDS" -> handleQueueItemIds(json) | |
| 456 | "QUEUE_ITEMS" -> handleQueueItems(json) | |
| 457 | // Broadcast by the receiver whenever any sender edits | |
| 458 | // the queue (insert/remove/reorder/replace). Refetch | |
| 459 | // rather than applying the delta — queues here are | |
| 460 | // small and a full refetch is unconditionally correct. | |
| 461 | "QUEUE_CHANGE" -> scheduleQueueRefetch() | |
| 462 | } | |
| 463 | } | |
| 464 | ||
| 465 | NS_CONNECTION -> { | |
| 466 | if (type == "CLOSE") { | |
| 467 | // Session ended | |
| 468 | android.util.Log.i("EarhornCast", "transport session CLOSE from ${msg.sourceId}") | |
| 469 | transportId = null | |
| 470 | _mediaStatus.value = MediaStatus(statusSeq = _mediaStatus.value.statusSeq + 1) | |
| 471 | clearQueueState() | |
| 472 | } | |
| 473 | } | |
| 474 | } | |
| 475 | } | |
| 476 | ||
| 477 | private suspend fun handleReceiverStatus(json: JSONObject) { | |
| 478 | val status = json.optJSONObject("status") ?: return | |
| 479 | // Stamp the age here, before the no-app early return: an idle | |
| 480 | // device otherwise never refreshed it and was polled every 10 s | |
| 481 | // instead of every 25 (review N.3, 2026-09-02). | |
| 482 | lastReceiverStatusAt = System.currentTimeMillis() | |
| 483 | val apps = status.optJSONArray("applications") | |
| 484 | if (apps == null || apps.length() == 0) { | |
| 485 | // No media app running on the device. If we were attached to a | |
| 486 | // session, this is POSITIVE evidence it is dead — and it is the | |
| 487 | // only evidence we get on the freeze-then-recover path: the | |
| 488 | // leader freezes, the socket dies, the reconnect supervisor | |
| 489 | // succeeds once the device recovers, and the recovered device | |
| 490 | // reports no application. Logging-and-returning here left the | |
| 491 | // last PLAYING MediaStatus in place forever: sessionLive stuck | |
| 492 | // true, hand-back structurally unable to fire, screens frozen | |
| 493 | // on a stale queue (observed on three devices at once, | |
| 494 | // 2026-08-28). Clear exactly as the CLOSE handler does. | |
| 495 | if (transportId != null || _mediaStatus.value.mediaSessionId != 0) { | |
| 496 | android.util.Log.i("EarhornCast", | |
| 497 | "RECEIVER_STATUS reports no running app while we were " + | |
| 498 | "attached — treating as session death (was tid=$transportId)") | |
| 499 | transportId = null | |
| 500 | _mediaStatus.value = MediaStatus(statusSeq = _mediaStatus.value.statusSeq + 1) | |
| 501 | clearQueueState() | |
| 502 | } else { | |
| 503 | android.util.Log.i("EarhornCast", | |
| 504 | "RECEIVER_STATUS: no media app running (not attached — no-op)") | |
| 505 | } | |
| 506 | return | |
| 507 | } | |
| 508 | ||
| 509 | val app = apps.getJSONObject(0) | |
| 510 | val tid = app.optString("transportId", "") | |
| 511 | val appId = app.optString("appId", "") | |
| 512 | val displayName = app.optString("displayName", "") | |
| 513 | if (tid.isBlank()) { | |
| 514 | android.util.Log.i("EarhornCast", "RECEIVER_STATUS app has no transportId — appId=$appId displayName=$displayName") | |
| 515 | return | |
| 516 | } | |
| 517 | ||
| 518 | // Extract volume (device volume, not a playback report — statusSeq | |
| 519 | // is deliberately left unchanged by this copy). | |
| 520 | val vol = status.optJSONObject("volume") | |
| 521 | if (vol != null) { | |
| 522 | _mediaStatus.value = _mediaStatus.value.copy( | |
| 523 | volume = vol.optDouble("level", 1.0), | |
| 524 | isMuted = vol.optBoolean("muted", false), | |
| 525 | ) | |
| 526 | } | |
| 527 | ||
| 528 | if (tid != transportId) { | |
| 529 | android.util.Log.i("EarhornCast", | |
| 530 | "attaching to transport tid=$tid appId=$appId displayName=$displayName") | |
| 531 | transportId = tid | |
| 532 | // Connect to the app's transport and get media status | |
| 533 | sendJson(tid, NS_CONNECTION, """{"type":"CONNECT","origin":{}}""") | |
| 534 | sendJson(tid, NS_MEDIA, | |
| 535 | """{"type":"GET_STATUS","requestId":${nextRequestId()}}""") | |
| 536 | } | |
| 537 | } | |
| 538 | ||
| 539 | private fun handleMediaStatus(json: JSONObject) { | |
| 540 | val statusArray = json.optJSONArray("status") ?: return | |
| 541 | if (statusArray.length() == 0) { | |
| 542 | // Empty status = idle. Don't wipe the previously-seen track metadata — | |
| 543 | // a tap of "stop" briefly returns an empty status array before the next | |
| 544 | // track loads, and the user shouldn't see the album art / title vanish | |
| 545 | // for the millisecond between broadcasts. | |
| 546 | _mediaStatus.value = _mediaStatus.value.let { | |
| 547 | it.copy(playerState = "IDLE", currentTime = 0.0, statusSeq = it.statusSeq + 1) | |
| 548 | } | |
| 549 | return | |
| 550 | } | |
| 551 | ||
| 552 | val previous = _mediaStatus.value | |
| 553 | val s = statusArray.getJSONObject(0) | |
| 554 | ||
| 555 | // Always-present transport fields. Fall back to previous when the receiver | |
| 556 | // omits a field rather than to a hardcoded default — receivers do send | |
| 557 | // partial updates and we don't want a missing field to overwrite a known | |
| 558 | // value with garbage. | |
| 559 | val playerState = s.optString("playerState", previous.playerState) | |
| 560 | .ifBlank { previous.playerState } | |
| 561 | val currentTime = s.optDouble("currentTime", previous.currentTime) | |
| 562 | val mediaSessionId = s.optInt("mediaSessionId", previous.mediaSessionId) | |
| 563 | .let { if (it == 0) previous.mediaSessionId else it } | |
| 564 | val supportedMediaCommands = | |
| 565 | s.optLong("supportedMediaCommands", previous.supportedMediaCommands) | |
| 566 | val repeatMode = s.optString("repeatMode").ifBlank { null } ?: previous.repeatMode | |
| 567 | ||
| 568 | // Volume can ride on a status update even when there's no media block | |
| 569 | // (the receiver layer exposes its own volume separately from the media | |
| 570 | // app). Pull it before the media-block check so PAUSE/PLAY/SEEK updates | |
| 571 | // can still propagate volume changes. | |
| 572 | // ── Volume: DEVICE volume only ──────────────────────────────── | |
| 573 | // The media-namespace status carries the per-media STREAM volume, | |
| 574 | // which sits at 1.0 essentially always — it is NOT the speaker's | |
| 575 | // volume. The real device volume arrives via RECEIVER_STATUS on | |
| 576 | // the receiver namespace (captured in the RECEIVER_STATUS handler | |
| 577 | // and broadcast by the receiver whenever ANY sender or hardware | |
| 578 | // control changes it). Previously this line overwrote the device | |
| 579 | // volume with the stream volume on every media push, so observers | |
| 580 | // read "100%" regardless of the actual speaker level — a first | |
| 581 | // volume-up tap then sent an absolute 100% and blasted the room. | |
| 582 | val newVolume = previous.volume | |
| 583 | val newMuted = previous.isMuted | |
| 584 | ||
| 585 | // ── Queue-relevant fields ───────────────────────────────────── | |
| 586 | lastMediaStatusAt = System.currentTimeMillis() | |
| 587 | val currentItemId = s.optInt("currentItemId", previous.currentItemId) | |
| 588 | // New media session = new load = new queue. Clear the stale queue | |
| 589 | // and fetch the fresh one. Also covers the initial connect (previous | |
| 590 | // sessionId 0 → real id). | |
| 591 | if (mediaSessionId != previous.mediaSessionId && mediaSessionId != 0) { | |
| 592 | clearQueueState() | |
| 593 | scheduleQueueRefetch() | |
| 594 | } | |
| 595 | // Receivers piggyback the current (and sometimes preloaded) queue | |
| 596 | // items on MEDIA_STATUS. Merge them opportunistically so the | |
| 597 | // now-playing row has metadata even before the batched fetch lands. | |
| 598 | s.optJSONArray("items")?.let { inline -> | |
| 599 | synchronized(queueLock) { | |
| 600 | var merged = false | |
| 601 | for (i in 0 until inline.length()) { | |
| 602 | val qi = parseQueueItem(inline.optJSONObject(i) ?: continue) ?: continue | |
| 603 | queueItemBuf[qi.itemId] = qi | |
| 604 | merged = true | |
| 605 | } | |
| 606 | if (merged) publishQueueSnapshotLocked() | |
| 607 | } | |
| 608 | } | |
| 609 | ||
| 610 | val media = s.optJSONObject("media") | |
| 611 | if (media == null) { | |
| 612 | // Transport-state-only update — PAUSE, RESUME, SEEK, or a heartbeat-ish | |
| 613 | // refresh. The receiver does NOT redeliver the media block on every | |
| 614 | // status push. Preserve the track metadata we already had; only update | |
| 615 | // the volatile transport fields. THIS is the fix for the symptom where | |
| 616 | // the displayed album art / title flickered back to a stale local- | |
| 617 | // renderer track between Cast broadcasts. | |
| 618 | _mediaStatus.value = previous.copy( | |
| 619 | playerState = playerState, | |
| 620 | currentTime = currentTime, | |
| 621 | mediaSessionId = mediaSessionId, | |
| 622 | supportedMediaCommands = supportedMediaCommands, | |
| 623 | repeatMode = repeatMode, | |
| 624 | volume = newVolume, | |
| 625 | isMuted = newMuted, | |
| 626 | currentItemId = currentItemId, | |
| 627 | statusSeq = previous.statusSeq + 1, | |
| 628 | ) | |
| 629 | return | |
| 630 | } | |
| 631 | ||
| 632 | // A media block IS present. Re-derive every metadata field from it, but | |
| 633 | // treat empty strings as "not provided" and keep the previous value — | |
| 634 | // `JSONObject.optString` returns "" (not null) for missing keys, and we | |
| 635 | // don't want a partial metadata payload to blank the artist or album. | |
| 636 | val duration = media.optDouble("duration", previous.duration) | |
| 637 | val streamType = media.optString("streamType").ifBlank { null } ?: previous.streamType | |
| 638 | val contentId = media.optString("contentId").ifBlank { null } ?: previous.contentId | |
| 639 | ||
| 640 | val metadata = media.optJSONObject("metadata") | |
| 641 | val title = metadata?.optString("title")?.ifBlank { null } ?: previous.title | |
| 642 | val artist = (metadata?.optString("artist")?.ifBlank { null } | |
| 643 | ?: metadata?.optString("albumArtist")?.ifBlank { null }) | |
| 644 | ?: previous.artist | |
| 645 | val album = metadata?.optString("albumName")?.ifBlank { null } ?: previous.album | |
| 646 | ||
| 647 | val images = metadata?.optJSONArray("images") | |
| 648 | val albumArtUrl = if (images != null && images.length() > 0) { | |
| 649 | images.getJSONObject(0).optString("url").ifBlank { null } ?: previous.albumArtUrl | |
| 650 | } else { | |
| 651 | previous.albumArtUrl | |
| 652 | } | |
| 653 | ||
| 654 | _mediaStatus.value = MediaStatus( | |
| 655 | title = title, | |
| 656 | artist = artist, | |
| 657 | album = album, | |
| 658 | albumArtUrl = albumArtUrl, | |
| 659 | contentId = contentId, | |
| 660 | duration = duration, | |
| 661 | currentTime = currentTime, | |
| 662 | playerState = playerState, | |
| 663 | mediaSessionId = mediaSessionId, | |
| 664 | supportedMediaCommands = supportedMediaCommands, | |
| 665 | streamType = streamType, | |
| 666 | repeatMode = repeatMode, | |
| 667 | volume = newVolume, | |
| 668 | isMuted = newMuted, | |
| 669 | currentItemId = currentItemId, | |
| 670 | statusSeq = previous.statusSeq + 1, | |
| 671 | ) | |
| 672 | } | |
| 673 | ||
| 674 | // ── Queue message handlers ─────────────────────────────────── | |
| 675 | ||
| 676 | private suspend fun handleQueueItemIds(json: JSONObject) { | |
| 677 | val arr = json.optJSONArray("itemIds") ?: return | |
| 678 | val ids = (0 until arr.length()).map { arr.optInt(it) }.filter { it != 0 } | |
| 679 | android.util.Log.i("EarhornCast", "QUEUE_ITEM_IDS: ${ids.size} ids") | |
| 680 | // Mutate under the lock; compute what to fetch; then suspend-send | |
| 681 | // OUTSIDE the lock (never suspend while holding a monitor). | |
| 682 | val toFetch = synchronized(queueLock) { | |
| 683 | queueItemIdOrder = ids | |
| 684 | // Drop buffered items that fell out of the queue, keep the rest | |
| 685 | // so the list doesn't flash empty during a refetch. Surviving | |
| 686 | // itemIds are trustworthy: item IDs are unique per insert, so an | |
| 687 | // edited/replaced entry always arrives under a NEW id — there is | |
| 688 | // no need to re-fetch metadata we already hold. (The previous | |
| 689 | // "cheap safety" re-fetch of buffered items doubled QUEUE_GET_ITEMS | |
| 690 | // traffic on every refetch; with several observers attached that | |
| 691 | // measurably loads a group leader that is also doing multiroom.) | |
| 692 | queueItemBuf.keys.retainAll(ids.toSet()) | |
| 693 | publishQueueSnapshotLocked() | |
| 694 | ids.filterNot { queueItemBuf.containsKey(it) } | |
| 695 | } | |
| 696 | // Fetch missing metadata in batches of 20 — the SDK's MediaQueue | |
| 697 | // uses the same chunk size to keep individual frames small. | |
| 698 | toFetch.chunked(20).forEach { requestQueueItemsBatch(it) } | |
| 699 | } | |
| 700 | ||
| 701 | private fun handleQueueItems(json: JSONObject) { | |
| 702 | val arr = json.optJSONArray("items") ?: return | |
| 703 | var changed = false | |
| 704 | synchronized(queueLock) { | |
| 705 | for (i in 0 until arr.length()) { | |
| 706 | val qi = parseQueueItem(arr.optJSONObject(i) ?: continue) ?: continue | |
| 707 | queueItemBuf[qi.itemId] = qi | |
| 708 | changed = true | |
| 709 | } | |
| 710 | if (changed) { | |
| 711 | android.util.Log.i("EarhornCast", | |
| 712 | "QUEUE_ITEMS batch → buf ${queueItemBuf.size}/${queueItemIdOrder.size}") | |
| 713 | publishQueueSnapshotLocked() | |
| 714 | } | |
| 715 | } | |
| 716 | } | |
| 717 | ||
| 718 | /** Parse one queue item object: `{itemId, media: {contentId, duration, | |
| 719 | * metadata: {title, artist|albumArtist, albumName, images: [{url}]}}}`. */ | |
| 720 | private fun parseQueueItem(obj: JSONObject): QueueItem? { | |
| 721 | val itemId = obj.optInt("itemId", 0) | |
| 722 | if (itemId == 0) return null | |
| 723 | val media = obj.optJSONObject("media") | |
| 724 | val metadata = media?.optJSONObject("metadata") | |
| 725 | val images = metadata?.optJSONArray("images") | |
| 726 | val artUrl = if (images != null && images.length() > 0) { | |
| 727 | images.optJSONObject(0)?.optString("url")?.ifBlank { null } | |
| 728 | } else null | |
| 729 | return QueueItem( | |
| 730 | itemId = itemId, | |
| 731 | contentId = media?.optString("contentId")?.ifBlank { null }, | |
| 732 | title = metadata?.optString("title")?.ifBlank { null }, | |
| 733 | artist = metadata?.optString("artist")?.ifBlank { null } | |
| 734 | ?: metadata?.optString("albumArtist")?.ifBlank { null }, | |
| 735 | album = metadata?.optString("albumName")?.ifBlank { null }, | |
| 736 | albumArtUrl = artUrl, | |
| 737 | durationSeconds = media?.optDouble("duration", 0.0) ?: 0.0, | |
| 738 | ) | |
| 739 | } | |
| 740 | ||
| 741 | /** Publish the ordered snapshot. Callers MUST hold [queueLock]. */ | |
| 742 | private fun publishQueueSnapshotLocked() { | |
| 743 | if (queueItemIdOrder.isEmpty()) { | |
| 744 | // No ID order yet (e.g. inline items merged before the first | |
| 745 | // QUEUE_ITEM_IDS response) — publish buffered items by itemId | |
| 746 | // order as a best-effort interim view. | |
| 747 | _queueItems.value = queueItemBuf.values.sortedBy { it.itemId } | |
| 748 | return | |
| 749 | } | |
| 750 | _queueItems.value = queueItemIdOrder.mapNotNull { queueItemBuf[it] } | |
| 751 | } | |
| 752 | ||
| 753 | /** Debounced full refetch — coalesces bursts of QUEUE_CHANGE broadcasts | |
| 754 | * during bulk sender edits into a single round-trip. */ | |
| 755 | private fun scheduleQueueRefetch() { | |
| 756 | val sc = scope ?: return | |
| 757 | queueRefetchJob?.cancel() | |
| 758 | queueRefetchJob = sc.launch { | |
| 759 | delay(300) | |
| 760 | runCatching { requestQueueItemIds() } | |
| 761 | } | |
| 762 | } | |
| 763 | ||
| 764 | private fun clearQueueState() { | |
| 765 | queueRefetchJob?.cancel() | |
| 766 | queueRefetchJob = null | |
| 767 | synchronized(queueLock) { | |
| 768 | queueItemIdOrder = emptyList() | |
| 769 | queueItemBuf.clear() | |
| 770 | _queueItems.value = emptyList() | |
| 771 | } | |
| 772 | } | |
| 773 | ||
| 774 | private fun nextRequestId(): Int = synchronized(this) { requestId++ } | |
| 775 | ||
| 776 | // ── TLS trust (Chromecast uses self-signed certs) ─────────── | |
| 777 | ||
| 778 | private object PermissiveTrustManager : X509TrustManager { | |
| 779 | override fun checkClientTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {} | |
| 780 | override fun checkServerTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {} | |
| 781 | override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = emptyArray() | |
| 782 | } | |
| 783 | } | |
| 784 | ||
| 785 | /** | |
| 786 | * Minimal hand-rolled protobuf codec for CastMessage. | |
| 787 | * | |
| 788 | * The CastMessage protobuf has exactly 7 fields, all simple types. Rather than | |
| 789 | * pulling in the full protobuf-javalite dependency, we encode/decode the ~80 bytes | |
| 790 | * of wire format directly. This keeps the client zero-dependency. | |
| 791 | * | |
| 792 | * Wire format reference: | |
| 793 | * message CastMessage { | |
| 794 | * required ProtocolVersion protocol_version = 1; // varint, always 0 | |
| 795 | * required string source_id = 2; | |
| 796 | * required string destination_id = 3; | |
| 797 | * required string namespace = 4; | |
| 798 | * required PayloadType payload_type = 5; // varint, 0=STRING 1=BINARY | |
| 799 | * optional string payload_utf8 = 6; | |
| 800 | * optional bytes payload_binary = 7; | |
| 801 | * } | |
| 802 | */ | |
| 803 | object CastMessageCodec { | |
| 804 | ||
| 805 | data class DecodedMessage( | |
| 806 | val sourceId: String, | |
| 807 | val destinationId: String, | |
| 808 | val namespace: String, | |
| 809 | val payloadUtf8: String, | |
| 810 | ) | |
| 811 | ||
| 812 | fun encode( | |
| 813 | sourceId: String, | |
| 814 | destinationId: String, | |
| 815 | namespace: String, | |
| 816 | payloadUtf8: String, | |
| 817 | ): ByteArray { | |
| 818 | val buf = ByteArrayOutputStream(256) | |
| 819 | // field 1: protocol_version = 0 (CASTV2_1_0), wire type 0 (varint) | |
| 820 | writeVarint(buf, (1 shl 3) or 0) | |
| 821 | writeVarint(buf, 0) | |
| 822 | // field 2: source_id | |
| 823 | writeString(buf, 2, sourceId) | |
| 824 | // field 3: destination_id | |
| 825 | writeString(buf, 3, destinationId) | |
| 826 | // field 4: namespace | |
| 827 | writeString(buf, 4, namespace) | |
| 828 | // field 5: payload_type = 0 (STRING) | |
| 829 | writeVarint(buf, (5 shl 3) or 0) | |
| 830 | writeVarint(buf, 0) | |
| 831 | // field 6: payload_utf8 | |
| 832 | writeString(buf, 6, payloadUtf8) | |
| 833 | return buf.toByteArray() | |
| 834 | } | |
| 835 | ||
| 836 | fun decode(data: ByteArray): DecodedMessage? { | |
| 837 | var sourceId = "" | |
| 838 | var destinationId = "" | |
| 839 | var namespace = "" | |
| 840 | var payloadUtf8 = "" | |
| 841 | ||
| 842 | var pos = 0 | |
| 843 | while (pos < data.size) { | |
| 844 | val (tag, newPos) = readVarint(data, pos) | |
| 845 | pos = newPos | |
| 846 | val fieldNumber = tag ushr 3 | |
| 847 | val wireType = tag and 0x07 | |
| 848 | ||
| 849 | when (wireType) { | |
| 850 | 0 -> { // varint — skip the value | |
| 851 | val (_, p) = readVarint(data, pos) | |
| 852 | pos = p | |
| 853 | } | |
| 854 | 2 -> { // length-delimited | |
| 855 | val (len, p) = readVarint(data, pos) | |
| 856 | pos = p | |
| 857 | if (pos + len > data.size) return null | |
| 858 | val bytes = data.copyOfRange(pos, pos + len) | |
| 859 | pos += len | |
| 860 | when (fieldNumber) { | |
| 861 | 2 -> sourceId = String(bytes, Charsets.UTF_8) | |
| 862 | 3 -> destinationId = String(bytes, Charsets.UTF_8) | |
| 863 | 4 -> namespace = String(bytes, Charsets.UTF_8) | |
| 864 | 6 -> payloadUtf8 = String(bytes, Charsets.UTF_8) | |
| 865 | // 7 = payload_binary, ignored | |
| 866 | } | |
| 867 | } | |
| 868 | else -> return null // unexpected wire type | |
| 869 | } | |
| 870 | } | |
| 871 | ||
| 872 | return DecodedMessage(sourceId, destinationId, namespace, payloadUtf8) | |
| 873 | } | |
| 874 | ||
| 875 | private fun writeString(buf: ByteArrayOutputStream, fieldNumber: Int, value: String) { | |
| 876 | val bytes = value.toByteArray(Charsets.UTF_8) | |
| 877 | writeVarint(buf, (fieldNumber shl 3) or 2) | |
| 878 | writeVarint(buf, bytes.size) | |
| 879 | buf.write(bytes) | |
| 880 | } | |
| 881 | ||
| 882 | private fun writeVarint(buf: ByteArrayOutputStream, value: Int) { | |
| 883 | var v = value | |
| 884 | while (v > 0x7F) { | |
| 885 | buf.write((v and 0x7F) or 0x80) | |
| 886 | v = v ushr 7 | |
| 887 | } | |
| 888 | buf.write(v and 0x7F) | |
| 889 | } | |
| 890 | ||
| 891 | private fun readVarint(data: ByteArray, startPos: Int): Pair<Int, Int> { | |
| 892 | var result = 0 | |
| 893 | var shift = 0 | |
| 894 | var pos = startPos | |
| 895 | while (pos < data.size) { | |
| 896 | val b = data[pos].toInt() and 0xFF | |
| 897 | result = result or ((b and 0x7F) shl shift) | |
| 898 | pos++ | |
| 899 | if (b and 0x80 == 0) break | |
| 900 | shift += 7 | |
| 901 | } | |
| 902 | return result to pos | |
| 903 | } | |
| 904 | } |


