android: 媒体库+MDT离线分析查看器(缩放/调色板/备注) 阶段4-5a
This commit is contained in:
@@ -45,14 +45,14 @@ object Mdt {
|
|||||||
* @param jpg rendered image JPEG (offset 0, used as thumbnail + analysis base)
|
* @param jpg rendered image JPEG (offset 0, used as thumbnail + analysis base)
|
||||||
* @param info0 camera info block from command 0x6BB6B66B (0x38B)
|
* @param info0 camera info block from command 0x6BB6B66B (0x38B)
|
||||||
* @param info1 second cached info block (0x38B) or null
|
* @param info1 second cached info block (0x38B) or null
|
||||||
* @param rawFrame latest raw USB frame (0x38-byte header + 38400B pixels)
|
* @param framePixels raw measurement frame (38400 bytes, 19200 x uint16 LE) or null
|
||||||
* @param text UTF-8 note bytes or null
|
* @param text UTF-8 note bytes or null
|
||||||
*/
|
*/
|
||||||
fun compose(
|
fun compose(
|
||||||
jpg: ByteArray,
|
jpg: ByteArray,
|
||||||
info0: ByteArray?,
|
info0: ByteArray?,
|
||||||
info1: ByteArray?,
|
info1: ByteArray?,
|
||||||
rawFrame: ByteArray?,
|
framePixels: ByteArray?,
|
||||||
text: ByteArray? = null,
|
text: ByteArray? = null,
|
||||||
): ByteArray {
|
): ByteArray {
|
||||||
val out = ByteArrayOutputStream(align4(jpg.size) + 0x88 + 38400 + 320)
|
val out = ByteArrayOutputStream(align4(jpg.size) + 0x88 + 38400 + 320)
|
||||||
@@ -72,9 +72,8 @@ object Mdt {
|
|||||||
}
|
}
|
||||||
info0?.let { emit(BLOCK_INFO0, it) }
|
info0?.let { emit(BLOCK_INFO0, it) }
|
||||||
info1?.let { emit(BLOCK_INFO1, it) }
|
info1?.let { emit(BLOCK_INFO1, it) }
|
||||||
val raw = rawFrame
|
if (framePixels != null && framePixels.size == 38400) {
|
||||||
if (raw != null && raw.size >= 0x1C + 38400) {
|
emit(BLOCK_FRAME, framePixels)
|
||||||
emit(BLOCK_FRAME, raw.copyOfRange(0x1C, 0x1C + 38400))
|
|
||||||
}
|
}
|
||||||
text?.let { emit(BLOCK_TXT, it) }
|
text?.let { emit(BLOCK_TXT, it) }
|
||||||
|
|
||||||
|
|||||||
@@ -25,9 +25,13 @@ object PhotoSaver {
|
|||||||
fun encodeJpeg(frame: IntArray, w: Int = 320, h: Int = 240, quality: Int = 92): ByteArray {
|
fun encodeJpeg(frame: IntArray, w: Int = 320, h: Int = 240, quality: Int = 92): ByteArray {
|
||||||
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||||
bmp.setPixels(frame, 0, w, 0, 0, w, h)
|
bmp.setPixels(frame, 0, w, 0, 0, w, h)
|
||||||
val out = ByteArrayOutputStream(w * h / 4)
|
return encodeJpeg(bmp, quality)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encode an existing bitmap to JPEG bytes. */
|
||||||
|
fun encodeJpeg(bmp: Bitmap, quality: Int = 92): ByteArray {
|
||||||
|
val out = ByteArrayOutputStream(bmp.width * bmp.height / 4)
|
||||||
bmp.compress(Bitmap.CompressFormat.JPEG, quality, out)
|
bmp.compress(Bitmap.CompressFormat.JPEG, quality, out)
|
||||||
bmp.recycle()
|
|
||||||
return out.toByteArray()
|
return out.toByteArray()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package com.mag160c.thermal.ui.analyze
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.mag160c.thermal.media.Mdt
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offline MDT analysis state: loaded container, palette re-render, probes.
|
||||||
|
*/
|
||||||
|
class AnalyzeViewModel(
|
||||||
|
app: Application,
|
||||||
|
private val containerBytes: ByteArray,
|
||||||
|
private val fileUri: android.net.Uri,
|
||||||
|
) : AndroidViewModel(app) {
|
||||||
|
val parsed: Mdt.Parsed? = Mdt.parse(containerBytes)
|
||||||
|
|
||||||
|
/** Raw measurement frame (19200 uint16) if present. */
|
||||||
|
val rawFrame: IntArray? by lazy {
|
||||||
|
parsed?.frame?.let { raw ->
|
||||||
|
val out = IntArray(19200)
|
||||||
|
for (i in out.indices) {
|
||||||
|
out[i] = (raw[i * 2].toInt() and 0xFF) or ((raw[i * 2 + 1].toInt() and 0xFF) shl 8)
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _render = MutableStateFlow<Bitmap?>(null)
|
||||||
|
val render: StateFlow<Bitmap?> = _render
|
||||||
|
|
||||||
|
private val _paletteIndex = MutableStateFlow(2)
|
||||||
|
val paletteIndex: StateFlow<Int> = _paletteIndex
|
||||||
|
|
||||||
|
/** Re-render the raw frame with the given palette + auto window. */
|
||||||
|
fun render(paletteIdx: Int) {
|
||||||
|
val raw = rawFrame ?: return
|
||||||
|
_paletteIndex.value = paletteIdx
|
||||||
|
viewModelScope.launch(Dispatchers.Default) {
|
||||||
|
var mn = Int.MAX_VALUE
|
||||||
|
var mx = -1
|
||||||
|
for (v in raw) {
|
||||||
|
if (v < mn) mn = v
|
||||||
|
if (v > mx) mx = v
|
||||||
|
}
|
||||||
|
if (mx <= mn) mx = mn + 1
|
||||||
|
val pal = com.mag160c.thermal.core.Palettes.buildAll()[paletteIdx.coerceIn(0, 11)]
|
||||||
|
val argb = IntArray(19200)
|
||||||
|
val scale = (255 shl 12) / (mx - mn)
|
||||||
|
for (i in argb.indices) {
|
||||||
|
var g = ((raw[i] - mn) * scale) shr 8
|
||||||
|
if (g < 0) g = 0 else if (g > 255) g = 255
|
||||||
|
argb[i] = pal[g]
|
||||||
|
}
|
||||||
|
val bmp = Bitmap.createBitmap(160, 120, Bitmap.Config.ARGB_8888)
|
||||||
|
bmp.setPixels(argb, 0, 160, 0, 0, 160, 120)
|
||||||
|
withContext(Dispatchers.Main) { _render.value = bmp }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decodeNote(): String? {
|
||||||
|
val t = parsed?.text ?: return null
|
||||||
|
return String(t, Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Probe temperature approximation at a raw pixel (millidegrees C). */
|
||||||
|
fun probeTemp(x: Int, y: Int): Int? {
|
||||||
|
val raw = rawFrame ?: return null
|
||||||
|
if (x < 0 || y < 0 || x >= 160 || y >= 120) return null
|
||||||
|
return com.mag160c.thermal.core.TempMath.countsToTempMc(raw[y * 160 + x])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save note: rewrite the container in place (jpg = current render). */
|
||||||
|
fun saveNote(note: String, onDone: (Boolean) -> Unit) {
|
||||||
|
val bmp = _render.value
|
||||||
|
if (bmp == null) {
|
||||||
|
onDone(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val jpg = com.mag160c.thermal.media.PhotoSaver.encodeJpeg(bmp)
|
||||||
|
val mdt = Mdt.compose(
|
||||||
|
jpg = jpg,
|
||||||
|
info0 = parsed?.info0,
|
||||||
|
info1 = parsed?.info1,
|
||||||
|
framePixels = parsed?.frame,
|
||||||
|
text = note.toByteArray(Charsets.UTF_8),
|
||||||
|
)
|
||||||
|
val ok = runCatching {
|
||||||
|
val ctx = getApplication<Application>()
|
||||||
|
ctx.contentResolver.openOutputStream(fileUri, "w")?.use { it.write(mdt) } != null
|
||||||
|
}.getOrDefault(false)
|
||||||
|
withContext(Dispatchers.Main) { onDone(ok) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package com.mag160c.thermal.ui.analyze
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
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.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.mag160c.thermal.core.Palettes
|
||||||
|
import com.mag160c.thermal.ui.gallery.GalleryViewModel
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-file MDT analysis viewer: pinch zoom/pan, palette re-render,
|
||||||
|
* text note editing.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AnalyzeViewer(item: GalleryViewModel.Item, galleryVm: GalleryViewModel) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val vm = remember(item.name) {
|
||||||
|
val bytes = runCatching {
|
||||||
|
context.contentResolver.openInputStream(item.uri)?.use { it.readBytes() }
|
||||||
|
}.getOrNull() ?: ByteArray(0)
|
||||||
|
AnalyzeViewModel(context.applicationContext as android.app.Application, bytes, item.uri)
|
||||||
|
}
|
||||||
|
val render by vm.render.collectAsState()
|
||||||
|
val paletteIdx by vm.paletteIndex.collectAsState()
|
||||||
|
var zoom by remember { mutableStateOf(1f) }
|
||||||
|
var pan by remember { mutableStateOf(Offset.Zero) }
|
||||||
|
var showNote by remember { mutableStateOf(false) }
|
||||||
|
var note by remember { mutableStateOf(vm.decodeNote() ?: "") }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { vm.render(2) }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black),
|
||||||
|
) {
|
||||||
|
Canvas(
|
||||||
|
modifier = Modifier
|
||||||
|
.aspectRatio(4f / 3f)
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTransformGestures { _, gesturePan, gestureZoom, _ ->
|
||||||
|
zoom = (zoom * gestureZoom).coerceIn(1f, 4f)
|
||||||
|
pan += gesturePan
|
||||||
|
if (zoom <= 1.01f) {
|
||||||
|
zoom = 1f
|
||||||
|
pan = Offset.Zero
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
val img = render?.asImageBitmap()
|
||||||
|
if (img != null) {
|
||||||
|
val w = size.width * zoom
|
||||||
|
val h = size.height * zoom
|
||||||
|
val left = (size.width - w) / 2 + pan.x
|
||||||
|
val top = (size.height - h) / 2 + pan.y
|
||||||
|
drawImage(
|
||||||
|
image = img,
|
||||||
|
dstOffset = androidx.compose.ui.unit.IntOffset(left.toInt(), top.toInt()),
|
||||||
|
dstSize = IntSize(w.toInt(), h.toInt()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(Color(0x66000000))
|
||||||
|
.horizontalScroll(rememberScrollState())
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
) {
|
||||||
|
Palettes.NAMES.forEachIndexed { idx, name ->
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
color = if (paletteIdx == idx) MaterialTheme.colorScheme.primary else Color.White,
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable { vm.render(idx) }
|
||||||
|
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = { showNote = true },
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(12.dp),
|
||||||
|
) {
|
||||||
|
Text("备注")
|
||||||
|
}
|
||||||
|
if (showNote) {
|
||||||
|
NoteEditor(
|
||||||
|
initial = note,
|
||||||
|
onSave = {
|
||||||
|
note = it
|
||||||
|
vm.saveNote(it) { }
|
||||||
|
showNote = false
|
||||||
|
},
|
||||||
|
onDismiss = { showNote = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun IntOffsetCompat(x: Float, y: Float): Offset = Offset(x, y)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NoteEditor(initial: String, onSave: (String) -> Unit, onDismiss: () -> Unit) {
|
||||||
|
var text by remember { mutableStateOf(initial) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("文字备注") },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(value = text, onValueChange = { text = it })
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { onSave(text) }) { Text("保存") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text("取消") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package com.mag160c.thermal.ui.gallery
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
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.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
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.asImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.mag160c.thermal.ui.analyze.AnalyzeViewer
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GalleryScreen(vm: GalleryViewModel = viewModel()) {
|
||||||
|
val items by vm.items.collectAsState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
var showViewer by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text("媒体库 (${items.size})", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Button(onClick = { vm.refresh() }) { Text("刷新") }
|
||||||
|
}
|
||||||
|
LazyVerticalGrid(columns = GridCells.Fixed(if (isLandscape()) 4 else 3)) {
|
||||||
|
items(items.size) { idx ->
|
||||||
|
val item = items[idx]
|
||||||
|
var bmp by remember(item.name) { mutableStateOf<android.graphics.Bitmap?>(null) }
|
||||||
|
LaunchedEffect(item.name) {
|
||||||
|
vm.thumbnail(item) { b -> bmp = b }
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.aspectRatio(4f / 3f)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.clickable {
|
||||||
|
vm.select(item)
|
||||||
|
showViewer = true
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
val b = bmp
|
||||||
|
if (b != null) {
|
||||||
|
Image(
|
||||||
|
bitmap = b.asImageBitmap(),
|
||||||
|
contentDescription = item.name,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
item.name.removePrefix("MAG160C_"),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.padding(4.dp)
|
||||||
|
.background(Color(0x88000000)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showViewer) {
|
||||||
|
val sel = vm.selected.value
|
||||||
|
if (sel != null) {
|
||||||
|
AnalyzeViewer(item = sel, galleryVm = vm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun isLandscape(): Boolean =
|
||||||
|
androidx.compose.ui.platform.LocalConfiguration.current.orientation ==
|
||||||
|
android.content.res.Configuration.ORIENTATION_LANDSCAPE
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package com.mag160c.thermal.ui.gallery
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.ContentUris
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.mag160c.thermal.media.Mdt
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Media library: lists MDT thermal files under DCIM/MAG160C via MediaStore
|
||||||
|
* with async embedded-JPEG thumbnails.
|
||||||
|
*/
|
||||||
|
class GalleryViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
|
data class Item(
|
||||||
|
val id: Long,
|
||||||
|
val uri: android.net.Uri,
|
||||||
|
val name: String,
|
||||||
|
val size: Long,
|
||||||
|
val dateMs: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _items = MutableStateFlow<List<Item>>(emptyList())
|
||||||
|
val items: StateFlow<List<Item>> = _items
|
||||||
|
|
||||||
|
private val _selected = MutableStateFlow<Item?>(null)
|
||||||
|
val selected: StateFlow<Item?> = _selected
|
||||||
|
|
||||||
|
private val thumbs = ConcurrentHashMap<String, Bitmap>()
|
||||||
|
|
||||||
|
init {
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val ctx = getApplication<Application>()
|
||||||
|
val uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||||
|
val proj = arrayOf(
|
||||||
|
MediaStore.MediaColumns._ID,
|
||||||
|
MediaStore.MediaColumns.DISPLAY_NAME,
|
||||||
|
MediaStore.MediaColumns.DATE_MODIFIED,
|
||||||
|
MediaStore.MediaColumns.SIZE,
|
||||||
|
)
|
||||||
|
val list = ArrayList<Item>()
|
||||||
|
ctx.contentResolver.query(
|
||||||
|
uri, proj,
|
||||||
|
"${MediaStore.MediaColumns.RELATIVE_PATH} LIKE ?",
|
||||||
|
arrayOf("%DCIM/MAG160C%"),
|
||||||
|
"${MediaStore.MediaColumns.DATE_MODIFIED} DESC",
|
||||||
|
)?.use { c ->
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
val id = c.getLong(0)
|
||||||
|
val name = c.getString(1) ?: ""
|
||||||
|
val date = c.getLong(2) * 1000
|
||||||
|
val size = c.getLong(3)
|
||||||
|
if (size < 152 + 136) continue
|
||||||
|
list.add(Item(id, ContentUris.withAppendedId(uri, id), name, size, date))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// validate MDT containers
|
||||||
|
val valid = list.filter { isMdt(it) }
|
||||||
|
withContext(Dispatchers.Main) { _items.value = valid }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isMdt(item: Item): Boolean = runCatching {
|
||||||
|
val ctx = getApplication<Application>()
|
||||||
|
ctx.contentResolver.openInputStream(item.uri)?.use { s ->
|
||||||
|
val buf = ByteArray(152)
|
||||||
|
if (s.skip(item.size - 152) != item.size - 152) return false
|
||||||
|
var off = 0
|
||||||
|
while (off < 152) {
|
||||||
|
val n = s.read(buf, off, 152 - off)
|
||||||
|
if (n <= 0) return false
|
||||||
|
off += n
|
||||||
|
}
|
||||||
|
Mdt.u32(buf, 0) == Mdt.SECTION_TAIL
|
||||||
|
} ?: false
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
|
/** Embedded-JPEG thumbnail (async). */
|
||||||
|
fun thumbnail(item: Item, onReady: (Bitmap) -> Unit) {
|
||||||
|
thumbs[item.name]?.let { onReady(it); return }
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val ctx = getApplication<Application>()
|
||||||
|
val bmp = runCatching {
|
||||||
|
ctx.contentResolver.openInputStream(item.uri)?.use { s ->
|
||||||
|
val all = s.readBytes()
|
||||||
|
val parsed = Mdt.parse(all)
|
||||||
|
if (parsed != null) {
|
||||||
|
BitmapFactory.decodeByteArray(parsed.jpg, 0, parsed.jpg.size)
|
||||||
|
} else null
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
if (bmp != null) {
|
||||||
|
thumbs[item.name] = bmp
|
||||||
|
withContext(Dispatchers.Main) { onReady(bmp) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun select(item: Item?) {
|
||||||
|
_selected.value = item
|
||||||
|
}
|
||||||
|
|
||||||
|
fun delete(item: Item) {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val ctx = getApplication<Application>()
|
||||||
|
runCatching { ctx.contentResolver.delete(item.uri, null, null) }
|
||||||
|
thumbs.remove(item.name)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -100,11 +100,15 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
val frame = latestFrame ?: return
|
val frame = latestFrame ?: return
|
||||||
val s = session
|
val s = session
|
||||||
val jpg = com.mag160c.thermal.media.PhotoSaver.encodeJpeg(frame)
|
val jpg = com.mag160c.thermal.media.PhotoSaver.encodeJpeg(frame)
|
||||||
|
val rawFrame = s.lastRawFrame
|
||||||
|
val pixels = if (rawFrame != null && rawFrame.size >= 0x1C + 38400) {
|
||||||
|
rawFrame.copyOfRange(0x1C, 0x1C + 38400)
|
||||||
|
} else null
|
||||||
val mdt = com.mag160c.thermal.media.Mdt.compose(
|
val mdt = com.mag160c.thermal.media.Mdt.compose(
|
||||||
jpg = jpg,
|
jpg = jpg,
|
||||||
info0 = s.lastInfo0,
|
info0 = s.lastInfo0,
|
||||||
info1 = s.lastInfo1,
|
info1 = s.lastInfo1,
|
||||||
rawFrame = s.lastRawFrame,
|
framePixels = pixels,
|
||||||
)
|
)
|
||||||
val saved = com.mag160c.thermal.media.PhotoSaver.saveMdt(
|
val saved = com.mag160c.thermal.media.PhotoSaver.saveMdt(
|
||||||
context, mdt, com.mag160c.thermal.media.PhotoSaver.fileName(),
|
context, mdt, com.mag160c.thermal.media.PhotoSaver.fileName(),
|
||||||
|
|||||||
Reference in New Issue
Block a user