android: LAN remote preview (UDP discovery + raw-frame TCP stream, client-side rendering)

This commit is contained in:
ZXCLI
2026-09-11 00:54:13 +08:00
parent f8b3200464
commit 512508e629
18 changed files with 2113 additions and 15 deletions
+3
View File
@@ -5,6 +5,9 @@
<!-- Visible-light PIP overlay (Phase E): optional, requested at runtime -->
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
<uses-permission android:name="android.permission.CAMERA" />
<!-- LAN remote preview (Phase F): host broadcasts on UDP 47510, streams on TCP 47511 -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
@@ -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<RemoteContract.HostInfo> {
val out = MutableSharedFlow<RemoteContract.HostInfo>(
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<String, Long>()
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<String>(
replay = 8,
extraBufferCapacity = 32,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/** Control lines from the host; only meaningful before the stream starts. */
val lines: Flow<String> = _lines.asSharedFlow()
private val _frames = MutableSharedFlow<ByteArray>(
extraBufferCapacity = 8,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/** Raw 38400-byte sensor payloads, ready for the local RenderPipeline. */
val frames: Flow<ByteArray> = _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<Unit>(extraBufferCapacity = 1)
val closedFlow: Flow<Unit> = _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() }
}
}
}
@@ -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":"<model>","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<ByteArray> {
append(bytes)
val out = ArrayList<ByteArray>(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
}
}
}
@@ -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<ByteArray>? = 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<String> {
val out = ArrayList<String>(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()
}
}
@@ -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<String?>(null) }
var remotePort by rememberSaveable { mutableStateOf(0) }
var notice by remember { mutableStateOf<String?>(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("") }
}
}
}
}
}
@@ -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()
}
@@ -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<List<RemoteContract.HostInfo>>(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))
}
@@ -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)
}
}
@@ -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,
)
}
}
}
@@ -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> = _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<Unit>(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<Application>().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()
}
}
@@ -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<String?>(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
@@ -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++
@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M12,4a8,8 0 1,0 0.01,0 a8,8 0 1,0 -0.01,0z" android:strokeColor="#FF000000" android:strokeWidth="2" android:fillColor="#00000000" />
<path android:pathData="M11,8 L7,12 L11,16" android:strokeColor="#FF000000" android:strokeWidth="2" android:fillColor="#00000000" />
<path android:pathData="M7,12 L17,12" android:strokeColor="#FF000000" android:strokeWidth="2" />
</vector>
@@ -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<ByteArray>()
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])
}
}
@@ -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<ByteArray>()
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)
}
}
Binary file not shown.
+21 -1
View File
@@ -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(卡片显示 `<主机名>``<IP>:47511`);未列出可用"手动添加"填 A 的 IP |
| 20 | B:点该卡片(或手动 IP 后点"连接" | A 端:`[remote] client connected from <B的IP>`B 端:`[remote] client connected to <A的IP>: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 <IP> disconnected (frames=<N>)` | 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`
+17 -1
View File
@@ -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.ktUDP 47510 每秒广播 + TCP 47511 单客户端;
**单一协程拥有 socket**,控制回复与帧记录不会交错;帧队列 DROP_OLDEST 不拖慢
相机);net/RemoteClient.ktdiscover 去重 3sconnect 行模式→收到
stream-start 后切帧模式,同一 chunk 内混合也能正确切分;10s 无数据判死);
IrSession 加 rawHook(与 recorderHook 并列);LiveViewModel 接 rawHook →
remoteHost.offerFrame + setRemoteHostEnabled(需活跃 USB 会话);
设置页新增"远程预览服务端"/"远程预览客户端"两行(前置校验弹"先连接热像仪");
ui/remote/ 三文件:RemoteClientListScreen56dp 标题栏/64dp 卡片/重新扫描/
手动添加/空态)、RemoteViewerViewModel(本地 RenderPipeline + 内置 DDT
渲染)、RemoteViewerScreen + RemoteRendererHost(与 LiveRenderer 同构图,
未改冻结文件)、AppRoot 加两个全屏目的地。Manifest 加 INTERNET。
单测 44 个全绿,含 **loopback 端到端测试**(真实 TCPhello→welcome→
start→stream-start→5 帧逐字节往返→ffc→stop→stream-stop)与粘包/截断/
坏长度/重同步用例。debug+release 双构建通过。真机双机步骤写入清单第 16-24 步。
- [ ] Phase Z:收尾(文档/全量构建/APK)
## 里程碑日志