16 changed files with 780 additions and 951 deletions
+2 -2
View File
@@ -89,8 +89,8 @@ android {
applicationId = "com.yzx.kiosk"
minSdk = 26
targetSdk = 36
versionCode = 21
versionName = "1.1.1"
versionCode = 23
versionName = "1.1.3"
//multiDexEnabled = true
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -44,7 +44,6 @@ class LocalAudioPlayService @Inject constructor(
AppRoutes.FACE_RECOGNITION_RESULT to R.raw.face_recognition_success_result_page,
AppRoutes.UPLOAD_PHOTO to R.raw.scan_qrcode_upload_page,
AppRoutes.PHOTO_SELECT to R.raw.upload_unpaid_result_page,
AppRoutes.PAY_SUCCESS to R.raw.pay_success_select_photo_print_page,
AppRoutes.PRINTING to R.raw.printing_page,
AppRoutes.PRINT_SUCCESS to R.raw.print_complete_page,
// 特殊状态页面
@@ -17,7 +17,6 @@ import com.yzx.kiosk.ui.printer.view.PrinterManageScreen
import com.yzx.kiosk.ui.setting.view.SettingScreen
import com.yzx.kiosk.ui.face.view.FaceRecognitionScreen
import com.yzx.kiosk.ui.face.view.FaceRecognitionResultScreen
import com.yzx.kiosk.ui.upload.view.PaySuccessScreen
import com.yzx.kiosk.ui.upload.view.PhotoSelectScreen
import com.yzx.kiosk.ui.upload.view.PrintingScreen
import com.yzx.kiosk.ui.upload.view.UploadPhotoScreen
@@ -157,48 +156,6 @@ fun AppNavHost(
FaceRecognitionResultScreen(results = results)
}
// 支付成功页面
composable(route = "${AppRoutes.PAY_SUCCESS}?urls={urls}&orderNumber={orderNumber}&captureType={captureType}") { backStackEntry ->
val urlsParam = backStackEntry.arguments?.getString("urls") ?: ""
val orderNumberParam = backStackEntry.arguments?.getString("orderNumber") ?: ""
val captureTypeParam = backStackEntry.arguments?.getString("captureType") ?: ""
val photoUrls = if (urlsParam.isNotEmpty()) {
try {
val decoded = URLDecoder.decode(urlsParam, "UTF-8")
// 如果是 JSON 格式(以 [ 开头),作为单个元素传递
if (decoded.startsWith("[")) {
listOf(decoded)
} else {
// 旧格式:逗号分隔的 URL 列表
decoded.split(",")
}
} catch (e: Exception) {
emptyList()
}
} else {
emptyList()
}
val orderNumber = if (orderNumberParam.isNotEmpty()) {
try {
URLDecoder.decode(orderNumberParam, "UTF-8")
} catch (e: Exception) {
""
}
} else {
""
}
val captureType = captureTypeParam.toIntOrNull()
PaySuccessScreen(
photoUrls = photoUrls,
orderNumber = orderNumber,
captureType = captureType
)
}
// 打印中页面
composable(route = "${AppRoutes.PRINTING}?urls={urls}&orderNumber={orderNumber}&captureType={captureType}") { backStackEntry ->
val urlsParam = backStackEntry.arguments?.getString("urls") ?: ""
@@ -7,11 +7,24 @@ object AppRoutes {
const val DEVICE_CONFIG = "device_config"
const val UPLOAD_PHOTO = "upload_photo"
const val PHOTO_SELECT = "photo_select"
const val PAY_SUCCESS = "pay_success"
const val PRINTING = "printing"
const val PRINT_SUCCESS = "print_success"
const val FACE_RECOGNITION = "face_recognition"
const val FACE_RECOGNITION_RESULT = "face_recognition_result"
const val AGREEMENT = "agreement"
fun buildPrintingRoute(
photoData: String,
orderNumber: String?,
captureType: Int?
): String {
val urlsParam = java.net.URLEncoder.encode(photoData, "UTF-8")
val orderNumberParam = orderNumber
?.let { java.net.URLEncoder.encode(it, "UTF-8") }
.orEmpty()
val captureTypeParam = captureType?.toString().orEmpty()
return "$PRINTING?urls=$urlsParam&orderNumber=$orderNumberParam&captureType=$captureTypeParam"
}
}
@@ -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
@@ -23,8 +22,11 @@ import jp.co.dnpLib.print.PrintManager
import jp.co.dnpLib.print.PrintQueue
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
@@ -40,9 +42,15 @@ class PrinterService(
) {
companion object {
private const val TAG = "PrinterService"
// DNP BmpUtil 在 300 DPI 下会将 RX1 输入画布编码为 1844x1240 BMP。
// 1840x1240 仍是上层排版画布尺寸,不应用它校验 SDK 的最终文件头。
private const val RX1_ENCODED_BMP_WIDTH_300_DPI = 1844
private const val RX1_ENCODED_BMP_HEIGHT_300_DPI = 1240
}
private var mDnpPhotoPrint: DNPPhotoPrint? = null
private val printMutex = Mutex()
/**
* 初始化打印机(耗时操作,需在IO线程调用)
@@ -182,12 +190,29 @@ class PrinterService(
* @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, "获取图片成功")
// 在开始打印前,检查打印机是否在线
@@ -224,17 +249,22 @@ class PrinterService(
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 +274,45 @@ class PrinterService(
PRINTSIZE.QW410.height
)
}
if (!saved) {
throw IOException("BMP 保存接口返回失败")
}
val expectedBmpWidth: Int
val expectedBmpHeight: Int
if (printId != PrintManager.EPrinter.QW410_DEF.id) {
expectedBmpWidth = RX1_ENCODED_BMP_WIDTH_300_DPI
expectedBmpHeight = RX1_ENCODED_BMP_HEIGHT_300_DPI
} else {
expectedBmpWidth = printSize.width
expectedBmpHeight = printSize.height
}
val validation = PrintImageIntegrityValidator.validateBmp(
file = outFile,
expectedWidth = expectedBmpWidth,
expectedHeight = expectedBmpHeight
)
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,
@@ -269,8 +332,8 @@ class PrinterService(
printStatusManager.setIdle()
// 回收 outputBitmap
try {
if (!outputBitmap.isRecycled) {
outputBitmap.recycle()
if (!validatedOutputBitmap.isRecycled) {
validatedOutputBitmap.recycle()
LogUtils.d(TAG, "outputBitmap 已回收")
}
} catch (ex: Exception) {
@@ -285,8 +348,8 @@ class PrinterService(
printStatusManager.setIdle()
// 回收 outputBitmap
try {
if (!outputBitmap.isRecycled) {
outputBitmap.recycle()
if (!validatedOutputBitmap.isRecycled) {
validatedOutputBitmap.recycle()
LogUtils.d(TAG, "outputBitmap 已回收")
}
} catch (ex: Exception) {
@@ -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
@@ -65,6 +68,7 @@ fun FaceRecognitionResultScreen(
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
@@ -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,6 +259,9 @@ 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) }
)
}
@@ -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 是有效值
@@ -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
@@ -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()
}
}
@@ -1,13 +1,10 @@
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
import com.yzx.kiosk.navigation.routes.AppRoutes
import com.yzx.kiosk.network.model.request.GetPayUrlRequest
import com.yzx.kiosk.network.model.request.PaySuccessMessageRequest
import com.yzx.kiosk.network.model.request.VerifyResultRequest
@@ -24,11 +21,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 +32,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
@@ -134,6 +128,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
viewModelScope.launch {
// 保存原始结果列表
originalResults = results
urlToIdMap.clear()
// 先使用默认宽高比创建列表
val initialPhotos = results.mapNotNull { result ->
@@ -167,70 +162,35 @@ class FaceRecognitionResultViewModel @Inject constructor(
// 页面初始化时调用验证接口(空列表,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()
fun updatePhotoAspectRatio(photoId: Int, width: Int, height: Int) {
if (width <= 0 || height <= 0) {
LogUtils.i(TAG, "忽略无效图片尺寸 - id: $photoId, size: ${width}x${height}")
return
}
val result = imageLoader.execute(request)
val drawable = result.drawable
val aspectRatio = width.toFloat() / height.toFloat()
if (!aspectRatio.isFinite() || aspectRatio !in 0.05f..20f) {
LogUtils.i(TAG, "忽略异常图片宽高比 - id: $photoId, ratio: $aspectRatio")
return
}
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}")
}
_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 // 超时或失败,返回默认值
}
}
@@ -521,17 +481,26 @@ class FaceRecognitionResultViewModel @Inject constructor(
stopPayStatusPolling()
_payQrCodeUrl.value = null
_dismissPayDialogEvents.tryEmit(Unit)
navigateToPaySuccess(orderNumber, captureType, imageIds)
navigateToPrinting(orderNumber, captureType, imageIds)
}
/**
* 跳转到支付成功页面
* 支付成功后直接跳转到打印页面
* @param orderNumber 订单号
* @param captureType 抓拍类型
* @param imageIds 图片ID数组(从支付成功消息中获取)
*/
private fun navigateToPaySuccess(orderNumber: String?, captureType: Int?, imageIds: List<Int>) {
private fun navigateToPrinting(orderNumber: String?, captureType: Int?, imageIds: List<Int>) {
viewModelScope.launch {
if (orderNumber.isNullOrBlank() || captureType == null) {
LogUtils.e(
TAG,
"支付成功消息订单信息不完整 - orderNumber: $orderNumber, captureType: $captureType"
)
ToastUtils.show("支付成功,但订单信息不完整")
return@launch
}
// 根据 imageIds 从 originalResults 中获取对应的 FaceSearchResult
val selectedResults = originalResults.filter { imageIds.contains(it.id) }
@@ -555,19 +524,13 @@ class FaceRecognitionResultViewModel @Inject constructor(
// 将 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)
.setPopUpTo(AppRoutes.FACE_RECOGNITION_RESULT, inclusive = true)
.build()
// 跳转到支付成功页面
val route = "${com.yzx.kiosk.navigation.routes.AppRoutes.PAY_SUCCESS}?urls=$encodedJson&orderNumber=$orderNumberParam&captureType=$captureTypeParam"
val route = AppRoutes.buildPrintingRoute(jsonArray, orderNumber, captureType)
toPage(route, navOptions)
}
}
@@ -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}")
@@ -342,7 +349,7 @@ class FaceRecognitionViewModel @Inject constructor(
} else {
// 识别成功
_recognitionStatus.value = RecognitionStatus.SUCCESS
ToastUtils.show("找到 ${result.count} 张照片")
LogUtils.i(TAG, "找到 ${result.count} 张照片")
// 播放识别成功音频(在跳转前播放)
localAudioPlayService.playByRoute(com.yzx.kiosk.navigation.routes.AppRoutes.FACE_RECOGNITION_RESULT)
@@ -1,338 +0,0 @@
package com.yzx.kiosk.ui.upload.view
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.foundation.lazy.staggeredgrid.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import com.yzx.kiosk.R
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
import com.yzx.kiosk.component.appbar.AppTitleBarNoStatusBarPadding
import com.yzx.kiosk.component.appbar.FaceBarNoStatusBarPadding
import com.yzx.kiosk.component.scaffold.AppScaffold
import com.yzx.kiosk.ui.common.view.FullScreenMode
import com.yzx.kiosk.ui.upload.viewmodel.PaySuccessViewModel
/** 与心跳动画 target一致;槽位高度 = 72.dp * 该值,避免放大时盖住下方区域 */
private const val PRINT_BTN_HEARTBEAT_MAX_SCALE = 1.09f
private fun isValidRemoteImageUrl(url: String): Boolean {
val t = url.trim()
if (t.isEmpty()) return false
return t.startsWith("http://", ignoreCase = true) ||
t.startsWith("https://", ignoreCase = true)
}
/** 按钮左右缩进,宽度变短;同等缩放比例下横向多出的像素更少,不易贴边/溢出 */
private val PRINT_BTN_HORIZONTAL_INSET = 20.dp
/**
* 心跳 `tween` 的缓动曲线(两头「略缓」强弱可在这里换):
* - [FastOutSlowInEasing]:Material 默认,起止略慢、中间略快
* - LinearEasing:匀速,无额外缓急(需自行 `import …LinearEasing` 并赋值)
* - [CubicBezierEasing]:自定义贝塞尔;(x1,y1) 靠近起点、(x2,y2) 靠近终点,数值越大往往起止越「肉」、需边调边看
*/
private val PRINT_BTN_HEARTBEAT_EASING: Easing = LinearEasing
// private val PRINT_BTN_HEARTBEAT_EASING = CubicBezierEasing(0.45f, 0f, 0.55f, 1f) // 示例:两头再柔和一点
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PaySuccessScreen(
photoUrls: List<String>,
orderNumber: String = "",
captureType: Int? = null,
viewModel: PaySuccessViewModel = hiltViewModel()
) {
val photoList by viewModel.photoList.collectAsState()
val selectedPhotoIds by viewModel.selectedPhotoIds.collectAsState()
val buttonPrintUrl by viewModel.buttonPrintUrl.collectAsState()
val printBtnRemoteUrl = buttonPrintUrl.trim()
val useRemotePrintButton = isValidRemoteImageUrl(printBtnRemoteUrl)
// 初始化照片列表和订单信息
LaunchedEffect(photoUrls, orderNumber, captureType) {
viewModel.initPhotoList(photoUrls, orderNumber, captureType)
}
val selectedCount = selectedPhotoIds.size
val isAllSelected = photoList.isNotEmpty() && selectedPhotoIds.size == photoList.size
FullScreenMode()
AppScaffold(
topBar = {
Column {
AppTitleBarNoStatusBarPadding(
backgroundColor = Color(0xFF4CAF50), // 绿色
title = "支付成功",
isWhite = true,
isShowBackIcon = true,
onBackClick = { viewModel.navigateBack() },
isShowButtonLine = false
)
FaceBarNoStatusBarPadding(
title = "为保护您的隐私与权益,系统不保存人脸照片。素材将在 24 小时后自动清理"
)
}
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.White)
) {
// 打印选中照片按钮:心跳缩放走 graphicsLayer;外层槽高为 72dp×最大缩放,预留上下空间,不压盖下方
val heartbeatTransition = rememberInfiniteTransition(label = "printBtnHeartbeat")
val pulseScale by heartbeatTransition.animateFloat(
initialValue = 1f,
targetValue = PRINT_BTN_HEARTBEAT_MAX_SCALE,
animationSpec = infiniteRepeatable(
animation = tween(600, easing = PRINT_BTN_HEARTBEAT_EASING),
repeatMode = RepeatMode.Reverse
),
label = "pulseScale"
)
val drawScale = if (selectedCount > 0) pulseScale else 1f
Box(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp)
.height(72.dp * PRINT_BTN_HEARTBEAT_MAX_SCALE),
contentAlignment = Alignment.Center
) {
Button(
onClick = { viewModel.onPrintClick() },
modifier = Modifier
.padding(horizontal = PRINT_BTN_HORIZONTAL_INSET)
.fillMaxWidth()
.height(72.dp)
.graphicsLayer {
scaleX = drawScale
scaleY = drawScale
transformOrigin = TransformOrigin.Center
},
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF0073FF)),
enabled = selectedCount > 0
) {
// Row(
// modifier = Modifier.fillMaxWidth(),
// horizontalArrangement = Arrangement.Center,
// verticalAlignment = Alignment.CenterVertically
// ) {
// Text(
// text = "选中照片点击打印",
// fontSize = 24.sp,
// color = Color.White,
// fontWeight = FontWeight.Bold
// )
// Spacer(modifier = Modifier.width(6.dp))
if (useRemotePrintButton) {
AsyncImage(
model = printBtnRemoteUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit,
placeholder = painterResource(id = R.mipmap.btn_pay_success),
error = painterResource(id = R.mipmap.btn_pay_success)
)
} else {
Icon(
painter = painterResource(id = R.mipmap.btn_pay_success),
contentDescription = null,
tint = Color.Unspecified,
modifier = Modifier.fillMaxSize()
)
}
// }
}
}
// 全选行
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier.clickable { viewModel.toggleSelectAll() },
verticalAlignment = Alignment.CenterVertically
) {
// 复选框
Box(
modifier = Modifier
.size(24.dp)
.clip(RoundedCornerShape(6.dp))
.background(if (isAllSelected) Color(0xFF2196F3) else Color.White)
.border(
width = 2.dp,
color = if (isAllSelected) Color(0xFF2196F3) else Color(0xFFCCCCCC),
shape = RoundedCornerShape(6.dp)
),
contentAlignment = Alignment.Center
) {
if (isAllSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
}
Spacer(modifier = Modifier.width(18.dp))
Text(
text = "全选",
fontSize = 24.sp,
color = Color.Black
)
Text(
text = "(共${photoList.size}张)",
fontSize = 24.sp,
color = Color.Black
)
}
// 已选择数量
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "已选择",
fontSize = 24.sp,
color = Color.Black
)
Text(
modifier = Modifier.padding(top = 4.dp),
text = selectedCount.toString(),
fontSize = 28.sp,
fontWeight = FontWeight.Medium,
color = Color(0xFF2196F3)
)
Text(
text = "张",
fontSize = 24.sp,
color = Color.Black
)
}
}
Spacer(modifier = Modifier.height(18.dp))
// 图片瀑布流列表
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(2),
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
horizontalArrangement = Arrangement.spacedBy(18.dp),
verticalItemSpacing = 18.dp
) {
itemsIndexed(photoList) { index, photo ->
val isSelected = selectedPhotoIds.contains(photo.id)
PaySuccessPhotoItem(
photoUrl = photo.url,
aspectRatio = photo.aspectRatio,
isSelected = isSelected,
onToggleSelection = { viewModel.togglePhotoSelection(photo.id) }
)
}
}
}
}
}
@Composable
fun PaySuccessPhotoItem(
photoUrl: String,
aspectRatio: Float,
isSelected: Boolean,
onToggleSelection: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(aspectRatio) // 根据原比例计算高度
.clip(RoundedCornerShape(18.dp))
.clickable { onToggleSelection() }
) {
// 图片 - 按原比例显示
AsyncImage(
model = photoUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit
)
// 选中遮罩
Box(
modifier = Modifier
.fillMaxSize()
.background(
if (isSelected) Color.Black.copy(alpha = 0.3f) else Color.Transparent
)
)
// 右上角复选框
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(18.dp)
.size(24.dp)
.clip(RoundedCornerShape(6.dp))
.background(if (isSelected) Color(0xFF2196F3) else Color.Black.copy(alpha = 0.5f))
.border(
width = 2.dp,
color = if (isSelected) Color(0xFF2196F3) else Color(0xFF666666),
shape = RoundedCornerShape(6.dp)
),
contentAlignment = Alignment.Center
) {
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
}
}
}
@@ -275,16 +275,10 @@ fun PhotoSelectScreen(
// 支付弹框
if (showPayDialog && selectedCount > 0 && payQrCodeUrl != null) {
val selectedUrls = photoList.filter { selectedPhotoIds.contains(it.id) }.map { it.url }
WechatPayDialog(
qrCodeUrl = payQrCodeUrl!!,
totalPrice = if (totalPrice != "--") totalPrice else "0",
onDismiss = { showPayDialog = false },
onPaySuccess = {
showPayDialog = false
// 支付成功,等待 WebSocket 消息(code=5)触发跳转到支付成功页面
// 跳转逻辑在 PhotoSelectViewModel 的 navigateToPaySuccess 方法中处理
}
onDismiss = { showPayDialog = false }
)
}
}
@@ -453,8 +447,7 @@ fun PhotoItem(
fun WechatPayDialog(
qrCodeUrl: String,
totalPrice: String,
onDismiss: () -> Unit,
onPaySuccess: () -> Unit
onDismiss: () -> Unit
) {
// 使用接口返回的URL生成支付二维码
val qrCodeBitmap = remember(qrCodeUrl) {
@@ -1,273 +0,0 @@
package com.yzx.kiosk.ui.upload.viewmodel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavOptions
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
import com.yzx.kiosk.navigation.routes.AppRoutes
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.yzx.kiosk.datastore.AppStoreDataSource
import com.yzx.kiosk.utils.LogUtils
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
@HiltViewModel
class PaySuccessViewModel @Inject constructor(
navigator: AppNavigator,
private val appStoreDataSource: AppStoreDataSource,
appState: AppState
) : BaseViewModel(
navigator = navigator,
appState = appState
) {
// 图片列表(包含宽高比)
private val _photoList = MutableStateFlow<List<PhotoData>>(emptyList())
val photoList: StateFlow<List<PhotoData>> = _photoList.asStateFlow()
// 已选中的图片ID
private val _selectedPhotoIds = MutableStateFlow<Set<Int>>(emptySet())
val selectedPhotoIds: StateFlow<Set<Int>> = _selectedPhotoIds.asStateFlow()
// 订单号
private val _orderNumber = MutableStateFlow<String>("")
val orderNumber: StateFlow<String> = _orderNumber.asStateFlow()
// 抓拍类型
private val _captureType = MutableStateFlow<Int?>(null)
val captureType: StateFlow<Int?> = _captureType.asStateFlow()
/** 设备配置下发的打印按钮图 URL(可能为空) */
private val _buttonPrintUrl = MutableStateFlow("")
val buttonPrintUrl: StateFlow<String> = _buttonPrintUrl.asStateFlow()
init {
_buttonPrintUrl.value = appStoreDataSource.getButtonPrintUrl()
}
/**
* 初始化图片列表,默认全选
* @param photoUrls 图片URL列表(JSON 格式或旧格式)
* @param orderNumber 订单号
* @param captureType 抓拍类型
* urls 格式:
* - JSON 格式(新):FileMapData 数组的 JSON 字符串
* - id:url 格式(兼容):id:url,id:url
* - url 格式(兼容):url,url
*/
fun initPhotoList(photoUrls: List<String>, orderNumber: String = "", captureType: Int? = null) {
// 保存订单信息
_orderNumber.value = orderNumber
_captureType.value = captureType
LogUtils.d("PaySuccessViewModel", "初始化 - orderNumber: $orderNumber, captureType: $captureType")
if (photoUrls.isEmpty()) {
_photoList.value = emptyList()
_selectedPhotoIds.value = emptySet()
return
}
viewModelScope.launch {
var initialPhotos: List<PhotoData>
// 尝试解析为 JSON 格式(新格式:file_map 数组的 JSON 字符串)
if (photoUrls.size == 1 && photoUrls[0].startsWith("[")) {
// JSON 数组格式:将 file_map 列表序列化后的 JSON 数组字符串反序列化为 FileMapData 对象数组
try {
val jsonString = java.net.URLDecoder.decode(photoUrls[0], "UTF-8")
LogUtils.d("PaySuccessViewModel", "接收到 JSON 数组字符串: $jsonString")
// 使用 Gson 将 JSON 数组反序列化为 FileMapData 对象数组
val type = object : TypeToken<List<FileMapData>>() {}.type
val fileMapList: List<FileMapData> = Gson().fromJson(jsonString, type) ?: emptyList()
LogUtils.d("PaySuccessViewModel", "反序列化成功,FileMapData 对象数量: ${fileMapList.size}")
// 将 FileMapData 对象数组转换为 PhotoData 列表
initialPhotos = fileMapList.mapNotNull { fileMap ->
val thumbnailUrl = if (appStoreDataSource.getUseLan()) fileMap.thumbnailLanUrl else fileMap.thumbnailOssUrl
if (thumbnailUrl.isNullOrEmpty()) {
LogUtils.i("PaySuccessViewModel", "FileMapData id=${fileMap.id} 的 thumbnailOssUrl 为空,跳过")
null
} else {
PhotoData(
id = fileMap.id,
url = thumbnailUrl.trim(),
aspectRatio = 1f,
fileMapData = fileMap // 保存完整的 FileMapData 对象,供后续使用
)
}
}
LogUtils.d("PaySuccessViewModel", "成功创建 PhotoData 列表,数量: ${initialPhotos.size}")
} catch (e: Exception) {
LogUtils.e("PaySuccessViewModel", "解析 JSON 格式失败: ${e.message}")
e.printStackTrace()
initialPhotos = emptyList()
}
} else {
// 兼容旧格式:id:url 或 url
initialPhotos = photoUrls.mapNotNull { urlStr ->
val trimmed = urlStr.trim()
if (trimmed.contains(":")) {
// id:url 格式
val parts = trimmed.split(":", limit = 2)
if (parts.size == 2) {
val id = parts[0].toIntOrNull()
val url = parts[1]
if (id != null && url.isNotEmpty()) {
PhotoData(
id = id,
url = url,
aspectRatio = 1f
)
} else null
} else null
} else {
// 只有 url,使用 hashCode 作为临时 id(不推荐,但兼容)
PhotoData(
id = trimmed.hashCode(),
url = trimmed,
aspectRatio = 1f
)
}
}
}
if (initialPhotos.isEmpty()) {
_photoList.value = emptyList()
_selectedPhotoIds.value = emptySet()
return@launch
}
_photoList.value = initialPhotos
// 默认全选(使用 id)
_selectedPhotoIds.value = initialPhotos.map { it.id }.toSet()
// 并发获取所有图片的真实尺寸并更新宽高比
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 {
1f
}
photo.copy(aspectRatio = validAspectRatio)
} catch (e: Exception) {
LogUtils.e("PaySuccessViewModel", "获取图片 ${photo.url} 尺寸失败: ${e.message}")
// 获取失败,保持默认值
photo
}
}
}.awaitAll()
_photoList.value = updatedPhotos
}
}
/**
* 获取图片的宽高比
*/
private suspend fun getImageAspectRatio(imageUrl: String): Float = withContext(Dispatchers.IO) {
try {
val imageLoader = ImageLoader(App.instance)
val request = ImageRequest.Builder(App.instance)
.data(imageUrl)
.size(Size.ORIGINAL)
.build()
val drawable = imageLoader.execute(request).drawable
if (drawable != null) {
val width = drawable.intrinsicWidth
val height = drawable.intrinsicHeight
if (width > 0 && height > 0) {
return@withContext width.toFloat() / height.toFloat()
}
}
} catch (e: Exception) {
// 获取失败,使用默认值
}
return@withContext 1f // 默认1:1
}
/**
* 切换单张图片选中状态
*/
fun togglePhotoSelection(id: Int) {
val current = _selectedPhotoIds.value.toMutableSet()
if (current.contains(id)) {
current.remove(id)
} else {
current.add(id)
}
_selectedPhotoIds.value = current
}
/**
* 切换全选状态
*/
fun toggleSelectAll() {
val allIds = _photoList.value.map { it.id }.toSet()
val newSelected = if (_selectedPhotoIds.value.size == allIds.size) {
// 当前全选,取消全选
emptySet()
} else {
// 全选
allIds
}
_selectedPhotoIds.value = newSelected
}
/**
* 点击打印
*/
fun onPrintClick() {
val selectedIds = _selectedPhotoIds.value.toList()
if (selectedIds.isEmpty()) return
// 获取选中的图片的完整 FileMapData 列表
val selectedFileMapData = _photoList.value
.filter { selectedIds.contains(it.id) }
.mapNotNull { it.fileMapData }
if (selectedFileMapData.isEmpty()) {
LogUtils.e("PaySuccessViewModel", "选中的图片没有 FileMapData,无法打印")
return
}
// 将 FileMapData 数组序列化为 JSON 数组字符串
val gson = Gson()
val jsonArray = gson.toJson(selectedFileMapData)
val encodedJson = java.net.URLEncoder.encode(jsonArray, "UTF-8")
// 构建路由参数
val orderNumberParam = _orderNumber.value.let {
if (it.isNotEmpty()) java.net.URLEncoder.encode(it, "UTF-8") else ""
}
val captureTypeParam = _captureType.value?.toString() ?: ""
// 创建 NavOptions,清除回退栈(清除到支付成功页面,包括它自己)
val navOptions = NavOptions.Builder()
.setPopUpTo(AppRoutes.PAY_SUCCESS, inclusive = true)
.build()
// 跳转到打印页面
val route = "${AppRoutes.PRINTING}?urls=$encodedJson&orderNumber=$orderNumberParam&captureType=$captureTypeParam"
toPage(route, navOptions)
}
}
@@ -8,6 +8,7 @@ import com.yzx.kiosk.App
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.model.request.GetPayUrlRequest
import com.yzx.kiosk.network.model.request.VerifyResultRequest
import com.yzx.kiosk.network.repository.NetWorkRepository
@@ -32,6 +33,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import com.yzx.kiosk.utils.LogUtils
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
/**
@@ -80,6 +82,8 @@ class PhotoSelectViewModel @Inject constructor(
navigator = navigator,
appState = appState
) {
private val paymentHandled = AtomicBoolean(false)
// 图片列表
private val _photoList = MutableStateFlow<List<PhotoData>>(emptyList())
val photoList: StateFlow<List<PhotoData>> = _photoList.asStateFlow()
@@ -126,9 +130,9 @@ class PhotoSelectViewModel @Inject constructor(
}
}
is UploadPhotoEvent.PaySuccess -> {
// 支付成功,跳转到支付成功页面
// 支付成功,直接进入打印页面
LogUtils.d("PhotoSelectViewModel", "收到支付成功事件 - order_number: ${event.orderNumber}, capture_type: ${event.captureType}, fileMap: ${event.fileMap}")
navigateToPaySuccess(event.orderNumber, event.captureType, event.fileMap)
navigateToPrinting(event.orderNumber, event.captureType, event.fileMap)
}
else -> {
// 其他事件不处理
@@ -472,6 +476,7 @@ class PhotoSelectViewModel @Inject constructor(
if (url.isNullOrEmpty()) {
ToastUtils.show("获取支付二维码失败")
} else {
paymentHandled.set(false)
_payQrCodeUrl.value = url
onSuccess(url)
}
@@ -501,13 +506,26 @@ class PhotoSelectViewModel @Inject constructor(
}
/**
* 跳转到支付成功页面
* 支付成功后直接跳转到打印页面
* @param orderNumber 订单号
* @param captureType 抓拍类型
* @param imageIds 图片ID数组(从支付成功消息中获取)
* @param fileMap 支付成功消息中确认的图片数据
*/
private fun navigateToPaySuccess(orderNumber: String?, captureType: Int?, fileMap: String) {
private fun navigateToPrinting(orderNumber: String?, captureType: Int?, fileMap: String) {
viewModelScope.launch {
if (captureType != 1) {
LogUtils.i(
"PhotoSelectViewModel",
"忽略非手机上传流程的支付成功消息 - captureType: $captureType"
)
return@launch
}
if (orderNumber.isNullOrBlank()) {
LogUtils.e("PhotoSelectViewModel", "支付成功消息缺少订单号")
ToastUtils.show("支付成功,但订单信息不完整")
return@launch
}
if (fileMap.isBlank()) {
LogUtils.e("PhotoSelectViewModel", "未找到对应的 FileMapData,fileMap: $fileMap")
@@ -515,18 +533,33 @@ class PhotoSelectViewModel @Inject constructor(
return@launch
}
val paidFileMapList = try {
val type = object : TypeToken<List<FileMapData>>() {}.type
Gson().fromJson<List<FileMapData>>(fileMap, type).orEmpty()
} catch (e: Exception) {
LogUtils.e("PhotoSelectViewModel", "解析支付成功图片数据失败: ${e.message}")
emptyList()
}
if (paidFileMapList.isEmpty()) {
ToastUtils.show("支付成功,但未找到对应的图片数据")
return@launch
}
// 构建路由参数
val orderNumberParam = orderNumber?.let { java.net.URLEncoder.encode(it, "UTF-8") } ?: ""
val captureTypeParam = captureType?.toString() ?: ""
if (!paymentHandled.compareAndSet(false, true)) {
LogUtils.d("PhotoSelectViewModel", "支付成功已处理,忽略重复消息 - orderNumber: $orderNumber")
return@launch
}
// 创建 NavOptions,清除选择页面
val navOptions = androidx.navigation.NavOptions.Builder()
.setPopUpTo(com.yzx.kiosk.navigation.routes.AppRoutes.PHOTO_SELECT, inclusive = true)
.setPopUpTo(AppRoutes.PHOTO_SELECT, inclusive = true)
.build()
// 跳转到支付成功页面
val route = "${com.yzx.kiosk.navigation.routes.AppRoutes.PAY_SUCCESS}?urls=$fileMap&orderNumber=$orderNumberParam&captureType=$captureTypeParam"
val route = AppRoutes.buildPrintingRoute(
photoData = Gson().toJson(paidFileMapList),
orderNumber = orderNumber,
captureType = captureType
)
toPage(route, navOptions)
}
}
@@ -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,6 +60,7 @@ 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
}
/**
@@ -502,17 +508,64 @@ class PrintingViewModel @Inject constructor(
* 下载网络图片
*/
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
}
/**
@@ -0,0 +1,32 @@
package com.yzx.kiosk.navigation.routes
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
import java.net.URLDecoder
class AppRoutesTest {
@Test
fun `printing route safely encodes photo data and order number`() {
val photoData =
"""[{"id":1,"original_oss_url":"https://example.com/photo.jpg?token=a&size=4x6"}]"""
val orderNumber = "ORDER 1&2"
val route = AppRoutes.buildPrintingRoute(photoData, orderNumber, 1)
val urlsParam = route.substringAfter("urls=").substringBefore("&orderNumber=")
val orderNumberParam = route.substringAfter("&orderNumber=").substringBefore("&captureType=")
assertEquals(photoData, URLDecoder.decode(urlsParam, "UTF-8"))
assertEquals(orderNumber, URLDecoder.decode(orderNumberParam, "UTF-8"))
assertEquals("1", route.substringAfter("&captureType="))
assertFalse(route.contains("token=a&size=4x6"))
}
@Test
fun `printing route leaves missing order metadata empty`() {
val route = AppRoutes.buildPrintingRoute("[]", null, null)
assertEquals("printing?urls=%5B%5D&orderNumber=&captureType=", route)
}
}
+3 -5
View File
@@ -92,7 +92,7 @@ sequenceDiagram
K->>API: get-pay-url 获取支付二维码
User->>API: 手机支付
WS-->>K: code=5 支付成功及订单信息
K-->>User: 支付成功,确认要打印的照片
K-->>User: 支付成功,直接进入打印页
K->>P: 逐张下载、处理并打印
K->>API: print-notify 上报每张结果
K->>API: print-complete 上报订单完成
@@ -104,7 +104,6 @@ sequenceDiagram
首页
-> 上传照片二维码页 UploadPhotoScreen
-> 照片选择页 PhotoSelectScreen
-> 支付成功页 PaySuccessScreen
-> 打印页 PrintingScreen
-> 自动返回首页
```
@@ -112,8 +111,7 @@ sequenceDiagram
核心类:
- [`UploadPhotoViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/UploadPhotoViewModel.kt):加载上传二维码,监听扫码和文件列表事件
- [`PhotoSelectViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PhotoSelectViewModel.kt):选片、计价、获取支付二维码
- [`PaySuccessViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PaySuccessViewModel.kt):支付后再次确认打印照片
- [`PhotoSelectViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PhotoSelectViewModel.kt):选片、计价、获取支付二维码,并在支付成功后直接进入打印页
- [`PrintingViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PrintingViewModel.kt):逐张下载、打印、记录并上报结果
服务端通过 `file_map` 同时下发 OSS 和局域网地址。客户端根据设置中的“使用 LAN”开关选择 `*_lan_url` 或 `*_oss_url`。
@@ -130,7 +128,7 @@ sequenceDiagram
4. 服务根据人脸相似度返回当天匹配的照片。
5. 终端展示缩略图,游客选择照片。
6. 客户端请求后端计算价格并生成支付二维码。
7. WebSocket 收到支付成功事件后进入支付成功页和打印页。
7. WebSocket 或 HTTP 轮询确认支付成功后直接进入打印页。
主要代码: