android: 拍照MDT容器保存(MediaStore DCIM/MAG160C)+MP4录像(Surface编码) 阶段3b

This commit is contained in:
ZXCLI
2026-09-06 19:40:56 +08:00
parent 6297a06b97
commit ed0a0882d8
7 changed files with 444 additions and 2 deletions
@@ -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<Int, ByteArray>()
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?,
)
}
@@ -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
}
}
}
}
@@ -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()
}
}
@@ -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,
@@ -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
@@ -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)
}
}
@@ -0,0 +1 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#FF000000" android:pathData="M12,4C7.31,4 3.5,7.81 3.5,12.5S7.31,21 12,21s8.5,-3.81 8.5,-8.5S16.69,4 12,4zM12,18c-3.04,0 -5.5,-2.46 -5.5,-5.5S8.96,7 12,7s5.5,2.46 5.5,5.5S15.04,18 12,18zM12,17c2.49,0 4.5,-2.01 4.5,-4.5S14.49,8 12,8s-4.5,2.01 -4.5,4.5S9.51,17 12,17z" /></vector>