From 86bc9528ddb71a1b20bc78153bddd1ab566da454 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Fri, 21 Aug 2026 17:51:50 +0800 Subject: [PATCH] fix: improve photo grid and print reliability --- app/build.gradle.kts | 4 +- .../priter/PrintImageIntegrityValidator.kt | 303 ++++++++++++++++++ .../com/yzx/kiosk/priter/PrinterService.kt | 148 ++++++--- .../face/view/FaceRecognitionResultScreen.kt | 94 ++++-- .../FaceRecognitionResultViewModel.kt | 177 ++++------ .../viewmodel/FaceRecognitionViewModel.kt | 7 + .../ui/upload/viewmodel/PrintingViewModel.kt | 187 +++++++---- 7 files changed, 658 insertions(+), 262 deletions(-) create mode 100644 app/src/main/java/com/yzx/kiosk/priter/PrintImageIntegrityValidator.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 459ee6f..f62bbf2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -89,8 +89,8 @@ android { applicationId = "com.yzx.kiosk" minSdk = 26 targetSdk = 36 - versionCode = 21 - versionName = "1.1.1" + versionCode = 22 + versionName = "1.1.2" //multiDexEnabled = true testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/java/com/yzx/kiosk/priter/PrintImageIntegrityValidator.kt b/app/src/main/java/com/yzx/kiosk/priter/PrintImageIntegrityValidator.kt new file mode 100644 index 0000000..02d83f9 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/priter/PrintImageIntegrityValidator.kt @@ -0,0 +1,303 @@ +package com.yzx.kiosk.priter + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import java.io.File +import java.io.FileInputStream +import java.io.IOException +import java.security.MessageDigest +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +/** + * 打印图片完整性校验。 + * + * 网络图片必须先完整下载为字节数组,再经过容器、尺寸和软件 Bitmap 解码校验; + * 最终 BMP 必须通过文件头、尺寸、位深和精确文件长度校验后才允许送入打印 SDK。 + */ +object PrintImageIntegrityValidator { + const val MAX_DOWNLOAD_BYTES: Long = 50L * 1024L * 1024L + + private const val MIN_IMAGE_BYTES = 128 + private const val MAX_IMAGE_DIMENSION = 30_000 + private const val MAX_DECODED_PIXELS = 24_000_000L + private const val TARGET_LONG_EDGE = 1_840 + private const val TARGET_SHORT_EDGE = 1_240 + + data class DecodedImage( + val bitmap: Bitmap, + val format: String, + val mimeType: String?, + val sourceWidth: Int, + val sourceHeight: Int, + val sampleSize: Int, + val sha256: String + ) + + data class BmpValidation( + val width: Int, + val height: Int, + val bitsPerPixel: Int, + val fileSize: Long, + val sha256: String + ) + + fun decodeDownloadedImage(bytes: ByteArray): DecodedImage { + if (bytes.size < MIN_IMAGE_BYTES) { + throw IOException("图片数据过小: ${bytes.size} bytes") + } + if (bytes.size.toLong() > MAX_DOWNLOAD_BYTES) { + throw IOException("图片数据超过上限: ${bytes.size} bytes") + } + + val format = detectAndValidateContainer(bytes) + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + + val sourceWidth = bounds.outWidth + val sourceHeight = bounds.outHeight + if (sourceWidth <= 0 || sourceHeight <= 0) { + throw IOException("无法解析图片尺寸,format=$format, mime=${bounds.outMimeType}") + } + if (sourceWidth > MAX_IMAGE_DIMENSION || sourceHeight > MAX_IMAGE_DIMENSION) { + throw IOException("图片尺寸异常: ${sourceWidth}x${sourceHeight}") + } + + val sampleSize = calculateInSampleSize(sourceWidth, sourceHeight) + val decodeOptions = BitmapFactory.Options().apply { + inJustDecodeBounds = false + inPreferredConfig = Bitmap.Config.ARGB_8888 + inSampleSize = sampleSize + inScaled = false + } + val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, decodeOptions) + ?: throw IOException("图片完整解码失败: ${sourceWidth}x${sourceHeight}, format=$format") + + if (decoded.width <= 0 || decoded.height <= 0 || decoded.isRecycled) { + decoded.recycle() + throw IOException("解码后的 Bitmap 无效") + } + if (decoded.config == Bitmap.Config.HARDWARE) { + decoded.recycle() + throw IOException("打印图片意外解码为硬件 Bitmap") + } + + // 主动读取像素,确保 Bitmap 的像素缓冲区可访问。 + try { + decoded.getPixel(decoded.width / 2, decoded.height / 2) + } catch (e: Exception) { + decoded.recycle() + throw IOException("Bitmap 像素缓冲区不可读", e) + } + + return DecodedImage( + bitmap = decoded, + format = format, + mimeType = bounds.outMimeType, + sourceWidth = sourceWidth, + sourceHeight = sourceHeight, + sampleSize = sampleSize, + sha256 = sha256(bytes) + ) + } + + fun validateBmp(file: File, expectedWidth: Int, expectedHeight: Int): BmpValidation { + if (!file.isFile || file.length() < 54L) { + throw IOException("BMP 文件不存在或过小: ${file.absolutePath}") + } + + val header = ByteArray(54) + FileInputStream(file).use { input -> + var offset = 0 + while (offset < header.size) { + val read = input.read(header, offset, header.size - offset) + if (read < 0) break + offset += read + } + if (offset != header.size) { + throw IOException("BMP 文件头不完整: $offset/${header.size}") + } + } + + if (header[0] != 'B'.code.toByte() || header[1] != 'M'.code.toByte()) { + throw IOException("BMP 文件签名错误") + } + + val declaredFileSize = uint32Le(header, 2) + val pixelOffset = uint32Le(header, 10) + val dibHeaderSize = uint32Le(header, 14) + val width = int32Le(header, 18) + val rawHeight = int32Le(header, 22) + val height = abs(rawHeight) + val planes = uint16Le(header, 26) + val bitsPerPixel = uint16Le(header, 28) + val compression = uint32Le(header, 30) + + if (dibHeaderSize < 40L || pixelOffset < 54L) { + throw IOException("BMP 文件头结构异常") + } + if (width != expectedWidth || height != expectedHeight) { + throw IOException("BMP 尺寸不匹配: ${width}x${height}, expected=${expectedWidth}x${expectedHeight}") + } + if (planes != 1 || bitsPerPixel != 24 || compression != 0L) { + throw IOException("BMP 格式不受支持: planes=$planes, bpp=$bitsPerPixel, compression=$compression") + } + + val rowStride = ((width.toLong() * 3L + 3L) / 4L) * 4L + val expectedFileSize = pixelOffset + rowStride * height.toLong() + val actualFileSize = file.length() + if (declaredFileSize != actualFileSize || expectedFileSize != actualFileSize) { + throw IOException( + "BMP 文件长度异常: declared=$declaredFileSize, expected=$expectedFileSize, actual=$actualFileSize" + ) + } + + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(file.absolutePath, bounds) + if (bounds.outWidth != expectedWidth || abs(bounds.outHeight) != expectedHeight) { + throw IOException("BMP 无法重新解析: ${bounds.outWidth}x${bounds.outHeight}") + } + + return BmpValidation( + width = width, + height = height, + bitsPerPixel = bitsPerPixel, + fileSize = actualFileSize, + sha256 = sha256(file) + ) + } + + private fun calculateInSampleSize(width: Int, height: Int): Int { + val longEdge = max(width, height) + val shortEdge = min(width, height) + var sampleSize = 1 + + while ( + longEdge / (sampleSize * 2) >= TARGET_LONG_EDGE && + shortEdge / (sampleSize * 2) >= TARGET_SHORT_EDGE + ) { + sampleSize *= 2 + } + + while ( + width.toLong() / sampleSize * (height.toLong() / sampleSize) > MAX_DECODED_PIXELS + ) { + sampleSize *= 2 + } + return sampleSize + } + + private fun detectAndValidateContainer(bytes: ByteArray): String { + return when { + isJpeg(bytes) -> { + val searchStart = max(2, bytes.size - 64) + var hasEndMarker = false + for (index in bytes.size - 2 downTo searchStart) { + if (byteAt(bytes, index) == 0xFF && byteAt(bytes, index + 1) == 0xD9) { + hasEndMarker = true + break + } + } + if (!hasEndMarker) throw IOException("JPEG 缺少结束标记,文件可能被截断") + "JPEG" + } + + isPng(bytes) -> { + if ( + bytes.size < 20 || + bytes[bytes.size - 8] != 'I'.code.toByte() || + bytes[bytes.size - 7] != 'E'.code.toByte() || + bytes[bytes.size - 6] != 'N'.code.toByte() || + bytes[bytes.size - 5] != 'D'.code.toByte() + ) { + throw IOException("PNG 缺少 IEND,文件可能被截断") + } + "PNG" + } + + isWebp(bytes) -> { + val declaredSize = uint32Le(bytes, 4) + 8L + if (declaredSize != bytes.size.toLong()) { + throw IOException("WebP 文件长度异常: declared=$declaredSize, actual=${bytes.size}") + } + "WEBP" + } + + isGif(bytes) -> { + if (byteAt(bytes, bytes.lastIndex) != 0x3B) { + throw IOException("GIF 缺少结束标记,文件可能被截断") + } + "GIF" + } + + isBmp(bytes) -> { + val declaredSize = uint32Le(bytes, 2) + if (declaredSize != bytes.size.toLong()) { + throw IOException("BMP 文件长度异常: declared=$declaredSize, actual=${bytes.size}") + } + "BMP" + } + + isIsoBaseMedia(bytes) -> "HEIF/AVIF" + else -> throw IOException("不支持或无法识别的图片格式") + } + } + + private fun isJpeg(bytes: ByteArray): Boolean = + bytes.size >= 4 && byteAt(bytes, 0) == 0xFF && byteAt(bytes, 1) == 0xD8 + + private fun isPng(bytes: ByteArray): Boolean = + bytes.size >= 8 && + byteAt(bytes, 0) == 0x89 && bytes[1] == 'P'.code.toByte() && + bytes[2] == 'N'.code.toByte() && bytes[3] == 'G'.code.toByte() && + byteAt(bytes, 4) == 0x0D && byteAt(bytes, 5) == 0x0A && + byteAt(bytes, 6) == 0x1A && byteAt(bytes, 7) == 0x0A + + private fun isWebp(bytes: ByteArray): Boolean = + bytes.size >= 12 && ascii(bytes, 0, 4) == "RIFF" && ascii(bytes, 8, 4) == "WEBP" + + private fun isGif(bytes: ByteArray): Boolean = + bytes.size >= 6 && (ascii(bytes, 0, 6) == "GIF87a" || ascii(bytes, 0, 6) == "GIF89a") + + private fun isBmp(bytes: ByteArray): Boolean = + bytes.size >= 14 && bytes[0] == 'B'.code.toByte() && bytes[1] == 'M'.code.toByte() + + private fun isIsoBaseMedia(bytes: ByteArray): Boolean = + bytes.size >= 12 && ascii(bytes, 4, 4) == "ftyp" + + private fun ascii(bytes: ByteArray, offset: Int, length: Int): String = + bytes.copyOfRange(offset, offset + length).toString(Charsets.US_ASCII) + + private fun byteAt(bytes: ByteArray, index: Int): Int = bytes[index].toInt() and 0xFF + + private fun uint16Le(bytes: ByteArray, offset: Int): Int = + byteAt(bytes, offset) or (byteAt(bytes, offset + 1) shl 8) + + private fun int32Le(bytes: ByteArray, offset: Int): Int = + byteAt(bytes, offset) or + (byteAt(bytes, offset + 1) shl 8) or + (byteAt(bytes, offset + 2) shl 16) or + (byteAt(bytes, offset + 3) shl 24) + + private fun uint32Le(bytes: ByteArray, offset: Int): Long = + int32Le(bytes, offset).toLong() and 0xFFFF_FFFFL + + private fun sha256(bytes: ByteArray): String = + MessageDigest.getInstance("SHA-256").digest(bytes).toHex() + + private fun sha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + FileInputStream(file).use { input -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val count = input.read(buffer) + if (count < 0) break + if (count > 0) digest.update(buffer, 0, count) + } + } + return digest.digest().toHex() + } + + private fun ByteArray.toHex(): String = joinToString(separator = "") { byte -> "%02x".format(byte) } +} diff --git a/app/src/main/java/com/yzx/kiosk/priter/PrinterService.kt b/app/src/main/java/com/yzx/kiosk/priter/PrinterService.kt index 47ce045..807b08f 100644 --- a/app/src/main/java/com/yzx/kiosk/priter/PrinterService.kt +++ b/app/src/main/java/com/yzx/kiosk/priter/PrinterService.kt @@ -5,7 +5,6 @@ import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color import android.graphics.Matrix -import android.graphics.Paint import com.yzx.kiosk.App import com.yzx.kiosk.datastore.AppStoreDataSource import com.yzx.kiosk.utils.LogUtils @@ -21,10 +20,13 @@ import jp.co.dnpLib.print.PrintJob import jp.co.dnpLib.print.PrintJobStatus import jp.co.dnpLib.print.PrintManager import jp.co.dnpLib.print.PrintQueue -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.withContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import java.io.File +import java.io.IOException import javax.inject.Singleton import kotlin.math.min @@ -41,9 +43,10 @@ class PrinterService( companion object { private const val TAG = "PrinterService" } - - private var mDnpPhotoPrint: DNPPhotoPrint? = null - + + private var mDnpPhotoPrint: DNPPhotoPrint? = null + private val printMutex = Mutex() + /** * 初始化打印机(耗时操作,需在IO线程调用) * @return 是否初始化成功 @@ -77,14 +80,14 @@ class PrinterService( false } } - + /** * 获取当前打印机ID */ fun getPrintId(): Int { return appStoreDataSource.getPrintId() } - + /** * 准备打印图片:保持原比例,全内容居中,空白区域背景为白色,宽图可调整方向 * @param originalBitmap 原始图片(不会被回收) @@ -96,14 +99,14 @@ class PrinterService( var workingBitmap: Bitmap? = null // 用于处理的临时bitmap var rotatedBitmap: Bitmap? = null // 旋转后的bitmap(如果需要) var scaledBitmap: Bitmap? = null // 缩放后的bitmap - + try { val originalWidth = originalBitmap.width val originalHeight = originalBitmap.height val isWideImage = originalWidth > originalHeight // 是否为宽图 - + LogUtils.d(TAG, "原始图片: ${originalWidth}x${originalHeight}, 是否宽图: $isWideImage") - + // 判断是否需要旋转 // RX1打印机需要横向图片(1840x1240),QW410需要纵向图片(1266x1836) val needRotate = if (printId != PrintManager.EPrinter.QW410_DEF.id) { @@ -113,7 +116,7 @@ class PrinterService( // QW410打印机:如果图片是横向的,需要旋转90度 isWideImage } - + // 如果需要旋转,先旋转图片 if (needRotate) { LogUtils.d(TAG, "旋转图片90度以适应打印方向") @@ -128,21 +131,21 @@ class PrinterService( } else { workingBitmap = originalBitmap } - + // 计算缩放比例,保持原比例 val scale = min( printSize.width.toFloat() / workingBitmap.width, printSize.height.toFloat() / workingBitmap.height ) - + val scaledWidth = (workingBitmap.width * scale).toInt() val scaledHeight = (workingBitmap.height * scale).toInt() - + LogUtils.d(TAG, "缩放比例: $scale, 缩放后尺寸: ${scaledWidth}x${scaledHeight}") - + // 创建缩放后的图片 scaledBitmap = Bitmap.createScaledBitmap(workingBitmap, scaledWidth, scaledHeight, true) - + // 创建白色背景的画布 val outputBitmap = Bitmap.createBitmap( printSize.width, @@ -150,23 +153,23 @@ class PrinterService( Bitmap.Config.ARGB_8888 ) val canvas = Canvas(outputBitmap) - + // 填充白色背景 canvas.drawColor(Color.WHITE) - + // 计算居中位置 val left = (printSize.width - scaledWidth) / 2f val top = (printSize.height - scaledHeight) / 2f - + LogUtils.d(TAG, "居中位置: left=$left, top=$top") - + // 在画布上绘制缩放后的图片(居中) canvas.drawBitmap(scaledBitmap, left, top, null) - + // 回收临时bitmap scaledBitmap?.let { if (!it.isRecycled) it.recycle() } rotatedBitmap?.let { if (!it.isRecycled) it.recycle() } - + return outputBitmap } catch (e: Exception) { LogUtils.e(TAG, "准备打印图片失败: ${e.message}") @@ -176,34 +179,51 @@ class PrinterService( throw e } } - + /** * 打印图片(耗时操作) * @param originalBitmap 要打印的原始图片 * @param callback 打印结果回调 */ - suspend fun startPrint(originalBitmap: Bitmap?, callback: PrintCallback) = withContext(Dispatchers.IO) { + suspend fun startPrint(originalBitmap: Bitmap?, callback: PrintCallback) = printMutex.withLock { + startPrintInternal(originalBitmap, callback) + } + + private suspend fun startPrintInternal( + originalBitmap: Bitmap?, + callback: PrintCallback + ) = withContext(Dispatchers.IO) { if (originalBitmap == null) { LogUtils.e(TAG, "获取图片失败") callback.onPrintResult(false,"获取图片失败") return@withContext } + if ( + originalBitmap.isRecycled || + originalBitmap.width <= 0 || + originalBitmap.height <= 0 || + originalBitmap.config == Bitmap.Config.HARDWARE + ) { + LogUtils.e(TAG, "图片 Bitmap 无效或为硬件 Bitmap,禁止打印") + callback.onPrintResult(false, "图片数据无效") + return@withContext + } LogUtils.e(TAG, "获取图片成功") - + // 在开始打印前,检查打印机是否在线 if (printStatusManager.isOffline()) { LogUtils.e(TAG, "打印机离线,无法打印") callback.onPrintResult(false, "打印机离线,无法打印") return@withContext } - + // 设置打印状态为正在打印 printStatusManager.setPrinting() - + try { val printQueue = PrintQueue(context) val printId = appStoreDataSource.getPrintId() - + // 再次检查打印机ID是否有效(如果打印机未初始化,printId 可能为 0) if (printId == 0) { LogUtils.e(TAG, "打印机未初始化,printId=0") @@ -211,10 +231,10 @@ class PrinterService( callback.onPrintResult(false, "打印机未初始化") return@withContext } - + LogUtils.d(TAG, "开始打印,打印机ID: $printId") LogUtils.d(TAG, "原始图片尺寸: ${originalBitmap.width} x ${originalBitmap.height}") - + // 获取打印尺寸 val printSize = if (printId != PrintManager.EPrinter.QW410_DEF.id) { PRINTSIZE.RX1 @@ -222,19 +242,24 @@ class PrinterService( PRINTSIZE.QW410 } LogUtils.d(TAG, "打印尺寸: ${printSize.width} x ${printSize.height}") - + // 准备打印图片(保持原比例,居中,白色背景) - val outputBitmap: Bitmap + var outputBitmap: Bitmap? = null val outFile = File( context.getExternalFilesDir(null), "ProcessedPhotos/${System.currentTimeMillis()}_print.bmp" ) - outFile.parentFile?.mkdirs() - + val outputDirectory = outFile.parentFile + if (outputDirectory == null || (!outputDirectory.exists() && !outputDirectory.mkdirs())) { + printStatusManager.setIdle() + callback.onPrintResult(false, "无法创建打印文件目录") + return@withContext + } + try { outputBitmap = preparePrintBitmap(originalBitmap, printSize, printId) - - if (printId != PrintManager.EPrinter.QW410_DEF.id) { + + val saved = if (printId != PrintManager.EPrinter.QW410_DEF.id) { BmpUtil.save(outputBitmap, outFile.absolutePath, EResolution.RESO300.mValue) } else { AndroidBmpUtil.save( @@ -244,12 +269,35 @@ class PrinterService( PRINTSIZE.QW410.height ) } + + if (!saved) { + throw IOException("BMP 保存接口返回失败") + } + + val validation = PrintImageIntegrityValidator.validateBmp( + file = outFile, + expectedWidth = printSize.width, + expectedHeight = printSize.height + ) + LogUtils.d( + TAG, + "打印 BMP 校验成功 - path=${outFile.absolutePath}, " + + "size=${validation.width}x${validation.height}, " + + "bpp=${validation.bitsPerPixel}, bytes=${validation.fileSize}, " + + "sha256=${validation.sha256.take(16)}" + ) } catch (e: Exception) { - e.printStackTrace() - callback.onPrintResult(false,"图片处理失败: ${e.message}") + LogUtils.e(TAG, "图片处理或 BMP 完整性校验失败: ${e.message}") + outputBitmap?.let { bitmap -> + if (!bitmap.isRecycled) bitmap.recycle() + } + printStatusManager.setIdle() + callback.onPrintResult(false, "图片处理失败: ${e.message}") return@withContext } - + + val validatedOutputBitmap = checkNotNull(outputBitmap) + // 创建打印任务 val job = PrintJob( outFile.absolutePath, @@ -263,14 +311,14 @@ class PrinterService( 1 ) printQueue.addJob(job) - + if (printId != PrintManager.EPrinter.QW410_DEF.id) { // 打印完成,设置为空闲 printStatusManager.setIdle() // 回收 outputBitmap try { - if (!outputBitmap.isRecycled) { - outputBitmap.recycle() + if (!validatedOutputBitmap.isRecycled) { + validatedOutputBitmap.recycle() LogUtils.d(TAG, "outputBitmap 已回收") } } catch (ex: Exception) { @@ -285,8 +333,8 @@ class PrinterService( printStatusManager.setIdle() // 回收 outputBitmap try { - if (!outputBitmap.isRecycled) { - outputBitmap.recycle() + if (!validatedOutputBitmap.isRecycled) { + validatedOutputBitmap.recycle() LogUtils.d(TAG, "outputBitmap 已回收") } } catch (ex: Exception) { @@ -294,15 +342,15 @@ class PrinterService( } callback.onPrintResult(true) } - + } catch (e: Exception) { LogUtils.e(TAG, "打印失败: ${e.message}") // 打印失败,设置为空闲 printStatusManager.setIdle() callback.onPrintResult(false,"打印失败: ${e.message}") - } - } -} + } + } +} /** * 打印结果回调接口 diff --git a/app/src/main/java/com/yzx/kiosk/ui/face/view/FaceRecognitionResultScreen.kt b/app/src/main/java/com/yzx/kiosk/ui/face/view/FaceRecognitionResultScreen.kt index 153a346..db0f156 100644 --- a/app/src/main/java/com/yzx/kiosk/ui/face/view/FaceRecognitionResultScreen.kt +++ b/app/src/main/java/com/yzx/kiosk/ui/face/view/FaceRecognitionResultScreen.kt @@ -1,5 +1,6 @@ package com.yzx.kiosk.ui.face.view +import android.graphics.Bitmap import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -24,6 +25,7 @@ import androidx.compose.ui.draw.clip 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.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -35,6 +37,7 @@ import coil.compose.AsyncImage import coil.compose.SubcomposeAsyncImage import coil.compose.SubcomposeAsyncImageContent import coil.compose.SubcomposeAsyncImageScope +import coil.request.ImageRequest import com.yzx.kiosk.R import com.yzx.kiosk.component.appbar.AppTitleBar import com.yzx.kiosk.component.appbar.FaceBarNoStatusBarPadding @@ -60,19 +63,20 @@ fun FaceRecognitionResultScreen( viewModel.initPhotoList(results) } } - + val photoList by viewModel.photoList.collectAsState() val selectedPhotos by viewModel.selectedPhotos.collectAsState() val pricePerPhoto by viewModel.pricePerPhoto.collectAsState() val totalPrice by viewModel.totalPrice.collectAsState() - + val imageLoadStates by viewModel.imageLoadStates.collectAsState() + val selectedCount = selectedPhotos.size val isAllSelected = photoList.isNotEmpty() && selectedPhotos.size == photoList.size - + // 预览弹框状态 var showPreviewDialog by remember { mutableStateOf(false) } var previewInitialIndex by remember { mutableIntStateOf(0) } - + // 支付弹框状态 var showPayDialog by remember { mutableStateOf(false) } val payQrCodeUrl by viewModel.payQrCodeUrl.collectAsState() @@ -82,9 +86,9 @@ fun FaceRecognitionResultScreen( showPayDialog = false } } - + FullScreenMode() - + AppScaffold( topBar = { Column { @@ -152,9 +156,9 @@ fun FaceRecognitionResultScreen( color = Color(0xFF000000) ) } - + Button( - onClick = { + onClick = { viewModel.getPayUrl { url -> showPayDialog = true } @@ -175,7 +179,7 @@ fun FaceRecognitionResultScreen( } } } - + // 全选行 Row( modifier = Modifier @@ -206,9 +210,9 @@ fun FaceRecognitionResultScreen( ) } } - + Spacer(modifier = Modifier.width(18.dp)) - + Text( text = "全选", fontSize = 24.sp, @@ -220,7 +224,7 @@ fun FaceRecognitionResultScreen( color = Color.Black ) } - + // 图片瀑布流列表 LazyVerticalStaggeredGrid( columns = StaggeredGridCells.Fixed(2), @@ -230,12 +234,15 @@ fun FaceRecognitionResultScreen( horizontalArrangement = Arrangement.spacedBy(18.dp), verticalItemSpacing = 18.dp ) { - itemsIndexed(photoList) { index, photo -> + itemsIndexed( + items = photoList, + key = { _, photo -> photo.id } + ) { _, photo -> val isSelected = selectedPhotos.contains(photo.url) - val imageLoadStates by viewModel.imageLoadStates.collectAsState() val loadState = imageLoadStates[photo.url] ?: FaceRecognitionResultViewModel.ImageLoadState() - + FaceRecognitionPhotoItem( + photoId = photo.id, photoUrl = photo.url, aspectRatio = photo.aspectRatio, isSelected = isSelected, @@ -252,13 +259,16 @@ fun FaceRecognitionResultScreen( }, onLoadError = { viewModel.handleImageLoadError(photo.url) }, onLoadSuccess = { viewModel.handleImageLoadSuccess(photo.url) }, + onImageDimensionsResolved = { width, height -> + viewModel.updatePhotoAspectRatio(photo.id, width, height) + }, onRetryClick = { viewModel.retryLoadImage(photo.url) } ) } } } } - + // 预览弹框 - 仅预览已选择的图片 val selectedPhotoUrls = photoList.filter { selectedPhotos.contains(it.url) }.map { it.url } if (showPreviewDialog && selectedPhotoUrls.isNotEmpty()) { @@ -268,7 +278,7 @@ fun FaceRecognitionResultScreen( onDismiss = { showPreviewDialog = false } ) } - + // 支付弹框 if (showPayDialog && selectedCount > 0 && payQrCodeUrl != null) { DisposableEffect(payQrCodeUrl) { @@ -291,6 +301,7 @@ fun FaceRecognitionResultScreen( @Composable fun FaceRecognitionPhotoItem( + photoId: Int, photoUrl: String, aspectRatio: Float, isSelected: Boolean, @@ -299,6 +310,7 @@ fun FaceRecognitionPhotoItem( onPreviewClick: () -> Unit, onLoadError: () -> Unit = {}, onLoadSuccess: () -> Unit = {}, + onImageDimensionsResolved: (width: Int, height: Int) -> Unit = { _, _ -> }, onRetryClick: () -> Unit = {} ) { // 确保 aspectRatio 是有效值 @@ -307,7 +319,7 @@ fun FaceRecognitionPhotoItem( } else { 1f // 默认1:1 } - + Box( modifier = Modifier .fillMaxWidth() @@ -316,11 +328,20 @@ fun FaceRecognitionPhotoItem( .clickable { onToggleSelection() } ) { // 图片 - 按原比例显示 - var lastErrorState by remember { mutableStateOf(false) } - var lastSuccessState by remember { mutableStateOf(false) } - + var lastErrorState by remember(photoId, photoUrl) { mutableStateOf(false) } + var lastSuccessState by remember(photoId, photoUrl) { mutableStateOf(false) } + val context = LocalContext.current + val imageRequest = remember(context, photoUrl) { + ImageRequest.Builder(context) + .data(photoUrl) + .allowHardware(false) + .bitmapConfig(Bitmap.Config.ARGB_8888) + .crossfade(false) + .build() + } + SubcomposeAsyncImage( - model = photoUrl, + model = imageRequest, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Fit @@ -349,16 +370,16 @@ fun FaceRecognitionPhotoItem( onLoadError() } } - + // 如果已经重试3次,显示加载失败,支持点击重试 if (loadState.isFailed) { Box( modifier = Modifier .fillMaxSize() .background(Color(0xFFF5F5F5)) - .clickable { + .clickable { lastErrorState = false - onRetryClick() + onRetryClick() }, contentAlignment = Alignment.Center ) { @@ -400,6 +421,11 @@ fun FaceRecognitionPhotoItem( if (state is coil.compose.AsyncImagePainter.State.Success && !lastSuccessState) { lastSuccessState = true lastErrorState = false + val drawable = state.result.drawable + onImageDimensionsResolved( + drawable.intrinsicWidth, + drawable.intrinsicHeight + ) onLoadSuccess() } } @@ -407,7 +433,7 @@ fun FaceRecognitionPhotoItem( } } } - + // 选中遮罩 Box( modifier = Modifier @@ -416,7 +442,7 @@ fun FaceRecognitionPhotoItem( if (isSelected) Color.Black.copy(alpha = 0.3f) else Color.Transparent ) ) - + // 右上角复选框 Box( modifier = Modifier @@ -441,7 +467,7 @@ fun FaceRecognitionPhotoItem( ) } } - + // 点击预览文字(选中时显示)- 点击打开预览弹框 if (isSelected) { Text( @@ -475,7 +501,7 @@ fun WechatPayDialog( cornerRadius = 0f ) } - + Dialog( onDismissRequest = onDismiss, properties = DialogProperties( @@ -507,7 +533,7 @@ fun WechatPayDialog( color = Color.Black, modifier = Modifier.align(Alignment.Center) ) - + // 关闭按钮 Box( modifier = Modifier @@ -526,9 +552,9 @@ fun WechatPayDialog( ) } } - + Spacer(modifier = Modifier.height(36.dp)) - + // 二维码 Box( modifier = Modifier @@ -542,9 +568,9 @@ fun WechatPayDialog( modifier = Modifier.fillMaxSize() ) } - + Spacer(modifier = Modifier.height(22.dp)) - + // 提示文字 Text( text = "请使用微信扫描二维码支付", diff --git a/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionResultViewModel.kt b/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionResultViewModel.kt index f8b9176..302d7d2 100644 --- a/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionResultViewModel.kt +++ b/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionResultViewModel.kt @@ -1,10 +1,6 @@ package com.yzx.kiosk.ui.face.viewmodel import androidx.lifecycle.viewModelScope -import coil.ImageLoader -import coil.request.ImageRequest -import coil.size.Size -import com.yzx.kiosk.App import com.yzx.kiosk.base.BaseViewModel import com.yzx.kiosk.datastore.AppState import com.yzx.kiosk.navigation.AppNavigator @@ -24,11 +20,8 @@ import com.yzx.kiosk.datastore.AppStoreDataSource import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.flow.MutableSharedFlow @@ -38,8 +31,8 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeoutOrNull import com.yzx.kiosk.utils.LogUtils import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject @@ -57,26 +50,26 @@ class FaceRecognitionResultViewModel @Inject constructor( ) { // 原始结果列表(用于获取图片id) private var originalResults: List = emptyList() - + // URL到ID的映射 private val urlToIdMap = mutableMapOf() - + // 图片列表 private val _photoList = MutableStateFlow>(emptyList()) val photoList: StateFlow> = _photoList.asStateFlow() - + // 已选中的图片URL(用于UI显示) private val _selectedPhotos = MutableStateFlow>(emptySet()) val selectedPhotos: StateFlow> = _selectedPhotos.asStateFlow() - + // 单价 private val _pricePerPhoto = MutableStateFlow("--") val pricePerPhoto: StateFlow = _pricePerPhoto.asStateFlow() - + // 总价 private val _totalPrice = MutableStateFlow("--") val totalPrice: StateFlow = _totalPrice.asStateFlow() - + // 支付二维码URL private val _payQrCodeUrl = MutableStateFlow(null) val payQrCodeUrl: StateFlow = _payQrCodeUrl.asStateFlow() @@ -88,23 +81,23 @@ class FaceRecognitionResultViewModel @Inject constructor( private var activePaymentOrderNumber: String? = null private var paymentPollingJob: Job? = null private val paymentHandled = AtomicBoolean(false) - + // 图片加载状态:URL -> (重试次数, 是否加载失败) private val _imageLoadStates = MutableStateFlow>(emptyMap()) val imageLoadStates: StateFlow> = _imageLoadStates.asStateFlow() - + companion object { private const val TAG = "FaceRecognitionResultViewModel" private const val MAX_RETRY_COUNT = 3 private const val PAY_STATUS_POLL_INTERVAL_MS = 2_000L } - + data class ImageLoadState( val retryCount: Int = 0, val isFailed: Boolean = false, val isLoading: Boolean = false ) - + init { // 监听 WebSocket 支付成功事件 webSocketService.uploadPhotoEvents @@ -126,7 +119,7 @@ class FaceRecognitionResultViewModel @Inject constructor( } .launchIn(viewModelScope) } - + /** * 初始化图片列表(从人脸识别结果) */ @@ -134,7 +127,8 @@ class FaceRecognitionResultViewModel @Inject constructor( viewModelScope.launch { // 保存原始结果列表 originalResults = results - + urlToIdMap.clear() + // 先使用默认宽高比创建列表 val initialPhotos = results.mapNotNull { result -> // 使用 thumbnail_oss_url @@ -153,87 +147,52 @@ class FaceRecognitionResultViewModel @Inject constructor( ) } } - + if (initialPhotos.isEmpty()) { LogUtils.i("FaceRecognitionResultViewModel", "没有有效的图片URL") _photoList.value = emptyList() _imageLoadStates.value = emptyMap() return@launch } - + _photoList.value = initialPhotos // 初始化加载状态 _imageLoadStates.value = initialPhotos.associate { it.url to ImageLoadState() } - + // 页面初始化时调用验证接口(空列表,type: 2),获取默认价格 verifyResult(emptyList()) - - // 并发获取所有图片的真实尺寸并更新宽高比 - val updatedPhotos = initialPhotos.mapIndexed { index, photo -> - async(Dispatchers.IO) { - try { - val aspectRatio = getImageAspectRatio(photo.url) - // 确保 aspectRatio 是有效值 - val validAspectRatio = if (aspectRatio > 0 && aspectRatio.isFinite()) { - aspectRatio - } else { - LogUtils.i("FaceRecognitionResultViewModel", "图片 ${photo.url} 的宽高比无效: $aspectRatio,使用默认值") - 1f - } - photo.copy(aspectRatio = validAspectRatio) - } catch (e: Exception) { - LogUtils.e("FaceRecognitionResultViewModel", "获取图片 ${photo.url} 尺寸失败: ${e.message}") - // 获取失败,保持默认值 - photo - } - } - }.awaitAll() - - _photoList.value = updatedPhotos - LogUtils.d("FaceRecognitionResultViewModel", "图片列表初始化完成,共 ${updatedPhotos.size} 张") + LogUtils.d(TAG, "图片列表初始化完成,共 ${initialPhotos.size} 张,宽高比将在图片加载成功后逐张更新") } } - + /** - * 获取图片的宽高比 + * 使用网格中已经加载成功的 Drawable 尺寸更新宽高比。 + * 这样不会为了获取尺寸额外下载、解码一次图片。 */ - private suspend fun getImageAspectRatio(imageUrl: String): Float { - return withTimeoutOrNull(10000) { // 10秒超时 - try { - val imageLoader = ImageLoader(App.instance) - val request = ImageRequest.Builder(App.instance) - .data(imageUrl) - .size(Size.ORIGINAL) - .allowHardware(false) // 禁用硬件加速,避免某些图片无法获取尺寸 - .build() - - val result = imageLoader.execute(request) - val drawable = result.drawable - - if (drawable != null) { - val width = drawable.intrinsicWidth - val height = drawable.intrinsicHeight - - if (width > 0 && height > 0) { - val aspectRatio = width.toFloat() / height.toFloat() - LogUtils.d("FaceRecognitionResultViewModel", "图片 $imageUrl 尺寸: ${width}x${height}, 宽高比: $aspectRatio") - return@withTimeoutOrNull aspectRatio - } else { - LogUtils.i("FaceRecognitionResultViewModel", "图片 $imageUrl 尺寸无效: ${width}x${height}") - } + fun updatePhotoAspectRatio(photoId: Int, width: Int, height: Int) { + if (width <= 0 || height <= 0) { + LogUtils.i(TAG, "忽略无效图片尺寸 - id: $photoId, size: ${width}x${height}") + return + } + + val aspectRatio = width.toFloat() / height.toFloat() + if (!aspectRatio.isFinite() || aspectRatio !in 0.05f..20f) { + LogUtils.i(TAG, "忽略异常图片宽高比 - id: $photoId, ratio: $aspectRatio") + return + } + + _photoList.update { photos -> + photos.map { photo -> + if (photo.id == photoId && kotlin.math.abs(photo.aspectRatio - aspectRatio) > 0.01f) { + LogUtils.d(TAG, "更新图片比例 - id: $photoId, size: ${width}x${height}, ratio: $aspectRatio") + photo.copy(aspectRatio = aspectRatio) } else { - LogUtils.i("FaceRecognitionResultViewModel", "图片 $imageUrl 加载失败,drawable为null") + photo } - } catch (e: Exception) { - LogUtils.e("FaceRecognitionResultViewModel"+"获取图片 $imageUrl 尺寸异常: ${e.message}") } - null - } ?: run { - LogUtils.i("FaceRecognitionResultViewModel", "获取图片 $imageUrl 尺寸超时") - 1f // 超时或失败,返回默认值 } } - + /** * 切换单张图片选中状态 */ @@ -245,12 +204,12 @@ class FaceRecognitionResultViewModel @Inject constructor( current.add(url) } _selectedPhotos.value = current - + // 选择变化时调用接口获取价格 val selectedIds = getSelectedImageIds() verifyResult(selectedIds) } - + /** * 切换全选状态 */ @@ -264,12 +223,12 @@ class FaceRecognitionResultViewModel @Inject constructor( allUrls } _selectedPhotos.value = newSelected - + // 选择变化时调用接口获取价格 val selectedIds = getSelectedImageIds() verifyResult(selectedIds) } - + /** * 获取已选中的图片ID列表 */ @@ -278,7 +237,7 @@ class FaceRecognitionResultViewModel @Inject constructor( urlToIdMap[url] } } - + /** * 验证结果接口 */ @@ -289,7 +248,7 @@ class FaceRecognitionResultViewModel @Inject constructor( type = 2, imageId = imageIds ) - + handleResultWithData( flow = netWorkRepository.verifyResult(request).asResult(), showToast = false, @@ -301,7 +260,7 @@ class FaceRecognitionResultViewModel @Inject constructor( } else { _pricePerPhoto.value = "--" } - + // 更新总价(使用接口返回的 amount) val amount = response.amount if (!amount.isNullOrEmpty()) { @@ -309,7 +268,7 @@ class FaceRecognitionResultViewModel @Inject constructor( } else { _totalPrice.value = "--" } - + LogUtils.d("FaceRecognitionResultViewModel", "验证结果成功 - 单价: $priceImage, 总价: $amount") }, onError = { msg, _ -> @@ -330,7 +289,7 @@ class FaceRecognitionResultViewModel @Inject constructor( } } } - + /** * 获取支付URL */ @@ -347,7 +306,7 @@ class FaceRecognitionResultViewModel @Inject constructor( type = 2, imageId = selectedIds ) - + handleResultWithData( flow = netWorkRepository.getPayUrl(request).asResult(), showToast = false, @@ -523,7 +482,7 @@ class FaceRecognitionResultViewModel @Inject constructor( _dismissPayDialogEvents.tryEmit(Unit) navigateToPaySuccess(orderNumber, captureType, imageIds) } - + /** * 跳转到支付成功页面 * @param orderNumber 订单号 @@ -534,13 +493,13 @@ class FaceRecognitionResultViewModel @Inject constructor( viewModelScope.launch { // 根据 imageIds 从 originalResults 中获取对应的 FaceSearchResult val selectedResults = originalResults.filter { imageIds.contains(it.id) } - + if (selectedResults.isEmpty()) { LogUtils.e("FaceRecognitionResultViewModel", "未找到对应的图片数据,imageIds: $imageIds") ToastUtils.show("支付成功,但未找到对应的图片数据") return@launch } - + // 将 FaceSearchResult 转换为 FileMapData val fileMapDataList = selectedResults.map { result -> FileMapData( @@ -551,27 +510,27 @@ class FaceRecognitionResultViewModel @Inject constructor( originalLanUrl = result.originalLanUrl ) } - + // 将 FileMapData 数组序列化为 JSON 数组字符串 val gson = Gson() val jsonArray = gson.toJson(fileMapDataList) val encodedJson = java.net.URLEncoder.encode(jsonArray, "UTF-8") - + // 构建路由参数 val orderNumberParam = orderNumber?.let { java.net.URLEncoder.encode(it, "UTF-8") } ?: "" val captureTypeParam = captureType?.toString() ?: "" - + // 创建 NavOptions,清除当前页面 val navOptions = androidx.navigation.NavOptions.Builder() .setPopUpTo(com.yzx.kiosk.navigation.routes.AppRoutes.FACE_RECOGNITION_RESULT, inclusive = true) .build() - + // 跳转到支付成功页面 val route = "${com.yzx.kiosk.navigation.routes.AppRoutes.PAY_SUCCESS}?urls=$encodedJson&orderNumber=$orderNumberParam&captureType=$captureTypeParam" toPage(route, navOptions) } } - + /** * 处理图片加载失败 */ @@ -579,12 +538,12 @@ class FaceRecognitionResultViewModel @Inject constructor( viewModelScope.launch { val currentStates = _imageLoadStates.value.toMutableMap() val currentState = currentStates[url] ?: ImageLoadState() - + // 如果已经在加载中,避免重复处理 if (currentState.isLoading) { return@launch } - + if (currentState.retryCount < MAX_RETRY_COUNT) { // 重试 val newRetryCount = currentState.retryCount + 1 @@ -594,10 +553,10 @@ class FaceRecognitionResultViewModel @Inject constructor( isLoading = true ) _imageLoadStates.value = currentStates - + // 延迟后重试(给 Coil 一些时间) kotlinx.coroutines.delay(500) - + // 触发重新加载(通过更新 URL 来强制 Coil 重新加载) val updatedList = _photoList.value.map { photo -> if (photo.url == url || photo.url.startsWith("$url?")) { @@ -608,7 +567,7 @@ class FaceRecognitionResultViewModel @Inject constructor( } } _photoList.value = updatedList - + // 恢复原始 URL(避免 URL 被污染) kotlinx.coroutines.delay(200) val restoredList = _photoList.value.map { photo -> @@ -631,7 +590,7 @@ class FaceRecognitionResultViewModel @Inject constructor( } } } - + /** * 处理图片加载成功 */ @@ -647,7 +606,7 @@ class FaceRecognitionResultViewModel @Inject constructor( } } } - + /** * 手动重试加载图片 */ @@ -657,9 +616,9 @@ class FaceRecognitionResultViewModel @Inject constructor( // 重置状态,重新开始重试 currentStates[url] = ImageLoadState(isLoading = true, retryCount = 0) _imageLoadStates.value = currentStates - + LogUtils.d("FaceRecognitionResultViewModel", "手动重试加载图片: $url") - + // 触发重新加载 val updatedList = _photoList.value.map { photo -> if (photo.url == url || photo.url.startsWith("$url?")) { @@ -669,7 +628,7 @@ class FaceRecognitionResultViewModel @Inject constructor( } } _photoList.value = updatedList - + // 恢复原始 URL kotlinx.coroutines.delay(200) val restoredList = _photoList.value.map { photo -> diff --git a/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionViewModel.kt b/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionViewModel.kt index d53bf8f..5666dc9 100644 --- a/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionViewModel.kt +++ b/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionViewModel.kt @@ -26,6 +26,7 @@ import com.yzx.kiosk.utils.LogUtils import com.yzx.kiosk.utils.ToastUtils import java.net.URLEncoder import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -220,6 +221,9 @@ class FaceRecognitionViewModel @Inject constructor( try { // 直接调用人脸识别接口 searchFace(photoFile) + } catch (e: CancellationException) { + // 页面跳转或 ViewModel 销毁时的正常协程取消,不向用户报错 + throw e } catch (e: Exception) { LogUtils.e(TAG, "处理失败: ${e.message}") ToastUtils.show("处理失败: ${e.message}") @@ -315,6 +319,9 @@ class FaceRecognitionViewModel @Inject constructor( localAudioPlayService.playByRoute("face_recognition_failed") } } + } catch (e: CancellationException) { + // 页面离开时保持协程取消语义,避免误显示 "Job was cancelled" + throw e } catch (e: Exception) { LogUtils.e("人脸识别失败: ${e.message}") ToastUtils.show("识别失败: ${e.message}") diff --git a/app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PrintingViewModel.kt b/app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PrintingViewModel.kt index a26e634..da45dca 100644 --- a/app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PrintingViewModel.kt +++ b/app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PrintingViewModel.kt @@ -1,12 +1,12 @@ package com.yzx.kiosk.ui.upload.viewmodel import android.graphics.Bitmap -import android.graphics.BitmapFactory import androidx.lifecycle.viewModelScope import com.yzx.kiosk.base.BaseViewModel import com.yzx.kiosk.datastore.AppState import com.yzx.kiosk.navigation.AppNavigator import com.yzx.kiosk.navigation.routes.AppRoutes +import com.yzx.kiosk.network.di.NetworkModule.CLIENT_DOWNLOAD import com.yzx.kiosk.network.model.request.PrintCompleteRequest import com.yzx.kiosk.network.model.request.PrintNotifyRequest import com.yzx.kiosk.network.model.request.SaveAlbumUrlRequest @@ -17,6 +17,7 @@ import com.yzx.kiosk.priter.PrintCallback import com.yzx.kiosk.priter.PrintRecord import com.yzx.kiosk.priter.PrintStatusManager import com.yzx.kiosk.priter.PrinterService +import com.yzx.kiosk.priter.PrintImageIntegrityValidator import com.yzx.kiosk.utils.LogUtils import com.yzx.kiosk.utils.ToastUtils import com.google.gson.Gson @@ -35,9 +36,12 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job -import java.net.URL +import okhttp3.CacheControl +import okhttp3.OkHttpClient +import okhttp3.Request import kotlin.coroutines.resume import javax.inject.Inject +import javax.inject.Named @HiltViewModel class PrintingViewModel @Inject constructor( @@ -46,7 +50,8 @@ class PrintingViewModel @Inject constructor( private val printerService: PrinterService, private val appStoreDataSource: AppStoreDataSource, private val printStatusManager: PrintStatusManager, - private val netWorkRepository: NetWorkRepository + private val netWorkRepository: NetWorkRepository, + @Named(CLIENT_DOWNLOAD) private val printDownloadClient: OkHttpClient ) : BaseViewModel( navigator = navigator, appState = appState @@ -55,8 +60,9 @@ class PrintingViewModel @Inject constructor( private const val TAG = "PrintingViewModel" private const val PRINT_TIME_PER_PHOTO = 15 // 每张照片预计打印时间(秒) private const val COUNTDOWN_AFTER_PRINT = 90 // 打印完成后倒计时(秒) + private const val IMAGE_DOWNLOAD_RETRY_COUNT = 3 } - + /** * 打印状态通知请求数据类 */ @@ -66,10 +72,10 @@ class PrintingViewModel @Inject constructor( val imageId: Int, val printStatus: Int ) - + // 打印状态通知请求队列,使用 Channel 确保所有请求都能被处理 private val printNotifyChannel = Channel(Channel.UNLIMITED) - + // 是否正在打印 private val _isPrinting = MutableStateFlow(true) val isPrinting: StateFlow = _isPrinting.asStateFlow() @@ -80,22 +86,22 @@ class PrintingViewModel @Inject constructor( // 预计等待时间 private val _estimatedTime = MutableStateFlow(0) val estimatedTime: StateFlow = _estimatedTime.asStateFlow() - + // 预计等待时间倒计时协程 private var estimatedTimeCountdownJob: Job? = null - + // 打印完成后倒计时 private val _countdown = MutableStateFlow(COUNTDOWN_AFTER_PRINT) val countdown: StateFlow = _countdown.asStateFlow() - + // 打印记录列表 private val _printRecords = MutableStateFlow>(emptyList()) val printRecords: StateFlow> = _printRecords.asStateFlow() - + // 二维码 URL private val _qrCodeUrl = MutableStateFlow("") val qrCodeUrl: StateFlow = _qrCodeUrl.asStateFlow() - + init { _hotline.value = appStoreDataSource.getHomepageServicePhone() // 启动一个协程来处理打印状态通知队列,确保串行执行 @@ -103,7 +109,7 @@ class PrintingViewModel @Inject constructor( processPrintNotifyQueue() } } - + /** * 处理打印状态通知队列,串行执行所有请求 */ @@ -116,7 +122,7 @@ class PrintingViewModel @Inject constructor( // Channel.receive() 在协程被取消时会抛出 CancellationException val task = printNotifyChannel.receive() LogUtils.d(TAG, ">>> 从队列中取出打印状态通知任务 - imageId: ${task.imageId}, printStatus: ${task.printStatus}, orderNumber: ${task.orderNumber}") - + // 执行实际的接口调用 executePrintNotify( captureType = task.captureType, @@ -142,7 +148,7 @@ class PrintingViewModel @Inject constructor( } LogUtils.d(TAG, ">>> 打印状态通知队列处理协程已结束 <<<") } - + /** * 开始打印 * @param photoUrls 图片URL列表(JSON 格式或旧格式) @@ -154,20 +160,20 @@ class PrintingViewModel @Inject constructor( ToastUtils.show("没有要打印的照片") return } - + if (orderNumber.isEmpty() || captureType == null) { LogUtils.e(TAG, "订单号或抓拍类型为空,无法打印") ToastUtils.show("订单信息不完整,无法打印") return } - + viewModelScope.launch { _isPrinting.value = true _printRecords.value = emptyList() - + // 获取二维码 URL loadQrCodeUrl(orderNumber) - + // 解析图片列表(支持 JSON 格式的 FileMapData 数组) val printItems = parsePhotoList(photoUrls) if (printItems.isEmpty()) { @@ -176,16 +182,16 @@ class PrintingViewModel @Inject constructor( onPrintComplete(orderNumber, captureType) return@launch } - + _estimatedTime.value = printItems.size * PRINT_TIME_PER_PHOTO - + // 启动预计等待时间倒计时 startEstimatedTimeCountdown() - + // 初始化打印机 val initResult = printerService.initDnpPrint() val isPrinterAvailable = initResult && !printStatusManager.isOffline() - + if (!initResult) { LogUtils.e(TAG, "打印机初始化失败,将为所有图片上传打印失败状态") ToastUtils.show("打印机初始化失败") @@ -193,9 +199,9 @@ class PrintingViewModel @Inject constructor( LogUtils.e(TAG, "打印机离线,将为所有图片上传打印失败状态") ToastUtils.show("打印机离线") } - + val records = mutableListOf() - + // 循环处理每张图片(无论打印机是否在线,都要上传打印状态) for ((index, item) in printItems.withIndex()) { // 更新预计时间(倒计时协程会自动减少,这里只更新剩余时间) @@ -203,9 +209,9 @@ class PrintingViewModel @Inject constructor( if (remainingTime > _estimatedTime.value) { _estimatedTime.value = remainingTime } - + LogUtils.d(TAG, "开始处理第 ${index + 1}/${printItems.size} 张图片 - imageId: ${item.id}, url: ${item.originalOssUrl}") - + // 1. 检查打印机在线状态(如果初始化失败或离线,直接标记为失败) if (!isPrinterAvailable || printStatusManager.isOffline()) { LogUtils.e(TAG, "打印机不可用,标记为打印失败 - imageId: ${item.id}") @@ -222,7 +228,7 @@ class PrintingViewModel @Inject constructor( appStoreDataSource.addPrintRecord(record) continue // 继续处理下一张图片 } - + // 2. 下载图片(使用 original_oss_url) val bitmap = downloadImage(item.originalOssUrl) @@ -241,14 +247,14 @@ class PrintingViewModel @Inject constructor( appStoreDataSource.addPrintRecord(record) continue // 继续下一张 } - + // 3. 打印图片,等待回调结果 val printResult = printImageWithCallback(bitmap) val printStatus = if (printResult.isSuccess) 1 else 2 // 1=成功, 2=失败 - + // 调用 print-notify 接口 notifyPrintStatus(captureType, orderNumber, item.id, printStatus) - + val record = PrintRecord( fileName = item.originalOssUrl.substringAfterLast("/"), printEndTime = System.currentTimeMillis(), @@ -257,20 +263,20 @@ class PrintingViewModel @Inject constructor( ) records.add(record) appStoreDataSource.addPrintRecord(record) - + // 回收Bitmap bitmap.recycle() - + LogUtils.d(TAG, "打印${if (printResult.isSuccess) "成功" else "失败"} - imageId: ${item.id}, message: ${printResult.message}") } - + _printRecords.value = records - + // 打印完所有图片,调用 print-complete 接口 onPrintComplete(orderNumber, captureType) } } - + /** * 解析图片列表 * 支持 JSON 格式的 FileMapData 数组或旧格式的 URL 列表 @@ -279,17 +285,17 @@ class PrintingViewModel @Inject constructor( if (photoUrls.isEmpty()) { return@withContext emptyList() } - + // 尝试解析为 JSON 格式(新格式:file_map 数组的 JSON 字符串) if (photoUrls.size == 1 && photoUrls[0].startsWith("[")) { try { val jsonString = java.net.URLDecoder.decode(photoUrls[0], "UTF-8") LogUtils.d(TAG, "解析 JSON 数组字符串: $jsonString") - + // 使用 Gson 将 JSON 数组反序列化为 FileMapData 对象数组 val type = object : TypeToken>() {}.type val fileMapList: List = Gson().fromJson(jsonString, type) ?: emptyList() - + // 转换为 PrintItem 列表 return@withContext fileMapList.mapNotNull { fileMap -> val originalUrl = if (appStoreDataSource.getUseLan()) fileMap.originalLanUrl else fileMap.originalOssUrl ?:fileMap.thumbnailOssUrl @@ -319,7 +325,7 @@ class PrintingViewModel @Inject constructor( } } } - + /** * 打印图片并等待回调结果 */ @@ -333,7 +339,7 @@ class PrintingViewModel @Inject constructor( }) } } - + /** * 通知打印状态(异步,将请求放入队列) * 使用 Channel 队列确保高并发调用时所有请求都能被处理,串行执行避免并发问题 @@ -351,7 +357,7 @@ class PrintingViewModel @Inject constructor( imageId = imageId, printStatus = printStatus ) - + // 将请求放入队列,如果队列已满会挂起等待 printNotifyChannel.send(task) LogUtils.d(TAG, ">>> 打印状态通知请求已加入队列 <<< - imageId: $imageId, printStatus: $printStatus") @@ -360,7 +366,7 @@ class PrintingViewModel @Inject constructor( e.printStackTrace() } } - + /** * 执行实际的打印状态通知接口调用 */ @@ -373,7 +379,7 @@ class PrintingViewModel @Inject constructor( try { LogUtils.d(TAG, ">>> 开始执行 print-notify 接口调用 <<<") LogUtils.d(TAG, "参数 - captureType: $captureType, orderNumber: $orderNumber, imageId: $imageId, printStatus: $printStatus") - + // 如果打印成功,更新打印纸张数(减1) var remainingPaperNum: Int? = null if (printStatus == 1) { @@ -385,7 +391,7 @@ class PrintingViewModel @Inject constructor( remainingPaperNum = newPaperNum LogUtils.d(TAG, "打印成功,更新纸张数: $currentPaperNum -> $newPaperNum") } - + val request = PrintNotifyRequest( captureType = captureType, orderNumber = orderNumber, @@ -409,7 +415,7 @@ class PrintingViewModel @Inject constructor( e.printStackTrace() } } - + /** * 打印项数据类 */ @@ -417,7 +423,7 @@ class PrintingViewModel @Inject constructor( val id: Int, val originalOssUrl: String ) - + /** * 打印结果 */ @@ -425,14 +431,14 @@ class PrintingViewModel @Inject constructor( val isSuccess: Boolean, val message: String ) - + /** * 启动预计等待时间倒计时 */ private fun startEstimatedTimeCountdown() { // 取消之前的倒计时(如果存在) estimatedTimeCountdownJob?.cancel() - + // 启动新的倒计时 estimatedTimeCountdownJob = viewModelScope.launch { while (_isPrinting.value) { @@ -444,7 +450,7 @@ class PrintingViewModel @Inject constructor( } } } - + /** * 打印完成 */ @@ -452,13 +458,13 @@ class PrintingViewModel @Inject constructor( // 停止预计等待时间倒计时 estimatedTimeCountdownJob?.cancel() estimatedTimeCountdownJob = null - + // 调用 print-complete 接口 notifyPrintComplete(captureType, orderNumber) - + _isPrinting.value = false _countdown.value = COUNTDOWN_AFTER_PRINT - + // 开始倒计时 viewModelScope.launch { while (_countdown.value > 0) { @@ -469,19 +475,19 @@ class PrintingViewModel @Inject constructor( navigator.closeAllExcept(AppRoutes.HOME) } } - + /** * 通知打印完成 */ private suspend fun notifyPrintComplete(captureType: Int, orderNumber: String) { try { LogUtils.d(TAG, "调用 print-complete 接口 - captureType: $captureType, orderNumber: $orderNumber") - + val request = PrintCompleteRequest( captureType = captureType, orderNumber = orderNumber ) - + handleResultWithData( flow = netWorkRepository.printComplete(request).asResult(), showToast = false, @@ -497,24 +503,71 @@ class PrintingViewModel @Inject constructor( e.printStackTrace() } } - + /** * 下载网络图片 */ private suspend fun downloadImage(url: String): Bitmap? = withContext(Dispatchers.IO) { - try { - val connection = URL(url).openConnection() - connection.connectTimeout = 10000 - connection.readTimeout = 10000 - connection.getInputStream().use { inputStream -> - BitmapFactory.decodeStream(inputStream) + val logUrl = url.substringBefore('?') + + for (attempt in 1..IMAGE_DOWNLOAD_RETRY_COUNT) { + try { + val request = Request.Builder() + .url(url) + .get() + .cacheControl(CacheControl.FORCE_NETWORK) + .build() + + printDownloadClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw java.io.IOException("HTTP ${response.code}") + } + + val body = response.body ?: throw java.io.IOException("响应体为空") + val declaredLength = body.contentLength() + if (declaredLength > PrintImageIntegrityValidator.MAX_DOWNLOAD_BYTES) { + throw java.io.IOException("图片超过大小上限: $declaredLength bytes") + } + + // bytes() 会完整读取响应;服务端声明了 Content-Length 时,OkHttp 会校验实际长度。 + val bytes = body.bytes() + if (declaredLength >= 0L && declaredLength != bytes.size.toLong()) { + throw java.io.IOException( + "响应长度不一致: declared=$declaredLength, actual=${bytes.size}" + ) + } + + val decoded = PrintImageIntegrityValidator.decodeDownloadedImage(bytes) + LogUtils.d( + TAG, + "打印图片校验成功 - url=$logUrl, attempt=$attempt, bytes=${bytes.size}, " + + "format=${decoded.format}, mime=${decoded.mimeType}, " + + "source=${decoded.sourceWidth}x${decoded.sourceHeight}, " + + "decoded=${decoded.bitmap.width}x${decoded.bitmap.height}, " + + "sample=${decoded.sampleSize}, sha256=${decoded.sha256.take(16)}" + ) + return@withContext decoded.bitmap + } + } catch (e: CancellationException) { + throw e + } catch (e: OutOfMemoryError) { + LogUtils.e(TAG, "打印图片解码内存不足 - url=$logUrl, attempt=$attempt") + return@withContext null + } catch (e: Exception) { + LogUtils.e( + TAG, + "打印图片下载或校验失败 - url=$logUrl, attempt=$attempt/$IMAGE_DOWNLOAD_RETRY_COUNT, error=${e.message}" + ) + if (attempt < IMAGE_DOWNLOAD_RETRY_COUNT) { + delay(500L * attempt) + } } - } catch (e: Exception) { - LogUtils.e(TAG, "下载图片失败: $url, ${e.message}") - null } + + LogUtils.e(TAG, "打印图片连续 $IMAGE_DOWNLOAD_RETRY_COUNT 次校验失败,禁止送入打印机 - url=$logUrl") + null } - + /** * 获取二维码 URL */ @@ -544,6 +597,6 @@ class PrintingViewModel @Inject constructor( } } } - + }