fix: improve photo grid and print reliability
This commit is contained in:
@@ -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) }
|
||||
}
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印结果回调接口
|
||||
|
||||
@@ -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 = "请使用微信扫描二维码支付",
|
||||
|
||||
+68
-109
@@ -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<FaceSearchResult> = emptyList()
|
||||
|
||||
|
||||
// URL到ID的映射
|
||||
private val urlToIdMap = mutableMapOf<String, Int>()
|
||||
|
||||
|
||||
// 图片列表
|
||||
private val _photoList = MutableStateFlow<List<PhotoData>>(emptyList())
|
||||
val photoList: StateFlow<List<PhotoData>> = _photoList.asStateFlow()
|
||||
|
||||
|
||||
// 已选中的图片URL(用于UI显示)
|
||||
private val _selectedPhotos = MutableStateFlow<Set<String>>(emptySet())
|
||||
val selectedPhotos: StateFlow<Set<String>> = _selectedPhotos.asStateFlow()
|
||||
|
||||
|
||||
// 单价
|
||||
private val _pricePerPhoto = MutableStateFlow<String>("--")
|
||||
val pricePerPhoto: StateFlow<String> = _pricePerPhoto.asStateFlow()
|
||||
|
||||
|
||||
// 总价
|
||||
private val _totalPrice = MutableStateFlow<String>("--")
|
||||
val totalPrice: StateFlow<String> = _totalPrice.asStateFlow()
|
||||
|
||||
|
||||
// 支付二维码URL
|
||||
private val _payQrCodeUrl = MutableStateFlow<String?>(null)
|
||||
val payQrCodeUrl: StateFlow<String?> = _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<Map<String, ImageLoadState>>(emptyMap())
|
||||
val imageLoadStates: StateFlow<Map<String, ImageLoadState>> = _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 ->
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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<PrintNotifyTask>(Channel.UNLIMITED)
|
||||
|
||||
|
||||
// 是否正在打印
|
||||
private val _isPrinting = MutableStateFlow(true)
|
||||
val isPrinting: StateFlow<Boolean> = _isPrinting.asStateFlow()
|
||||
@@ -80,22 +86,22 @@ class PrintingViewModel @Inject constructor(
|
||||
// 预计等待时间
|
||||
private val _estimatedTime = MutableStateFlow(0)
|
||||
val estimatedTime: StateFlow<Int> = _estimatedTime.asStateFlow()
|
||||
|
||||
|
||||
// 预计等待时间倒计时协程
|
||||
private var estimatedTimeCountdownJob: Job? = null
|
||||
|
||||
|
||||
// 打印完成后倒计时
|
||||
private val _countdown = MutableStateFlow(COUNTDOWN_AFTER_PRINT)
|
||||
val countdown: StateFlow<Int> = _countdown.asStateFlow()
|
||||
|
||||
|
||||
// 打印记录列表
|
||||
private val _printRecords = MutableStateFlow<List<PrintRecord>>(emptyList())
|
||||
val printRecords: StateFlow<List<PrintRecord>> = _printRecords.asStateFlow()
|
||||
|
||||
|
||||
// 二维码 URL
|
||||
private val _qrCodeUrl = MutableStateFlow<String>("")
|
||||
val qrCodeUrl: StateFlow<String> = _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<PrintRecord>()
|
||||
|
||||
|
||||
// 循环处理每张图片(无论打印机是否在线,都要上传打印状态)
|
||||
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<List<FileMapData>>() {}.type
|
||||
val fileMapList: List<FileMapData> = 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(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user