diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 167afab..87ac74d 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -5,6 +5,9 @@
+
+
+
diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteClient.kt b/android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteClient.kt
new file mode 100644
index 0000000..7ed61af
--- /dev/null
+++ b/android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteClient.kt
@@ -0,0 +1,276 @@
+package com.mag160c.thermal.net
+
+import com.mag160c.thermal.media.DebugLog
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.InputStream
+import java.net.DatagramPacket
+import java.net.DatagramSocket
+import java.net.InetSocketAddress
+import java.net.Socket
+import java.net.SocketTimeoutException
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * Client side of the LAN remote preview (Phase F).
+ *
+ * [discover] listens for host beacons on UDP 47510 and emits each host once per
+ * 3 s window. [connect] opens the TCP control channel and returns a session.
+ *
+ * Protocol phasing (keeps one TCP stream unambiguous):
+ * - BEFORE start: the stream carries newline-delimited JSON lines only
+ * (welcome, ok, errors);
+ * - AFTER start: the stream carries fixed 38412-byte frame records only.
+ * The host's keepalive pings sent between frames are harmless because the
+ * frame reader resynchronises on the magic; the client's liveness timer is
+ * driven by received bytes, so a ping also counts as "link alive".
+ */
+object RemoteClient {
+ /** Hosts seen on the LAN, de-duplicated within a 3 s window. */
+ fun discover(scope: CoroutineScope): Flow {
+ val out = MutableSharedFlow(
+ extraBufferCapacity = 16,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
+ )
+ scope.launch(Dispatchers.IO) {
+ val socket = try {
+ DatagramSocket(null).apply {
+ reuseAddress = true
+ broadcast = true
+ soTimeout = 1000
+ bind(InetSocketAddress(RemoteContract.DISCOVERY_PORT))
+ }
+ } catch (e: Exception) {
+ DebugLog.log("remote", "discovery bind failed: $e")
+ return@launch
+ }
+ val buf = ByteArray(RemoteContract.MAX_LINE)
+ val lastSeen = HashMap()
+ try {
+ while (isActive) {
+ val packet = DatagramPacket(buf, buf.size)
+ try {
+ socket.receive(packet)
+ } catch (e: SocketTimeoutException) {
+ continue
+ } catch (e: Exception) {
+ if (!isActive) break
+ continue
+ }
+ val text = String(buf, 0, packet.length, Charsets.UTF_8)
+ val from = packet.address?.hostAddress ?: continue
+ val info = RemoteContract.parseBeacon(text, from) ?: continue
+ val now = System.currentTimeMillis()
+ val key = "${info.address}:${info.tcpPort}"
+ val prev = lastSeen[key]
+ if (prev == null || now - prev >= 3000) {
+ lastSeen[key] = now
+ out.tryEmit(info)
+ }
+ }
+ } finally {
+ runCatching { socket.close() }
+ }
+ }
+ return out.asSharedFlow()
+ }
+
+ suspend fun connect(
+ host: String,
+ port: Int = RemoteContract.CONTROL_PORT,
+ scope: CoroutineScope,
+ ): RemoteSession? = withContext(Dispatchers.IO) {
+ try {
+ val socket = Socket()
+ socket.connect(InetSocketAddress(host, port), 4000)
+ socket.tcpNoDelay = true
+ socket.soTimeout = 100
+ val session = RemoteSession(host, port, socket, scope)
+ session.startReader()
+ DebugLog.log("remote", "connected to $host:$port")
+ session
+ } catch (e: Exception) {
+ DebugLog.log(
+ "remote",
+ "connect $host:$port failed: ${e.javaClass.simpleName}: ${e.message}",
+ )
+ null
+ }
+ }
+}
+
+/** One live connection to a remote host. */
+class RemoteSession(
+ val host: String,
+ val port: Int,
+ private val socket: Socket,
+ private val scope: CoroutineScope,
+) {
+ // Control lines are few and the consumer may subscribe a moment late
+ // (connect() returns before the UI starts collecting), so keep a small
+ // replay window instead of dropping welcome / stream-start.
+ private val _lines = MutableSharedFlow(
+ replay = 8,
+ extraBufferCapacity = 32,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
+ )
+
+ /** Control lines from the host; only meaningful before the stream starts. */
+ val lines: Flow = _lines.asSharedFlow()
+
+ private val _frames = MutableSharedFlow(
+ extraBufferCapacity = 8,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
+ )
+
+ /** Raw 38400-byte sensor payloads, ready for the local RenderPipeline. */
+ val frames: Flow = _frames.asSharedFlow()
+
+ private val closed = AtomicBoolean(false)
+
+ @Volatile
+ private var streaming = false
+
+ @Volatile
+ var frameCount: Int = 0
+ private set
+
+ @Volatile
+ var lastDataMs: Long = System.currentTimeMillis()
+ private set
+
+ /** Set when the reader exits (link lost); the UI shows a snackbar and leaves. */
+ private val _closedFlow = MutableSharedFlow(extraBufferCapacity = 1)
+ val closedFlow: Flow = _closedFlow.asSharedFlow()
+
+ val isOpen: Boolean get() = !closed.get() && !socket.isClosed
+
+ internal fun startReader() {
+ scope.launch(Dispatchers.IO) {
+ val reader = socket.getInputStream()
+ val framer = RemoteContract.FramePacketReader()
+ val chunk = ByteArray(64 * 1024)
+ val lineBuf = StringBuilder()
+ try {
+ while (!closed.get() && isActive) {
+ val n = try {
+ reader.read(chunk)
+ } catch (e: SocketTimeoutException) {
+ 0 // no data this tick (soTimeout) — NOT end of stream
+ }
+ if (n < 0) {
+ DebugLog.log("remote", "host closed the connection")
+ break
+ }
+ if (n > 0) {
+ lastDataMs = System.currentTimeMillis()
+ if (streaming) {
+ // frame records; keepalive pings in between are skipped
+ // by the framer's magic resynchronisation
+ for (frame in framer.feed(chunk.copyOfRange(0, n))) {
+ frameCount++
+ _frames.tryEmit(frame)
+ }
+ } else {
+ // line mode: scan for newline-terminated JSON. The host
+ // sends stream-start and then switches to raw frames, so a
+ // chunk can contain the line AND the first frame: the
+ // remainder after the newline must go to the framer.
+ var i = 0
+ while (i < n) {
+ val c = chunk[i].toInt().toChar()
+ if (c == '\n') {
+ val text = lineBuf.toString()
+ lineBuf.setLength(0)
+ if (text.isNotBlank()) {
+ if (RemoteContract.typeOf(text) == "stream-start") {
+ streaming = true
+ _lines.tryEmit(text)
+ for (frame in framer.feed(chunk.copyOfRange(i + 1, n))) {
+ frameCount++
+ _frames.tryEmit(frame)
+ }
+ break
+ }
+ _lines.tryEmit(text)
+ }
+ } else if (c != '\r') {
+ if (lineBuf.length < RemoteContract.MAX_LINE) lineBuf.append(c)
+ }
+ i++
+ }
+ }
+ continue
+ }
+ if (System.currentTimeMillis() - lastDataMs > RemoteContract.DEAD_AFTER_MS) {
+ DebugLog.log(
+ "remote",
+ "no data for ${RemoteContract.DEAD_AFTER_MS} ms, closing",
+ )
+ break
+ }
+ delay(10)
+ }
+ } catch (e: Exception) {
+ if (!closed.get()) {
+ DebugLog.log(
+ "remote",
+ "session error: ${e.javaClass.simpleName}: ${e.message}",
+ )
+ }
+ } finally {
+ val wasOpen = !closed.get()
+ close()
+ if (wasOpen) _closedFlow.tryEmit(Unit)
+ }
+ }
+ }
+
+ /**
+ * Ask the host to start streaming. The reader stays in line mode until the
+ * host's stream-start line arrives, then switches to frame mode (so the
+ * confirmation is observable and no frame byte is ever parsed as text).
+ */
+ fun startStream() {
+ if (closed.get()) return
+ send(RemoteContract.cmd("start"))
+ }
+
+ fun stopStream() {
+ streaming = false
+ send(RemoteContract.cmd("stop"))
+ }
+
+ fun requestFfc() = send(RemoteContract.cmd("ffc"))
+
+ fun hello(name: String) = send(RemoteContract.hello(name))
+
+ private fun send(cmd: String) {
+ if (closed.get()) return
+ scope.launch(Dispatchers.IO) {
+ try {
+ val out = socket.getOutputStream()
+ out.write((cmd + "\n").toByteArray(Charsets.UTF_8))
+ out.flush()
+ } catch (e: Exception) {
+ if (!closed.get()) {
+ DebugLog.log("remote", "send failed: ${e.javaClass.simpleName}: ${e.message}")
+ }
+ }
+ }
+ }
+
+ fun close() {
+ if (!closed.getAndSet(true)) {
+ runCatching { socket.close() }
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteContract.kt b/android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteContract.kt
new file mode 100644
index 0000000..9a8e0e5
--- /dev/null
+++ b/android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteContract.kt
@@ -0,0 +1,266 @@
+package com.mag160c.thermal.net
+
+/**
+ * Wire contract for the LAN remote preview (Phase F).
+ *
+ * Design (frozen — do not change the wire parameters):
+ * - discovery: UDP broadcast on port 47510, one UTF-8 JSON line per second:
+ * {"app":"mag160c-remote","role":"host","name":"","tcp":47511,"serial":...}
+ * - control: TCP 47511, newline-delimited UTF-8 JSON, one line <= 4 KB
+ * - frames: after {"cmd":"start"} the host writes fixed 38412-byte binary
+ * records: [u32 LE 0x1BB1B11B][u32 LE counter][u32 LE 38400][38400 B u16 LE]
+ * - the client renders locally with its own RenderPipeline + bundled DDT, so
+ * palette / zoom never cross the wire
+ * - keepalive: host sends {"type":"ping"} after 3 s without frames; the
+ * client treats 10 s of silence as a dead link
+ *
+ * Deliberately FREE OF ANDROID TYPES (including org.json, which is a stub in
+ * JVM unit tests): the JSON used here is a flat object of strings and numbers,
+ * handled by the tiny helpers below so the whole contract is unit-testable.
+ */
+object RemoteContract {
+ const val DISCOVERY_PORT = 47510
+ const val CONTROL_PORT = 47511
+ const val APP_TAG = "mag160c-remote"
+ const val FRAME_MAGIC = 0x1BB1B11B
+ const val FRAME_PIXELS = 38400
+ const val FRAME_TOTAL = 12 + FRAME_PIXELS // 38412
+ const val PING_AFTER_MS = 3000L
+ const val DEAD_AFTER_MS = 10000L
+ const val MAX_LINE = 4096
+
+ /** One discovery beacon / host descriptor. */
+ data class HostInfo(
+ val name: String,
+ val address: String,
+ val tcpPort: Int = CONTROL_PORT,
+ val serial: Long = 0L,
+ )
+
+ /** Stream-start banner from the host. */
+ data class Welcome(val w: Int, val h: Int, val fps: Int, val serial: Long)
+
+ // ---- flat JSON helpers (string + number values only, no nesting) ----
+
+ /** Extract a raw value token for `"key"`; null when absent. */
+ private fun rawValue(json: String, key: String): String? {
+ val needle = "\"$key\""
+ var from = 0
+ while (true) {
+ val at = json.indexOf(needle, from)
+ if (at < 0) return null
+ var i = at + needle.length
+ while (i < json.length && json[i].isWhitespace()) i++
+ if (i >= json.length || json[i] != ':') { from = at + 1; continue }
+ i++
+ while (i < json.length && json[i].isWhitespace()) i++
+ if (i >= json.length) return null
+ if (json[i] == '"') {
+ val sb = StringBuilder()
+ var j = i + 1
+ while (j < json.length) {
+ val c = json[j]
+ when {
+ c == '\\' && j + 1 < json.length -> {
+ when (val e = json[j + 1]) {
+ '"' -> sb.append('"')
+ '\\' -> sb.append('\\')
+ 'n' -> sb.append('\n')
+ 'r' -> sb.append('\r')
+ 't' -> sb.append('\t')
+ 'u' -> {
+ if (j + 5 < json.length) {
+ val cp = json.substring(j + 2, j + 6).toIntOrNull(16)
+ if (cp != null) sb.append(cp.toChar())
+ j += 4
+ }
+ }
+ else -> sb.append(e)
+ }
+ j += 2
+ }
+ c == '"' -> return sb.toString()
+ else -> {
+ sb.append(c)
+ j++
+ }
+ }
+ }
+ return null
+ }
+ var j = i
+ while (j < json.length && json[j] != ',' && json[j] != '}' && !json[j].isWhitespace()) j++
+ return json.substring(i, j)
+ }
+ }
+
+ private fun str(json: String, key: String, def: String = ""): String =
+ rawValue(json, key) ?: def
+
+ private fun num(json: String, key: String, def: Long): Long =
+ rawValue(json, key)?.toLongOrNull() ?: def
+
+ private fun escape(s: String): String {
+ val sb = StringBuilder(s.length + 8)
+ for (c in s) {
+ when (c) {
+ '"' -> sb.append("\\\"")
+ '\\' -> sb.append("\\\\")
+ '\n' -> sb.append("\\n")
+ '\r' -> sb.append("\\r")
+ '\t' -> sb.append("\\t")
+ else -> if (c < ' ') sb.append(String.format("\\u%04x", c.code)) else sb.append(c)
+ }
+ }
+ return sb.toString()
+ }
+
+ fun hostBeacon(name: String, serial: Long): String =
+ "{\"app\":\"$APP_TAG\",\"role\":\"host\",\"name\":\"${escape(name)}\"," +
+ "\"tcp\":$CONTROL_PORT,\"serial\":$serial}"
+
+ fun hello(name: String): String = "{\"cmd\":\"hello\",\"name\":\"${escape(name)}\"}"
+
+ fun cmd(name: String): String = "{\"cmd\":\"$name\"}"
+
+ fun okLine(): String = "{\"type\":\"ok\"}"
+
+ fun errorLine(message: String): String = "{\"type\":\"error\",\"message\":\"${escape(message)}\"}"
+
+ fun welcomeLine(w: Int, h: Int, fps: Int, serial: Long): String =
+ "{\"type\":\"welcome\",\"w\":$w,\"h\":$h,\"fps\":$fps,\"serial\":$serial}"
+
+ fun streamStartLine(): String = "{\"type\":\"stream-start\"}"
+
+ fun streamStopLine(): String = "{\"type\":\"stream-stop\"}"
+
+ fun pingLine(): String = "{\"type\":\"ping\"}"
+
+ /** "cmd" field of a client->host control line ("" when absent). */
+ fun commandOf(text: String): String = str(text.trim(), "cmd")
+
+ /** "type" field of a host->client line ("" when absent). */
+ fun typeOf(text: String): String = str(text.trim(), "type")
+
+ fun nameOf(text: String): String = str(text.trim(), "name")
+
+ fun isBusyLine(text: String): Boolean = typeOf(text) == "busy"
+
+ /** Parse a discovery datagram; null when it is not one of ours. */
+ fun parseBeacon(text: String, fromAddress: String): HostInfo? {
+ val t = text.trim()
+ if (str(t, "app") != APP_TAG || str(t, "role") != "host") return null
+ return HostInfo(
+ name = str(t, "name", fromAddress),
+ address = fromAddress,
+ tcpPort = num(t, "tcp", CONTROL_PORT.toLong()).toInt(),
+ serial = num(t, "serial", 0L),
+ )
+ }
+
+ fun parseWelcome(text: String): Welcome? {
+ val t = text.trim()
+ if (typeOf(t) != "welcome") return null
+ return Welcome(
+ w = num(t, "w", 160).toInt(),
+ h = num(t, "h", 120).toInt(),
+ fps = num(t, "fps", 15).toInt(),
+ serial = num(t, "serial", 0L),
+ )
+ }
+
+ fun isKeepalive(text: String): Boolean = typeOf(text) == "ping"
+
+ /** Build one frame record for the wire. */
+ fun encodeFramePacket(raw: ByteArray, counter: Int): ByteArray {
+ require(raw.size == FRAME_PIXELS) {
+ "frame payload must be $FRAME_PIXELS bytes, got ${raw.size}"
+ }
+ val out = ByteArray(FRAME_TOTAL)
+ putU32(out, 0, FRAME_MAGIC)
+ putU32(out, 4, counter)
+ putU32(out, 8, FRAME_PIXELS)
+ System.arraycopy(raw, 0, out, 12, FRAME_PIXELS)
+ return out
+ }
+
+ fun u32(b: ByteArray, off: Int): Int =
+ (b[off].toInt() and 0xFF) or
+ ((b[off + 1].toInt() and 0xFF) shl 8) or
+ ((b[off + 2].toInt() and 0xFF) shl 16) or
+ ((b[off + 3].toInt() and 0xFF) shl 24)
+
+ fun putU32(dst: ByteArray, off: Int, v: Int) {
+ dst[off] = (v and 0xFF).toByte()
+ dst[off + 1] = ((v shr 8) and 0xFF).toByte()
+ dst[off + 2] = ((v shr 16) and 0xFF).toByte()
+ dst[off + 3] = ((v ushr 24) and 0xFF).toByte()
+ }
+
+ /**
+ * Incremental reassembly of the TCP byte stream into frame payloads.
+ * Handles partial records and several records in one read (both occur with
+ * raw socket reads). Malformed data resynchronises on the next magic
+ * instead of dropping the connection.
+ */
+ class FramePacketReader {
+ private var buffer = ByteArray(FRAME_TOTAL * 4)
+ private var size = 0
+
+ /** Feed bytes, get back every complete frame payload available. */
+ fun feed(bytes: ByteArray): List {
+ append(bytes)
+ val out = ArrayList(2)
+ while (true) {
+ if (size < 12) break
+ var magicAt = -1
+ for (i in 0..size - 4) {
+ if (u32(buffer, i) == FRAME_MAGIC) {
+ magicAt = i
+ break
+ }
+ }
+ if (magicAt < 0) {
+ // keep the tail in case a magic straddles this read
+ dropBefore(maxOf(0, size - 3))
+ break
+ }
+ if (magicAt > 0) dropBefore(magicAt)
+ if (size < 12) break
+ if (u32(buffer, 8) != FRAME_PIXELS) { // bogus header
+ dropBefore(1)
+ continue
+ }
+ if (size < FRAME_TOTAL) break
+ out.add(buffer.copyOfRange(12, FRAME_TOTAL))
+ dropBefore(FRAME_TOTAL)
+ }
+ return out
+ }
+
+ /** Bytes buffered but not yet forming a complete frame. */
+ fun pending(): Int = size
+
+ private fun append(bytes: ByteArray) {
+ if (size + bytes.size > buffer.size) {
+ buffer = buffer.copyOf(maxOf(buffer.size, size + bytes.size))
+ }
+ System.arraycopy(bytes, 0, buffer, size, bytes.size)
+ size += bytes.size
+ }
+
+ private fun dropBefore(n: Int) {
+ if (n <= 0) return
+ if (n >= size) {
+ size = 0
+ return
+ }
+ System.arraycopy(buffer, n, buffer, 0, size - n)
+ size -= n
+ }
+
+ fun reset() {
+ size = 0
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteHost.kt b/android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteHost.kt
new file mode 100644
index 0000000..64bd6f6
--- /dev/null
+++ b/android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteHost.kt
@@ -0,0 +1,270 @@
+package com.mag160c.thermal.net
+
+import com.mag160c.thermal.media.DebugLog
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import java.io.BufferedOutputStream
+import java.io.InputStream
+import java.net.DatagramPacket
+import java.net.DatagramSocket
+import java.net.InetAddress
+import java.net.ServerSocket
+import java.net.Socket
+import java.net.SocketException
+import java.net.SocketTimeoutException
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * Host side of the LAN remote preview (Phase F).
+ *
+ * Broadcasts a discovery beacon on UDP 47510 once a second and serves exactly
+ * one preview client on TCP 47511. Frames arrive as raw 38400-byte sensor
+ * payloads (the same bytes the thermal pipeline consumes) and go out as fixed
+ * 38412-byte records; all rendering happens on the client.
+ *
+ * Concurrency: ONE coroutine owns the client socket — it reads control lines
+ * and writes both control replies and frame records. A second coroutine only
+ * feeds a bounded queue, so a frame can never interleave into the middle of a
+ * write (which would desynchronise the client's frame reader).
+ *
+ * Any error tears the client down and returns to "waiting"; the thermal session
+ * is never affected by a network problem.
+ */
+class RemoteHost(
+ private val deviceName: String,
+ private val serial: Long,
+) {
+ enum class State { STOPPED, WAITING, STREAMING }
+
+ @Volatile
+ var state: State = State.STOPPED
+ private set
+
+ @Volatile
+ var clientAddress: String? = null
+ private set
+
+ /** Callback for {"cmd":"ffc"} — wired to the live session's triggerFfc. */
+ @Volatile
+ var onFfcRequest: (() -> Unit)? = null
+
+ private var scope: CoroutineScope? = null
+ private var serverSocket: ServerSocket? = null
+ private var discoverySocket: DatagramSocket? = null
+ private val running = AtomicBoolean(false)
+
+ /** Bounded queue; a slow client drops the oldest frame instead of stalling. */
+ private var frameQueueRef: Channel? = null
+
+ val isRunning: Boolean get() = running.get()
+
+ fun start() {
+ if (!running.compareAndSet(false, true)) return
+ frameQueueRef = Channel(capacity = 8, onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST)
+ val s = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ scope = s
+ state = State.WAITING
+ s.launch { discoveryLoop() }
+ s.launch { acceptLoop() }
+ DebugLog.log("remote", "host started (name=$deviceName serial=$serial)")
+ }
+
+ fun stop() {
+ if (!running.getAndSet(false)) return
+ state = State.STOPPED
+ clientAddress = null
+ runCatching { discoverySocket?.close() }
+ discoverySocket = null
+ runCatching { serverSocket?.close() }
+ serverSocket = null
+ frameQueueRef?.close()
+ frameQueueRef = null
+ scope?.cancel()
+ scope = null
+ DebugLog.log("remote", "host stopped")
+ }
+
+ /** Push one raw sensor frame from the live session. */
+ fun offerFrame(raw: ByteArray) {
+ if (!running.get()) return
+ if (raw.size != RemoteContract.FRAME_PIXELS) return
+ frameQueueRef?.trySend(raw)
+ }
+
+ /** One UDP beacon per second on the broadcast address. */
+ private suspend fun discoveryLoop() {
+ val bytes = RemoteContract.hostBeacon(deviceName, serial).toByteArray(Charsets.UTF_8)
+ val socket = try {
+ DatagramSocket().apply {
+ broadcast = true
+ soTimeout = 1000
+ discoverySocket = this
+ }
+ } catch (e: Exception) {
+ DebugLog.log("remote", "discovery socket failed: $e")
+ return
+ }
+ val target = try {
+ InetAddress.getByName("255.255.255.255")
+ } catch (e: Exception) {
+ DebugLog.log("remote", "broadcast address failed: $e")
+ return
+ }
+ while (running.get()) {
+ try {
+ socket.send(DatagramPacket(bytes, bytes.size, target, RemoteContract.DISCOVERY_PORT))
+ } catch (e: Exception) {
+ if (!running.get()) break
+ DebugLog.log("remote", "beacon send failed: $e")
+ }
+ kotlinx.coroutines.delay(1000)
+ }
+ runCatching { socket.close() }
+ }
+
+ private suspend fun acceptLoop() {
+ val server = try {
+ ServerSocket(RemoteContract.CONTROL_PORT).apply { serverSocket = this }
+ } catch (e: Exception) {
+ DebugLog.log("remote", "cannot listen on ${RemoteContract.CONTROL_PORT}: $e")
+ state = State.STOPPED
+ running.set(false)
+ return
+ }
+ while (running.get()) {
+ val socket = try {
+ server.accept()
+ } catch (e: SocketException) {
+ break // closed by stop()
+ } catch (e: Exception) {
+ DebugLog.log("remote", "accept failed: $e")
+ break
+ }
+ serve(socket)
+ }
+ runCatching { server.close() }
+ }
+
+ /**
+ * Serve one client to completion. Single-threaded by design: every write to
+ * the socket happens here, so control replies and frame records stay framed.
+ */
+ private suspend fun serve(socket: Socket) {
+ val addr = socket.inetAddress?.hostAddress ?: "?"
+ DebugLog.log("remote", "client connected from $addr")
+ clientAddress = addr
+ try {
+ socket.tcpNoDelay = true
+ socket.soTimeout = 100
+ } catch (_: Exception) {
+ }
+ val out = BufferedOutputStream(socket.getOutputStream(), 128 * 1024)
+ val input = socket.getInputStream()
+ var streaming = false
+ var counter = 0
+ var sentFrames = 0
+ var lastWrite = System.currentTimeMillis()
+ val lineBuf = StringBuilder()
+
+ try {
+ while (running.get()) {
+ // 1) control lines available this tick
+ val lines = drainLines(input, lineBuf)
+ for (raw in lines) {
+ val text = raw.trim()
+ if (text.isEmpty()) continue
+ when (RemoteContract.commandOf(text)) {
+ "hello" ->
+ writeLine(out, RemoteContract.welcomeLine(160, 120, 15, serial))
+ "start" -> if (!streaming) {
+ streaming = true
+ state = State.STREAMING
+ writeLine(out, RemoteContract.streamStartLine())
+ DebugLog.log("remote", "streaming to $addr")
+ }
+ "stop" -> if (streaming) {
+ streaming = false
+ state = State.WAITING
+ writeLine(out, RemoteContract.streamStopLine())
+ }
+ "ffc" -> {
+ onFfcRequest?.invoke()
+ writeLine(out, RemoteContract.okLine())
+ }
+ else -> if (RemoteContract.typeOf(text).isEmpty()) {
+ writeLine(out, RemoteContract.errorLine("unknown command"))
+ }
+ }
+ }
+ // 2) frames (only while streaming)
+ if (streaming) {
+ var wrote = false
+ while (true) {
+ val frame = frameQueueRef?.tryReceive()?.getOrNull() ?: break
+ out.write(RemoteContract.encodeFramePacket(frame, counter++))
+ sentFrames++
+ wrote = true
+ }
+ if (wrote) {
+ out.flush()
+ lastWrite = System.currentTimeMillis()
+ } else if (System.currentTimeMillis() - lastWrite >= RemoteContract.PING_AFTER_MS) {
+ writeLine(out, RemoteContract.pingLine())
+ lastWrite = System.currentTimeMillis()
+ }
+ if (wrote && sentFrames % 300 == 0) {
+ DebugLog.log("remote", "frames=$sentFrames to $addr")
+ }
+ }
+ kotlinx.coroutines.delay(5)
+ }
+ } catch (e: Exception) {
+ DebugLog.log("remote", "client $addr error: ${e.javaClass.simpleName}: ${e.message}")
+ } finally {
+ if (streaming) state = State.WAITING
+ clientAddress = null
+ runCatching { socket.close() }
+ DebugLog.log("remote", "client $addr disconnected (frames=$sentFrames)")
+ }
+ }
+
+ /** Read whatever complete newline-terminated lines are available right now. */
+ private fun drainLines(input: InputStream, buf: StringBuilder): List {
+ val out = ArrayList(2)
+ val chunk = ByteArray(1024)
+ while (true) {
+ val n = try {
+ input.read(chunk)
+ } catch (e: SocketTimeoutException) {
+ break
+ } catch (e: Exception) {
+ if (running.get()) throw e else break
+ }
+ if (n < 0) throw SocketException("client closed the control connection")
+ if (n == 0) break
+ var i = 0
+ while (i < n) {
+ val b = chunk[i]
+ if (b == '\n'.code.toByte()) {
+ out.add(buf.toString())
+ buf.setLength(0)
+ } else if (b != '\r'.code.toByte()) {
+ if (buf.length < RemoteContract.MAX_LINE) buf.append(b.toInt().toChar())
+ }
+ i++
+ }
+ }
+ return out
+ }
+
+ private fun writeLine(out: BufferedOutputStream, line: String) {
+ out.write(line.toByteArray(Charsets.UTF_8))
+ out.write('\n'.code)
+ out.flush()
+ }
+}
diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/AppRoot.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/AppRoot.kt
index 25f57cf..53dc591 100644
--- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/AppRoot.kt
+++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/AppRoot.kt
@@ -11,11 +11,13 @@ import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -25,9 +27,13 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
+import androidx.lifecycle.viewmodel.compose.viewModel
import com.mag160c.thermal.R
import com.mag160c.thermal.ui.gallery.GalleryScreen
import com.mag160c.thermal.ui.live.LiveScreen
+import com.mag160c.thermal.ui.live.LiveViewModel
+import com.mag160c.thermal.ui.remote.RemoteClientListScreen
+import com.mag160c.thermal.ui.remote.RemoteViewerScreen
import com.mag160c.thermal.ui.settings.SettingsScreen
private data class Tab(val label: String, val icon: Int)
@@ -45,12 +51,22 @@ private val TABS = listOf(
* position never moves, however the phone is physically held). Bar content
* (icons/text) is pre-rotated by the physical device orientation so it stays
* readable in any grip. Tab content lives in a stable slot.
+ *
+ * Phase F adds two full-screen destinations on top of the tabs: the remote
+ * host list and the remote viewer (which keeps the same bottom navigation).
*/
@Composable
fun AppRoot() {
var tab by rememberSaveable { mutableStateOf(0) }
val phi by DeviceOrientation.deg.collectAsState()
val ctx = LocalContext.current
+ val liveVm: LiveViewModel = viewModel()
+
+ // remote preview navigation (Phase F)
+ var showRemoteList by rememberSaveable { mutableStateOf(false) }
+ var remoteHost by rememberSaveable { mutableStateOf(null) }
+ var remotePort by rememberSaveable { mutableStateOf(0) }
+ var notice by remember { mutableStateOf(null) }
DisposableEffect(Unit) {
DeviceOrientation.start(ctx)
@@ -58,20 +74,41 @@ fun AppRoot() {
}
Box(modifier = Modifier.fillMaxSize()) {
- when (tab) {
- 0 -> LiveScreen(onOpenGallery = { tab = 1 })
- 1 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) {
- GalleryScreen()
- }
- 2 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) {
+ when {
+ remoteHost != null -> RemoteViewerScreen(
+ host = remoteHost!!,
+ port = remotePort,
+ onDisconnected = { reason ->
+ notice = reason
+ remoteHost = null
+ remotePort = 0
+ },
+ )
+ showRemoteList -> RemoteClientListScreen(
+ onBack = { showRemoteList = false },
+ onConnect = { host, port ->
+ showRemoteList = false
+ remoteHost = host
+ remotePort = port
+ },
+ )
+ tab == 0 -> LiveScreen(vm = liveVm, onOpenGallery = { tab = 1 })
+ tab == 1 || tab == 2 -> Column(
+ modifier = Modifier.fillMaxSize().padding(bottom = 84.dp),
+ ) {
GalleryScreen()
}
else -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) {
- SettingsScreen()
+ SettingsScreen(
+ onOpenRemoteClient = { showRemoteList = true },
+ remoteHostRunning = liveVm.isRemoteHostRunning(),
+ onToggleRemoteHost = { on -> liveVm.setRemoteHostEnabled(on) },
+ )
}
}
- // bottom navigation overlay (glued to the portrait bottom edge)
+ // bottom navigation overlay (glued to the portrait bottom edge).
+ // The remote viewer keeps it, matching the plan's "底导航不变".
Surface(
color = MaterialTheme.colorScheme.surface,
modifier = Modifier
@@ -82,7 +119,12 @@ fun AppRoot() {
TABS.forEachIndexed { i, t ->
NavigationBarItem(
selected = tab == i,
- onClick = { tab = i },
+ onClick = {
+ tab = i
+ // leaving the remote screens returns to the tabs
+ remoteHost = null
+ showRemoteList = false
+ },
// pre-rotate so the item is upright in the current grip
modifier = Modifier.graphicsLayer { rotationZ = -phi.toFloat() },
icon = { Icon(painterResource(t.icon), null) },
@@ -91,5 +133,23 @@ fun AppRoot() {
}
}
}
+
+ // "连接已断开" notice after returning from the remote viewer
+ notice?.let { msg ->
+ Surface(
+ color = MaterialTheme.colorScheme.inverseSurface,
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(16.dp),
+ ) {
+ androidx.compose.foundation.layout.Row(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(msg, color = MaterialTheme.colorScheme.inverseOnSurface)
+ TextButton(onClick = { notice = null }) { Text("好") }
+ }
+ }
+ }
}
-}
\ No newline at end of file
+}
diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt
index 9e6c4de..c1a23f9 100644
--- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt
+++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt
@@ -149,8 +149,37 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
rec.offerFrame(bmp)
}
}
+ // feed the LAN remote host (Phase F) with raw sensor frames
+ session.rawHook = { raw ->
+ if (raw.size >= 0x1C + 38400) {
+ remoteHost.offerFrame(raw.copyOfRange(0x1C, 0x1C + 38400))
+ }
+ }
}
+ // ---- LAN remote preview host (Phase F) ----
+
+ private val remoteHost = com.mag160c.thermal.net.RemoteHost(
+ deviceName = android.os.Build.MODEL ?: "Android",
+ serial = 0L,
+ )
+
+ val remoteHostState: com.mag160c.thermal.net.RemoteHost get() = remoteHost
+
+ /** Start/stop the preview server. Requires an active USB session. */
+ fun setRemoteHostEnabled(enabled: Boolean): Boolean {
+ if (enabled) {
+ if (!session.isStreaming()) return false
+ remoteHost.onFfcRequest = { triggerFfc() }
+ remoteHost.start()
+ } else {
+ remoteHost.stop()
+ }
+ return true
+ }
+
+ fun isRemoteHostRunning(): Boolean = remoteHost.isRunning
+
/** Begin USB permission flow, then start streaming. No device -> idle notice. */
fun connect() {
val now = android.os.SystemClock.elapsedRealtime()
@@ -400,6 +429,7 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
}
override fun onCleared() {
+ remoteHost.stop()
session.destroy()
super.onCleared()
}
diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteClientListScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteClientListScreen.kt
new file mode 100644
index 0000000..2225c1b
--- /dev/null
+++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteClientListScreen.kt
@@ -0,0 +1,196 @@
+package com.mag160c.thermal.ui.remote
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.mag160c.thermal.R
+import com.mag160c.thermal.net.RemoteClient
+import com.mag160c.thermal.net.RemoteContract
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+
+/**
+ * Remote-preview host list (Phase F).
+ *
+ * Layout is fixed by the execution plan: 56dp title bar with a back arrow,
+ * 64dp host cards (24dp icon, host name 16sp, "IP:47511" 12sp grey, trailing
+ * "连接" button), a full-width 48dp "重新扫描" button, then a "手动添加" row with
+ * an IP field, and a centred empty state while scanning.
+ */
+@Composable
+fun RemoteClientListScreen(
+ onBack: () -> Unit,
+ onConnect: (String, Int) -> Unit,
+) {
+ val context = LocalContext.current
+ val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
+ var hosts by remember { mutableStateOf>(emptyList()) }
+ var scanning by remember { mutableStateOf(true) }
+ var manualIp by remember { mutableStateOf("") }
+ var scanGeneration by remember { mutableStateOf(0) }
+
+ DisposableEffect(Unit) {
+ onDispose { scope.cancel() }
+ }
+
+ LaunchedEffect(scanGeneration) {
+ hosts = emptyList()
+ scanning = true
+ val job = scope.launch {
+ RemoteClient.discover(scope).collect { info ->
+ // newest first, de-duplicated by address
+ hosts = (listOf(info) + hosts.filter { it.address != info.address }).take(12)
+ }
+ }
+ // stop showing the empty state after the 10 s discovery window
+ kotlinx.coroutines.delay(10_000)
+ scanning = false
+ job.cancel()
+ }
+
+ Column(modifier = Modifier.fillMaxSize()) {
+ // ---- title bar (56dp) ----
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp)
+ .padding(horizontal = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ painterResource(R.drawable.ic_back_arrow), "返回",
+ modifier = Modifier
+ .size(24.dp)
+ .clickable { onBack() },
+ )
+ Spacer(Modifier.width(12.dp))
+ Text("远程预览", style = MaterialTheme.typography.titleMedium)
+ }
+ HorizontalDivider()
+
+ LazyColumn(modifier = Modifier.weight(1f)) {
+ items(hosts, key = { it.address }) { h ->
+ HostCard(h) { onConnect(h.address, h.tcpPort) }
+ }
+ item {
+ Spacer(Modifier.height(8.dp))
+ OutlinedButton(
+ onClick = { scanGeneration++ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(48.dp)
+ .padding(horizontal = 16.dp),
+ ) { Text("重新扫描") }
+ Spacer(Modifier.height(8.dp))
+ HorizontalDivider()
+ Text(
+ "手动添加",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp),
+ )
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ OutlinedTextField(
+ value = manualIp,
+ onValueChange = { manualIp = it },
+ placeholder = { Text("例如 192.168.1.23") },
+ singleLine = true,
+ modifier = Modifier.weight(1f),
+ )
+ TextButton(
+ onClick = {
+ val ip = manualIp.trim()
+ if (ip.isNotEmpty()) onConnect(ip, RemoteContract.CONTROL_PORT)
+ },
+ ) { Text("连接") }
+ }
+ Spacer(Modifier.height(16.dp))
+ }
+ }
+
+ if (hosts.isEmpty()) {
+ Box(modifier = Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) {
+ Text(
+ if (scanning) "正在扫描局域网主机…" else "未发现主机",
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun HostCard(info: RemoteContract.HostInfo, onConnect: () -> Unit) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(64.dp)
+ .clickable { onConnect() }
+ .padding(horizontal = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ painterResource(R.drawable.ic_pip), null,
+ modifier = Modifier.size(24.dp),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ Spacer(Modifier.width(16.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ info.name,
+ style = MaterialTheme.typography.bodyLarge,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ "${info.address}:${info.tcpPort}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ TextButton(onClick = onConnect) { Text("连接") }
+ }
+ HorizontalDivider(color = Color(0x1F000000))
+}
diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteRendererHost.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteRendererHost.kt
new file mode 100644
index 0000000..1ab08ee
--- /dev/null
+++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteRendererHost.kt
@@ -0,0 +1,192 @@
+package com.mag160c.thermal.ui.remote
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.Typeface
+import android.view.SurfaceHolder
+import android.view.SurfaceView
+import com.mag160c.thermal.ui.DeviceOrientation
+
+/**
+ * Software renderer for the remote preview (Phase F).
+ *
+ * Composition is intentionally IDENTICAL to LiveRenderer (which is frozen and
+ * must not be edited): the thermal frame is drawn rotated 90 deg CW into a
+ * fitted 3:4 rect inside the area between the top bar and the bottom nav, so a
+ * remote and a local view of the same camera look the same. Only the data
+ * source differs — this one reads RemoteViewerViewModel.
+ */
+class RemoteRendererHost(
+ private val surfaceView: SurfaceView,
+ private val vm: RemoteViewerViewModel,
+) : SurfaceHolder.Callback, Runnable {
+ private var thread: Thread? = null
+ private val running = java.util.concurrent.atomic.AtomicBoolean(false)
+ private val density = surfaceView.resources.displayMetrics.density
+ private val bitmap = Bitmap.createBitmap(320, 240, Bitmap.Config.ARGB_8888)
+ private val paint = Paint(Paint.FILTER_BITMAP_FLAG)
+ private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = Color.WHITE
+ typeface = Typeface.SANS_SERIF
+ textSize = 15f * density
+ setShadowLayer(3f * density, 0f, 0f, Color.BLACK)
+ }
+ private val viewport = android.graphics.RectF()
+
+ private val textRot: Float
+ get() = -DeviceOrientation.deg.value.toFloat()
+
+ fun attach() {
+ surfaceView.holder.addCallback(this)
+ }
+
+ override fun surfaceCreated(holder: SurfaceHolder) {
+ running.set(true)
+ thread = Thread(this, "remote-render").also { it.start() }
+ }
+
+ override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {}
+
+ override fun surfaceDestroyed(holder: SurfaceHolder) {
+ running.set(false)
+ thread?.join(200)
+ thread = null
+ }
+
+ override fun run() {
+ val holder = surfaceView.holder
+ while (running.get()) {
+ val canvas = holder.lockCanvas() ?: continue
+ try {
+ drawFrame(canvas)
+ } finally {
+ holder.unlockCanvasAndPost(canvas)
+ }
+ try {
+ Thread.sleep(33)
+ } catch (_: InterruptedException) {
+ }
+ }
+ }
+
+ private fun drawFrame(canvas: Canvas) {
+ val w = canvas.width.toFloat()
+ val h = canvas.height.toFloat()
+ canvas.drawColor(Color.BLACK)
+ val frame = vm.latestFrame ?: return
+ bitmap.setPixels(frame, 0, 320, 0, 0, 320, 240)
+
+ val top = vm.uiTopPx.toFloat()
+ val bottom = h - vm.uiBottomPx.toFloat()
+ val availW = w
+ val availH = bottom - top
+ if (availH <= 0) return
+
+ // fit the 3:4 (rotated) image into the available rect (same as local view)
+ var dstW = availW
+ var dstH = availW * 4f / 3f
+ if (dstH > availH) {
+ dstH = availH
+ dstW = availH * 3f / 4f
+ }
+ val left = (availW - dstW) / 2f
+ val vpTop = top + (availH - dstH) / 2f
+ viewport.set(left, vpTop, left + dstW, vpTop + dstH)
+
+ val cx = viewport.centerX()
+ val cy = viewport.centerY()
+ val zoom = vm.state.value.zoom
+ val srcRect = if (zoom > 1) {
+ val cw = 320 / zoom
+ val ch = 240 / zoom
+ android.graphics.Rect(160 - cw / 2, 120 - ch / 2, 160 + cw / 2, 120 + ch / 2)
+ } else null
+
+ canvas.save()
+ canvas.rotate(90f, cx, cy)
+ val w0 = dstH
+ val h0 = dstW
+ val dst = android.graphics.RectF(cx - w0 / 2f, cy - h0 / 2f, cx + w0 / 2f, cy + h0 / 2f)
+ if (srcRect != null) canvas.drawBitmap(bitmap, srcRect, dst, paint)
+ else canvas.drawBitmap(bitmap, null, dst, paint)
+ canvas.restore()
+
+ drawColorBar(canvas, vm.state.value)
+ drawOsd(canvas, vm.state.value)
+ }
+
+ private fun drawColorBar(canvas: Canvas, state: RemoteViewerViewModel.State) {
+ if (state.maxTempC == null || state.minTempC == null) return
+ val pal = com.mag160c.thermal.core.Palettes.buildAll()[state.paletteIndex]
+ val barW = 20f * density
+ val barH = viewport.height() * 0.8f
+ val x = viewport.right - barW - 12f * density
+ val y0 = viewport.top + (viewport.height() - barH) / 2f
+ val seg = Paint()
+ val n = 96
+ for (i in 0 until n) {
+ val c = pal[255 - i * 255 / (n - 1)]
+ seg.color = c
+ canvas.drawRect(x, y0 + barH * i / n, x + barW, y0 + barH * (i + 1) / n + 0.5f, seg)
+ }
+ textPaint.color = Color.WHITE
+ val maxT = "%.1f".format(state.maxTempC)
+ val minT = "%.1f".format(state.minTempC)
+ val labelX = x + barW / 2f - textPaint.measureText(maxT) / 2
+ val labelMinX = x + barW / 2f - textPaint.measureText(minT) / 2
+ canvas.save()
+ canvas.rotate(textRot, x + barW / 2f, y0 - 8f * density)
+ canvas.drawText(maxT, labelX, y0 - 8f * density, textPaint)
+ canvas.restore()
+ canvas.save()
+ canvas.rotate(textRot, x + barW / 2f, y0 + barH + textPaint.textSize)
+ canvas.drawText(minT, labelMinX, y0 + barH + textPaint.textSize, textPaint)
+ canvas.restore()
+ }
+
+ private fun drawOsd(canvas: Canvas, state: RemoteViewerViewModel.State) {
+ textPaint.color = Color.WHITE
+ val ox = viewport.left + 12f * density
+ val oy = viewport.top + textPaint.textSize + 10f * density
+ state.centerTempC?.let {
+ canvas.save()
+ canvas.rotate(textRot, ox, oy)
+ canvas.drawText("中心 %.1f℃".format(it), ox, oy, textPaint)
+ canvas.restore()
+ }
+ // max-temperature marker, same geometry as the local view
+ if (state.maxTraceOn && state.maxPos >= 0 && state.maxTempC != null) {
+ val sx = state.maxPos % 160
+ val sy = state.maxPos / 160
+ val p = probeToScreen(sx, sy)
+ val markerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = Color.WHITE
+ setShadowLayer(3f * density, 0f, 0f, Color.BLACK)
+ }
+ markerPaint.style = Paint.Style.STROKE
+ markerPaint.strokeWidth = 2.5f * density
+ canvas.drawCircle(p[0], p[1], 7f * density, markerPaint)
+ markerPaint.style = Paint.Style.FILL
+ canvas.drawCircle(p[0], p[1], 3.5f * density, markerPaint)
+ }
+ }
+
+ /** Sensor pixel -> screen position under the fixed 90 CW rotation + insets. */
+ private fun probeToScreen(sx: Int, sy: Int): FloatArray {
+ val viewW = vm.uiViewW.toFloat().coerceAtLeast(1f)
+ val availH = (vm.uiViewH - vm.uiTopPx - vm.uiBottomPx).toFloat().coerceAtLeast(1f)
+ var dstW = viewW
+ var dstH = viewW * 4f / 3f
+ if (dstH > availH) {
+ dstH = availH
+ dstW = availH * 3f / 4f
+ }
+ val left = (viewW - dstW) / 2f
+ val top = vm.uiTopPx + (availH - dstH) / 2f
+ val fx = 1f - sy / 120f
+ val fy = sx / 160f
+ return floatArrayOf(left + fx * dstW, top + fy * dstH)
+ }
+}
diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteViewerScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteViewerScreen.kt
new file mode 100644
index 0000000..0ec4a49
--- /dev/null
+++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteViewerScreen.kt
@@ -0,0 +1,237 @@
+package com.mag160c.thermal.ui.remote
+
+import android.view.SurfaceView
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.WindowInsetsSides
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.only
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.mag160c.thermal.R
+import com.mag160c.thermal.core.Palettes
+import com.mag160c.thermal.ui.DeviceOrientation
+
+/**
+ * Remote preview viewer (Phase F): same skeleton as the live screen — surface
+ * renderer, 4-item control top bar, colour bar, OSD — but the frame source is
+ * the LAN session and the shutter row is replaced by a single red "断开" button.
+ *
+ * FFC goes back to the host; zoom / max-trace / palette are local pipeline
+ * operations, so no round trip is needed for them.
+ */
+@Composable
+fun RemoteViewerScreen(
+ host: String,
+ port: Int,
+ onDisconnected: (String) -> Unit,
+ vm: RemoteViewerViewModel = viewModel(),
+) {
+ val state by vm.state.collectAsState()
+ val phi by DeviceOrientation.deg.collectAsState()
+ val density = LocalDensity.current.density
+ var showPalette by remember { mutableStateOf(false) }
+ var navPx by remember { mutableStateOf(com.mag160c.thermal.ui.UiInsets.navPx) }
+ var shutterPx by remember { mutableStateOf(0) }
+
+ LaunchedEffect(host, port) { vm.connect(host, port) }
+ LaunchedEffect(Unit) {
+ while (true) {
+ kotlinx.coroutines.delay(400)
+ navPx = com.mag160c.thermal.ui.UiInsets.navPx
+ vm.uiBottomPx = navPx + shutterPx
+ }
+ }
+ LaunchedEffect(Unit) {
+ vm.disconnected.collect {
+ // read the live status, not a value captured at composition time
+ onDisconnected(
+ if (vm.state.value.status == "connect_fail") "无法连接主机" else "连接已断开",
+ )
+ }
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ AndroidView(
+ factory = { ctx ->
+ SurfaceView(ctx).also { sv ->
+ val renderer = RemoteRendererHost(sv, vm)
+ renderer.attach()
+ }
+ },
+ modifier = Modifier
+ .fillMaxSize()
+ .onSizeChanged {
+ vm.uiViewW = it.width
+ vm.uiViewH = it.height
+ },
+ )
+
+ if (!state.connected) {
+ Text(
+ text = when (state.status) {
+ "connect_fail" -> "无法连接主机"
+ "disconnected" -> "连接已断开"
+ "ddt_fail" -> "本地标定文件加载失败"
+ else -> "连接中…"
+ },
+ modifier = Modifier
+ .align(Alignment.Center)
+ .graphicsLayer { rotationZ = -phi.toFloat() }
+ .padding(16.dp),
+ )
+ }
+
+ // ---- control TOP bar (4 items, same as live) ----
+ Surface(
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .fillMaxWidth()
+ .onSizeChanged { vm.uiTopPx = it.height }
+ .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ RemoteTopItem(phi, "FFC") { vm.requestFfc() }
+ RemoteTopItem(phi, "${state.zoom}×") { vm.setZoom(state.zoom % 4 + 1) }
+ RemoteTopItem(
+ phi,
+ if (state.maxTraceOn) "追踪·开" else "追踪",
+ icon = R.drawable.ic_target,
+ highlight = state.maxTraceOn,
+ ) { vm.toggleMaxTrace() }
+ RemoteTopItem(
+ phi,
+ Palettes.NAMES[state.paletteIndex],
+ icon = R.drawable.ic_palette,
+ ) { showPalette = true }
+ }
+ }
+
+ // ---- single red disconnect button in the shutter row slot ----
+ Row(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .graphicsLayer { translationY = -navPx.toFloat() }
+ .onSizeChanged {
+ shutterPx = it.height
+ vm.uiBottomPx = navPx + shutterPx
+ }
+ .fillMaxWidth()
+ .padding(vertical = 6.dp),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier.graphicsLayer { rotationZ = -phi.toFloat() },
+ ) {
+ Box(
+ modifier = Modifier
+ .size(60.dp)
+ .border(4.dp, Color(0xFFFF5252), CircleShape)
+ .clickable {
+ vm.disconnect()
+ onDisconnected("已断开")
+ },
+ )
+ Text(
+ "断开", color = Color.White,
+ style = MaterialTheme.typography.labelSmall,
+ )
+ }
+ }
+ }
+
+ if (showPalette) {
+ AlertDialog(
+ onDismissRequest = { showPalette = false },
+ title = { Text("调色板") },
+ text = {
+ LazyVerticalGrid(columns = GridCells.Fixed(3), modifier = Modifier.height(320.dp)) {
+ items((0..11).toList()) { idx ->
+ Text(
+ Palettes.NAMES[idx],
+ color = if (idx == state.paletteIndex) MaterialTheme.colorScheme.primary
+ else MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier
+ .clickable { vm.setPalette(idx); showPalette = false }
+ .padding(14.dp),
+ )
+ }
+ }
+ },
+ confirmButton = {},
+ )
+ }
+}
+
+@Composable
+private fun androidx.compose.foundation.layout.RowScope.RemoteTopItem(
+ phi: Int,
+ label: String,
+ icon: Int = R.drawable.ic_ffc,
+ highlight: Boolean = false,
+ onClick: () -> Unit,
+) {
+ Box(
+ modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() },
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier.clickable(onClick = onClick).padding(4.dp),
+ ) {
+ Icon(
+ painterResource(icon), null,
+ tint = if (highlight) MaterialTheme.colorScheme.primary
+ else MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ label,
+ style = MaterialTheme.typography.labelSmall,
+ color = if (highlight) MaterialTheme.colorScheme.primary
+ else MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteViewerViewModel.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteViewerViewModel.kt
new file mode 100644
index 0000000..579ea83
--- /dev/null
+++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteViewerViewModel.kt
@@ -0,0 +1,189 @@
+package com.mag160c.thermal.ui.remote
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import com.mag160c.thermal.core.RenderPipeline
+import com.mag160c.thermal.media.DebugLog
+import com.mag160c.thermal.net.RemoteClient
+import com.mag160c.thermal.net.RemoteContract
+import com.mag160c.thermal.net.RemoteSession
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+/**
+ * Client-side remote preview (Phase F).
+ *
+ * The session sends raw sensor frames; this view model renders them with the
+ * LOCAL pipeline (bundled DDT) so palette and zoom changes are instant and
+ * never round-trip. Temperature readout mirrors the live screen's local
+ * branch (probe at sensor pixel 80,60).
+ */
+class RemoteViewerViewModel(app: Application) : AndroidViewModel(app) {
+ data class State(
+ val connected: Boolean = false,
+ val host: String = "",
+ val frames: Int = 0,
+ val centerTempC: Float? = null,
+ val maxTempC: Float? = null,
+ val minTempC: Float? = null,
+ val maxPos: Int = -1,
+ val minPos: Int = -1,
+ val paletteIndex: Int = 2,
+ val zoom: Int = 1,
+ val maxTraceOn: Boolean = true,
+ val status: String = "",
+ )
+
+ private val _state = MutableStateFlow(State())
+ val state: StateFlow = _state
+
+ /** Latest rendered 320x240 ARGB frame for the renderer. */
+ @Volatile
+ var latestFrame: IntArray? = null
+ private set
+
+ /** UI insets (px) reported from Compose, consumed by the renderer. */
+ @Volatile
+ var uiTopPx: Int = 0
+
+ @Volatile
+ var uiBottomPx: Int = 0
+
+ @Volatile
+ var uiViewW: Int = 1080
+
+ @Volatile
+ var uiViewH: Int = 2280
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private var session: RemoteSession? = null
+ private var pipeline: RenderPipeline? = null
+ private var renderJob: Job? = null
+
+ /** Emits once when the link drops (UI shows a snackbar and returns). */
+ private val _disconnected = MutableSharedFlow(extraBufferCapacity = 1)
+ val disconnected = _disconnected
+
+ /** Connect, then immediately start the stream. */
+ fun connect(host: String, port: Int) {
+ scope.launch {
+ val s = RemoteClient.connect(host, port, scope)
+ if (s == null) {
+ _state.value = _state.value.copy(connected = false, status = "connect_fail")
+ _disconnected.tryEmit(Unit)
+ return@launch
+ }
+ session = s
+ _state.value = _state.value.copy(connected = true, host = "$host:$port")
+ DebugLog.log("remote", "client connected to $host:$port")
+
+ // local render pipeline with the bundled DDT
+ val ddt = runCatching {
+ getApplication().assets.open("mag160c.ddt").readBytes()
+ }.getOrDefault(ByteArray(0))
+ val pipe = RenderPipeline(w = 160, h = 120)
+ if (!pipe.loadDdt(ddt)) {
+ DebugLog.log("remote", "ddt load failed -> cannot render remote frames")
+ _state.value = _state.value.copy(status = "ddt_fail")
+ return@launch
+ }
+ pipe.setPalette(_state.value.paletteIndex)
+ pipeline = pipe
+
+ // control lines (welcome / stream-start) for logging
+ launch {
+ s.lines.collect { line -> DebugLog.log("remote", "line: $line") }
+ }
+ // frames -> local pipeline -> latestFrame
+ val out = IntArray(320 * 240)
+ renderJob = launch {
+ s.frames.collect { raw ->
+ val n = _state.value.frames + 1
+ if (n == 1) DebugLog.log("remote", "first remote frame")
+ _state.value = _state.value.copy(frames = n)
+ if (pipe.frame(raw, false, out)) {
+ latestFrame = out.copyOf()
+ }
+ }
+ }
+ launch {
+ s.closedFlow.collect {
+ _state.value = _state.value.copy(connected = false, status = "disconnected")
+ _disconnected.tryEmit(Unit)
+ }
+ }
+ s.hello("mag160c-client")
+ s.startStream()
+ // temperature refresh on a slow timer
+ while (true) {
+ kotlinx.coroutines.delay(400)
+ refreshTemps()
+ }
+ }
+ }
+
+ private fun refreshTemps() {
+ val pipe = pipeline ?: return
+ // pipeline.probeTemp already returns millidegrees C (counts -> temp)
+ val centerMc = pipe.probeTemp(80, 60)
+ val nuc = IntArray(19200)
+ pipe.copyNuc(nuc)
+ var mn = Int.MAX_VALUE
+ var mx = -1
+ var mnPos = -1
+ var mxPos = -1
+ for (i in nuc.indices) {
+ val v = nuc[i]
+ if (v < mn) { mn = v; mnPos = i }
+ if (v > mx) { mx = v; mxPos = i }
+ }
+ _state.value = _state.value.copy(
+ centerTempC = centerMc / 1000f,
+ maxTempC = if (mx >= 0) com.mag160c.thermal.core.TempMath.countsToTempMc(mx) / 1000f else null,
+ minTempC = if (mn <= Int.MAX_VALUE) com.mag160c.thermal.core.TempMath.countsToTempMc(mn) / 1000f else null,
+ maxPos = mxPos,
+ minPos = mnPos,
+ )
+ }
+
+ fun setPalette(index: Int) {
+ pipeline?.setPalette(index)
+ _state.value = _state.value.copy(paletteIndex = index)
+ }
+
+ fun setZoom(z: Int) {
+ _state.value = _state.value.copy(zoom = z.coerceIn(1, 4))
+ }
+
+ fun toggleMaxTrace() {
+ _state.value = _state.value.copy(maxTraceOn = !_state.value.maxTraceOn)
+ }
+
+ fun requestFfc() {
+ session?.requestFfc()
+ }
+
+ /** Disconnect and return; also called from onCleared. */
+ fun disconnect() {
+ session?.stopStream()
+ session?.close()
+ session = null
+ renderJob?.cancel()
+ renderJob = null
+ _state.value = _state.value.copy(connected = false)
+ }
+
+ override fun onCleared() {
+ disconnect()
+ scope.cancel()
+ super.onCleared()
+ }
+}
diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt
index d9171be..634d180 100644
--- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt
+++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt
@@ -24,12 +24,19 @@ import androidx.compose.ui.unit.dp
import com.mag160c.thermal.core.Palettes
@Composable
-fun SettingsScreen() {
+fun SettingsScreen(
+ onOpenRemoteClient: () -> Unit = {},
+ remoteHostRunning: Boolean = false,
+ onToggleRemoteHost: (Boolean) -> Boolean = { false },
+) {
val context = androidx.compose.ui.platform.LocalContext.current
val settings = remember { AppSettings(context) }
var dialog by remember { mutableStateOf(null) }
// mirror the persisted flag in Compose state so the row label updates
var cloudEnabled by remember { mutableStateOf(settings.cloudEnabled) }
+ // remote-preview server toggle (Phase F); off by default, needs live USB
+ var remoteOn by remember { mutableStateOf(remoteHostRunning) }
+ var showNeedDevice by remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxSize().padding(8.dp)) {
SettingRow("默认调色板", Palettes.NAMES[settings.defaultPaletteIndex]) { dialog = "palette" }
@@ -40,6 +47,19 @@ fun SettingsScreen() {
SettingRow("报警温度", "%.1f℃".format(settings.alarmTempC / 10f)) { dialog = "alarm" }
SettingRow("语言", settings.language) { dialog = "language" }
SettingRow("云同步", if (cloudEnabled) "已开启" else "已关闭") { dialog = "cloud" }
+ SettingRow("远程预览服务端", if (remoteOn) "已开启" else "已关闭") {
+ if (remoteOn) {
+ onToggleRemoteHost(false)
+ remoteOn = false
+ } else {
+ if (onToggleRemoteHost(true)) {
+ remoteOn = true
+ } else {
+ showNeedDevice = true
+ }
+ }
+ }
+ SettingRow("远程预览客户端", "查找主机") { onOpenRemoteClient() }
SettingRow("关于", "MAG160C 统一热像版 1.0.0") { dialog = null }
}
@@ -114,6 +134,17 @@ fun SettingsScreen() {
)
else -> {}
}
+
+ if (showNeedDevice) {
+ AlertDialog(
+ onDismissRequest = { showNeedDevice = false },
+ title = { Text("远程预览服务端") },
+ text = { Text("先连接热像仪") },
+ confirmButton = {
+ TextButton(onClick = { showNeedDevice = false }) { Text("好") }
+ },
+ )
+ }
}
@Composable
diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt b/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt
index cddaf1e..b16f00c 100644
--- a/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt
+++ b/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt
@@ -93,6 +93,10 @@ class IrSession(context: Context) {
@Volatile
var recorderHook: ((IntArray) -> Unit)? = null
+ /** Optional raw-frame hook (LAN remote preview), runs on the reader thread. */
+ @Volatile
+ var rawHook: ((ByteArray) -> Unit)? = null
+
fun isStreaming(): Boolean = running.get()
/** Latest raw frame (with 0x38-byte header) for MDT capture. */
@@ -535,6 +539,7 @@ class IrSession(context: Context) {
)
}
lastRawFrame = frameBuf.copyOf()
+ lastRawFrame?.let { raw -> rawHook?.invoke(raw) }
val rendered = pipe.frame(frameBuf, true, out)
if (rendered) {
renderCount++
diff --git a/android/app/src/main/res/drawable/ic_back_arrow.xml b/android/app/src/main/res/drawable/ic_back_arrow.xml
new file mode 100644
index 0000000..0ef2fcd
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_back_arrow.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/test/kotlin/com/mag160c/thermal/net/RemoteContractTest.kt b/android/app/src/test/kotlin/com/mag160c/thermal/net/RemoteContractTest.kt
new file mode 100644
index 0000000..af77260
--- /dev/null
+++ b/android/app/src/test/kotlin/com/mag160c/thermal/net/RemoteContractTest.kt
@@ -0,0 +1,168 @@
+package com.mag160c.thermal.net
+
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.util.Random
+
+/**
+ * Phase F wire contract: encode/feed round trip plus the two cases a raw socket
+ * read always produces — truncated records and several records glued together.
+ */
+class RemoteContractTest {
+ private fun payload(seed: Int): ByteArray {
+ val out = ByteArray(RemoteContract.FRAME_PIXELS)
+ Random(seed.toLong()).nextBytes(out)
+ return out
+ }
+
+ @Test
+ fun encodeFramePacketLayoutMatchesTheContract() {
+ val raw = payload(1)
+ val pkt = RemoteContract.encodeFramePacket(raw, counter = 7)
+ assertEquals(38412, pkt.size)
+ assertEquals(RemoteContract.FRAME_MAGIC, RemoteContract.u32(pkt, 0))
+ assertEquals(7, RemoteContract.u32(pkt, 4))
+ assertEquals(RemoteContract.FRAME_PIXELS, RemoteContract.u32(pkt, 8))
+ assertArrayEquals(raw, pkt.copyOfRange(12, pkt.size))
+ }
+
+ @Test
+ fun singleFrameRoundTrip() {
+ val raw = payload(2)
+ val reader = RemoteContract.FramePacketReader()
+ val frames = reader.feed(RemoteContract.encodeFramePacket(raw, 1))
+ assertEquals(1, frames.size)
+ assertArrayEquals(raw, frames[0])
+ assertEquals(0, reader.pending())
+ }
+
+ @Test
+ fun truncatedInputIsBufferedUntilComplete() {
+ val raw = payload(3)
+ val pkt = RemoteContract.encodeFramePacket(raw, 2)
+ val reader = RemoteContract.FramePacketReader()
+ // split at an awkward offset: mid-header, then mid-payload
+ assertTrue("nothing complete yet", reader.feed(pkt.copyOfRange(0, 7)).isEmpty())
+ assertTrue("still incomplete", reader.feed(pkt.copyOfRange(7, 1000)).isEmpty())
+ val frames = reader.feed(pkt.copyOfRange(1000, pkt.size))
+ assertEquals(1, frames.size)
+ assertArrayEquals(raw, frames[0])
+ }
+
+ @Test
+ fun threeFramesGluedInOneRead() {
+ val raws = listOf(payload(4), payload(5), payload(6))
+ val glued = java.io.ByteArrayOutputStream()
+ raws.forEachIndexed { i, r -> glued.write(RemoteContract.encodeFramePacket(r, i)) }
+ val reader = RemoteContract.FramePacketReader()
+ val frames = reader.feed(glued.toByteArray())
+ assertEquals(3, frames.size)
+ frames.forEachIndexed { i, f -> assertArrayEquals(raws[i], f) }
+ }
+
+ @Test
+ fun gluedWithRaggedBoundaries() {
+ // 3 frames, fed in chunks that straddle record boundaries unevenly
+ val raws = List(3) { payload(10 + it) }
+ val glued = java.io.ByteArrayOutputStream()
+ raws.forEachIndexed { i, r -> glued.write(RemoteContract.encodeFramePacket(r, i)) }
+ val bytes = glued.toByteArray()
+ val reader = RemoteContract.FramePacketReader()
+ val got = ArrayList()
+ var pos = 0
+ val chunkPattern = intArrayOf(5, 38411, 1, 20000, 100, 40000)
+ var ci = 0
+ while (pos < bytes.size) {
+ val n = minOf(chunkPattern[ci % chunkPattern.size], bytes.size - pos)
+ got += reader.feed(bytes.copyOfRange(pos, pos + n))
+ pos += n
+ ci++
+ }
+ assertEquals(3, got.size)
+ got.forEachIndexed { i, f -> assertArrayEquals(raws[i], f) }
+ }
+
+ @Test
+ fun garbageBeforeMagicResynchronises() {
+ val raw = payload(20)
+ val pkt = RemoteContract.encodeFramePacket(raw, 3)
+ val reader = RemoteContract.FramePacketReader()
+ val frames = reader.feed(byteArrayOf(1, 2, 3, 4, 5) + pkt)
+ assertEquals(1, frames.size)
+ assertArrayEquals(raw, frames[0])
+ }
+
+ @Test
+ fun bogusLengthHeaderIsSkipped() {
+ val raw = payload(21)
+ val pkt = RemoteContract.encodeFramePacket(raw, 4)
+ val bad = RemoteContract.encodeFramePacket(payload(22), 5).copyOf()
+ RemoteContract.putU32(bad, 8, 12345) // wrong payload length
+ val reader = RemoteContract.FramePacketReader()
+ val frames = reader.feed(bad + pkt)
+ assertEquals("only the valid frame is delivered", 1, frames.size)
+ assertArrayEquals(raw, frames[0])
+ }
+
+ @Test
+ fun beaconRoundTrip() {
+ val line = RemoteContract.hostBeacon("Pixel 8 \"test\"", 160043865L)
+ val info = RemoteContract.parseBeacon(line, "192.168.1.23")
+ assertNotNull(info)
+ info!!
+ assertEquals("Pixel 8 \"test\"", info.name)
+ assertEquals("192.168.1.23", info.address)
+ assertEquals(47511, info.tcpPort)
+ assertEquals(160043865L, info.serial)
+ }
+
+ @Test
+ fun foreignOrBrokenBeaconsAreIgnored() {
+ assertNull(RemoteContract.parseBeacon("{\"app\":\"other\"}", "10.0.0.1"))
+ assertNull(RemoteContract.parseBeacon("not json at all", "10.0.0.1"))
+ assertNull(RemoteContract.parseBeacon("{}", "10.0.0.1"))
+ }
+
+ @Test
+ fun controlLineParsing() {
+ assertEquals("hello", RemoteContract.commandOf(RemoteContract.hello("Client")))
+ assertEquals("Client", RemoteContract.nameOf(RemoteContract.hello("Client")))
+ assertEquals("start", RemoteContract.commandOf(RemoteContract.cmd("start")))
+ assertEquals("ffc", RemoteContract.commandOf(RemoteContract.cmd("ffc")))
+ assertEquals("welcome", RemoteContract.typeOf(RemoteContract.welcomeLine(160, 120, 15, 1)))
+ assertTrue(RemoteContract.isKeepalive(RemoteContract.pingLine()))
+ assertTrue(!RemoteContract.isKeepalive(RemoteContract.okLine()))
+ assertEquals("type", RemoteContract.typeOf(RemoteContract.streamStartLine()).let { "type" })
+ assertEquals("stream-start", RemoteContract.typeOf(RemoteContract.streamStartLine()))
+ assertEquals("stream-stop", RemoteContract.typeOf(RemoteContract.streamStopLine()))
+ }
+
+ @Test
+ fun welcomeParsing() {
+ val w = RemoteContract.parseWelcome(RemoteContract.welcomeLine(160, 120, 15, 160043865L))
+ assertNotNull(w)
+ w!!
+ assertEquals(160, w.w)
+ assertEquals(120, w.h)
+ assertEquals(15, w.fps)
+ assertEquals(160043865L, w.serial)
+ assertNull(RemoteContract.parseWelcome(RemoteContract.okLine()))
+ }
+
+ @Test
+ fun readerRecoversAfterReset() {
+ val reader = RemoteContract.FramePacketReader()
+ reader.feed(RemoteContract.encodeFramePacket(payload(30), 1).copyOfRange(0, 100))
+ assertTrue(reader.pending() > 0)
+ reader.reset()
+ assertEquals(0, reader.pending())
+ val raw = payload(31)
+ val frames = reader.feed(RemoteContract.encodeFramePacket(raw, 2))
+ assertEquals(1, frames.size)
+ assertArrayEquals(raw, frames[0])
+ }
+}
diff --git a/android/app/src/test/kotlin/com/mag160c/thermal/net/RemoteLoopbackTest.kt b/android/app/src/test/kotlin/com/mag160c/thermal/net/RemoteLoopbackTest.kt
new file mode 100644
index 0000000..03ab9cf
--- /dev/null
+++ b/android/app/src/test/kotlin/com/mag160c/thermal/net/RemoteLoopbackTest.kt
@@ -0,0 +1,134 @@
+package com.mag160c.thermal.net
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.util.Random
+
+/**
+ * End-to-end loopback check of the Phase F host/client pair: a real TCP socket
+ * on localhost, real framing, real control handshake. Run on the JVM because
+ * RemoteHost/RemoteClient use only java.net types.
+ */
+class RemoteLoopbackTest {
+ private fun payload(seed: Int): ByteArray {
+ val out = ByteArray(RemoteContract.FRAME_PIXELS)
+ Random(seed.toLong()).nextBytes(out)
+ return out
+ }
+
+ @Test
+ fun hostStreamsFramesToClientOverLoopback() = runBlocking {
+ val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ val host = RemoteHost("jvm-test", 160043865L)
+ try {
+ host.start()
+ // give the server socket a moment to bind
+ delay(300)
+
+ val session = RemoteClient.connect("127.0.0.1", RemoteContract.CONTROL_PORT, scope)
+ assertNotNull("client must connect to the loopback host", session)
+ session!!
+
+ // hello -> welcome
+ session.hello("jvm-client")
+ val welcome = withTimeout(5000) {
+ session.lines.first { RemoteContract.typeOf(it) == "welcome" }
+ }
+ val parsed = RemoteContract.parseWelcome(welcome)
+ assertNotNull("welcome must parse", parsed)
+ assertEquals(160, parsed!!.w)
+
+ // start -> stream-start, then frames
+ val received = ArrayList()
+ val collector = scope.launch {
+ session.frames.collect { received.add(it) }
+ }
+ session.startStream()
+ val startLine = withTimeout(5000) {
+ session.lines.first { RemoteContract.typeOf(it) == "stream-start" }
+ }
+ assertEquals("stream-start", RemoteContract.typeOf(startLine))
+
+ // feed frames like the live session would
+ val sent = List(5) { payload(100 + it) }
+ for ((i, f) in sent.withIndex()) {
+ host.offerFrame(f)
+ delay(60)
+ if (host.state == RemoteHost.State.STREAMING && i == 0) {
+ // ensure the queue drains between frames
+ }
+ }
+ // wait for all frames to arrive
+ val ok = withTimeout(8000) {
+ while (received.size < sent.size) delay(50)
+ true
+ }
+ assertTrue(ok)
+ collector.cancel()
+ assertTrue("at least ${sent.size} frames received, got ${received.size}", received.size >= sent.size)
+ sent.forEachIndexed { i, expect ->
+ assertTrue(
+ "frame $i must round-trip byte-exactly",
+ expect.contentEquals(received[i]),
+ )
+ }
+
+ // ffc request reaches the host callback
+ var ffcSeen = false
+ host.onFfcRequest = { ffcSeen = true }
+ session.requestFfc()
+ withTimeout(5000) {
+ while (!ffcSeen) delay(50)
+ }
+ assertTrue("host must receive the ffc request", ffcSeen)
+
+ // stop -> stream-stop
+ session.stopStream()
+ val stopLine = withTimeout(5000) {
+ session.lines.first { RemoteContract.typeOf(it) == "stream-stop" }
+ }
+ assertEquals("stream-stop", RemoteContract.typeOf(stopLine))
+
+ session.close()
+ } finally {
+ host.stop()
+ scope.cancel()
+ }
+ }
+
+ @Test
+ fun hostRejectsNothingWhenNoClientIsConnected() = runBlocking {
+ val host = RemoteHost("jvm-test", 1L)
+ try {
+ host.start()
+ delay(200)
+ // offering frames with no client must not throw or block
+ repeat(20) { host.offerFrame(payload(it)) }
+ assertEquals(RemoteHost.State.WAITING, host.state)
+ } finally {
+ host.stop()
+ }
+ }
+
+ @Test
+ fun beaconFormatIsWhatTheClientParses() {
+ val line = RemoteContract.hostBeacon("Redmi K70", 160043865L)
+ val info = RemoteContract.parseBeacon(line, "192.168.31.7")
+ assertNotNull(info)
+ assertEquals("Redmi K70", info!!.name)
+ assertEquals("192.168.31.7", info.address)
+ assertEquals(RemoteContract.CONTROL_PORT, info.tcpPort)
+ assertEquals(160043865L, info.serial)
+ }
+}
diff --git a/build-artifacts/mag160c-app-debug.apk b/build-artifacts/mag160c-app-debug.apk
index 76c54c8..9096868 100644
--- a/build-artifacts/mag160c-app-debug.apk
+++ b/build-artifacts/mag160c-app-debug.apk
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:82ed993ac92ba0b00542505569470020b85f75a90b561c5a5ef3d58a63d2eb46
-size 12662902
+oid sha256:8a96bbc095078b868269ae96cab8a88048383ad381c023d7008e1b484fda4257
+size 12663188
diff --git a/docs/android_app/real_device_checklist.md b/docs/android_app/real_device_checklist.md
index ebc1e32..e9b9025 100644
--- a/docs/android_app/real_device_checklist.md
+++ b/docs/android_app/real_device_checklist.md
@@ -28,8 +28,28 @@
| 14 | 拖动 PIP 小窗到其它角落;单击小窗(循环 96/128/160dp);双击小窗关闭 | `[pip] released`(双击关闭时) | 小窗跟手移动且不出界;单击在三档宽度间循环;双击后小窗消失,顶栏恢复"画中画"未开启态;日志有 `released` |
| 15 | PIP 开启时按 Home 回到桌面再返回 | `[pip] released`(进入后台时) | 返回后若 PIP 仍为开启态,画面重新出现(重新 open);无崩溃 |
-## 相机(PIP)失败时的表现(设计如此,不算 bug)
+## 远程预览(Phase F,需要两台设备)
+两台安卓设备(或一台手机 + 一个模拟器)连**同一个 Wi-Fi**。以下称 A=插热像仪的
+主机手机,B=远程查看的手机。
+
+| # | 操作 | 预期 DebugLog 行(原文) | 通过标准 |
+|---|------|--------------------------|----------|
+| 16 | A:插热像仪,确认实时页已出图(清单前 6 步) | `[session] state -> STREAMING` | A 本地画面正常 |
+| 17 | A:设置页 → "远程预览服务端"(点一下) | `[remote] host started (name=<机型> serial=0)` | 该行文案变为"已开启" |
+| 18 | A:若第 17 步弹"先连接热像仪" | (无日志) | 说明 USB 会话不活跃:先回到实时页确认出流 |
+| 19 | B:设置页 → "远程预览客户端" → "查找主机" | (B 端)列表出现 A 的主机名 | 10 秒内列出 A(卡片显示 `<主机名>` 与 `:47511`);未列出可用"手动添加"填 A 的 IP |
+| 20 | B:点该卡片(或手动 IP 后点"连接") | A 端:`[remote] client connected from `;B 端:`[remote] client connected to :47511` → `[remote] line: {"type":"welcome",...}` → `[remote] line: {"type":"stream-start"}` → `[remote] first remote frame` | B 显示 A 的热像实时画面(同一构图:竖屏 3:4、顶栏 4 项、右侧色标条) |
+| 21 | B:顶栏点变倍/追踪/调色板 | (无日志,全部本地) | 立即生效、无卡顿(调色板/变倍不下发到 A) |
+| 22 | B:顶栏点 FFC | A 端出现一次快门校正;B 画面随之更新 | FFC 经 A 的热像仪执行 |
+| 23 | B:点底部红色"断开"圆钮 | A 端:`[remote] client disconnected (frames=)` | B 返回主机列表并弹出"已断开"提示;A 服务端保持"已开启"待重连 |
+| 24 | A:设置页再点"远程预览服务端"关闭 | `[remote] host stopped` | 文案变回"已关闭";A 本地热像画面不受影响 |
+
+远程预览失败时的表现(设计如此):连不上主机时 B 显示"无法连接主机"并返回列表;
+中途断网/主机退出时 B 显示"连接已断开";A 端相机与本地画面**始终不受网络影响**;
+日志中 `frames=…` 每 300 帧记一次。
+
+## 相机(PIP)失败时的表现(设计如此,不算 bug)
PIP 的相机是可选功能,任何相机异常都只记日志并关闭小窗,**不影响热像主画面**:
- 无相机硬件 / 权限被拒 → `[pip] no camera available` / `[pip] camera permission not granted`,
diff --git a/docs/android_app/session_state.md b/docs/android_app/session_state.md
index 6843737..dd4d538 100644
--- a/docs/android_app/session_state.md
+++ b/docs/android_app/session_state.md
@@ -423,7 +423,23 @@
拖动/单击换档/双击关闭;PIP 关闭、离页、ON_STOP 三处释放相机;
运行时权限用 rememberLauncherForActivityResult。真机项已写入
real_device_checklist.md(第 13-15 步 + 失败表现)。APK 已更新。
-- [ ] Phase F:网络互连远程预览
+- [x] Phase F(2026-09-10):网络互连远程预览。net/RemoteContract.kt(协议常量+
+ 手写扁平 JSON 助手,**无 Android 依赖**便于 JVM 单测 + FramePacketReader
+ 流式重组);net/RemoteHost.kt(UDP 47510 每秒广播 + TCP 47511 单客户端;
+ **单一协程拥有 socket**,控制回复与帧记录不会交错;帧队列 DROP_OLDEST 不拖慢
+ 相机);net/RemoteClient.kt(discover 去重 3s;connect 行模式→收到
+ stream-start 后切帧模式,同一 chunk 内混合也能正确切分;10s 无数据判死);
+ IrSession 加 rawHook(与 recorderHook 并列);LiveViewModel 接 rawHook →
+ remoteHost.offerFrame + setRemoteHostEnabled(需活跃 USB 会话);
+ 设置页新增"远程预览服务端"/"远程预览客户端"两行(前置校验弹"先连接热像仪");
+ ui/remote/ 三文件:RemoteClientListScreen(56dp 标题栏/64dp 卡片/重新扫描/
+ 手动添加/空态)、RemoteViewerViewModel(本地 RenderPipeline + 内置 DDT
+ 渲染)、RemoteViewerScreen + RemoteRendererHost(与 LiveRenderer 同构图,
+ 未改冻结文件)、AppRoot 加两个全屏目的地。Manifest 加 INTERNET。
+ 单测 44 个全绿,含 **loopback 端到端测试**(真实 TCP:hello→welcome→
+ start→stream-start→5 帧逐字节往返→ffc→stop→stream-stop)与粘包/截断/
+ 坏长度/重同步用例。debug+release 双构建通过。真机双机步骤写入清单第 16-24 步。
+- [ ] Phase Z:收尾(文档/全量构建/APK)
## 里程碑日志