Compare commits
1
Commits
1000488c04
...
1.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86bc9528dd |
@@ -89,8 +89,8 @@ android {
|
|||||||
applicationId = "com.yzx.kiosk"
|
applicationId = "com.yzx.kiosk"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 21
|
versionCode = 22
|
||||||
versionName = "1.1.1"
|
versionName = "1.1.2"
|
||||||
//multiDexEnabled = true
|
//multiDexEnabled = true
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
|||||||
@@ -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.Canvas
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.Matrix
|
import android.graphics.Matrix
|
||||||
import android.graphics.Paint
|
|
||||||
import com.yzx.kiosk.App
|
import com.yzx.kiosk.App
|
||||||
import com.yzx.kiosk.datastore.AppStoreDataSource
|
import com.yzx.kiosk.datastore.AppStoreDataSource
|
||||||
import com.yzx.kiosk.utils.LogUtils
|
import com.yzx.kiosk.utils.LogUtils
|
||||||
@@ -23,8 +22,11 @@ import jp.co.dnpLib.print.PrintManager
|
|||||||
import jp.co.dnpLib.print.PrintQueue
|
import jp.co.dnpLib.print.PrintQueue
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
|
|
||||||
@@ -43,6 +45,7 @@ class PrinterService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var mDnpPhotoPrint: DNPPhotoPrint? = null
|
private var mDnpPhotoPrint: DNPPhotoPrint? = null
|
||||||
|
private val printMutex = Mutex()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化打印机(耗时操作,需在IO线程调用)
|
* 初始化打印机(耗时操作,需在IO线程调用)
|
||||||
@@ -182,12 +185,29 @@ class PrinterService(
|
|||||||
* @param originalBitmap 要打印的原始图片
|
* @param originalBitmap 要打印的原始图片
|
||||||
* @param callback 打印结果回调
|
* @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) {
|
if (originalBitmap == null) {
|
||||||
LogUtils.e(TAG, "获取图片失败")
|
LogUtils.e(TAG, "获取图片失败")
|
||||||
callback.onPrintResult(false,"获取图片失败")
|
callback.onPrintResult(false,"获取图片失败")
|
||||||
return@withContext
|
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, "获取图片成功")
|
LogUtils.e(TAG, "获取图片成功")
|
||||||
|
|
||||||
// 在开始打印前,检查打印机是否在线
|
// 在开始打印前,检查打印机是否在线
|
||||||
@@ -224,17 +244,22 @@ class PrinterService(
|
|||||||
LogUtils.d(TAG, "打印尺寸: ${printSize.width} x ${printSize.height}")
|
LogUtils.d(TAG, "打印尺寸: ${printSize.width} x ${printSize.height}")
|
||||||
|
|
||||||
// 准备打印图片(保持原比例,居中,白色背景)
|
// 准备打印图片(保持原比例,居中,白色背景)
|
||||||
val outputBitmap: Bitmap
|
var outputBitmap: Bitmap? = null
|
||||||
val outFile = File(
|
val outFile = File(
|
||||||
context.getExternalFilesDir(null),
|
context.getExternalFilesDir(null),
|
||||||
"ProcessedPhotos/${System.currentTimeMillis()}_print.bmp"
|
"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 {
|
try {
|
||||||
outputBitmap = preparePrintBitmap(originalBitmap, printSize, printId)
|
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)
|
BmpUtil.save(outputBitmap, outFile.absolutePath, EResolution.RESO300.mValue)
|
||||||
} else {
|
} else {
|
||||||
AndroidBmpUtil.save(
|
AndroidBmpUtil.save(
|
||||||
@@ -244,12 +269,35 @@ class PrinterService(
|
|||||||
PRINTSIZE.QW410.height
|
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) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
LogUtils.e(TAG, "图片处理或 BMP 完整性校验失败: ${e.message}")
|
||||||
callback.onPrintResult(false,"图片处理失败: ${e.message}")
|
outputBitmap?.let { bitmap ->
|
||||||
|
if (!bitmap.isRecycled) bitmap.recycle()
|
||||||
|
}
|
||||||
|
printStatusManager.setIdle()
|
||||||
|
callback.onPrintResult(false, "图片处理失败: ${e.message}")
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val validatedOutputBitmap = checkNotNull(outputBitmap)
|
||||||
|
|
||||||
// 创建打印任务
|
// 创建打印任务
|
||||||
val job = PrintJob(
|
val job = PrintJob(
|
||||||
outFile.absolutePath,
|
outFile.absolutePath,
|
||||||
@@ -269,8 +317,8 @@ class PrinterService(
|
|||||||
printStatusManager.setIdle()
|
printStatusManager.setIdle()
|
||||||
// 回收 outputBitmap
|
// 回收 outputBitmap
|
||||||
try {
|
try {
|
||||||
if (!outputBitmap.isRecycled) {
|
if (!validatedOutputBitmap.isRecycled) {
|
||||||
outputBitmap.recycle()
|
validatedOutputBitmap.recycle()
|
||||||
LogUtils.d(TAG, "outputBitmap 已回收")
|
LogUtils.d(TAG, "outputBitmap 已回收")
|
||||||
}
|
}
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
@@ -285,8 +333,8 @@ class PrinterService(
|
|||||||
printStatusManager.setIdle()
|
printStatusManager.setIdle()
|
||||||
// 回收 outputBitmap
|
// 回收 outputBitmap
|
||||||
try {
|
try {
|
||||||
if (!outputBitmap.isRecycled) {
|
if (!validatedOutputBitmap.isRecycled) {
|
||||||
outputBitmap.recycle()
|
validatedOutputBitmap.recycle()
|
||||||
LogUtils.d(TAG, "outputBitmap 已回收")
|
LogUtils.d(TAG, "outputBitmap 已回收")
|
||||||
}
|
}
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.yzx.kiosk.ui.face.view
|
package com.yzx.kiosk.ui.face.view
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
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.Color
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -35,6 +37,7 @@ import coil.compose.AsyncImage
|
|||||||
import coil.compose.SubcomposeAsyncImage
|
import coil.compose.SubcomposeAsyncImage
|
||||||
import coil.compose.SubcomposeAsyncImageContent
|
import coil.compose.SubcomposeAsyncImageContent
|
||||||
import coil.compose.SubcomposeAsyncImageScope
|
import coil.compose.SubcomposeAsyncImageScope
|
||||||
|
import coil.request.ImageRequest
|
||||||
import com.yzx.kiosk.R
|
import com.yzx.kiosk.R
|
||||||
import com.yzx.kiosk.component.appbar.AppTitleBar
|
import com.yzx.kiosk.component.appbar.AppTitleBar
|
||||||
import com.yzx.kiosk.component.appbar.FaceBarNoStatusBarPadding
|
import com.yzx.kiosk.component.appbar.FaceBarNoStatusBarPadding
|
||||||
@@ -65,6 +68,7 @@ fun FaceRecognitionResultScreen(
|
|||||||
val selectedPhotos by viewModel.selectedPhotos.collectAsState()
|
val selectedPhotos by viewModel.selectedPhotos.collectAsState()
|
||||||
val pricePerPhoto by viewModel.pricePerPhoto.collectAsState()
|
val pricePerPhoto by viewModel.pricePerPhoto.collectAsState()
|
||||||
val totalPrice by viewModel.totalPrice.collectAsState()
|
val totalPrice by viewModel.totalPrice.collectAsState()
|
||||||
|
val imageLoadStates by viewModel.imageLoadStates.collectAsState()
|
||||||
|
|
||||||
val selectedCount = selectedPhotos.size
|
val selectedCount = selectedPhotos.size
|
||||||
val isAllSelected = photoList.isNotEmpty() && selectedPhotos.size == photoList.size
|
val isAllSelected = photoList.isNotEmpty() && selectedPhotos.size == photoList.size
|
||||||
@@ -230,12 +234,15 @@ fun FaceRecognitionResultScreen(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
||||||
verticalItemSpacing = 18.dp
|
verticalItemSpacing = 18.dp
|
||||||
) {
|
) {
|
||||||
itemsIndexed(photoList) { index, photo ->
|
itemsIndexed(
|
||||||
|
items = photoList,
|
||||||
|
key = { _, photo -> photo.id }
|
||||||
|
) { _, photo ->
|
||||||
val isSelected = selectedPhotos.contains(photo.url)
|
val isSelected = selectedPhotos.contains(photo.url)
|
||||||
val imageLoadStates by viewModel.imageLoadStates.collectAsState()
|
|
||||||
val loadState = imageLoadStates[photo.url] ?: FaceRecognitionResultViewModel.ImageLoadState()
|
val loadState = imageLoadStates[photo.url] ?: FaceRecognitionResultViewModel.ImageLoadState()
|
||||||
|
|
||||||
FaceRecognitionPhotoItem(
|
FaceRecognitionPhotoItem(
|
||||||
|
photoId = photo.id,
|
||||||
photoUrl = photo.url,
|
photoUrl = photo.url,
|
||||||
aspectRatio = photo.aspectRatio,
|
aspectRatio = photo.aspectRatio,
|
||||||
isSelected = isSelected,
|
isSelected = isSelected,
|
||||||
@@ -252,6 +259,9 @@ fun FaceRecognitionResultScreen(
|
|||||||
},
|
},
|
||||||
onLoadError = { viewModel.handleImageLoadError(photo.url) },
|
onLoadError = { viewModel.handleImageLoadError(photo.url) },
|
||||||
onLoadSuccess = { viewModel.handleImageLoadSuccess(photo.url) },
|
onLoadSuccess = { viewModel.handleImageLoadSuccess(photo.url) },
|
||||||
|
onImageDimensionsResolved = { width, height ->
|
||||||
|
viewModel.updatePhotoAspectRatio(photo.id, width, height)
|
||||||
|
},
|
||||||
onRetryClick = { viewModel.retryLoadImage(photo.url) }
|
onRetryClick = { viewModel.retryLoadImage(photo.url) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -291,6 +301,7 @@ fun FaceRecognitionResultScreen(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun FaceRecognitionPhotoItem(
|
fun FaceRecognitionPhotoItem(
|
||||||
|
photoId: Int,
|
||||||
photoUrl: String,
|
photoUrl: String,
|
||||||
aspectRatio: Float,
|
aspectRatio: Float,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean,
|
||||||
@@ -299,6 +310,7 @@ fun FaceRecognitionPhotoItem(
|
|||||||
onPreviewClick: () -> Unit,
|
onPreviewClick: () -> Unit,
|
||||||
onLoadError: () -> Unit = {},
|
onLoadError: () -> Unit = {},
|
||||||
onLoadSuccess: () -> Unit = {},
|
onLoadSuccess: () -> Unit = {},
|
||||||
|
onImageDimensionsResolved: (width: Int, height: Int) -> Unit = { _, _ -> },
|
||||||
onRetryClick: () -> Unit = {}
|
onRetryClick: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
// 确保 aspectRatio 是有效值
|
// 确保 aspectRatio 是有效值
|
||||||
@@ -316,11 +328,20 @@ fun FaceRecognitionPhotoItem(
|
|||||||
.clickable { onToggleSelection() }
|
.clickable { onToggleSelection() }
|
||||||
) {
|
) {
|
||||||
// 图片 - 按原比例显示
|
// 图片 - 按原比例显示
|
||||||
var lastErrorState by remember { mutableStateOf(false) }
|
var lastErrorState by remember(photoId, photoUrl) { mutableStateOf(false) }
|
||||||
var lastSuccessState by remember { 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(
|
SubcomposeAsyncImage(
|
||||||
model = photoUrl,
|
model = imageRequest,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Fit
|
contentScale = ContentScale.Fit
|
||||||
@@ -400,6 +421,11 @@ fun FaceRecognitionPhotoItem(
|
|||||||
if (state is coil.compose.AsyncImagePainter.State.Success && !lastSuccessState) {
|
if (state is coil.compose.AsyncImagePainter.State.Success && !lastSuccessState) {
|
||||||
lastSuccessState = true
|
lastSuccessState = true
|
||||||
lastErrorState = false
|
lastErrorState = false
|
||||||
|
val drawable = state.result.drawable
|
||||||
|
onImageDimensionsResolved(
|
||||||
|
drawable.intrinsicWidth,
|
||||||
|
drawable.intrinsicHeight
|
||||||
|
)
|
||||||
onLoadSuccess()
|
onLoadSuccess()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-62
@@ -1,10 +1,6 @@
|
|||||||
package com.yzx.kiosk.ui.face.viewmodel
|
package com.yzx.kiosk.ui.face.viewmodel
|
||||||
|
|
||||||
import androidx.lifecycle.viewModelScope
|
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.base.BaseViewModel
|
||||||
import com.yzx.kiosk.datastore.AppState
|
import com.yzx.kiosk.datastore.AppState
|
||||||
import com.yzx.kiosk.navigation.AppNavigator
|
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.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.awaitAll
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
@@ -38,8 +31,8 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
|
||||||
import com.yzx.kiosk.utils.LogUtils
|
import com.yzx.kiosk.utils.LogUtils
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -134,6 +127,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
// 保存原始结果列表
|
// 保存原始结果列表
|
||||||
originalResults = results
|
originalResults = results
|
||||||
|
urlToIdMap.clear()
|
||||||
|
|
||||||
// 先使用默认宽高比创建列表
|
// 先使用默认宽高比创建列表
|
||||||
val initialPhotos = results.mapNotNull { result ->
|
val initialPhotos = results.mapNotNull { result ->
|
||||||
@@ -167,70 +161,35 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
|||||||
|
|
||||||
// 页面初始化时调用验证接口(空列表,type: 2),获取默认价格
|
// 页面初始化时调用验证接口(空列表,type: 2),获取默认价格
|
||||||
verifyResult(emptyList())
|
verifyResult(emptyList())
|
||||||
|
LogUtils.d(TAG, "图片列表初始化完成,共 ${initialPhotos.size} 张,宽高比将在图片加载成功后逐张更新")
|
||||||
// 并发获取所有图片的真实尺寸并更新宽高比
|
|
||||||
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} 张")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取图片的宽高比
|
* 使用网格中已经加载成功的 Drawable 尺寸更新宽高比。
|
||||||
|
* 这样不会为了获取尺寸额外下载、解码一次图片。
|
||||||
*/
|
*/
|
||||||
private suspend fun getImageAspectRatio(imageUrl: String): Float {
|
fun updatePhotoAspectRatio(photoId: Int, width: Int, height: Int) {
|
||||||
return withTimeoutOrNull(10000) { // 10秒超时
|
if (width <= 0 || height <= 0) {
|
||||||
try {
|
LogUtils.i(TAG, "忽略无效图片尺寸 - id: $photoId, size: ${width}x${height}")
|
||||||
val imageLoader = ImageLoader(App.instance)
|
return
|
||||||
val request = ImageRequest.Builder(App.instance)
|
}
|
||||||
.data(imageUrl)
|
|
||||||
.size(Size.ORIGINAL)
|
|
||||||
.allowHardware(false) // 禁用硬件加速,避免某些图片无法获取尺寸
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val result = imageLoader.execute(request)
|
val aspectRatio = width.toFloat() / height.toFloat()
|
||||||
val drawable = result.drawable
|
if (!aspectRatio.isFinite() || aspectRatio !in 0.05f..20f) {
|
||||||
|
LogUtils.i(TAG, "忽略异常图片宽高比 - id: $photoId, ratio: $aspectRatio")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (drawable != null) {
|
_photoList.update { photos ->
|
||||||
val width = drawable.intrinsicWidth
|
photos.map { photo ->
|
||||||
val height = drawable.intrinsicHeight
|
if (photo.id == photoId && kotlin.math.abs(photo.aspectRatio - aspectRatio) > 0.01f) {
|
||||||
|
LogUtils.d(TAG, "更新图片比例 - id: $photoId, size: ${width}x${height}, ratio: $aspectRatio")
|
||||||
if (width > 0 && height > 0) {
|
photo.copy(aspectRatio = aspectRatio)
|
||||||
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}")
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
LogUtils.i("FaceRecognitionResultViewModel", "图片 $imageUrl 加载失败,drawable为null")
|
photo
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
|
||||||
LogUtils.e("FaceRecognitionResultViewModel"+"获取图片 $imageUrl 尺寸异常: ${e.message}")
|
|
||||||
}
|
}
|
||||||
null
|
|
||||||
} ?: run {
|
|
||||||
LogUtils.i("FaceRecognitionResultViewModel", "获取图片 $imageUrl 尺寸超时")
|
|
||||||
1f // 超时或失败,返回默认值
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import com.yzx.kiosk.utils.LogUtils
|
|||||||
import com.yzx.kiosk.utils.ToastUtils
|
import com.yzx.kiosk.utils.ToastUtils
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -220,6 +221,9 @@ class FaceRecognitionViewModel @Inject constructor(
|
|||||||
try {
|
try {
|
||||||
// 直接调用人脸识别接口
|
// 直接调用人脸识别接口
|
||||||
searchFace(photoFile)
|
searchFace(photoFile)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
// 页面跳转或 ViewModel 销毁时的正常协程取消,不向用户报错
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
LogUtils.e(TAG, "处理失败: ${e.message}")
|
LogUtils.e(TAG, "处理失败: ${e.message}")
|
||||||
ToastUtils.show("处理失败: ${e.message}")
|
ToastUtils.show("处理失败: ${e.message}")
|
||||||
@@ -315,6 +319,9 @@ class FaceRecognitionViewModel @Inject constructor(
|
|||||||
localAudioPlayService.playByRoute("face_recognition_failed")
|
localAudioPlayService.playByRoute("face_recognition_failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
// 页面离开时保持协程取消语义,避免误显示 "Job was cancelled"
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
LogUtils.e("人脸识别失败: ${e.message}")
|
LogUtils.e("人脸识别失败: ${e.message}")
|
||||||
ToastUtils.show("识别失败: ${e.message}")
|
ToastUtils.show("识别失败: ${e.message}")
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
package com.yzx.kiosk.ui.upload.viewmodel
|
package com.yzx.kiosk.ui.upload.viewmodel
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.yzx.kiosk.base.BaseViewModel
|
import com.yzx.kiosk.base.BaseViewModel
|
||||||
import com.yzx.kiosk.datastore.AppState
|
import com.yzx.kiosk.datastore.AppState
|
||||||
import com.yzx.kiosk.navigation.AppNavigator
|
import com.yzx.kiosk.navigation.AppNavigator
|
||||||
import com.yzx.kiosk.navigation.routes.AppRoutes
|
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.PrintCompleteRequest
|
||||||
import com.yzx.kiosk.network.model.request.PrintNotifyRequest
|
import com.yzx.kiosk.network.model.request.PrintNotifyRequest
|
||||||
import com.yzx.kiosk.network.model.request.SaveAlbumUrlRequest
|
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.PrintRecord
|
||||||
import com.yzx.kiosk.priter.PrintStatusManager
|
import com.yzx.kiosk.priter.PrintStatusManager
|
||||||
import com.yzx.kiosk.priter.PrinterService
|
import com.yzx.kiosk.priter.PrinterService
|
||||||
|
import com.yzx.kiosk.priter.PrintImageIntegrityValidator
|
||||||
import com.yzx.kiosk.utils.LogUtils
|
import com.yzx.kiosk.utils.LogUtils
|
||||||
import com.yzx.kiosk.utils.ToastUtils
|
import com.yzx.kiosk.utils.ToastUtils
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
@@ -35,9 +36,12 @@ import kotlinx.coroutines.flow.first
|
|||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import java.net.URL
|
import okhttp3.CacheControl
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Named
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class PrintingViewModel @Inject constructor(
|
class PrintingViewModel @Inject constructor(
|
||||||
@@ -46,7 +50,8 @@ class PrintingViewModel @Inject constructor(
|
|||||||
private val printerService: PrinterService,
|
private val printerService: PrinterService,
|
||||||
private val appStoreDataSource: AppStoreDataSource,
|
private val appStoreDataSource: AppStoreDataSource,
|
||||||
private val printStatusManager: PrintStatusManager,
|
private val printStatusManager: PrintStatusManager,
|
||||||
private val netWorkRepository: NetWorkRepository
|
private val netWorkRepository: NetWorkRepository,
|
||||||
|
@Named(CLIENT_DOWNLOAD) private val printDownloadClient: OkHttpClient
|
||||||
) : BaseViewModel(
|
) : BaseViewModel(
|
||||||
navigator = navigator,
|
navigator = navigator,
|
||||||
appState = appState
|
appState = appState
|
||||||
@@ -55,6 +60,7 @@ class PrintingViewModel @Inject constructor(
|
|||||||
private const val TAG = "PrintingViewModel"
|
private const val TAG = "PrintingViewModel"
|
||||||
private const val PRINT_TIME_PER_PHOTO = 15 // 每张照片预计打印时间(秒)
|
private const val PRINT_TIME_PER_PHOTO = 15 // 每张照片预计打印时间(秒)
|
||||||
private const val COUNTDOWN_AFTER_PRINT = 90 // 打印完成后倒计时(秒)
|
private const val COUNTDOWN_AFTER_PRINT = 90 // 打印完成后倒计时(秒)
|
||||||
|
private const val IMAGE_DOWNLOAD_RETRY_COUNT = 3
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -502,17 +508,64 @@ class PrintingViewModel @Inject constructor(
|
|||||||
* 下载网络图片
|
* 下载网络图片
|
||||||
*/
|
*/
|
||||||
private suspend fun downloadImage(url: String): Bitmap? = withContext(Dispatchers.IO) {
|
private suspend fun downloadImage(url: String): Bitmap? = withContext(Dispatchers.IO) {
|
||||||
try {
|
val logUrl = url.substringBefore('?')
|
||||||
val connection = URL(url).openConnection()
|
|
||||||
connection.connectTimeout = 10000
|
for (attempt in 1..IMAGE_DOWNLOAD_RETRY_COUNT) {
|
||||||
connection.readTimeout = 10000
|
try {
|
||||||
connection.getInputStream().use { inputStream ->
|
val request = Request.Builder()
|
||||||
BitmapFactory.decodeStream(inputStream)
|
.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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user