Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63944db6fb | ||
|
|
fb5d3b71df | ||
|
|
cd25ada951 | ||
|
|
4c829f74a9 | ||
|
|
86bc9528dd | ||
|
|
1000488c04 | ||
|
|
342655cbb5 | ||
|
|
92dbd59c23 |
@@ -130,6 +130,7 @@ APK 文件名会自动包含应用名、版本号、版本代码、构建类型
|
||||
| 构建类型 | 后端环境 | 调试 | 代码压缩 |
|
||||
| --- | --- | --- | --- |
|
||||
| `debug` | 测试环境 `api-test.zhifly.cn` | 开启 | 关闭 |
|
||||
| `releaseDebug` | 正式环境 `api.zhifly.cn` | 开启 | 关闭 |
|
||||
| `release` | 正式环境 `api.zhifly.cn` | 关闭 | 关闭 |
|
||||
|
||||
请勿使用 Release 包连接测试设备随意操作,Release 会访问正式接口并连接正式 WebSocket。
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package com.yzx.kiosk.ui.face.resource
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class FaceThumbnailDecoderInstrumentedTest {
|
||||
private lateinit var testDirectory: File
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
testDirectory = File(context.cacheDir, "face_thumbnail_test").apply {
|
||||
deleteRecursively()
|
||||
mkdirs()
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
testDirectory.deleteRecursively()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun largeImagesAreBoundedAndAllExifOrientationsAreHandled() {
|
||||
val orientations = listOf(
|
||||
ExifInterface.ORIENTATION_NORMAL to false,
|
||||
ExifInterface.ORIENTATION_FLIP_HORIZONTAL to false,
|
||||
ExifInterface.ORIENTATION_ROTATE_180 to false,
|
||||
ExifInterface.ORIENTATION_FLIP_VERTICAL to false,
|
||||
ExifInterface.ORIENTATION_TRANSPOSE to true,
|
||||
ExifInterface.ORIENTATION_ROTATE_90 to true,
|
||||
ExifInterface.ORIENTATION_TRANSVERSE to true,
|
||||
ExifInterface.ORIENTATION_ROTATE_270 to true,
|
||||
)
|
||||
val decoder = FaceThumbnailDecoder()
|
||||
|
||||
orientations.forEachIndexed { index, (orientation, swapsDimensions) ->
|
||||
val imageFile = File(testDirectory, "orientation_$index.jpg")
|
||||
createLargeJpeg(imageFile)
|
||||
ExifInterface(imageFile).apply {
|
||||
setAttribute(ExifInterface.TAG_ORIENTATION, orientation.toString())
|
||||
saveAttributes()
|
||||
}
|
||||
|
||||
val decoded = decoder.decode(imageFile.absolutePath)
|
||||
|
||||
assertNotNull(decoded)
|
||||
decoded!!
|
||||
assertFalse(decoded.isRecycled)
|
||||
assertTrue(maxOf(decoded.width, decoded.height) <= FaceThumbnailDecoder.MAX_PREVIEW_EDGE_PX)
|
||||
if (swapsDimensions) {
|
||||
assertEquals(360, decoded.width)
|
||||
assertEquals(720, decoded.height)
|
||||
} else {
|
||||
assertEquals(720, decoded.width)
|
||||
assertEquals(360, decoded.height)
|
||||
}
|
||||
decoded.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun captureFilesAreCreatedInOwnedDirectoryAndDeletedIdempotently() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val fileStore = FaceCaptureFileStore(context)
|
||||
val captureFile = fileStore.createCaptureFile()
|
||||
|
||||
assertTrue(captureFile.exists())
|
||||
assertEquals(FaceCaptureFileStore.CAPTURE_DIRECTORY, captureFile.parentFile?.name)
|
||||
|
||||
fileStore.delete(captureFile)
|
||||
fileStore.delete(captureFile)
|
||||
assertFalse(captureFile.exists())
|
||||
}
|
||||
|
||||
private fun createLargeJpeg(file: File) {
|
||||
val bitmap = Bitmap.createBitmap(1600, 800, Bitmap.Config.ARGB_8888)
|
||||
Canvas(bitmap).drawColor(Color.MAGENTA)
|
||||
file.outputStream().use { output ->
|
||||
assertTrue(bitmap.compress(Bitmap.CompressFormat.JPEG, 95, output))
|
||||
}
|
||||
bitmap.recycle()
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import com.luck.picture.lib.basic.PictureSelectorSupporterActivity
|
||||
import com.luck.picture.lib.basic.PictureSelectorTransparentActivity
|
||||
import com.yzx.kiosk.datastore.AppState
|
||||
import com.yzx.kiosk.datastore.AppStoreDataSource
|
||||
import com.yzx.kiosk.ui.face.resource.FaceCaptureFileStore
|
||||
import com.yzx.kiosk.utils.LogUtils
|
||||
import com.yzx.kiosk.utils.MMKVUtils
|
||||
import com.yzx.kiosk.utils.NavigationBarUtil
|
||||
@@ -42,6 +43,9 @@ class App : Application() {
|
||||
@Inject
|
||||
lateinit var appStoreDataSource: AppStoreDataSource
|
||||
|
||||
@Inject
|
||||
lateinit var faceCaptureFileStore: FaceCaptureFileStore
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
@@ -59,6 +63,16 @@ class App : Application() {
|
||||
LogUtils.init(BuildConfig.DEBUG)
|
||||
MMKVUtils.init(this)
|
||||
|
||||
runCatching { faceCaptureFileStore.cleanupOrphans() }
|
||||
.onSuccess { deletedFaceCaptures ->
|
||||
if (deletedFaceCaptures > 0) {
|
||||
LogUtils.i("App", "Cleaned $deletedFaceCaptures orphaned face capture file(s)")
|
||||
}
|
||||
}
|
||||
.onFailure { error ->
|
||||
LogUtils.e("App", "Failed to clean orphaned face captures: ${error.message}")
|
||||
}
|
||||
|
||||
appState.initialize()
|
||||
|
||||
initCoil()
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -28,8 +28,10 @@ object NetworkModule {
|
||||
private const val CLIENT_DEFAULT = "defaultOkHttpClient"
|
||||
const val CLIENT_UPLOAD = "uploadOkHttpClient"
|
||||
const val CLIENT_DOWNLOAD = "downloadOkHttpClient"
|
||||
const val CLIENT_FACE = "faceOkHttpClient"
|
||||
const val RETROFIT_DEFAULT = "defaultRetrofit"
|
||||
const val RETROFIT_UPLOAD = "uploadRetrofit"
|
||||
const val RETROFIT_FACE = "faceRetrofit"
|
||||
const val TRANSFRTVIEWMODEL = "transferViewModel"
|
||||
|
||||
@Provides
|
||||
@@ -41,18 +43,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 +56,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 +67,11 @@ object NetworkModule {
|
||||
fun provideDefaultOkHttpClient(
|
||||
requestInterceptor: RequestInterceptor,
|
||||
responseInterceptor: ResponseInterceptor,
|
||||
loggingInterceptor: HttpLoggingInterceptor,
|
||||
): OkHttpClient = buildOkHttpClient(
|
||||
TIMEOUT_SHORT,
|
||||
requestInterceptor,
|
||||
responseInterceptor,
|
||||
loggingInterceptor,
|
||||
"api",
|
||||
)
|
||||
|
||||
@Provides
|
||||
@@ -84,7 +80,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 +99,22 @@ object NetworkModule {
|
||||
.callTimeout(TIMEOUT_LONG, TimeUnit.SECONDS)
|
||||
.addInterceptor(requestInterceptor)
|
||||
// 注意:不添加 ResponseInterceptor,避免将大文件加载到内存
|
||||
.apply {
|
||||
if (BuildConfig.DEBUG) {
|
||||
addInterceptor(DebugNetworkLoggingInterceptor("download"))
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@Named(CLIENT_FACE)
|
||||
fun provideFaceOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
|
||||
.apply {
|
||||
if (BuildConfig.DEBUG) {
|
||||
addInterceptor(DebugNetworkLoggingInterceptor("face-search"))
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
private fun buildRetrofit(
|
||||
@@ -127,4 +144,13 @@ object NetworkModule {
|
||||
gson: Gson,
|
||||
@Named(BASE_URL) baseUrl: String
|
||||
): Retrofit = buildRetrofit(baseUrl, okHttpClient, gson)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@Named(RETROFIT_FACE)
|
||||
fun provideFaceRetrofit(
|
||||
@Named(CLIENT_FACE) okHttpClient: OkHttpClient,
|
||||
gson: Gson,
|
||||
@Named(BASE_URL) baseUrl: String,
|
||||
): Retrofit = buildRetrofit(baseUrl, okHttpClient, gson)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import com.yzx.kiosk.audio.AudioPlayService
|
||||
import com.yzx.kiosk.network.service.NetworkService
|
||||
import com.yzx.kiosk.network.service.UploadService
|
||||
import com.yzx.kiosk.network.service.FaceSearchService
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
@@ -11,6 +12,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import com.yzx.kiosk.network.di.NetworkModule.RETROFIT_DEFAULT
|
||||
import com.yzx.kiosk.network.di.NetworkModule.RETROFIT_UPLOAD
|
||||
import com.yzx.kiosk.network.di.NetworkModule.RETROFIT_FACE
|
||||
import retrofit2.Retrofit
|
||||
import javax.inject.Named
|
||||
import javax.inject.Singleton
|
||||
@@ -31,6 +33,12 @@ object ServiceModule {
|
||||
@Named(RETROFIT_UPLOAD) retrofit: Retrofit
|
||||
): UploadService = retrofit.create(UploadService::class.java)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideFaceSearchService(
|
||||
@Named(RETROFIT_FACE) retrofit: Retrofit,
|
||||
): FaceSearchService = retrofit.create(FaceSearchService::class.java)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAudioPlayService(
|
||||
|
||||
+202
@@ -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)
|
||||
|
||||
@@ -8,13 +8,13 @@ import retrofit2.http.Header
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Part
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Url
|
||||
|
||||
interface FaceSearchService {
|
||||
@Multipart
|
||||
@POST("/{sn}/api/search")
|
||||
@POST
|
||||
suspend fun searchFace(
|
||||
@Path("sn") sn: String,
|
||||
@Url url: String,
|
||||
@Header("Authorization") authorization: String,
|
||||
@Part image: MultipartBody.Part,
|
||||
@Part("threshold") threshold: RequestBody?,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.yzx.kiosk.network.service
|
||||
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
|
||||
internal fun buildFaceSearchUrl(baseUrl: String, deviceSn: String): String {
|
||||
require(deviceSn.isNotBlank()) { "Face box SN is empty" }
|
||||
val parsedBaseUrl = baseUrl.trim().toHttpUrlOrNull()
|
||||
?: throw IllegalArgumentException("Invalid face search base URL")
|
||||
|
||||
return parsedBaseUrl.newBuilder()
|
||||
// Preserve the existing /{sn}/api/search endpoint semantics even if configuration
|
||||
// accidentally contains a path or query string.
|
||||
.encodedPath("/")
|
||||
.query(null)
|
||||
.fragment(null)
|
||||
.addPathSegment(deviceSn)
|
||||
.addPathSegment("api")
|
||||
.addPathSegment("search")
|
||||
.build()
|
||||
.toString()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.yzx.kiosk.ui.face.resource
|
||||
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
internal class CaptureGate {
|
||||
private val nextToken = AtomicLong(0L)
|
||||
private val activeToken = AtomicLong(NO_TOKEN)
|
||||
|
||||
fun tryAcquire(): Long? {
|
||||
val token = nextToken.incrementAndGet()
|
||||
return if (activeToken.compareAndSet(NO_TOKEN, token)) token else null
|
||||
}
|
||||
|
||||
fun release(token: Long): Boolean = activeToken.compareAndSet(token, NO_TOKEN)
|
||||
|
||||
companion object {
|
||||
private const val NO_TOKEN = 0L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.yzx.kiosk.ui.face.resource
|
||||
|
||||
import android.content.Context
|
||||
import com.yzx.kiosk.utils.LogUtils
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Owns the short-lived files created for face-search requests. */
|
||||
@Singleton
|
||||
class FaceCaptureFileStore @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
fun createCaptureFile(): File {
|
||||
val directory = File(context.cacheDir, CAPTURE_DIRECTORY)
|
||||
check(directory.exists() || directory.mkdirs()) {
|
||||
"Unable to create face capture cache directory"
|
||||
}
|
||||
return File.createTempFile(CAPTURE_PREFIX, CAPTURE_SUFFIX, directory)
|
||||
}
|
||||
|
||||
fun delete(file: File?) {
|
||||
if (file == null || !file.exists()) return
|
||||
if (!file.delete()) {
|
||||
LogUtils.e(TAG, "Failed to delete face capture file: ${file.name}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A capture cannot remain in use across a process restart, so every owned file is orphaned
|
||||
* when the application starts. Legacy root-cache captures are removed during migration too.
|
||||
*/
|
||||
fun cleanupOrphans(): Int = cleanupOrphans(context.cacheDir)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "FaceCaptureFileStore"
|
||||
internal const val CAPTURE_DIRECTORY = "face_recognition"
|
||||
internal const val CAPTURE_PREFIX = "capture_"
|
||||
internal const val CAPTURE_SUFFIX = ".jpg"
|
||||
internal const val LEGACY_PREFIX = "IMG_"
|
||||
|
||||
internal fun cleanupOrphans(cacheDirectory: File): Int {
|
||||
var deletedCount = 0
|
||||
|
||||
val captureDirectory = File(cacheDirectory, CAPTURE_DIRECTORY)
|
||||
captureDirectory.listFiles()?.forEach { file ->
|
||||
if (file.deleteRecursively()) deletedCount++
|
||||
}
|
||||
|
||||
cacheDirectory.listFiles()?.forEach { file ->
|
||||
if (
|
||||
file.isFile &&
|
||||
file.name.startsWith(LEGACY_PREFIX) &&
|
||||
file.name.endsWith(CAPTURE_SUFFIX, ignoreCase = true) &&
|
||||
file.delete()
|
||||
) {
|
||||
deletedCount++
|
||||
}
|
||||
}
|
||||
|
||||
return deletedCount
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.yzx.kiosk.ui.face.resource
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Matrix
|
||||
import androidx.core.graphics.scale
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import com.yzx.kiosk.utils.LogUtils
|
||||
import javax.inject.Inject
|
||||
|
||||
class FaceThumbnailDecoder @Inject constructor() {
|
||||
fun decode(imagePath: String): Bitmap? {
|
||||
var ownedBitmap: Bitmap? = null
|
||||
return try {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(imagePath, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||
|
||||
val options = BitmapFactory.Options().apply {
|
||||
inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||
inSampleSize = calculateInSampleSize(
|
||||
bounds.outWidth,
|
||||
bounds.outHeight,
|
||||
MAX_PREVIEW_EDGE_PX,
|
||||
)
|
||||
}
|
||||
ownedBitmap = BitmapFactory.decodeFile(imagePath, options) ?: return null
|
||||
|
||||
val orientation = ExifInterface(imagePath).getAttributeInt(
|
||||
ExifInterface.TAG_ORIENTATION,
|
||||
ExifInterface.ORIENTATION_NORMAL,
|
||||
)
|
||||
val oriented = transformForExif(ownedBitmap, orientation)
|
||||
if (oriented !== ownedBitmap) {
|
||||
ownedBitmap.recycle()
|
||||
ownedBitmap = oriented
|
||||
}
|
||||
|
||||
val scaled = scaleDown(ownedBitmap, MAX_PREVIEW_EDGE_PX)
|
||||
if (scaled !== ownedBitmap) {
|
||||
ownedBitmap.recycle()
|
||||
}
|
||||
ownedBitmap = null
|
||||
scaled
|
||||
} catch (e: Exception) {
|
||||
ownedBitmap?.let { if (!it.isRecycled) it.recycle() }
|
||||
LogUtils.e(TAG, "Failed to decode face preview: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun transformForExif(source: Bitmap, orientation: Int): Bitmap {
|
||||
val matrix = Matrix()
|
||||
when (orientation) {
|
||||
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.setScale(-1f, 1f)
|
||||
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.setRotate(180f)
|
||||
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.setScale(1f, -1f)
|
||||
ExifInterface.ORIENTATION_TRANSPOSE -> {
|
||||
matrix.setRotate(90f)
|
||||
matrix.postScale(-1f, 1f)
|
||||
}
|
||||
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.setRotate(90f)
|
||||
ExifInterface.ORIENTATION_TRANSVERSE -> {
|
||||
matrix.setRotate(270f)
|
||||
matrix.postScale(-1f, 1f)
|
||||
}
|
||||
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.setRotate(270f)
|
||||
else -> return source
|
||||
}
|
||||
return Bitmap.createBitmap(source, 0, 0, source.width, source.height, matrix, true)
|
||||
}
|
||||
|
||||
private fun scaleDown(source: Bitmap, maxEdgePx: Int): Bitmap {
|
||||
val currentMaxEdge = maxOf(source.width, source.height)
|
||||
if (currentMaxEdge <= maxEdgePx) return source
|
||||
|
||||
val scale = maxEdgePx.toFloat() / currentMaxEdge
|
||||
val targetWidth = (source.width * scale).toInt().coerceAtLeast(1)
|
||||
val targetHeight = (source.height * scale).toInt().coerceAtLeast(1)
|
||||
return source.scale(targetWidth, targetHeight)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "FaceThumbnailDecoder"
|
||||
const val MAX_PREVIEW_EDGE_PX = 720
|
||||
|
||||
internal fun calculateInSampleSize(width: Int, height: Int, maxEdgePx: Int): Int {
|
||||
if (width <= 0 || height <= 0 || maxEdgePx <= 0) return 1
|
||||
var sampleSize = 1
|
||||
while (maxOf(width / (sampleSize * 2), height / (sampleSize * 2)) > maxEdgePx) {
|
||||
sampleSize *= 2
|
||||
}
|
||||
return sampleSize
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -2,8 +2,6 @@ package com.yzx.kiosk.ui.face.view
|
||||
|
||||
import android.Manifest
|
||||
import android.graphics.Bitmap
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.camera.core.*
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
@@ -39,9 +37,6 @@ import com.yzx.kiosk.component.scaffold.AppScaffold
|
||||
import com.yzx.kiosk.ui.common.view.FullScreenMode
|
||||
import com.yzx.kiosk.ui.face.viewmodel.FaceRecognitionViewModel
|
||||
import com.yzx.kiosk.ui.face.viewmodel.RecognitionStatus
|
||||
import java.util.concurrent.Executor
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
@@ -56,6 +51,7 @@ fun FaceRecognitionScreen(
|
||||
val recognitionStatus by viewModel.recognitionStatus.collectAsState()
|
||||
val capturedImage by viewModel.capturedImage.collectAsState()
|
||||
val isRecognizing by viewModel.isRecognizing.collectAsState()
|
||||
val isCaptureInProgress by viewModel.isCaptureInProgress.collectAsState()
|
||||
|
||||
// 相机权限
|
||||
val permissionsState = rememberMultiplePermissionsState(
|
||||
@@ -103,6 +99,9 @@ fun FaceRecognitionScreen(
|
||||
onImageCaptureReady = { imageCapture ->
|
||||
viewModel.setImageCapture(imageCapture)
|
||||
},
|
||||
onImageCaptureReleased = { imageCapture ->
|
||||
viewModel.clearImageCapture(imageCapture)
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
@@ -201,7 +200,7 @@ fun FaceRecognitionScreen(
|
||||
}
|
||||
}
|
||||
// 拍照按钮(5秒倒计时期间也显示,识别失败时显示"重新拍照",其他情况显示"拍照")
|
||||
if (!isRecognizing) {
|
||||
if (!isRecognizing && !isCaptureInProgress) {
|
||||
Button(
|
||||
onClick = {
|
||||
if (recognitionStatus == RecognitionStatus.FAILED) {
|
||||
@@ -309,50 +308,62 @@ fun FaceRecognitionScreen(
|
||||
@Composable
|
||||
fun CameraPreview(
|
||||
onImageCaptureReady: (ImageCapture) -> Unit,
|
||||
onImageCaptureReleased: (ImageCapture) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val currentOnImageCaptureReady by rememberUpdatedState(onImageCaptureReady)
|
||||
val currentOnImageCaptureReleased by rememberUpdatedState(onImageCaptureReleased)
|
||||
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
val previewView = PreviewView(ctx)
|
||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
|
||||
|
||||
cameraProviderFuture.addListener({
|
||||
val cameraProvider = cameraProviderFuture.get()
|
||||
|
||||
// 创建预览
|
||||
val preview = Preview.Builder().build().also {
|
||||
it.setSurfaceProvider(previewView.surfaceProvider)
|
||||
}
|
||||
|
||||
// 创建图像捕获
|
||||
val imageCapture = ImageCapture.Builder()
|
||||
val previewView = remember(context) { PreviewView(context) }
|
||||
val preview = remember { Preview.Builder().build() }
|
||||
val imageCapture = remember {
|
||||
ImageCapture.Builder()
|
||||
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
|
||||
.build()
|
||||
}
|
||||
|
||||
// 通知ViewModel ImageCapture已准备好
|
||||
onImageCaptureReady(imageCapture)
|
||||
|
||||
// 绑定到生命周期
|
||||
val cameraSelector = CameraSelector.DEFAULT_FRONT_CAMERA
|
||||
try {
|
||||
cameraProvider.unbindAll()
|
||||
cameraProvider.bindToLifecycle(
|
||||
lifecycleOwner,
|
||||
cameraSelector,
|
||||
preview,
|
||||
imageCapture
|
||||
AndroidView(
|
||||
factory = { previewView },
|
||||
modifier = modifier
|
||||
)
|
||||
|
||||
DisposableEffect(context, lifecycleOwner, preview, imageCapture) {
|
||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
|
||||
val executor = ContextCompat.getMainExecutor(context)
|
||||
var cameraProvider: ProcessCameraProvider? = null
|
||||
var disposed = false
|
||||
|
||||
preview.setSurfaceProvider(previewView.surfaceProvider)
|
||||
|
||||
cameraProviderFuture.addListener({
|
||||
try {
|
||||
val resolvedProvider = cameraProviderFuture.get()
|
||||
if (disposed) {
|
||||
resolvedProvider.unbind(preview, imageCapture)
|
||||
return@addListener
|
||||
}
|
||||
|
||||
cameraProvider = resolvedProvider
|
||||
resolvedProvider.bindToLifecycle(
|
||||
lifecycleOwner,
|
||||
CameraSelector.DEFAULT_FRONT_CAMERA,
|
||||
preview,
|
||||
imageCapture,
|
||||
)
|
||||
currentOnImageCaptureReady(imageCapture)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(ctx))
|
||||
}, executor)
|
||||
|
||||
previewView
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
onDispose {
|
||||
disposed = true
|
||||
currentOnImageCaptureReleased(imageCapture)
|
||||
preview.setSurfaceProvider(null)
|
||||
cameraProvider?.unbind(preview, imageCapture)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
+213
-79
@@ -1,16 +1,12 @@
|
||||
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.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
|
||||
import com.yzx.kiosk.network.model.response.FaceSearchResult
|
||||
import com.yzx.kiosk.network.repository.NetWorkRepository
|
||||
@@ -25,15 +21,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 +75,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 +105,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 +128,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
// 保存原始结果列表
|
||||
originalResults = results
|
||||
urlToIdMap.clear()
|
||||
|
||||
// 先使用默认宽高比创建列表
|
||||
val initialPhotos = results.mapNotNull { result ->
|
||||
@@ -145,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
|
||||
|
||||
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 +312,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)
|
||||
}
|
||||
@@ -354,13 +342,165 @@ 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)
|
||||
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) }
|
||||
|
||||
@@ -384,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package com.yzx.kiosk.ui.face.viewmodel
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Matrix
|
||||
import androidx.camera.core.ImageCapture
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import androidx.camera.core.ImageCaptureException
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -16,15 +13,20 @@ import com.yzx.kiosk.datastore.AppStoreDataSource
|
||||
import com.google.gson.Gson
|
||||
import com.yzx.kiosk.network.model.response.FaceSearchResponse
|
||||
import com.yzx.kiosk.network.service.FaceSearchService
|
||||
import com.yzx.kiosk.network.service.buildFaceSearchUrl
|
||||
import com.yzx.kiosk.App
|
||||
import com.yzx.kiosk.BuildConfig
|
||||
import com.yzx.kiosk.navigation.AppNavigator
|
||||
import com.yzx.kiosk.navigation.routes.AppRoutes
|
||||
import com.yzx.kiosk.ui.setting.navigation.AgreementRoutes
|
||||
import com.yzx.kiosk.ui.face.resource.CaptureGate
|
||||
import com.yzx.kiosk.ui.face.resource.FaceCaptureFileStore
|
||||
import com.yzx.kiosk.ui.face.resource.FaceThumbnailDecoder
|
||||
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
|
||||
@@ -34,13 +36,8 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
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
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
@@ -58,7 +55,10 @@ class FaceRecognitionViewModel @Inject constructor(
|
||||
navigator: AppNavigator,
|
||||
appState: AppState,
|
||||
private val appStoreDataSource: AppStoreDataSource,
|
||||
private val localAudioPlayService: LocalAudioPlayService
|
||||
private val localAudioPlayService: LocalAudioPlayService,
|
||||
private val faceSearchService: FaceSearchService,
|
||||
private val faceCaptureFileStore: FaceCaptureFileStore,
|
||||
private val faceThumbnailDecoder: FaceThumbnailDecoder,
|
||||
) : BaseViewModel(
|
||||
navigator = navigator,
|
||||
appState = appState
|
||||
@@ -90,9 +90,16 @@ class FaceRecognitionViewModel @Inject constructor(
|
||||
private val _isRecognizing = MutableStateFlow(false)
|
||||
val isRecognizing: StateFlow<Boolean> = _isRecognizing.asStateFlow()
|
||||
|
||||
private val _isCaptureInProgress = MutableStateFlow(false)
|
||||
val isCaptureInProgress: StateFlow<Boolean> = _isCaptureInProgress.asStateFlow()
|
||||
|
||||
// 倒计时Job
|
||||
private var countdownJob: kotlinx.coroutines.Job? = null
|
||||
private var autoCaptureJob: kotlinx.coroutines.Job? = null
|
||||
private val captureGate = CaptureGate()
|
||||
private var activeCaptureToken: Long? = null
|
||||
private var activeCaptureFile: File? = null
|
||||
private var isCleared = false
|
||||
|
||||
// ImageCapture实例
|
||||
private var imageCapture: ImageCapture? = null
|
||||
@@ -108,6 +115,15 @@ class FaceRecognitionViewModel @Inject constructor(
|
||||
this.imageCapture = imageCapture
|
||||
}
|
||||
|
||||
fun clearImageCapture(imageCapture: ImageCapture) {
|
||||
if (this.imageCapture === imageCapture) {
|
||||
this.imageCapture = null
|
||||
activeCaptureToken?.let { captureToken ->
|
||||
finishCaptureFile(activeCaptureFile, captureToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始倒计时
|
||||
*/
|
||||
@@ -119,7 +135,7 @@ class FaceRecognitionViewModel @Inject constructor(
|
||||
_countdown.value = _countdown.value - 1
|
||||
}
|
||||
// 倒计时结束,自动返回首页
|
||||
toPage(com.yzx.kiosk.navigation.routes.AppRoutes.HOME)
|
||||
closeAllExcept(AppRoutes.HOME)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +169,12 @@ class FaceRecognitionViewModel @Inject constructor(
|
||||
* 手动拍照
|
||||
*/
|
||||
fun takePhoto() {
|
||||
if (_isRecognizing.value) return
|
||||
val captureToken = captureGate.tryAcquire() ?: return
|
||||
|
||||
activeCaptureToken = captureToken
|
||||
_isCaptureInProgress.value = true
|
||||
|
||||
// 如果正在自动拍照倒计时,先取消
|
||||
if (_captureCountdown.value > 0) {
|
||||
cancelAutoCapture()
|
||||
@@ -166,77 +188,124 @@ class FaceRecognitionViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
val capture = imageCapture ?: run {
|
||||
releaseCaptureGate(captureToken)
|
||||
ToastUtils.show("相机未准备好")
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
// 创建输出文件
|
||||
val photoFile = withContext(Dispatchers.IO) {
|
||||
File.createTempFile(
|
||||
"IMG_${SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())}",
|
||||
".jpg",
|
||||
App.instance.cacheDir
|
||||
)
|
||||
var photoFile: File? = null
|
||||
try {
|
||||
photoFile = withContext(Dispatchers.IO) {
|
||||
faceCaptureFileStore.createCaptureFile()
|
||||
}
|
||||
if (
|
||||
isCleared ||
|
||||
imageCapture !== capture ||
|
||||
activeCaptureToken != captureToken
|
||||
) {
|
||||
faceCaptureFileStore.delete(photoFile)
|
||||
releaseCaptureGate(captureToken)
|
||||
return@launch
|
||||
}
|
||||
activeCaptureFile = photoFile
|
||||
|
||||
// 创建输出选项
|
||||
val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
|
||||
|
||||
// 拍照
|
||||
capture.takePicture(
|
||||
outputOptions,
|
||||
ContextCompat.getMainExecutor(App.instance),
|
||||
object : ImageCapture.OnImageSavedCallback {
|
||||
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
|
||||
// 加载图片为Bitmap,并处理EXIF方向
|
||||
val bitmap = loadBitmapWithExifOrientation(photoFile.absolutePath)
|
||||
_capturedImage.value = bitmap
|
||||
// 直接调用搜索接口
|
||||
searchFaceDirectly(photoFile)
|
||||
if (
|
||||
isCleared ||
|
||||
imageCapture !== capture ||
|
||||
activeCaptureToken != captureToken
|
||||
) {
|
||||
finishCaptureFile(photoFile, captureToken)
|
||||
return
|
||||
}
|
||||
recognizeSavedPhoto(photoFile, captureToken)
|
||||
}
|
||||
|
||||
override fun onError(exception: ImageCaptureException) {
|
||||
LogUtils.e(TAG, "拍照失败: ${exception.message}")
|
||||
finishCaptureFile(photoFile, captureToken)
|
||||
if (!isCleared && imageCapture === capture) {
|
||||
ToastUtils.show("拍照失败")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
finishCaptureFile(photoFile, captureToken)
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LogUtils.e(TAG, "创建人脸拍照文件失败: ${e.message}")
|
||||
finishCaptureFile(photoFile, captureToken)
|
||||
ToastUtils.show("拍照失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接调用人脸搜索接口
|
||||
*/
|
||||
private fun searchFaceDirectly(photoFile: File) {
|
||||
private fun recognizeSavedPhoto(photoFile: File, captureToken: Long) {
|
||||
viewModelScope.launch {
|
||||
_isRecognizing.value = true
|
||||
_recognitionStatus.value = RecognitionStatus.RECOGNIZING
|
||||
// 播放识别中音频
|
||||
localAudioPlayService.playByRoute("face_recognition_recognizing")
|
||||
// 播放识别中音频
|
||||
releaseCaptureGate(captureToken)
|
||||
localAudioPlayService.playByRoute("face_recognition_recognizing")
|
||||
|
||||
try {
|
||||
// 直接调用人脸识别接口
|
||||
_capturedImage.value = withContext(Dispatchers.IO) {
|
||||
faceThumbnailDecoder.decode(photoFile.absolutePath)
|
||||
}
|
||||
searchFace(photoFile)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LogUtils.e(TAG, "处理失败: ${e.message}")
|
||||
ToastUtils.show("处理失败: ${e.message}")
|
||||
_isRecognizing.value = false
|
||||
_recognitionStatus.value = RecognitionStatus.FAILED
|
||||
} finally {
|
||||
finishCaptureFile(photoFile, captureToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseCaptureGate(captureToken: Long) {
|
||||
if (captureGate.release(captureToken)) {
|
||||
if (activeCaptureToken == captureToken) {
|
||||
activeCaptureToken = null
|
||||
}
|
||||
_isCaptureInProgress.value = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun finishCaptureFile(photoFile: File?, captureToken: Long) {
|
||||
faceCaptureFileStore.delete(photoFile)
|
||||
if (activeCaptureFile === photoFile) {
|
||||
activeCaptureFile = null
|
||||
}
|
||||
releaseCaptureGate(captureToken)
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索人脸
|
||||
*/
|
||||
private suspend fun searchFace(imageFile: File) = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
|
||||
val FACE_SEARCH_BASE_URL = if (appStoreDataSource.getUseLan()) appStoreDataSource.getBindBoxLanUrl() else appStoreDataSource.getBindBoxUrl()
|
||||
val FACE_SEARCH_TOKEN = appStoreDataSource.getBindBoxApiToken()
|
||||
val faceSearchBaseUrl = if (appStoreDataSource.getUseLan()) {
|
||||
appStoreDataSource.getBindBoxLanUrl()
|
||||
} else {
|
||||
appStoreDataSource.getBindBoxUrl()
|
||||
}
|
||||
val faceSearchToken = appStoreDataSource.getBindBoxApiToken()
|
||||
val requestUrl = buildFaceSearchUrl(
|
||||
baseUrl = faceSearchBaseUrl,
|
||||
deviceSn = appStoreDataSource.getBindBoxSn(),
|
||||
)
|
||||
|
||||
|
||||
// 创建MultipartBody
|
||||
@@ -250,47 +319,23 @@ class FaceRecognitionViewModel @Inject constructor(
|
||||
// index_date 参数:当前日期,格式 YYYYMMDD
|
||||
val dateFormat = SimpleDateFormat("yyyyMMdd", Locale.getDefault())
|
||||
val indexDate = dateFormat.format(Date())
|
||||
val indexDateBody = indexDate.toRequestBody("text/plain".toMediaType())
|
||||
|
||||
// Authorization header
|
||||
val authorization = "Bearer $FACE_SEARCH_TOKEN"
|
||||
val authorization = "Bearer $faceSearchToken"
|
||||
|
||||
// 打印请求信息
|
||||
val requestUrl = "$FACE_SEARCH_BASE_URL/api/search"
|
||||
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,添加日志拦截器
|
||||
val client = OkHttpClient.Builder()
|
||||
.addInterceptor(loggingInterceptor)
|
||||
.build()
|
||||
|
||||
// 创建Retrofit实例
|
||||
val retrofit = Retrofit.Builder()
|
||||
.baseUrl("$FACE_SEARCH_BASE_URL/")
|
||||
.client(client)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
|
||||
val service = retrofit.create(FaceSearchService::class.java)
|
||||
|
||||
// 调用接口
|
||||
val response = service.searchFace(
|
||||
sn = appStoreDataSource.getBindBoxSn(),
|
||||
val response = faceSearchService.searchFace(
|
||||
url = requestUrl,
|
||||
authorization = authorization,
|
||||
image = imagePart,
|
||||
threshold = thresholdBody,
|
||||
@@ -318,6 +363,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}")
|
||||
@@ -345,7 +393,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)
|
||||
@@ -365,84 +413,6 @@ class FaceRecognitionViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载Bitmap并处理EXIF方向信息
|
||||
* 解决不同设备拍照后图片方向不正确的问题
|
||||
*/
|
||||
private fun loadBitmapWithExifOrientation(imagePath: String): Bitmap? {
|
||||
return try {
|
||||
// 先读取EXIF方向信息
|
||||
val exif = ExifInterface(imagePath)
|
||||
val orientation = exif.getAttributeInt(
|
||||
ExifInterface.TAG_ORIENTATION,
|
||||
ExifInterface.ORIENTATION_NORMAL
|
||||
)
|
||||
|
||||
// 加载原始Bitmap
|
||||
val bitmap = BitmapFactory.decodeFile(imagePath) ?: return null
|
||||
|
||||
// 根据EXIF方向旋转Bitmap
|
||||
val rotationDegrees = when (orientation) {
|
||||
ExifInterface.ORIENTATION_ROTATE_90 -> 90f
|
||||
ExifInterface.ORIENTATION_ROTATE_180 -> 180f
|
||||
ExifInterface.ORIENTATION_ROTATE_270 -> 270f
|
||||
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> {
|
||||
// 水平翻转
|
||||
val matrix = Matrix()
|
||||
matrix.setScale(-1f, 1f)
|
||||
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
||||
}
|
||||
ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
|
||||
// 垂直翻转
|
||||
val matrix = Matrix()
|
||||
matrix.setScale(1f, -1f)
|
||||
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
||||
}
|
||||
ExifInterface.ORIENTATION_TRANSPOSE -> {
|
||||
// 转置(旋转90度+水平翻转)
|
||||
val matrix = Matrix()
|
||||
matrix.setRotate(90f)
|
||||
matrix.postScale(-1f, 1f)
|
||||
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
||||
}
|
||||
ExifInterface.ORIENTATION_TRANSVERSE -> {
|
||||
// 横向(旋转270度+水平翻转)
|
||||
val matrix = Matrix()
|
||||
matrix.setRotate(270f)
|
||||
matrix.postScale(-1f, 1f)
|
||||
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
||||
}
|
||||
else -> 0f // 不需要旋转
|
||||
}
|
||||
|
||||
// 如果需要旋转
|
||||
if (rotationDegrees != 0f) {
|
||||
val matrix = Matrix()
|
||||
matrix.postRotate(rotationDegrees)
|
||||
val rotatedBitmap = Bitmap.createBitmap(
|
||||
bitmap,
|
||||
0,
|
||||
0,
|
||||
bitmap.width,
|
||||
bitmap.height,
|
||||
matrix,
|
||||
true
|
||||
)
|
||||
// 回收原始bitmap
|
||||
if (rotatedBitmap != bitmap) {
|
||||
bitmap.recycle()
|
||||
}
|
||||
rotatedBitmap
|
||||
} else {
|
||||
bitmap
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtils.e(TAG, "加载图片并处理EXIF方向失败: ${e.message}")
|
||||
// 如果处理失败,返回原始Bitmap
|
||||
BitmapFactory.decodeFile(imagePath)
|
||||
}
|
||||
}
|
||||
|
||||
fun privatePolicy() {
|
||||
val args = mapOf(
|
||||
AgreementRoutes.AGREEMENT_URL to BuildConfig.BASE_URL + "/pages/oscar/private-policy.html",
|
||||
@@ -472,9 +442,15 @@ class FaceRecognitionViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
isCleared = true
|
||||
countdownJob?.cancel()
|
||||
autoCaptureJob?.cancel()
|
||||
imageCapture = null
|
||||
_capturedImage.value = null
|
||||
faceCaptureFileStore.delete(activeCaptureFile)
|
||||
activeCaptureFile = null
|
||||
activeCaptureToken?.let(::releaseCaptureGate)
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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,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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.yzx.kiosk.network.service
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Test
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
|
||||
class FaceSearchUrlTest {
|
||||
@Test
|
||||
fun `url uses configured host and safely encoded sn`() {
|
||||
assertEquals(
|
||||
"http://192.168.1.10/BOX%2F01/api/search",
|
||||
buildFaceSearchUrl("http://192.168.1.10/old/path?unused=true", "BOX/01"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid configuration is rejected`() {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
buildFaceSearchUrl("not-a-url", "BOX-1")
|
||||
}
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
buildFaceSearchUrl("http://192.168.1.10", "")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retrofit accepts multipart post with dynamic url`() {
|
||||
Retrofit.Builder()
|
||||
.baseUrl("https://example.test/")
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.validateEagerly(true)
|
||||
.build()
|
||||
.create(FaceSearchService::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.yzx.kiosk.ui.face.resource
|
||||
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
|
||||
class FaceCaptureFileStoreTest {
|
||||
private lateinit var cacheDirectory: File
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
cacheDirectory = Files.createTempDirectory("face-capture-test").toFile()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
cacheDirectory.deleteRecursively()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cleanup removes owned and legacy captures only`() {
|
||||
val captureDirectory = File(
|
||||
cacheDirectory,
|
||||
FaceCaptureFileStore.CAPTURE_DIRECTORY,
|
||||
).apply { mkdirs() }
|
||||
val ownedCapture = File(captureDirectory, "capture_1.jpg").apply { writeText("face") }
|
||||
val legacyCapture = File(cacheDirectory, "IMG_20260825.jpg").apply { writeText("face") }
|
||||
val unrelatedJpeg = File(cacheDirectory, "holiday.jpg").apply { writeText("keep") }
|
||||
val similarLegacyFile = File(cacheDirectory, "IMG_notes.txt").apply { writeText("keep") }
|
||||
|
||||
val deleted = FaceCaptureFileStore.cleanupOrphans(cacheDirectory)
|
||||
|
||||
assertEquals(2, deleted)
|
||||
assertFalse(ownedCapture.exists())
|
||||
assertFalse(legacyCapture.exists())
|
||||
assertTrue(unrelatedJpeg.exists())
|
||||
assertTrue(similarLegacyFile.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cleanup is idempotent`() {
|
||||
assertEquals(0, FaceCaptureFileStore.cleanupOrphans(cacheDirectory))
|
||||
assertEquals(0, FaceCaptureFileStore.cleanupOrphans(cacheDirectory))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.yzx.kiosk.ui.face.resource
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class FaceResourceHelpersTest {
|
||||
@Test
|
||||
fun `sample size bounds large source before final scale`() {
|
||||
assertEquals(4, FaceThumbnailDecoder.calculateInSampleSize(4000, 3000, 720))
|
||||
assertEquals(1, FaceThumbnailDecoder.calculateInSampleSize(1280, 720, 720))
|
||||
assertEquals(1, FaceThumbnailDecoder.calculateInSampleSize(0, 0, 720))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `capture gate permits only one active capture`() {
|
||||
val gate = CaptureGate()
|
||||
|
||||
val firstToken = gate.tryAcquire()
|
||||
assertTrue(firstToken != null)
|
||||
assertEquals(null, gate.tryAcquire())
|
||||
|
||||
assertTrue(gate.release(firstToken!!))
|
||||
val secondToken = gate.tryAcquire()
|
||||
assertTrue(secondToken != null)
|
||||
|
||||
assertFalse(gate.release(firstToken))
|
||||
assertEquals(null, gate.tryAcquire())
|
||||
assertTrue(gate.release(secondToken!!))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
# 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
|
||||
-> 打印页 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):选片、计价、获取支付二维码,并在支付成功后直接进入打印页
|
||||
- [`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 或 HTTP 轮询确认支付成功后直接进入打印页。
|
||||
|
||||
主要代码:
|
||||
|
||||
- [`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` 订阅是否仍处于活跃生命周期。
|
||||
Reference in New Issue
Block a user