4 Commits
20 changed files with 1795 additions and 305 deletions
+1
View File
@@ -130,6 +130,7 @@ APK 文件名会自动包含应用名、版本号、版本代码、构建类型
| 构建类型 | 后端环境 | 调试 | 代码压缩 |
| --- | --- | --- | --- |
| `debug` | 测试环境 `api-test.zhifly.cn` | 开启 | 关闭 |
| `releaseDebug` | 正式环境 `api.zhifly.cn` | 开启 | 关闭 |
| `release` | 正式环境 `api.zhifly.cn` | 关闭 | 关闭 |
请勿使用 Release 包连接测试设备随意操作,Release 会访问正式接口并连接正式 WebSocket。
+9 -2
View File
@@ -89,8 +89,8 @@ android {
applicationId = "com.yzx.kiosk"
minSdk = 26
targetSdk = 36
versionCode = 21
versionName = "1.1.1"
versionCode = 22
versionName = "1.1.2"
//multiDexEnabled = true
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -161,6 +161,13 @@ android {
"\"https://vipsky.oss-cn-shanghai.aliyuncs.com/cloud_driver\""
)
}
create("releaseDebug") {
initWith(getByName("release"))
isDebuggable = true
signingConfig = signingConfigs.getByName("debug")
matchingFallbacks += listOf("release", "debug")
}
}
compileOptions {
@@ -3,6 +3,7 @@ package com.yzx.kiosk.network.di
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.yzx.kiosk.BuildConfig
import com.yzx.kiosk.network.interceptor.DebugNetworkLoggingInterceptor
import com.yzx.kiosk.network.interceptor.RequestInterceptor
import com.yzx.kiosk.network.interceptor.ResponseInterceptor
import dagger.Module
@@ -10,7 +11,6 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
@@ -41,18 +41,11 @@ object NetworkModule {
@Singleton
fun provideGson(): Gson = GsonBuilder().create()
@Provides
@Singleton
fun provideLoggingInterceptor(): HttpLoggingInterceptor = HttpLoggingInterceptor().apply {
level =
if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY else HttpLoggingInterceptor.Level.NONE
}
private fun buildOkHttpClient(
timeout: Long,
requestInterceptor: RequestInterceptor,
responseInterceptor: ResponseInterceptor,
loggingInterceptor: HttpLoggingInterceptor?
clientName: String,
): OkHttpClient = OkHttpClient.Builder()
.connectTimeout(timeout, TimeUnit.SECONDS)
.writeTimeout(timeout, TimeUnit.SECONDS)
@@ -61,7 +54,9 @@ object NetworkModule {
.addInterceptor(requestInterceptor)
.addInterceptor(responseInterceptor)
.apply {
if (BuildConfig.DEBUG) loggingInterceptor?.let { addInterceptor(it) }
if (BuildConfig.DEBUG) {
addInterceptor(DebugNetworkLoggingInterceptor(clientName))
}
}.build()
@Provides
@@ -70,12 +65,11 @@ object NetworkModule {
fun provideDefaultOkHttpClient(
requestInterceptor: RequestInterceptor,
responseInterceptor: ResponseInterceptor,
loggingInterceptor: HttpLoggingInterceptor,
): OkHttpClient = buildOkHttpClient(
TIMEOUT_SHORT,
requestInterceptor,
responseInterceptor,
loggingInterceptor,
"api",
)
@Provides
@@ -84,7 +78,12 @@ object NetworkModule {
fun provideUploadOkHttpClient(
requestInterceptor: RequestInterceptor,
responseInterceptor: ResponseInterceptor,
): OkHttpClient = buildOkHttpClient(TIMEOUT_LONG, requestInterceptor, responseInterceptor, null)
): OkHttpClient = buildOkHttpClient(
TIMEOUT_LONG,
requestInterceptor,
responseInterceptor,
"upload",
)
@Provides
@Singleton
@@ -98,6 +97,11 @@ object NetworkModule {
.callTimeout(TIMEOUT_LONG, TimeUnit.SECONDS)
.addInterceptor(requestInterceptor)
// 注意:不添加 ResponseInterceptor,避免将大文件加载到内存
.apply {
if (BuildConfig.DEBUG) {
addInterceptor(DebugNetworkLoggingInterceptor("download"))
}
}
.build()
private fun buildRetrofit(
@@ -0,0 +1,202 @@
package com.yzx.kiosk.network.interceptor
import android.util.Log
import com.yzx.kiosk.BuildConfig
import okhttp3.Headers
import okhttp3.Interceptor
import okhttp3.MediaType
import okhttp3.Request
import okhttp3.Response
import okio.Buffer
import java.nio.charset.StandardCharsets
import java.util.Locale
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
/**
* Debug-only HTTP logger used by every application-owned OkHttpClient.
*
* Text bodies are capped to avoid flooding Logcat or buffering large payloads. Binary,
* multipart, one-shot and duplex bodies are represented by metadata only. Response bodies are
* inspected with [Response.peekBody], so logging never consumes the body used by business code.
*/
class DebugNetworkLoggingInterceptor(
private val clientName: String = "api",
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
if (!BuildConfig.DEBUG) return chain.proceed(chain.request())
val request = chain.request()
val requestId = REQUEST_SEQUENCE.incrementAndGet()
val startedAt = System.nanoTime()
log("┌─ #$requestId [$clientName] --> ${request.method} ${redactedUrl(request)}")
logHeaders(requestId, "request", request.headers)
logRequestBody(requestId, request)
val response = try {
chain.proceed(request)
} catch (error: Throwable) {
val durationMs = elapsedMillis(startedAt)
log("└─ #$requestId [$clientName] <-- HTTP FAILED (${durationMs}ms): ${error.javaClass.simpleName}: ${error.message}")
throw error
}
val durationMs = elapsedMillis(startedAt)
log("├─ #$requestId [$clientName] <-- ${response.code} ${response.message} (${durationMs}ms) ${redactedUrl(request)}")
logHeaders(requestId, "response", response.headers)
logResponseBody(requestId, response)
log("└─ #$requestId [$clientName] END HTTP")
return response
}
private fun logRequestBody(requestId: Long, request: Request) {
val body = request.body ?: run {
log("#$requestId request body: <none>")
return
}
val contentType = body.contentType()
val contentLength = runCatching { body.contentLength() }.getOrDefault(-1L)
val description = describeBody(contentType, contentLength)
if (body.isDuplex() || body.isOneShot()) {
log("#$requestId request body: $description <one-shot/duplex body omitted>")
return
}
if (!isText(contentType) || isEncoded(request.headers) || contentLength > MAX_BODY_BYTES) {
log("#$requestId request body: $description <binary/encoded/large body omitted>")
return
}
val bodyText = runCatching {
Buffer().use { buffer ->
body.writeTo(buffer)
if (buffer.size > MAX_BODY_BYTES) {
"$description <body exceeded ${MAX_BODY_BYTES / 1024}KB while buffering; omitted>"
} else {
val charset = contentType?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
"$description\n${buffer.readString(charset)}"
}
}
}.getOrElse { "$description <unable to read: ${it.message}>" }
log("#$requestId request body: $bodyText")
}
private fun logResponseBody(requestId: Long, response: Response) {
val body = response.body ?: run {
log("#$requestId response body: <none>")
return
}
val contentType = body.contentType()
val contentLength = body.contentLength()
val description = describeBody(contentType, contentLength)
if (!isText(contentType) || isEncoded(response.headers)) {
log("#$requestId response body: $description <binary/encoded body omitted>")
return
}
val bodyText = runCatching {
val preview = response.peekBody(MAX_BODY_BYTES)
val text = preview.string()
val truncated = contentLength > MAX_BODY_BYTES ||
(contentLength == -1L && preview.contentLength() >= MAX_BODY_BYTES)
buildString {
append(description).append('\n').append(text)
if (truncated) append("\n<response truncated at ${MAX_BODY_BYTES / 1024}KB>")
}
}.getOrElse { "$description <unable to preview: ${it.message}>" }
log("#$requestId response body: $bodyText")
}
private fun logHeaders(requestId: Long, direction: String, headers: Headers) {
if (headers.size == 0) {
log("#$requestId $direction headers: <none>")
return
}
val text = buildString {
append("#$requestId $direction headers:")
for (index in 0 until headers.size) {
val name = headers.name(index)
val value = if (isSensitiveHeader(name)) REDACTED else headers.value(index)
append('\n').append(name).append(": ").append(value)
}
}
log(text)
}
private fun redactedUrl(request: Request): String {
val url = request.url
val builder = url.newBuilder()
url.queryParameterNames.forEach { name ->
if (isSensitiveName(name)) builder.setQueryParameter(name, REDACTED)
}
return builder.build().toString()
}
private fun isEncoded(headers: Headers): Boolean {
val encoding = headers["Content-Encoding"] ?: return false
return !encoding.equals("identity", ignoreCase = true)
}
private fun isText(contentType: MediaType?): Boolean {
if (contentType == null) return false
val type = contentType.type.lowercase(Locale.US)
val subtype = contentType.subtype.lowercase(Locale.US)
if (type == "text") return true
return subtype.contains("json") ||
subtype.contains("xml") ||
subtype.contains("html") ||
subtype.contains("x-www-form-urlencoded") ||
subtype.contains("javascript")
}
private fun describeBody(contentType: MediaType?, contentLength: Long): String {
val length = if (contentLength >= 0) "$contentLength bytes" else "unknown length"
return "${contentType ?: "unknown content-type"}, $length"
}
private fun isSensitiveHeader(name: String): Boolean = SENSITIVE_NAMES.contains(name.lowercase(Locale.US))
private fun isSensitiveName(name: String): Boolean = SENSITIVE_NAMES.contains(name.lowercase(Locale.US))
private fun log(message: String) {
if (!BuildConfig.DEBUG) return
if (message.isEmpty()) {
Log.d(TAG, "")
return
}
var start = 0
while (start < message.length) {
val end = minOf(start + LOGCAT_CHUNK_SIZE, message.length)
Log.d(TAG, message.substring(start, end))
start = end
}
}
private fun elapsedMillis(startedAt: Long): Long =
TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt)
companion object {
const val TAG = "KIOSK-NET"
private const val MAX_BODY_BYTES = 64L * 1024L
private const val LOGCAT_CHUNK_SIZE = 3_500
private const val REDACTED = "██"
private val REQUEST_SEQUENCE = AtomicLong(0)
private val SENSITIVE_NAMES = setOf(
"authorization",
"token",
"access_token",
"api_key",
"apikey",
"cookie",
"set-cookie",
"sn",
"device_sn",
"password",
"secret",
)
}
}
@@ -0,0 +1,8 @@
package com.yzx.kiosk.network.model.request
import com.google.gson.annotations.SerializedName
data class PaySuccessMessageRequest(
@SerializedName("order_number")
val orderNumber: String
)
@@ -4,6 +4,9 @@ import com.google.gson.annotations.SerializedName
data class GetPayUrlResponse(
@SerializedName("url")
val url: String?
val url: String?,
@SerializedName("order_number")
val orderNumber: String?
)
@@ -0,0 +1,31 @@
package com.yzx.kiosk.network.model.response
import com.google.gson.annotations.SerializedName
data class PaySuccessMessageResponse(
@SerializedName("order_status")
val orderStatus: Int?,
@SerializedName("order_status_name")
val orderStatusName: String?,
@SerializedName("sn")
val sn: String?,
@SerializedName("type")
val type: Int?,
@SerializedName("data")
val data: PaySuccessMessageData?
)
data class PaySuccessMessageData(
@SerializedName("order_number")
val orderNumber: String?,
@SerializedName("capture_type")
val captureType: Int?,
@SerializedName("image_id")
val imageIds: List<Int>?
)
@@ -1,6 +1,8 @@
package com.yzx.kiosk.network.repository
import com.google.gson.Gson
import com.yzx.kiosk.network.model.request.GetPayUrlRequest
import com.yzx.kiosk.network.model.request.PaySuccessMessageRequest
import com.yzx.kiosk.network.model.request.PrintCompleteRequest
import com.yzx.kiosk.network.model.request.PrintNotifyRequest
import com.yzx.kiosk.network.model.request.SaveAlbumUrlRequest
@@ -13,6 +15,7 @@ import com.yzx.kiosk.network.model.response.GetPayUrlResponse
import com.yzx.kiosk.network.model.response.GetPrintInfoResponse
import com.yzx.kiosk.network.model.response.NetworkResponse
import com.yzx.kiosk.network.model.response.OscarLivesData
import com.yzx.kiosk.network.model.response.PaySuccessMessageResponse
import com.yzx.kiosk.network.model.response.SocketTokenResponse
import com.yzx.kiosk.network.model.response.VerifyResultResponse
import com.yzx.kiosk.network.model.response.VersionResponse
@@ -27,6 +30,7 @@ import javax.inject.Inject
class NetWorkRepository @Inject constructor(
private val netService: NetworkService,
private val uploadService: UploadService,
private val gson: Gson,
) {
// 基础方法示例,可根据需要添加
// fun example(): Flow<NetworkResponse<Any>> = flow {
@@ -82,6 +86,23 @@ class NetWorkRepository @Inject constructor(
emit(netService.getPayUrl(request))
}.flowOn(Dispatchers.IO)
fun getPaySuccessMessage(
request: PaySuccessMessageRequest
): Flow<NetworkResponse<PaySuccessMessageResponse>> = flow {
val response = netService.getPaySuccessMessage(request)
val parsedData = response.data
?.takeIf { it.isJsonObject }
?.let { gson.fromJson(it, PaySuccessMessageResponse::class.java) }
emit(
NetworkResponse(
data = parsedData,
code = response.code,
message = response.message
)
)
}.flowOn(Dispatchers.IO)
fun getSocketToken(): Flow<NetworkResponse<SocketTokenResponse>> = flow {
emit(netService.getSocketToken())
}.flowOn(Dispatchers.IO)
@@ -1,6 +1,7 @@
package com.yzx.kiosk.network.service
import com.yzx.kiosk.network.model.request.GetPayUrlRequest
import com.yzx.kiosk.network.model.request.PaySuccessMessageRequest
import com.yzx.kiosk.network.model.request.PrintCompleteRequest
import com.yzx.kiosk.network.model.request.PrintNotifyRequest
import com.yzx.kiosk.network.model.request.SaveAlbumUrlRequest
@@ -16,6 +17,7 @@ import com.yzx.kiosk.network.model.response.OscarLivesData
import com.yzx.kiosk.network.model.response.SocketTokenResponse
import com.yzx.kiosk.network.model.response.VerifyResultResponse
import com.yzx.kiosk.network.model.response.VersionResponse
import com.google.gson.JsonElement
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
@@ -69,6 +71,11 @@ interface NetworkService {
@POST("/api/oscar/order/get-pay-url")
suspend fun getPayUrl(@Body request: GetPayUrlRequest): NetworkResponse<GetPayUrlResponse>
@POST("/api/oscar/order/pay-success-message")
suspend fun getPaySuccessMessage(
@Body request: PaySuccessMessageRequest
): NetworkResponse<JsonElement>
@GET("/api/oscar/socket-token")
suspend fun getSocketToken(): NetworkResponse<SocketTokenResponse>
@@ -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
@@ -43,6 +45,7 @@ class PrinterService(
}
private var mDnpPhotoPrint: DNPPhotoPrint? = null
private val printMutex = Mutex()
/**
* 初始化打印机(耗时操作,需在IO线程调用)
@@ -182,12 +185,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 +244,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 +269,35 @@ class PrinterService(
PRINTSIZE.QW410.height
)
}
if (!saved) {
throw IOException("BMP 保存接口返回失败")
}
val validation = PrintImageIntegrityValidator.validateBmp(
file = outFile,
expectedWidth = printSize.width,
expectedHeight = printSize.height
)
LogUtils.d(
TAG,
"打印 BMP 校验成功 - path=${outFile.absolutePath}, " +
"size=${validation.width}x${validation.height}, " +
"bpp=${validation.bitsPerPixel}, bytes=${validation.fileSize}, " +
"sha256=${validation.sha256.take(16)}"
)
} catch (e: Exception) {
e.printStackTrace()
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 +317,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 +333,8 @@ class PrinterService(
printStatusManager.setIdle()
// 回收 outputBitmap
try {
if (!outputBitmap.isRecycled) {
outputBitmap.recycle()
if (!validatedOutputBitmap.isRecycled) {
validatedOutputBitmap.recycle()
LogUtils.d(TAG, "outputBitmap 已回收")
}
} catch (ex: Exception) {
@@ -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
@@ -46,7 +49,7 @@ import com.yzx.kiosk.ui.face.viewmodel.FaceRecognitionResultViewModel
import com.yzx.kiosk.ui.upload.view.PhotoPreviewDialog
import com.yzx.kiosk.ui.upload.view.RetryableAsyncImage
import com.yzx.kiosk.utils.QrCodeUtils
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.collect
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
@@ -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
@@ -77,6 +81,12 @@ fun FaceRecognitionResultScreen(
var showPayDialog by remember { mutableStateOf(false) }
val payQrCodeUrl by viewModel.payQrCodeUrl.collectAsState()
LaunchedEffect(viewModel) {
viewModel.dismissPayDialogEvents.collect {
showPayDialog = false
}
}
FullScreenMode()
AppScaffold(
@@ -224,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,
@@ -246,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) }
)
}
@@ -265,15 +281,19 @@ fun FaceRecognitionResultScreen(
// 支付弹框
if (showPayDialog && selectedCount > 0 && payQrCodeUrl != null) {
val selectedUrls = photoList.filter { selectedPhotos.contains(it.url) }.map { it.url }
DisposableEffect(payQrCodeUrl) {
viewModel.startPayStatusPolling()
onDispose {
viewModel.stopPayStatusPolling()
}
}
WechatPayDialog(
qrCodeUrl = payQrCodeUrl!!,
totalPrice = totalPrice,
onDismiss = { showPayDialog = false },
onPaySuccess = {
onDismiss = {
showPayDialog = false
// 支付成功,等待 WebSocket 消息(code=5)触发跳转到支付成功页面
// 跳转逻辑由 FaceRecognitionResultViewModel 中的 WebSocket 监听处理
viewModel.onPayDialogDismissed()
}
)
}
@@ -281,6 +301,7 @@ fun FaceRecognitionResultScreen(
@Composable
fun FaceRecognitionPhotoItem(
photoId: Int,
photoUrl: String,
aspectRatio: Float,
isSelected: Boolean,
@@ -289,6 +310,7 @@ fun FaceRecognitionPhotoItem(
onPreviewClick: () -> Unit,
onLoadError: () -> Unit = {},
onLoadSuccess: () -> Unit = {},
onImageDimensionsResolved: (width: Int, height: Int) -> Unit = { _, _ -> },
onRetryClick: () -> Unit = {}
) {
// 确保 aspectRatio 是有效值
@@ -306,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
@@ -390,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()
}
}
@@ -453,8 +489,7 @@ fun FaceRecognitionPhotoItem(
fun WechatPayDialog(
qrCodeUrl: String,
totalPrice: String,
onDismiss: () -> Unit,
onPaySuccess: () -> Unit
onDismiss: () -> Unit
) {
// 使用接口返回的URL生成支付二维码
val qrCodeBitmap = remember(qrCodeUrl) {
@@ -0,0 +1,26 @@
package com.yzx.kiosk.ui.face.viewmodel
import com.yzx.kiosk.network.model.response.PaySuccessMessageResponse
internal enum class FacePayStatusDecision {
CONTINUE_POLLING,
COMPLETE_PAYMENT,
STOP_WITH_TERMINAL_STATUS
}
internal fun decideFacePayStatus(orderStatus: Int?): FacePayStatusDecision = when (orderStatus) {
30 -> FacePayStatusDecision.COMPLETE_PAYMENT
40, 50, 60 -> FacePayStatusDecision.STOP_WITH_TERMINAL_STATUS
else -> FacePayStatusDecision.CONTINUE_POLLING
}
internal fun isValidFacePaySuccessMessage(
expectedOrderNumber: String,
message: PaySuccessMessageResponse?
): Boolean {
val paymentData = message?.data ?: return false
return message.type == 5 &&
paymentData.orderNumber == expectedOrderNumber &&
paymentData.captureType == 2 &&
!paymentData.imageIds.isNullOrEmpty()
}
@@ -1,16 +1,11 @@
package com.yzx.kiosk.ui.face.viewmodel
import android.graphics.ImageDecoder
import android.os.Build
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.network.model.request.GetPayUrlRequest
import com.yzx.kiosk.network.model.request.PaySuccessMessageRequest
import com.yzx.kiosk.network.model.request.VerifyResultRequest
import com.yzx.kiosk.network.model.response.FaceSearchResult
import com.yzx.kiosk.network.repository.NetWorkRepository
@@ -25,15 +20,21 @@ 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.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
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
@HiltViewModel
@@ -73,12 +74,22 @@ class FaceRecognitionResultViewModel @Inject constructor(
private val _payQrCodeUrl = MutableStateFlow<String?>(null)
val payQrCodeUrl: StateFlow<String?> = _payQrCodeUrl.asStateFlow()
// 支付弹框关闭事件(支付完成、取消或退款时由 ViewModel 驱动关闭)
private val _dismissPayDialogEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val dismissPayDialogEvents: SharedFlow<Unit> = _dismissPayDialogEvents.asSharedFlow()
private var activePaymentOrderNumber: String? = null
private var paymentPollingJob: Job? = null
private val paymentHandled = AtomicBoolean(false)
// 图片加载状态:URL -> (重试次数, 是否加载失败)
private val _imageLoadStates = MutableStateFlow<Map<String, ImageLoadState>>(emptyMap())
val imageLoadStates: StateFlow<Map<String, ImageLoadState>> = _imageLoadStates.asStateFlow()
companion object {
private const val TAG = "FaceRecognitionResultViewModel"
private const val MAX_RETRY_COUNT = 3
private const val PAY_STATUS_POLL_INTERVAL_MS = 2_000L
}
data class ImageLoadState(
@@ -93,9 +104,13 @@ class FaceRecognitionResultViewModel @Inject constructor(
.onEach { event ->
when (event) {
is UploadPhotoEvent.PaySuccess -> {
// 支付成功,跳转到支付成功页面
LogUtils.d("FaceRecognitionResultViewModel", "收到支付成功事件 - order_number: ${event.orderNumber}, capture_type: ${event.captureType}, image_ids: ${event.imageIds}")
navigateToPaySuccess(event.orderNumber, event.captureType, event.imageIds)
LogUtils.d(TAG, "收到 WebSocket 支付成功事件 - order_number: ${event.orderNumber}, capture_type: ${event.captureType}, image_ids: ${event.imageIds}")
handlePaymentSuccess(
orderNumber = event.orderNumber,
captureType = event.captureType,
imageIds = event.imageIds,
source = "WebSocket"
)
}
else -> {
// 其他事件不处理
@@ -112,6 +127,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
viewModelScope.launch {
// 保存原始结果列表
originalResults = results
urlToIdMap.clear()
// 先使用默认宽高比创建列表
val initialPhotos = results.mapNotNull { result ->
@@ -145,70 +161,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
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
if (!aspectRatio.isFinite() || aspectRatio !in 0.05f..20f) {
LogUtils.i(TAG, "忽略异常图片宽高比 - id: $photoId, ratio: $aspectRatio")
return
}
_photoList.update { photos ->
photos.map { photo ->
if (photo.id == photoId && kotlin.math.abs(photo.aspectRatio - aspectRatio) > 0.01f) {
LogUtils.d(TAG, "更新图片比例 - id: $photoId, size: ${width}x${height}, ratio: $aspectRatio")
photo.copy(aspectRatio = aspectRatio)
} else {
LogUtils.i("FaceRecognitionResultViewModel", "图片 $imageUrl 尺寸无效: ${width}x${height}")
photo
}
} else {
LogUtils.i("FaceRecognitionResultViewModel", "图片 $imageUrl 加载失败,drawable为null")
}
} catch (e: Exception) {
LogUtils.e("FaceRecognitionResultViewModel"+"获取图片 $imageUrl 尺寸异常: ${e.message}")
}
null
} ?: run {
LogUtils.i("FaceRecognitionResultViewModel", "获取图片 $imageUrl 尺寸超时")
1f // 超时或失败,返回默认值
}
}
@@ -330,10 +311,16 @@ class FaceRecognitionResultViewModel @Inject constructor(
flow = netWorkRepository.getPayUrl(request).asResult(),
showToast = false,
onData = { response ->
val url = response.url
if (url.isNullOrEmpty()) {
val url = response.url?.trim()
val orderNumber = response.orderNumber?.trim()
if (url.isNullOrEmpty() || orderNumber.isNullOrEmpty()) {
_payQrCodeUrl.value = null
LogUtils.e(TAG, "获取支付二维码成功,但 url 或 order_number 为空")
ToastUtils.show("获取支付二维码失败")
} else {
stopPayStatusPolling()
activePaymentOrderNumber = orderNumber
paymentHandled.set(false)
_payQrCodeUrl.value = url
onSuccess(url)
}
@@ -353,6 +340,149 @@ class FaceRecognitionResultViewModel @Inject constructor(
}
}
/**
* 二维码弹框显示时启动支付状态轮询。重复调用不会创建多个轮询任务。
*/
fun startPayStatusPolling() {
val orderNumber = activePaymentOrderNumber
if (orderNumber.isNullOrEmpty() || paymentHandled.get()) {
LogUtils.i(TAG, "未启动支付状态轮询:当前没有有效待支付订单")
return
}
if (paymentPollingJob?.isActive == true) {
return
}
paymentPollingJob = viewModelScope.launch {
LogUtils.d(TAG, "开始轮询支付状态 - order_number: $orderNumber")
while (
isActive &&
!paymentHandled.get() &&
activePaymentOrderNumber == orderNumber
) {
queryPayStatus(orderNumber)
delay(PAY_STATUS_POLL_INTERVAL_MS)
}
}
}
/** 二维码弹框关闭时停止 HTTP 轮询,WebSocket 监听仍保持有效。 */
fun stopPayStatusPolling() {
paymentPollingJob?.cancel()
paymentPollingJob = null
}
fun onPayDialogDismissed() {
stopPayStatusPolling()
}
private suspend fun queryPayStatus(orderNumber: String) {
try {
val response = netWorkRepository
.getPaySuccessMessage(PaySuccessMessageRequest(orderNumber))
.first()
if (!response.isSucceeded) {
LogUtils.e(
TAG,
"查询支付状态业务失败 - order_number: $orderNumber, code: ${response.code}, msg: ${response.message}"
)
return
}
val message = response.data
when (decideFacePayStatus(message?.orderStatus)) {
FacePayStatusDecision.CONTINUE_POLLING -> {
LogUtils.d(
TAG,
"订单尚未完成,继续轮询 - order_number: $orderNumber, status: ${message?.orderStatus}, name: ${message?.orderStatusName}"
)
}
FacePayStatusDecision.COMPLETE_PAYMENT -> {
if (!isValidFacePaySuccessMessage(orderNumber, message)) {
LogUtils.e(TAG, "支付完成消息校验失败,等待下一次查询或 WebSocket - order_number: $orderNumber, message: $message")
return
}
val paymentData = checkNotNull(message?.data)
handlePaymentSuccess(
orderNumber = paymentData.orderNumber,
captureType = paymentData.captureType,
imageIds = paymentData.imageIds.orEmpty(),
source = "HTTP"
)
}
FacePayStatusDecision.STOP_WITH_TERMINAL_STATUS -> {
handleTerminalPayStatus(message?.orderStatus, message?.orderStatusName)
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
LogUtils.e(TAG, "查询支付状态异常 - order_number: $orderNumber, error: ${e.message}")
}
}
private fun handleTerminalPayStatus(orderStatus: Int?, orderStatusName: String?) {
if (!paymentHandled.compareAndSet(false, true)) {
return
}
stopPayStatusPolling()
activePaymentOrderNumber = null
_payQrCodeUrl.value = null
_dismissPayDialogEvents.tryEmit(Unit)
val statusText = orderStatusName?.takeIf { it.isNotBlank() } ?: when (orderStatus) {
40 -> "已取消"
50 -> "已退款"
60 -> "部分退款"
else -> "状态异常"
}
ToastUtils.show("订单$statusText")
LogUtils.i(TAG, "订单进入终态,停止支付轮询 - status: $orderStatus, name: $statusText")
}
private fun handlePaymentSuccess(
orderNumber: String?,
captureType: Int?,
imageIds: List<Int>,
source: String
) {
val expectedOrderNumber = activePaymentOrderNumber
if (
expectedOrderNumber.isNullOrEmpty() ||
orderNumber != expectedOrderNumber ||
captureType != 2 ||
imageIds.isEmpty()
) {
LogUtils.i(
TAG,
"忽略不匹配的支付成功消息 - source: $source, expected: $expectedOrderNumber, actual: $orderNumber, capture_type: $captureType, image_ids: $imageIds"
)
return
}
val knownImageIds = originalResults.map { it.id }.toSet()
if (!knownImageIds.containsAll(imageIds)) {
LogUtils.e(TAG, "支付成功消息包含未知图片,暂不跳转 - source: $source, image_ids: $imageIds")
return
}
if (!paymentHandled.compareAndSet(false, true)) {
LogUtils.d(TAG, "支付成功已处理,忽略重复消息 - source: $source, order_number: $orderNumber")
return
}
LogUtils.d(TAG, "确认支付成功 - source: $source, order_number: $orderNumber")
stopPayStatusPolling()
_payQrCodeUrl.value = null
_dismissPayDialogEvents.tryEmit(Unit)
navigateToPaySuccess(orderNumber, captureType, imageIds)
}
/**
* 跳转到支付成功页面
* @param orderNumber 订单号
@@ -18,6 +18,7 @@ import com.yzx.kiosk.network.model.response.FaceSearchResponse
import com.yzx.kiosk.network.service.FaceSearchService
import com.yzx.kiosk.App
import com.yzx.kiosk.BuildConfig
import com.yzx.kiosk.network.interceptor.DebugNetworkLoggingInterceptor
import com.yzx.kiosk.navigation.AppNavigator
import com.yzx.kiosk.navigation.routes.AppRoutes
import com.yzx.kiosk.ui.setting.navigation.AgreementRoutes
@@ -25,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
@@ -38,7 +40,6 @@ import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.io.File
@@ -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}")
@@ -260,23 +264,20 @@ class FaceRecognitionViewModel @Inject constructor(
LogUtils.d(TAG, "========== 人脸识别接口请求信息 ==========")
LogUtils.d(TAG, "URL: $requestUrl")
LogUtils.d(TAG, "Headers:")
LogUtils.d(TAG, "Authorization: $authorization")
LogUtils.d(TAG, "Authorization: <redacted>")
LogUtils.d(TAG, "Parameters:")
LogUtils.d(TAG, "image: ${imageFile.name} (${imageFile.length()} bytes)")
LogUtils.d(TAG, "threshold: $thresholdValue")
LogUtils.d(TAG, "index_date: $indexDate")
LogUtils.d(TAG, "==========================================")
// 创建日志拦截器
val loggingInterceptor = HttpLoggingInterceptor { message ->
LogUtils.d(TAG, message)
}.apply {
level = HttpLoggingInterceptor.Level.BODY
}
// 创建OkHttpClient,添加日志拦截器
// Debug 环境记录请求与响应;图片等大文件只记录元数据,不读取文件内容
val client = OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.apply {
if (BuildConfig.DEBUG) {
addInterceptor(DebugNetworkLoggingInterceptor("face-search"))
}
}
.build()
// 创建Retrofit实例
@@ -318,6 +319,9 @@ class FaceRecognitionViewModel @Inject constructor(
localAudioPlayService.playByRoute("face_recognition_failed")
}
}
} catch (e: CancellationException) {
// 页面离开时保持协程取消语义,避免误显示 "Job was cancelled"
throw e
} catch (e: Exception) {
LogUtils.e("人脸识别失败: ${e.message}")
ToastUtils.show("识别失败: ${e.message}")
@@ -1,12 +1,12 @@
package com.yzx.kiosk.ui.upload.viewmodel
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.lifecycle.viewModelScope
import com.yzx.kiosk.base.BaseViewModel
import com.yzx.kiosk.datastore.AppState
import com.yzx.kiosk.navigation.AppNavigator
import com.yzx.kiosk.navigation.routes.AppRoutes
import com.yzx.kiosk.network.di.NetworkModule.CLIENT_DOWNLOAD
import com.yzx.kiosk.network.model.request.PrintCompleteRequest
import com.yzx.kiosk.network.model.request.PrintNotifyRequest
import com.yzx.kiosk.network.model.request.SaveAlbumUrlRequest
@@ -17,6 +17,7 @@ import com.yzx.kiosk.priter.PrintCallback
import com.yzx.kiosk.priter.PrintRecord
import com.yzx.kiosk.priter.PrintStatusManager
import com.yzx.kiosk.priter.PrinterService
import com.yzx.kiosk.priter.PrintImageIntegrityValidator
import com.yzx.kiosk.utils.LogUtils
import com.yzx.kiosk.utils.ToastUtils
import com.google.gson.Gson
@@ -35,9 +36,12 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import java.net.URL
import okhttp3.CacheControl
import okhttp3.OkHttpClient
import okhttp3.Request
import kotlin.coroutines.resume
import javax.inject.Inject
import javax.inject.Named
@HiltViewModel
class PrintingViewModel @Inject constructor(
@@ -46,7 +50,8 @@ class PrintingViewModel @Inject constructor(
private val printerService: PrinterService,
private val appStoreDataSource: AppStoreDataSource,
private val printStatusManager: PrintStatusManager,
private val netWorkRepository: NetWorkRepository
private val netWorkRepository: NetWorkRepository,
@Named(CLIENT_DOWNLOAD) private val printDownloadClient: OkHttpClient
) : BaseViewModel(
navigator = navigator,
appState = appState
@@ -55,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,18 +508,65 @@ class PrintingViewModel @Inject constructor(
* 下载网络图片
*/
private suspend fun downloadImage(url: String): Bitmap? = withContext(Dispatchers.IO) {
val logUrl = url.substringBefore('?')
for (attempt in 1..IMAGE_DOWNLOAD_RETRY_COUNT) {
try {
val connection = URL(url).openConnection()
connection.connectTimeout = 10000
connection.readTimeout = 10000
connection.getInputStream().use { inputStream ->
BitmapFactory.decodeStream(inputStream)
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, ${e.message}")
null
LogUtils.e(
TAG,
"打印图片下载或校验失败 - url=$logUrl, attempt=$attempt/$IMAGE_DOWNLOAD_RETRY_COUNT, error=${e.message}"
)
if (attempt < IMAGE_DOWNLOAD_RETRY_COUNT) {
delay(500L * attempt)
}
}
}
LogUtils.e(TAG, "打印图片连续 $IMAGE_DOWNLOAD_RETRY_COUNT 次校验失败,禁止送入打印机 - url=$logUrl")
null
}
/**
* 获取二维码 URL
@@ -7,7 +7,9 @@ import coil.request.ImageRequest
import coil.size.Size
import com.google.gson.Gson
import com.yzx.kiosk.App
import com.yzx.kiosk.BuildConfig
import com.yzx.kiosk.datastore.AppStoreDataSource
import com.yzx.kiosk.network.interceptor.DebugNetworkLoggingInterceptor
import com.yzx.kiosk.network.model.request.PrintCompleteRequest
import com.yzx.kiosk.network.model.request.PrintNotifyRequest
import com.yzx.kiosk.network.model.response.BatchQueryResponse
@@ -65,6 +67,11 @@ class PrivacyPrintService @Inject constructor(
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.apply {
if (BuildConfig.DEBUG) {
addInterceptor(DebugNetworkLoggingInterceptor("privacy-print"))
}
}
.build()
// 缓存 AppState 实例,避免频繁调用 Lazy.get()
@@ -4,6 +4,7 @@ import com.google.gson.Gson
import com.google.gson.JsonObject
import com.yzx.kiosk.BuildConfig
import com.yzx.kiosk.datastore.AppStoreDataSource
import com.yzx.kiosk.network.interceptor.DebugNetworkLoggingInterceptor
import com.yzx.kiosk.network.repository.NetWorkRepository
import com.yzx.kiosk.ui.poster.ScenicLivePosterController
import com.yzx.kiosk.priter.PrintStatusManager
@@ -100,6 +101,11 @@ class WebSocketService @Inject constructor(
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.pingInterval(30, TimeUnit.SECONDS) // 自动 ping
.apply {
if (BuildConfig.DEBUG) {
addInterceptor(DebugNetworkLoggingInterceptor("websocket"))
}
}
.build()
}
}
@@ -971,4 +977,3 @@ sealed class UploadPhotoEvent {
val imageIds: List<Int>
) : UploadPhotoEvent()
}
@@ -0,0 +1,102 @@
package com.yzx.kiosk.ui.face.viewmodel
import com.google.gson.Gson
import com.yzx.kiosk.network.model.request.PaySuccessMessageRequest
import com.yzx.kiosk.network.model.response.PaySuccessMessageData
import com.yzx.kiosk.network.model.response.PaySuccessMessageResponse
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class FacePayStatusDecisionTest {
@Test
fun `pending and unknown statuses continue polling`() {
listOf(null, 10, 20, 0, 70).forEach { status ->
assertEquals(
FacePayStatusDecision.CONTINUE_POLLING,
decideFacePayStatus(status)
)
}
}
@Test
fun `completed status completes payment`() {
assertEquals(
FacePayStatusDecision.COMPLETE_PAYMENT,
decideFacePayStatus(30)
)
}
@Test
fun `cancel and refund statuses stop polling`() {
listOf(40, 50, 60).forEach { status ->
assertEquals(
FacePayStatusDecision.STOP_WITH_TERMINAL_STATUS,
decideFacePayStatus(status)
)
}
}
@Test
fun `valid face payment message is accepted`() {
assertTrue(isValidFacePaySuccessMessage("ORDER-1", message()))
}
@Test
fun `empty or mismatched payment data is rejected`() {
assertFalse(isValidFacePaySuccessMessage("ORDER-1", null))
assertFalse(isValidFacePaySuccessMessage("OTHER", message()))
assertFalse(isValidFacePaySuccessMessage("ORDER-1", message(type = 4)))
assertFalse(isValidFacePaySuccessMessage("ORDER-1", message(captureType = 1)))
assertFalse(isValidFacePaySuccessMessage("ORDER-1", message(imageIds = emptyList())))
}
@Test
fun `pay status request uses backend order number field`() {
val json = Gson().toJson(PaySuccessMessageRequest("ORDER-1"))
assertEquals("{\"order_number\":\"ORDER-1\"}", json)
}
@Test
fun `pay status response deserializes nested websocket payload`() {
val json = """
{
"order_status": 30,
"order_status_name": "已完成",
"sn": "OSCAR_DEVICE_SN",
"type": 5,
"data": {
"order_number": "ORDER-1",
"capture_type": 2,
"image_id": [335, 340]
}
}
""".trimIndent()
val response = Gson().fromJson(json, PaySuccessMessageResponse::class.java)
assertEquals(30, response.orderStatus)
assertEquals("ORDER-1", response.data?.orderNumber)
assertEquals(listOf(335, 340), response.data?.imageIds)
assertTrue(isValidFacePaySuccessMessage("ORDER-1", response))
}
private fun message(
type: Int = 5,
captureType: Int = 2,
imageIds: List<Int> = listOf(335, 340)
) = PaySuccessMessageResponse(
orderStatus = 30,
orderStatusName = "已完成",
sn = "OSCAR_DEVICE_SN",
type = type,
data = PaySuccessMessageData(
orderNumber = "ORDER-1",
captureType = captureType,
imageIds = imageIds
)
)
}
+493
View File
@@ -0,0 +1,493 @@
# KIOSK 工程业务与技术导读
> 面向第一次接手工程的开发者。本文基于 2026-08-04 的代码状态整理,目标是先建立完整的业务和技术心智模型,再进入具体模块开发。
## 1. 一句话理解这个工程
KIOSK 是部署在景区或线下门店 Android 自助终端上的照片售卖与打印客户端:游客可以上传手机照片,或通过人脸识别找到景区相机拍摄的照片,完成选片和支付后,由终端连接 DNP 照片打印机出片。
它同时还是一台长期在线的设备,因此还承担:
- 设备身份认证和配置同步
- WebSocket 实时消息接收与心跳上报
- 微信扫码隐私取片任务
- 打印机、纸张余量和任务状态管理
- 待机海报、视频、直播和语音播放
- 运营素材上传
- APK 在线升级和开机自启动
应用名称是“自助照片打印”,Application ID 为 `com.yzx.kiosk`,最低支持 Android 8.0(API 26)。
## 2. 业务参与方
理解下面几个参与方后,工程中的接口和状态会清晰很多。
| 参与方 | 职责 |
| --- | --- |
| 游客 | 在终端上选业务、拍照识别人脸、选片、扫码支付和取走照片 |
| 手机端/微信页面 | 扫终端二维码、上传照片、支付或发起隐私取片 |
| KIOSK Android 客户端 | 展示业务页面,维护设备状态,接收订单并驱动打印机 |
| Oscar 业务后端 | 下发设备配置、二维码、价格、订单、Socket Token、版本信息等 |
| 照片盒子/人脸服务 | 保存景区相机照片,提供公网或局域网人脸检索及图片地址 |
| OSS | 保存照片、海报、视频、音频和升级包等文件 |
| DNP 打印机 | 实际输出照片,当前主要适配 RX1 和 QW410 |
| 运维人员 | 配置设备密钥、局域网模式、纸张数、素材、打印机和应用版本 |
整体关系如下:
```mermaid
flowchart LR
Visitor["游客"] --> Kiosk["Android KIOSK"]
Phone["手机端 / 微信"] <-->|"上传、支付、取片"| Backend["Oscar 业务后端"]
Kiosk <-->|"HTTPS API"| Backend
Kiosk <-->|"WebSocket 消息与心跳"| Backend
Kiosk <-->|"人脸检索、照片读取"| FaceBox["照片盒子 / 人脸服务"]
Backend <--> OSS["阿里云 OSS"]
Kiosk -->|"下载或上传素材"| OSS
Kiosk -->|"DNP SDK 打印任务"| Printer["RX1 / QW410 打印机"]
Operator["运维人员"] --> Kiosk
```
## 3. 用户能看到的主要业务
### 3.1 首页
首页是所有业务的入口,主要展示:
- 人脸识别照片/打卡点照片打印入口
- 上传手机照片打印入口
- 微信扫码隐私取片二维码
- 首页宣传视频
- 当前打印任务状态
- 剩余打印纸张数和客服电话
首页双击特定区域会弹出管理密码,校验后进入设置页。首页 60 秒无操作,且当前没有打印任务时,会进入待机海报或直播层;用户触摸后返回首页。
主要代码:
- [`HomeScreen.kt`](../app/src/main/java/com/yzx/kiosk/ui/home/view/HomeScreen.kt)
- [`HomeViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/home/viewmodel/HomeViewModel.kt)
- [`InactivityManager.kt`](../app/src/main/java/com/yzx/kiosk/utils/InactivityManager.kt)
### 3.2 手机照片上传打印
这是 `capture type = 1` 的业务。
```mermaid
sequenceDiagram
actor User as 游客
participant K as KIOSK
participant API as 业务后端
participant WS as WebSocket
participant P as DNP 打印机
User->>K: 进入“上传手机照片打印”
K->>API: 获取上传二维码和打印服务信息
K-->>User: 展示二维码、价格和规格
User->>API: 手机扫码并上传照片
WS-->>K: code=1 扫码成功
WS-->>K: code=2 下发 file_map 照片列表
K-->>User: 展示照片并选择
K->>API: verify-result 计算单价和总价
K->>API: get-pay-url 获取支付二维码
User->>API: 手机支付
WS-->>K: code=5 支付成功及订单信息
K-->>User: 支付成功,确认要打印的照片
K->>P: 逐张下载、处理并打印
K->>API: print-notify 上报每张结果
K->>API: print-complete 上报订单完成
```
对应页面链路:
```text
首页
-> 上传照片二维码页 UploadPhotoScreen
-> 照片选择页 PhotoSelectScreen
-> 支付成功页 PaySuccessScreen
-> 打印页 PrintingScreen
-> 自动返回首页
```
核心类:
- [`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):支付后再次确认打印照片
- [`PrintingViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PrintingViewModel.kt):逐张下载、打印、记录并上报结果
服务端通过 `file_map` 同时下发 OSS 和局域网地址。客户端根据设置中的“使用 LAN”开关选择 `*_lan_url` 或 `*_oss_url`。
### 3.3 人脸识别照片打印
这是 `capture type = 2` 的业务,适用于景区相机、无人机或打卡点预先拍摄的照片。
业务过程:
1. 游客进入人脸识别页。
2. CameraX 打开前置相机,支持 5 秒倒计时或手动拍照。
3. 客户端把照片上传到照片盒子的 `/{sn}/api/search` 接口。
4. 服务根据人脸相似度返回当天匹配的照片。
5. 终端展示缩略图,游客选择照片。
6. 客户端请求后端计算价格并生成支付二维码。
7. WebSocket 收到支付成功事件后进入支付成功页和打印页。
主要代码:
- [`FaceRecognitionScreen.kt`](../app/src/main/java/com/yzx/kiosk/ui/face/view/FaceRecognitionScreen.kt):相机页面
- [`FaceRecognitionViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionViewModel.kt):拍照、方向修正和人脸检索
- [`FaceRecognitionResultViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionResultViewModel.kt):结果展示、选片、计价和支付
- [`FaceSearchService.kt`](../app/src/main/java/com/yzx/kiosk/network/service/FaceSearchService.kt):人脸搜索 Retrofit 接口
人脸服务地址、盒子 SN 和 Token 来自设备配置。设置为 LAN 模式时走局域网地址,否则走公网地址。
### 3.4 微信扫码隐私取片
隐私取片不是通过当前页面一步步操作,而是后台 WebSocket 直接下发的打印任务。
处理过程:
1. 首页显示服务端配置的微信隐私取片二维码。
2. 用户在微信侧完成选片或确认。
3. WebSocket 收到 `code = 4` 消息。
4. `PrintQueueManager` 按订单去重并排队。
5. `PrivacyPrintService` 依次下载照片并调用打印机。
6. 首页顶部显示“手机尾号 xxxx 用户正在打印/打印完成”。
7. 客户端上报单张结果和订单完成状态,并更新纸张余量。
主要代码:
- [`WebSocketService.kt`](../app/src/main/java/com/yzx/kiosk/websocket/WebSocketService.kt)
- [`PrintQueueManager.kt`](../app/src/main/java/com/yzx/kiosk/websocket/PrintQueueManager.kt)
- [`PrivacyPrintService.kt`](../app/src/main/java/com/yzx/kiosk/websocket/PrivacyPrintService.kt)
- [`PrivacyPrintMessage.kt`](../app/src/main/java/com/yzx/kiosk/websocket/model/PrivacyPrintMessage.kt)
这条链路允许游客在手机端操作打印时,终端屏幕继续服务其他用户,因此打印状态通过全局状态栏展示,而不是强制占用前台页面。
### 3.5 打印机处理
打印能力封装在 [`PrinterService.kt`](../app/src/main/java/com/yzx/kiosk/priter/PrinterService.kt) 中,底层使用 DNP 本地 AAR/JNI SDK。
当前处理步骤:
1. 枚举打印机端口并保存打印机 ID。
2. 根据 ID 区分 RX1 和 QW410。
3. 按打印机方向旋转原图。
4. 保持原比例缩放,居中绘制到白色背景画布。
5. 保存为 300 DPI BMP 文件。
6. 创建 `PrintJob` 并加入 DNP `PrintQueue`。
7. 更新打印状态、打印记录和纸张余量。
尺寸配置位于 [`PRINTSIZE.kt`](../app/src/main/java/com/yzx/kiosk/priter/PRINTSIZE.kt):
| 打印机 | 画布尺寸 | 方向 |
| --- | --- | --- |
| DNP RX1 | 1840 × 1240 | 横向 |
| DNP QW410 | 1266 × 1836 | 纵向 |
工程里的打印包目录名是历史拼写 `priter`,并不是文档笔误。
### 3.6 待机广告、直播和语音
设备配置接口会下发海报、背景音乐、首页视频及直播信息。
- 图片/视频海报使用 Compose + Media3 展示
- 首页底部视频通过 ExoPlayer 循环播放
- 景区直播通过 RTMP 地址播放
- WebSocket 绑定成功后发送 `type = 304` 订阅直播变化
- 本地页面引导语音由 `LocalAudioPlayService` 管理
- 网络 BGM 和其他音频由 `AudioPlayService` 管理
- 打印通知还可通过阿里云语音合成播报
主要代码:
- [`PosterScreenWithLiveOrSlideshow.kt`](../app/src/main/java/com/yzx/kiosk/ui/poster/view/PosterScreenWithLiveOrSlideshow.kt)
- [`ScenicLivePosterController.kt`](../app/src/main/java/com/yzx/kiosk/ui/poster/ScenicLivePosterController.kt)
- [`AudioPlayService.kt`](../app/src/main/java/com/yzx/kiosk/audio/AudioPlayService.kt)
- [`LocalAudioPlayService.kt`](../app/src/main/java/com/yzx/kiosk/audio/LocalAudioPlayService.kt)
### 3.7 设备管理和运营配置
设置页用于现场运维,主要包括:
- 切换公网/局域网照片地址
- 设置剩余打印纸张数
- 打开设备配置页和打印机管理页
- 查看帮助中心、关于我们和协议页面
- 手动检查应用更新
设备配置页还可以维护:
- 设备密钥
- 景区和客服电话
- 隐私取片二维码开关
- 首页直播和新版首页开关
- 最多 5 个已选海报
- 背景音乐
- 上传自定义图片、视频和音频素材
运营配置既会调用 `/api/oscar/config/set` 保存到后端,也会同步一部分到本地 MMKV,供首页立即读取。
主要代码:
- [`SettingViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/setting/viewmodel/SettingViewModel.kt)
- [`DeviceConfigViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/device/viewmodel/DeviceConfigViewModel.kt)
- [`PrinterManageViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/printer/viewmodel/PrinterManageViewModel.kt)
### 3.8 在线升级与设备自启动
`VersionUpdateManager` 请求 `type = 8` 的最新 Android 终端版本,下载 APK 后通过 `FileProvider` 拉起系统安装界面。Manifest 同时注册了开机和包更新广播,目标是让自助终端重启或升级后恢复运行。
主要代码:
- [`VersionUpdateManager.kt`](../app/src/main/java/com/yzx/kiosk/utils/VersionUpdateManager.kt)
- [`BootReceiver.kt`](../app/src/main/java/com/yzx/kiosk/receiver/BootReceiver.kt)
- [`AndroidManifest.xml`](../app/src/main/AndroidManifest.xml)
## 4. 应用启动时发生什么
```text
应用进程启动,App.onCreate 完成全局初始化
-> 系统或桌面启动 SplashActivity
-> 等待约 1.5 秒
-> 进入 MainActivity
-> 初始化无操作检测并注册 WebSocket 监听器
-> MainActivity 获取 Socket Token
-> WebSocket 连接并发送 type=1000 绑定设备
-> Compose 创建 AppNavHost 并进入首页
-> HomeViewModel 拉取设备配置
-> 每 5 秒发送 type=1001 心跳
-> 首页开始展示入口、二维码、视频和纸张信息
-> 无操作 60 秒后展示待机海报或直播
```
Application 级初始化在 [`App.kt`](../app/src/main/java/com/yzx/kiosk/App.kt) 中完成,包括 Hilt、MMKV、日志、Toast、Coil 缓存和全局状态初始化。DNP SDK 延迟到第一次使用打印机时初始化。
## 5. WebSocket 协议在客户端中的作用
WebSocket 是业务闭环的关键,不只是普通通知。当前客户端使用的主要消息如下:
| 方向 | 类型或 code | 含义 |
| --- | --- | --- |
| 客户端 -> 服务端 | `type = 1000` | 使用 Socket Token 绑定设备 |
| 客户端 -> 服务端 | `type = 1001` | 心跳,上报设备、打印机状态和剩余纸张 |
| 客户端 -> 服务端 | `type = 304` | 订阅景区直播状态 |
| 服务端 -> 客户端 | `code = 1` | 手机扫码成功 |
| 服务端 -> 客户端 | `code = 2` | 上传照片列表到达 |
| 服务端 -> 客户端 | `code = 4` | 隐私取片打印任务 |
| 服务端 -> 客户端 | `code = 5` | 支付成功,包含订单和照片信息 |
| 服务端 -> 客户端 | `code = 100000` | 绑定、心跳或直播动作成功 |
| 服务端 -> 客户端 | `code = 100099` | Token 失效,需要刷新并重新绑定 |
`WebSocketService` 把上传/支付类消息转换为 `SharedFlow<UploadPhotoEvent>`,页面 ViewModel 各自订阅;隐私打印则直接进入后台打印队列。
## 6. 网络认证和环境
普通 HTTP 请求由 [`RequestInterceptor.kt`](../app/src/main/java/com/yzx/kiosk/network/interceptor/RequestInterceptor.kt) 添加:
```text
sn = 设备 SN
timestamp = 当前秒级时间戳
token = MD5(sn + deviceSecret + timestamp)
```
设备密钥需要先在设备配置页录入。没有密钥时客户端不会生成 `token` 请求头,首页会提示先配置设备密钥。
构建环境由 `BuildConfig` 区分:
| 构建类型 | HTTP API | WebSocket |
| --- | --- | --- |
| Debug | `api-test.zhifly.cn` | 测试 WSS |
| Release | `api.zhifly.cn` | 正式 WSS |
配置位置为 [`app/build.gradle.kts`](../app/build.gradle.kts),网络组件装配位于 [`NetworkModule.kt`](../app/src/main/java/com/yzx/kiosk/network/di/NetworkModule.kt)。
## 7. 工程采用的技术
### 7.1 语言、构建和 UI
| 技术 | 用途 |
| --- | --- |
| Kotlin 2.1.10 | 主要开发语言,JVM Target 11 |
| Gradle 8.11.1 + AGP 8.10.0 | Android 构建系统 |
| Jetpack Compose + Material 3 | 主要页面 UI |
| ViewBinding + XML | Splash 等少量传统页面 |
| Navigation Compose | 页面路由和回退栈 |
| StateFlow / SharedFlow | 页面状态、全局状态和实时事件 |
| Coroutines | 网络、打印、上传下载和倒计时等异步任务 |
整体采用接近单 Activity + MVVM 的结构:
```mermaid
flowchart TD
UI["Compose Screen"] --> VM["Hilt ViewModel"]
VM --> Repo["Repository / Manager"]
Repo --> Api["Retrofit Service"]
Repo --> Local["Room / MMKV"]
VM --> Device["打印机、相机、播放器"]
WS["WebSocketService"] --> Flow["SharedFlow / StateFlow"]
Flow --> VM
VM --> Nav["AppNavigator"]
Nav --> UI
```
### 7.2 依赖注入和架构组件
- Hilt:Application、Activity、ViewModel、Repository 和 Service 的依赖注入
- KSP:Hilt、Room 和 Glide 等代码生成
- Lifecycle ViewModel:页面状态和协程生命周期
- 自定义 `AppNavigator`:通过 SharedFlow 解耦 ViewModel 与 NavController
- `BaseViewModel`:统一导航、Loading、网络结果和登录失效处理
### 7.3 网络与实时通信
- Retrofit + Gson:业务 REST API
- OkHttp:HTTP、APK 下载和 WebSocket
- 自定义请求拦截器:设备 SN、时间戳和签名 Token
- 自定义响应拦截器与结果层:统一解析业务 code
- WebSocket:扫码、照片列表、支付成功、隐私打印、直播和设备心跳
### 7.4 数据存储
| 存储 | 保存内容 |
| --- | --- |
| MMKV | 设备密钥、设备配置、Socket Token、盒子地址、纸张数、打印记录和开关状态 |
| Room `AppDatabase` | 运营素材上传任务及进度 |
| Room `CloudDatabase` | OSS 上传、下载任务 |
| 应用私有文件目录 | 处理后的打印 BMP、缓存和下载文件 |
| Coil 磁盘缓存 | 网络图片缓存,当前限制约 50 MB |
MMKV 的统一访问入口是 [`AppStoreDataSource.kt`](../app/src/main/java/com/yzx/kiosk/datastore/AppStoreDataSource.kt),全局响应式状态集中在 [`AppState.kt`](../app/src/main/java/com/yzx/kiosk/datastore/AppState.kt)。
### 7.5 图片、相机和扫码
- CameraX:人脸识别页拍照
- Coil / Glide:网络图片加载
- uCrop:照片裁剪
- PictureSelector:本地运营素材选择
- ML Kit Barcode Scanning + ZXing:扫码识别和二维码生成
- Android Bitmap/Canvas:打印前旋转、缩放和白底合成
### 7.6 音视频与云能力
- AndroidX Media3 / ExoPlayer:视频、音频和 RTMP 直播
- 阿里云 OSS SDK:素材上传下载
- 阿里云语音 SDK:状态语音合成
- 高德地图 SDK:定位相关能力
- DNP PhotoPrint SDK:照片打印机控制
本地闭源 SDK 位于 `app/libs/`,这是工程能否完整构建和连接硬件的重要前提。
## 8. 代码目录怎么理解
```text
app/src/main/java/com/yzx/kiosk/
├── App.kt # Application 初始化
├── SplashActivity.kt # 启动页
├── MainActivity.kt # 单 Activity 容器、Socket 和待机层
├── audio/ # 在线/本地音频播放
├── base/ # ViewModel 基类
├── component/ # 通用 Compose 组件和全局 UI
├── datastore/ # MMKV 封装和全局状态
├── navigation/ # 路由、导航事件和 NavHost
├── network/
│ ├── db/ # 上传任务 Room 数据库
│ ├── di/ # Hilt 网络与协程模块
│ ├── interceptor/ # 请求签名和响应处理
│ ├── manager/ # 素材上传任务调度
│ ├── model/ # 请求、响应和实体
│ ├── repository/ # 数据仓库
│ └── service/ # Retrofit 接口
├── priter/ # DNP 打印服务和打印状态
├── receiver/ # 开机/更新广播
├── ui/
│ ├── device/ # 设备和运营素材配置
│ ├── face/ # 人脸识别业务
│ ├── home/ # 首页
│ ├── poster/ # 待机海报和直播
│ ├── printer/ # 打印机管理
│ ├── setting/ # 设置、协议和帮助
│ └── upload/ # 上传、选片、支付、打印
├── utils/ # 更新、日志、二维码、超时等工具
│ └── cloud/ # OSS 上传下载及第二套 Room 数据库
└── websocket/ # 长连接、实时事件和隐私打印队列
```
## 9. 建议的代码阅读顺序
第一次接手时,不建议从工具类或所有依赖开始看。按下面顺序更容易建立上下文:
1. [`README.md`](../README.md):先把工程跑起来,理解环境和硬件要求。
2. [`AppRoutes.kt`](../app/src/main/java/com/yzx/kiosk/navigation/routes/AppRoutes.kt) 和 [`AppNavHost.kt`](../app/src/main/java/com/yzx/kiosk/navigation/AppNavHost.kt):知道有哪些页面和用户动线。
3. [`HomeScreen.kt`](../app/src/main/java/com/yzx/kiosk/ui/home/view/HomeScreen.kt) 和 [`HomeViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/home/viewmodel/HomeViewModel.kt):理解首页配置如何驱动业务入口。
4. `ui/upload/viewmodel/`:完整看一遍上传、选片、支付、打印主链路。
5. `ui/face/viewmodel/`:理解第二条照片来源链路。
6. [`WebSocketService.kt`](../app/src/main/java/com/yzx/kiosk/websocket/WebSocketService.kt):理解页面为什么会因服务端消息自动跳转。
7. [`PrinterService.kt`](../app/src/main/java/com/yzx/kiosk/priter/PrinterService.kt) 和 [`PrivacyPrintService.kt`](../app/src/main/java/com/yzx/kiosk/websocket/PrivacyPrintService.kt):理解硬件和后台任务。
8. [`NetworkService.kt`](../app/src/main/java/com/yzx/kiosk/network/service/NetworkService.kt) 与 [`AppStoreDataSource.kt`](../app/src/main/java/com/yzx/kiosk/datastore/AppStoreDataSource.kt):梳理接口和持久化字段。
9. `ui/device/`、`utils/cloud/` 和 `VersionUpdateManager`:最后看运营配置、素材和升级能力。
10. [`工程代码审查报告.md`](工程代码审查报告.md):了解当前缺陷和发布风险。
## 10. 接手时最容易混淆的几个概念
### capture type
- `1`:用户从手机上传的照片
- `2`:通过人脸搜索得到的景区照片
它会贯穿计价、支付、打印通知和订单完成接口。
### file_map
一张照片不只有一个 URL,而是同时包含图片 ID、OSS 原图/缩略图地址和 LAN 原图/缩略图地址。打印必须保留图片 ID,服务端才能正确记录每张照片的状态。
### 设备 SN、设备密钥和 Socket Token
- 设备 SN:当前主要取 Android ID,标识终端
- 设备密钥:运维录入,用于普通 HTTP 请求签名
- Socket Token:后端接口动态获取,用于 WebSocket 绑定
这三个值用途不同,排查鉴权问题时不要混在一起。
### 公网模式和 LAN 模式
切换的是照片盒子服务和照片 URL 的访问方式,不是 Debug/Release 环境切换。Debug/Release 由构建类型决定,LAN/公网由设备设置决定。
### 两类打印
- 前台打印:游客在终端完成上传/人脸业务后进入 `PrintingScreen`
- 后台隐私打印:WebSocket `code = 4` 直接进入 `PrivacyPrintService`
两条链路最终都调用 `PrinterService`,但任务队列、UI 展示和状态上报入口不同。
## 11. 接手后的优先验证清单
在开始改业务前,建议用测试环境完成以下闭环:
1. 配置设备密钥,确认设备配置接口成功。
2. 确认 WebSocket 绑定成功且心跳包含正确纸张和打印机状态。
3. 手机扫码上传一张照片,走完选片、计价和支付事件。
4. 使用相机完成一次人脸搜索,分别测试公网和 LAN 地址。
5. 连接 RX1 或 QW410,各打印一张横图和竖图。
6. 验证单张 `print-notify` 和最终 `print-complete` 到达服务端。
7. 发送一条隐私打印任务,确认不会阻断前台用户操作。
8. 验证海报、视频、直播、BGM 和无操作返回逻辑。
9. 验证重启后开机自启动、配置恢复和 Socket 重连。
10. 在准备发布前阅读代码审查报告并处理签名、隐私、打印结果和并发问题。
## 12. 当前需要特别注意的技术债
本文重点是业务和技术导读,不展开缺陷细节;但以下问题会直接影响你对现有实现的判断:
- Release 当前实际使用 Debug 签名,且签名信息直接写在 Gradle 配置中。
- 打印任务加入 SDK 队列后不等于物理打印成功,当前成功判定不够可靠。
- 部分 Token、设备密钥和业务配置保存在未加密 MMKV 中。
- WebSocket、打印通知及上传下载存在并发和恢复方面的风险。
- APK 更新缺少足够的完整性与签名校验。
- 自动化测试覆盖少,Lint 当前仍有待处理项。
具体证据、文件位置和修复建议见 [`工程代码审查报告.md`](工程代码审查报告.md)。
---
如果要修改某个业务,优先从对应页面的 ViewModel 顺着 Repository、WebSocket 或 PrinterService 向下追踪;如果问题表现为“页面没有自动跳转”,通常还要同时检查 WebSocket 消息是否到达以及 `SharedFlow` 订阅是否仍处于活跃生命周期。