package com.sptmobile.voice import java.io.IOException import java.net.InetAddress import java.net.ServerSocket import java.net.Socket import java.net.SocketException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow // [impl->REQ-VOICE-PIPE] /** * The localhost webhook receiver's socket lifetime (DESIGN.md architecture: * "webhook receiver (HTTP, localhost)"), owned by the foreground service — * ruling 7's "one lifetime, two jobs". Loopback bind ONLY: the Pebble app on * this phone is the sole legitimate client. Connections are served serially * on one daemon thread — one wearable posting one recording at a time needs * no more, and serial service keeps spool order = arrival order (FIFO, * ruling 6). */ class WebhookServer( private val port: Int, private val handler: WebhookHandler, private val maxBody: Int = MAX_BODY_BYTES, private val now: () -> Long = System::currentTimeMillis, ) { @Volatile private var socket: ServerSocket? = null // [impl->REQ-HAZARD-RECEIVER-DOWN] private val _lastReceivedAt = MutableStateFlow(null) /** * Wall-clock of the last well-formed request the receiver read (any * status — even a 401 probe proves the process is alive and gating). * null = bound but never hit. Surfaced on the Hosts voice card so a * silently-dead receiver is visible, not a mystery ECONNREFUSED. */ val lastReceivedAt: StateFlow = _lastReceivedAt /** True while the loopback socket is bound and the accept loop is live. */ val bound: Boolean get() = socket != null /** The address the accept socket is bound to (test seam for the IPv4 pin). */ val boundAddress: InetAddress? get() = socket?.inetAddress /** Bind + start serving; returns the bound port. Throws [IOException]. */ fun start(): Int { check(socket == null) { "already started" } val server = ServerSocket(port, BACKLOG, LOOPBACK_V4) socket = server Thread({ acceptLoop(server) }, "voice-webhook").apply { isDaemon = true start() } return server.localPort } fun stop() { val server = socket ?: return socket = null try { server.close() } catch (_: IOException) { } } private fun acceptLoop(server: ServerSocket) { while (socket === server) { val conn = try { server.accept() } catch (_: SocketException) { return // closed by stop() } catch (_: IOException) { continue } conn.use { serve(it) } } } private fun serve(conn: Socket) { conn.soTimeout = READ_TIMEOUT_MS val response = try { val request = WebhookRequest.read(conn.getInputStream(), maxBody) // [impl->REQ-HAZARD-RECEIVER-DOWN] A request was read off the // socket — mark liveness before handling (independent of the // status the handler picks; a token-less probe still proves up). _lastReceivedAt.value = now() // handle() returns only after a spooled row is durable — the // response bytes below are written strictly after that commit // (REQ-HAZARD-DICTATION-LOSS ordering). handler.handle(request) } catch (e: WebhookRequest.TooLarge) { WebhookHandler.Response(413, e.message ?: "too large") } catch (e: WebhookRequest.Malformed) { WebhookHandler.Response(400, e.message ?: "malformed request") } catch (e: IOException) { return // client vanished mid-read; nothing to answer } try { val body = response.body.toByteArray(Charsets.UTF_8) conn.getOutputStream().apply { write( ("HTTP/1.1 ${response.statusLine}\r\n" + "Content-Type: application/json; charset=utf-8\r\n" + "Content-Length: ${body.size}\r\n" + "Connection: close\r\n\r\n").toByteArray(Charsets.ISO_8859_1) ) write(body) flush() } } catch (_: IOException) { // Answer lost on the wire. A spooled row stays spooled (at-least- // once toward the star); Pebble re-posts on its next recording at // worst — never a dropped dictation. } } companion object { // [impl->REQ-HAZARD-RECEIVER-BIND-V4] /** * Pin IPv4 loopback. Pebble's Index app connects to the literal * `127.0.0.1` (the webhook URL we display), but * `InetAddress.getLoopbackAddress()` resolves to IPv6 `::1` on * dual-stack devices (field-observed on razr+ 2024) — binding * `::1`-only, so the IPv4 connect is refused with `ECONNREFUSED` and * the dictation is silently lost. adb-forward probes hid this: adbd * bridges to `::1`. Bind the family the client actually uses. */ private val LOOPBACK_V4: InetAddress = InetAddress.getByName("127.0.0.1") /** Fixed default so the user's Pebble webhook URL config is stable. */ const val DEFAULT_PORT = 8646 /** Comfortable headroom over a transcription POST, well under an * audio-bearing one we would still rather read + skip than choke on. */ const val MAX_BODY_BYTES = 8 * 1024 * 1024 private const val BACKLOG = 4 private const val READ_TIMEOUT_MS = 10_000 } }