From ed0a0882d828edada6de50018bd38c0639bdd063 Mon Sep 17 00:00:00 2001 From: ZXCLI Date: Sun, 6 Sep 2026 19:40:56 +0800 Subject: [PATCH] =?UTF-8?q?android:=20=E6=8B=8D=E7=85=A7MDT=E5=AE=B9?= =?UTF-8?q?=E5=99=A8=E4=BF=9D=E5=AD=98(MediaStore=20DCIM/MAG160C)+MP4?= =?UTF-8?q?=E5=BD=95=E5=83=8F(Surface=E7=BC=96=E7=A0=81)=20=E9=98=B6?= =?UTF-8?q?=E6=AE=B53b?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/com/mag160c/thermal/media/Mdt.kt | 134 +++++++++++++++++ .../com/mag160c/thermal/media/Mp4Recorder.kt | 138 ++++++++++++++++++ .../com/mag160c/thermal/media/PhotoSaver.kt | 92 ++++++++++++ .../com/mag160c/thermal/ui/live/LiveScreen.kt | 12 +- .../mag160c/thermal/ui/live/LiveViewModel.kt | 47 ++++++ .../com/mag160c/thermal/usb/IrSession.kt | 22 ++- .../app/src/main/res/drawable/ic_record.xml | 1 + 7 files changed, 444 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/kotlin/com/mag160c/thermal/media/Mdt.kt create mode 100644 android/app/src/main/kotlin/com/mag160c/thermal/media/Mp4Recorder.kt create mode 100644 android/app/src/main/kotlin/com/mag160c/thermal/media/PhotoSaver.kt create mode 100644 android/app/src/main/res/drawable/ic_record.xml diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/media/Mdt.kt b/android/app/src/main/kotlin/com/mag160c/thermal/media/Mdt.kt new file mode 100644 index 0000000..8e20dd9 --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/media/Mdt.kt @@ -0,0 +1,134 @@ +package com.mag160c.thermal.media + +import java.io.ByteArrayOutputStream + +/** + * MDT thermal file container, mirroring the vendor ThermoScope layout + * (docs/android_app/reverse_apk_features.md §4, all little-endian): + * + * [JPEG section: raw jpg bytes at offset 0, padded to 4] + * [DDT section: 136B header (code 0x5BB5B55B + size + reserved 128) + body] + * [Tail: 152B — code 0x5BB5B57B + ddtOffset + reserved] + * + * DDT body = typed blocks {u32 magic, u32 len, data padded to 4}: + * 0x5BB5B55B camera info (0x38B from command 66b) + * 0x5BB5B55C second info block (0x38B from 66c, optional) + * 0x5BB5B55D raw measurement frame (19200 x uint16 LE) + * 0x5BB5B55E text note (UTF-8, optional) + */ +object Mdt { + const val SECTION_DDT = 0x5BB5B55B + const val SECTION_TAIL = 0x5BB5B57B + + const val BLOCK_INFO0 = 0x5BB5B55B + const val BLOCK_INFO1 = 0x5BB5B55C + const val BLOCK_FRAME = 0x5BB5B55D + const val BLOCK_TXT = 0x5BB5B55E + + private fun align4(n: Int): Int = (n + 3) / 4 * 4 + + 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 put32(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() + } + + /** + * Compose an MDT file. + * @param jpg rendered image JPEG (offset 0, used as thumbnail + analysis base) + * @param info0 camera info block from command 0x6BB6B66B (0x38B) + * @param info1 second cached info block (0x38B) or null + * @param rawFrame latest raw USB frame (0x38-byte header + 38400B pixels) + * @param text UTF-8 note bytes or null + */ + fun compose( + jpg: ByteArray, + info0: ByteArray?, + info1: ByteArray?, + rawFrame: ByteArray?, + text: ByteArray? = null, + ): ByteArray { + val out = ByteArrayOutputStream(align4(jpg.size) + 0x88 + 38400 + 320) + out.write(jpg, 0, jpg.size) + repeat(align4(jpg.size) - jpg.size) { out.write(0) } + + // --- DDT section --- + val ddtOffset = out.size() + val body = ByteArrayOutputStream() + fun emit(magic: Int, data: ByteArray) { + val n = align4(data.size) + val b = ByteArray(8 + n) + put32(b, 0, magic) + put32(b, 4, n) + System.arraycopy(data, 0, b, 8, data.size) + body.write(b, 0, b.size) + } + info0?.let { emit(BLOCK_INFO0, it) } + info1?.let { emit(BLOCK_INFO1, it) } + val raw = rawFrame + if (raw != null && raw.size >= 0x1C + 38400) { + emit(BLOCK_FRAME, raw.copyOfRange(0x1C, 0x1C + 38400)) + } + text?.let { emit(BLOCK_TXT, it) } + + val bodyBytes = body.toByteArray() + val header = ByteArray(0x88) + put32(header, 0, SECTION_DDT) + put32(header, 4, bodyBytes.size) + out.write(header, 0, 0x88) + out.write(bodyBytes, 0, bodyBytes.size) + + // --- Tail --- + val tail = ByteArray(152) + put32(tail, 0, SECTION_TAIL) + put32(tail, 4, ddtOffset) + out.write(tail, 0, 152) + return out.toByteArray() + } + + /** Parse an MDT file produced by [compose] (or any file whose last 152 + * bytes carry a valid tail). Returns the sections or null. */ + fun parse(bytes: ByteArray): Parsed? { + if (bytes.size < 152) return null + val tail = bytes.copyOfRange(bytes.size - 152, bytes.size) + if (u32(tail, 0) != SECTION_TAIL) return null + val ddtOffset = u32(tail, 4) + if (ddtOffset <= 0 || ddtOffset + 0x88 > bytes.size - 152) return null + if (u32(bytes, ddtOffset) != SECTION_DDT) return null + val bodySize = u32(bytes, ddtOffset + 4) + val bodyStart = ddtOffset + 0x88 + if (bodyStart + bodySize > bytes.size - 152) return null + val blocks = HashMap() + var p = bodyStart + val end = bodyStart + bodySize + while (p + 8 <= end) { + val magic = u32(bytes, p) + val len = u32(bytes, p + 4) + if (len < 0 || p + 8 + len > end) break + blocks[magic] = bytes.copyOfRange(p + 8, p + 8 + len) + p += 8 + len + } + return Parsed( + jpg = bytes.copyOfRange(0, ddtOffset), + info0 = blocks[BLOCK_INFO0], + info1 = blocks[BLOCK_INFO1], + frame = blocks[BLOCK_FRAME], + text = blocks[BLOCK_TXT], + ) + } + + class Parsed( + val jpg: ByteArray, + val info0: ByteArray?, + val info1: ByteArray?, + val frame: ByteArray?, + val text: ByteArray?, + ) +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/media/Mp4Recorder.kt b/android/app/src/main/kotlin/com/mag160c/thermal/media/Mp4Recorder.kt new file mode 100644 index 0000000..dd23989 --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/media/Mp4Recorder.kt @@ -0,0 +1,138 @@ +package com.mag160c.thermal.media + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Matrix +import android.graphics.Paint +import android.media.MediaCodec +import android.media.MediaCodecInfo +import android.media.MediaFormat +import android.media.MediaMuxer +import java.io.File +import java.util.concurrent.atomic.AtomicBoolean + +/** + * MP4 (H.264) recorder for the live 320x240 stream, replacing the vendor + * .mgs / FFmpeg recording paths. Uses a Surface-fed encoder so the codec + * handles color conversion; frames arrive as ARGB bitmaps. + */ +class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) { + private val fps = 15 + private val bitRate = 2_000_000 + + private var encoder: MediaCodec? = null + private var inputSurface: android.view.Surface? = null + private var muxer: MediaMuxer? = null + private var trackIndex = -1 + private var muxerStarted = false + private val active = AtomicBoolean(false) + private val canvas = Canvas() + private val paint = Paint() + + @Volatile + var outPath: File? = null + private set + + fun start(): Boolean { + if (active.get()) return true + return try { + val format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height).apply { + setInteger( + MediaFormat.KEY_COLOR_FORMAT, + MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface, + ) + setInteger(MediaFormat.KEY_BIT_RATE, bitRate) + setInteger(MediaFormat.KEY_FRAME_RATE, fps) + setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 5) + } + val enc = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC) + enc.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) + val surface = enc.createInputSurface() + enc.start() + encoder = enc + inputSurface = surface + val tmp = File.createTempFile("mag160c", ".mp4") + muxer = MediaMuxer(tmp.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) + outPath = tmp + active.set(true) + true + } catch (e: Exception) { + releaseAll() + false + } + } + + fun isRecording(): Boolean = active.get() + + /** Push one frame bitmap (called from the frame callback thread). */ + fun offerFrame(bmp: Bitmap) { + val surface = inputSurface ?: return + if (!active.get()) return + val c = surface.lockCanvas(null) ?: return + try { + c.drawBitmap(bmp, null, android.graphics.RectF(0f, 0f, width.toFloat(), height.toFloat()), paint) + } finally { + surface.unlockCanvasAndPost(c) + } + drain(false) + } + + /** Stop recording and finalize. Returns the output file. */ + fun stop(): File? { + if (!active.getAndSet(false)) return null + val enc = encoder ?: return null + // drain with EOS + val idx = enc.dequeueInputBuffer(10_000) + if (idx >= 0) enc.queueInputBuffer(idx, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM) + drain(true) + val f = outPath + runCatching { muxer?.stop() } + runCatching { muxer?.release() } + muxer = null + runCatching { enc.stop() } + runCatching { enc.release() } + encoder = null + inputSurface?.release() + inputSurface = null + return f + } + + private fun releaseAll() { + runCatching { encoder?.stop() } + runCatching { encoder?.release() } + encoder = null + runCatching { inputSurface?.release() } + inputSurface = null + runCatching { muxer?.stop() } + runCatching { muxer?.release() } + muxer = null + } + + private fun drain(end: Boolean) { + val enc = encoder ?: return + val mux = muxer ?: return + val info = MediaCodec.BufferInfo() + while (true) { + val outIdx = enc.dequeueOutputBuffer(info, if (end) 10_000 else 0) + when { + outIdx == MediaCodec.INFO_TRY_AGAIN_LATER -> if (!end) return + outIdx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { + trackIndex = mux.addTrack(enc.outputFormat) + mux.start() + muxerStarted = true + } + outIdx >= 0 -> { + if (info.size > 0 && muxerStarted && + info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG == 0 + ) { + val ob = enc.getOutputBuffer(outIdx) + if (ob != null) mux.writeSampleData(trackIndex, ob, info) + } + enc.releaseOutputBuffer(outIdx, false) + if (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) return + } + else -> return + } + } + } +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/media/PhotoSaver.kt b/android/app/src/main/kotlin/com/mag160c/thermal/media/PhotoSaver.kt new file mode 100644 index 0000000..726efce --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/media/PhotoSaver.kt @@ -0,0 +1,92 @@ +package com.mag160c.thermal.media + +import android.content.ContentValues +import android.content.Context +import android.graphics.Bitmap +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import java.io.ByteArrayOutputStream +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Save captured photos into MediaStore under DCIM/MAG160C (system gallery + * visible, no rogue folders). The stored file is a self-contained MDT + * container (JPG + temperature frame + note) named by capture time. + */ +object PhotoSaver { + private val TIME_FMT = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.ENGLISH) + + fun fileName(now: Date = Date()): String = "MAG160C_${TIME_FMT.format(now)}.jpg" + + /** Encode a rendered ARGB frame to JPEG bytes. */ + fun encodeJpeg(frame: IntArray, w: Int = 320, h: Int = 240, quality: Int = 92): ByteArray { + val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) + bmp.setPixels(frame, 0, w, 0, 0, w, h) + val out = ByteArrayOutputStream(w * h / 4) + bmp.compress(Bitmap.CompressFormat.JPEG, quality, out) + bmp.recycle() + return out.toByteArray() + } + + /** Save an MDT container into MediaStore. Returns the media uri string. */ + fun saveMdt( + context: Context, + mdt: ByteArray, + displayName: String, + ): String? { + val resolver = context.contentResolver + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) + put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg") + if (Build.VERSION.SDK_INT >= 29) { + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DCIM + "/MAG160C") + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + } + val uri = resolver.insert( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values, + ) ?: return null + try { + resolver.openOutputStream(uri)?.use { it.write(mdt) } + if (Build.VERSION.SDK_INT >= 29) { + values.clear() + values.put(MediaStore.MediaColumns.IS_PENDING, 0) + resolver.update(uri, values, null, null) + } + } catch (e: Exception) { + resolver.delete(uri, null, null) + return null + } + return uri.toString() + } + + /** Save a plain JPEG (no MDT wrapper). */ + fun saveJpeg(context: Context, jpg: ByteArray, displayName: String): String? { + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) + put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg") + if (Build.VERSION.SDK_INT >= 29) { + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DCIM + "/MAG160C") + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + } + val uri = context.contentResolver.insert( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values, + ) ?: return null + try { + context.contentResolver.openOutputStream(uri)?.use { it.write(jpg) } + if (Build.VERSION.SDK_INT >= 29) { + val done = ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) } + context.contentResolver.update(uri, done, null, null) + } + } catch (e: Exception) { + context.contentResolver.delete(uri, null, null) + return null + } + return uri.toString() + } + +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt index 30a5751..f183662 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt @@ -76,6 +76,7 @@ fun LiveScreen(vm: LiveViewModel = viewModel()) { @Composable private fun ControlBar(state: LiveViewModel.LiveState, vm: LiveViewModel) { var showPalette by remember { mutableStateOf(false) } + val context = LocalContext.current Surface(color = MaterialTheme.colorScheme.surfaceVariant) { Row( @@ -104,9 +105,18 @@ private fun ControlBar(state: LiveViewModel.LiveState, vm: LiveViewModel) { IconButton(onClick = { showPalette = true }) { Icon(painterResource(R.drawable.ic_palette), contentDescription = "调色板") } - IconButton(onClick = { /* capture: phase 3b */ }) { + IconButton(onClick = { + val ctx = context + vm.capturePhoto(ctx) + }) { Icon(painterResource(R.drawable.ic_camera), contentDescription = "拍照") } + IconButton(onClick = { + val ctx = context + vm.toggleRecording(ctx) + }) { + Icon(painterResource(R.drawable.ic_record), contentDescription = "录像") + } Text( text = Palettes.NAMES[state.paletteIndex], style = MaterialTheme.typography.labelMedium, 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 7eeec22..ec2d3fb 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 @@ -56,6 +56,15 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { init { session.setListener(sessionListener) + // push frames into the MP4 recorder while recording + session.recorderHook = { argb -> + val rec = recorder + if (rec != null && rec.isRecording()) { + val bmp = android.graphics.Bitmap.createBitmap(320, 240, android.graphics.Bitmap.Config.ARGB_8888) + bmp.setPixels(argb, 0, 320, 0, 0, 320, 240) + rec.offerFrame(bmp) + } + } } /** Begin USB permission flow, then start streaming. */ @@ -86,6 +95,44 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { _state.value = _state.value.copy(zoom = z.coerceIn(1, 4)) } + /** Capture: rendered JPEG + raw frame + camera info -> MDT -> MediaStore. */ + fun capturePhoto(context: android.content.Context) { + val frame = latestFrame ?: return + val s = session + val jpg = com.mag160c.thermal.media.PhotoSaver.encodeJpeg(frame) + val mdt = com.mag160c.thermal.media.Mdt.compose( + jpg = jpg, + info0 = s.lastInfo0, + info1 = s.lastInfo1, + rawFrame = s.lastRawFrame, + ) + val saved = com.mag160c.thermal.media.PhotoSaver.saveMdt( + context, mdt, com.mag160c.thermal.media.PhotoSaver.fileName(), + ) + _state.value = _state.value.copy(status = if (saved != null) "saved" else "save_fail") + } + + private var recorder: com.mag160c.thermal.media.Mp4Recorder? = null + + /** Toggle MP4 recording of the live stream. */ + fun toggleRecording(context: android.content.Context) { + val rec = recorder + if (rec == null) { + val r = com.mag160c.thermal.media.Mp4Recorder() + if (r.start()) { + recorder = r + _state.value = _state.value.copy(status = "recording") + } + } else { + val file = rec.stop() + recorder = null + if (file != null) { + val name = "MAG160C_V_${com.mag160c.thermal.media.PhotoSaver.fileName()}" + _state.value = _state.value.copy(status = "rec_done") + } + } + } + /** Update per-frame temperature stats (called on a slow timer). */ fun refreshTemps() { if (!session.isStreaming()) return 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 4797c50..8ac5ef9 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 @@ -51,6 +51,10 @@ class IrSession(context: Context) { listener = l } + /** Optional per-frame hook (MP4 recording), runs on the reader thread. */ + @Volatile + var recorderHook: ((IntArray) -> Unit)? = null + fun isStreaming(): Boolean = running.get() /** Latest raw frame (with 0x38-byte header) for MDT capture. */ @@ -58,6 +62,15 @@ class IrSession(context: Context) { var lastRawFrame: ByteArray? = null private set + /** Cached 66b/66c camera info blocks (0x38B each) for the MDT DDT section. */ + @Volatile + var lastInfo0: ByteArray? = null + private set + + @Volatile + var lastInfo1: ByteArray? = null + private set + fun identitySnapshot(): CameraIdentity = identity.copy() /** Connect + start the live stream. Must be called after USB permission. */ @@ -122,8 +135,12 @@ class IrSession(context: Context) { val n = conn.bulkTransfer(resp, buf, buf.size, 2000) if (n <= 3) return val magic = MagProtocol.u32(buf, 0) + if (magic == MagProtocol.RSP_INFO_1 && n >= 0x3C) { + lastInfo1 = buf.copyOfRange(4, 4 + 0x38) + } if (magic == MagProtocol.RSP_INFO_0 && n >= 0x3C) { val payload = buf.copyOfRange(4, n) + lastInfo0 = payload.copyOf(0x38) val newIdentity = CameraIdentity( pid = MagProtocol.u32(payload, 0), serial = (MagProtocol.u32(payload, 8).toLong() and 0xFFFFFFFFL) or @@ -155,7 +172,10 @@ class IrSession(context: Context) { while (len > 0 && running.get()) { lastRawFrame = frameBuf.copyOf() val rendered = pipe.frame(frameBuf, true, out) - if (rendered) listener?.onFrameReady(out) + if (rendered) { + listener?.onFrameReady(out) + recorderHook?.invoke(out) + } len = stream.push(noop, 0, frameBuf) } } diff --git a/android/app/src/main/res/drawable/ic_record.xml b/android/app/src/main/res/drawable/ic_record.xml new file mode 100644 index 0000000..cf1c302 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_record.xml @@ -0,0 +1 @@ +