Compare commits
6
Commits
1.1.2
..
712f6154bc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
712f6154bc | ||
|
|
1d66bd18d1 | ||
|
|
63944db6fb | ||
|
|
fb5d3b71df | ||
|
|
cd25ada951 | ||
|
|
4c829f74a9 |
@@ -89,8 +89,8 @@ android {
|
||||
applicationId = "com.yzx.kiosk"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 22
|
||||
versionName = "1.1.2"
|
||||
versionCode = 24
|
||||
versionName = "1.1.4"
|
||||
//multiDexEnabled = true
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.yzx.kiosk.audio
|
||||
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.yzx.kiosk.R
|
||||
import com.yzx.kiosk.navigation.routes.AppRoutes
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class LocalAudioPlaybackTest {
|
||||
@Test fun recentAndFaceEntriesSwitchAudioWithoutDuplicatePlayback() {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
lateinit var service: LocalAudioPlayService
|
||||
lateinit var player: ExoPlayer
|
||||
val transitions = AtomicInteger()
|
||||
instrumentation.runOnMainSync {
|
||||
service = LocalAudioPlayService(instrumentation.targetContext)
|
||||
player = LocalAudioPlayService::class.java.getDeclaredField("exoPlayer").apply { isAccessible = true }.get(service) as ExoPlayer
|
||||
player.addListener(object : Player.Listener {
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { transitions.incrementAndGet() }
|
||||
})
|
||||
}
|
||||
try {
|
||||
instrumentation.runOnMainSync { service.playByRoute(AppRoutes.buildRecentPhotosRoute()) }
|
||||
awaitPlaying(service)
|
||||
assertEquals(R.raw.recent_photos_result_page, service.currentResId.value)
|
||||
assertEquals(1, transitions.get())
|
||||
instrumentation.runOnMainSync { service.playByRoute(AppRoutes.buildRecentPhotosRoute()) }
|
||||
instrumentation.waitForIdleSync()
|
||||
assertEquals("Repeated recent entry must not reset the media item", 1, transitions.get())
|
||||
|
||||
instrumentation.runOnMainSync { service.playByRoute("${AppRoutes.FACE_RECOGNITION_RESULT}?results=%5B%5D&source=face") }
|
||||
awaitPlaying(service)
|
||||
assertEquals(R.raw.face_recognition_success_result_page, service.currentResId.value)
|
||||
assertEquals(2, transitions.get())
|
||||
instrumentation.runOnMainSync { service.playByRoute(AppRoutes.FACE_RECOGNITION_RESULT) }
|
||||
instrumentation.waitForIdleSync()
|
||||
assertEquals("Legacy face route uses the same success clip", 2, transitions.get())
|
||||
} finally {
|
||||
instrumentation.runOnMainSync { service.release() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun awaitPlaying(service: LocalAudioPlayService) {
|
||||
val deadline = System.currentTimeMillis() + 5000
|
||||
while (!service.isPlaying.value && System.currentTimeMillis() < deadline) Thread.sleep(20)
|
||||
assertTrue("Local audio should enter playback", service.isPlaying.value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package com.yzx.kiosk.ui.face
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.graphics.asAndroidBitmap
|
||||
import androidx.compose.ui.test.*
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import com.yzx.kiosk.network.model.response.FaceSearchResult
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.*
|
||||
import androidx.navigation.navArgument
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.google.gson.Gson
|
||||
import com.yzx.kiosk.audio.LocalAudioPlayService
|
||||
import com.yzx.kiosk.navigation.AppNavigator
|
||||
import com.yzx.kiosk.navigation.handleNavigationEvent
|
||||
import com.yzx.kiosk.navigation.routes.AppRoutes
|
||||
import com.yzx.kiosk.network.repository.NetWorkRepository
|
||||
import com.yzx.kiosk.network.service.*
|
||||
import com.yzx.kiosk.ui.face.resource.FaceCaptureFileStore
|
||||
import com.yzx.kiosk.ui.face.resource.FaceThumbnailDecoder
|
||||
import com.yzx.kiosk.ui.face.view.*
|
||||
import com.yzx.kiosk.ui.face.viewmodel.*
|
||||
import com.yzx.kiosk.websocket.WebSocketService
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import okhttp3.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.*
|
||||
import org.junit.Assert.*
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.io.File
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
/** All photo/order HTTP requests use an in-process interceptor; no orders or prints are submitted. */
|
||||
class RecentPhotosFlowTest {
|
||||
@get:Rule val compose = createComposeRule()
|
||||
private val context get() = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private lateinit var resultVm: FaceRecognitionResultViewModel
|
||||
private lateinit var createResultVm: () -> FaceRecognitionResultViewModel
|
||||
private val scopedResultVms = mutableListOf<FaceRecognitionResultViewModel>()
|
||||
private lateinit var faceVm: FaceRecognitionViewModel
|
||||
private lateinit var nav: AppNavigator
|
||||
private var restoreConfig: (() -> Unit)? = null
|
||||
private val requests = CopyOnWriteArrayList<Request>()
|
||||
@Volatile private var recentCode = 200
|
||||
@Volatile private var recentBody = """{"count":0,"results":[]}"""
|
||||
|
||||
@Before fun setup() {
|
||||
val app = context.applicationContext as com.yzx.kiosk.App
|
||||
// Access the existing singleton without launching MainActivity (which connects live services).
|
||||
val component = (app as dagger.hilt.internal.GeneratedComponentManager<*>).generatedComponent()
|
||||
val providerField = component.javaClass.getDeclaredField("webSocketServiceProvider").apply { isAccessible = true }
|
||||
val socket = (providerField.get(component) as javax.inject.Provider<*>).get() as WebSocketService
|
||||
val store = app.appStoreDataSource
|
||||
val oldRemote = store.getBindBoxUrl()
|
||||
val oldLan = store.getBindBoxLanUrl()
|
||||
val oldSn = store.getBindBoxSn()
|
||||
val oldToken = store.getBindBoxApiToken()
|
||||
restoreConfig = {
|
||||
store.saveBindBoxUrl(oldRemote)
|
||||
store.saveBindBoxLanUrl(oldLan)
|
||||
store.saveBindBoxSn(oldSn)
|
||||
store.saveBindBoxApiToken(oldToken)
|
||||
}
|
||||
store.saveBindBoxUrl("https://example.test")
|
||||
store.saveBindBoxLanUrl("https://example.test")
|
||||
store.saveBindBoxSn("MOCK-BOX")
|
||||
store.saveBindBoxApiToken("test-token")
|
||||
val client = OkHttpClient.Builder().addInterceptor { chain ->
|
||||
val req = chain.request()
|
||||
requests.add(req)
|
||||
val recent = req.url.encodedPath.endsWith("/api/photos/recent")
|
||||
val body = when {
|
||||
recent -> recentBody
|
||||
req.url.encodedPath.endsWith("verify-result") -> {
|
||||
val buffer = okio.Buffer()
|
||||
req.body!!.writeTo(buffer)
|
||||
val ids = Gson().fromJson(buffer.readUtf8(), com.google.gson.JsonObject::class.java).getAsJsonArray("image_id")
|
||||
val amount = "${ids.size() * 2}.00"
|
||||
"""{"code":100000,"data":{"price_image":"2.00","amount":"$amount"}}"""
|
||||
}
|
||||
req.url.encodedPath.endsWith("get-pay-url") -> """{"code":100000,"data":{"url":"https://example.test/mock-pay","order_number":"MOCK-RECENT-1"}}"""
|
||||
else -> """{"count":0,"results":[]}"""
|
||||
}
|
||||
Response.Builder().request(req).protocol(Protocol.HTTP_1_1).code(if (recent) recentCode else 200)
|
||||
.message("Mock").body(body.toResponseBody("application/json".toMediaType())).build()
|
||||
}.build()
|
||||
val retrofit = Retrofit.Builder().baseUrl("https://example.test/").client(client)
|
||||
.addConverterFactory(GsonConverterFactory.create()).build()
|
||||
val service = retrofit.create(FaceSearchService::class.java)
|
||||
val repository = NetWorkRepository(retrofit.create(NetworkService::class.java), retrofit.create(UploadService::class.java), Gson())
|
||||
compose.runOnUiThread {
|
||||
nav = AppNavigator()
|
||||
createResultVm = { FaceRecognitionResultViewModel(nav, app.appState, repository, service, socket, app.appStoreDataSource) }
|
||||
resultVm = createResultVm()
|
||||
faceVm = FaceRecognitionViewModel(nav, app.appState, app.appStoreDataSource, LocalAudioPlayService(context), service, FaceCaptureFileStore(context), FaceThumbnailDecoder())
|
||||
}
|
||||
}
|
||||
|
||||
@After fun cleanup() {
|
||||
compose.runOnUiThread {
|
||||
if (::resultVm.isInitialized) resultVm.viewModelScope.cancel()
|
||||
if (::faceVm.isInitialized) faceVm.viewModelScope.cancel()
|
||||
scopedResultVms.forEach { it.viewModelScope.cancel() }
|
||||
restoreConfig?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
private fun screenshot(name: String) {
|
||||
val image = compose.onRoot().captureToImage().asAndroidBitmap()
|
||||
File(context.getExternalFilesDir(null), "$name.png").outputStream().use { image.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||
}
|
||||
|
||||
|
||||
@Test fun resultToolbarPushesIndependentPageAndRestoresSelectionAndScroll() {
|
||||
val bitmap = Bitmap.createBitmap(160, 100, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.BLUE) }
|
||||
val faceResults = (1..30).map { id ->
|
||||
val file = File(context.cacheDir, "toolbar-photo-$id.png")
|
||||
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||
val url = file.toURI().toString()
|
||||
FaceSearchResult(id, url, url, url, url)
|
||||
}
|
||||
recentBody = Gson().toJson(mapOf("count" to 2, "results" to faceResults.takeLast(2)))
|
||||
lateinit var controller: NavHostController
|
||||
val factory = object : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T =
|
||||
createResultVm().also { scopedResultVms.add(it) } as T
|
||||
}
|
||||
compose.setContent {
|
||||
controller = rememberNavController()
|
||||
LaunchedEffect(Unit) { nav.navigationEvents.collect { controller.handleNavigationEvent(it) } }
|
||||
NavHost(controller, startDestination = AppRoutes.HOME) {
|
||||
composable(AppRoutes.HOME) { Text("测试首页") }
|
||||
composable(AppRoutes.FACE_RESULT_PATTERN, arguments = listOf(
|
||||
navArgument("results") { type = NavType.StringType; defaultValue = "" },
|
||||
navArgument("source") { type = NavType.StringType; defaultValue = AppRoutes.FACE_RESULT_SOURCE },
|
||||
)) { entry ->
|
||||
val vm: FaceRecognitionResultViewModel = viewModel(viewModelStoreOwner = entry, factory = factory)
|
||||
val source = entry.arguments?.getString("source")!!
|
||||
FaceRecognitionResultScreen(
|
||||
results = if (source == AppRoutes.FACE_RESULT_SOURCE) faceResults else emptyList(),
|
||||
source = source,
|
||||
viewModel = vm,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
compose.runOnIdle { controller.navigate(AppRoutes.FACE_RECOGNITION_RESULT) }
|
||||
compose.waitUntil(10_000) { scopedResultVms.firstOrNull()?.photoList?.value?.size == 30 }
|
||||
compose.onNodeWithText("人脸识别结果").assertIsDisplayed()
|
||||
val originalVm = scopedResultVms.first()
|
||||
compose.runOnIdle { originalVm.togglePhotoSelection(faceResults[0].thumbnailOssUrl!!) }
|
||||
compose.waitUntil(10_000) { originalVm.totalPrice.value == "2.00" }
|
||||
compose.onNodeWithText("查看最近照片").assertIsDisplayed().assertIsEnabled()
|
||||
screenshot("face-results-browse-button")
|
||||
val grid = compose.onNode(hasScrollToIndexAction())
|
||||
grid.performScrollToIndex(12)
|
||||
compose.waitForIdle()
|
||||
val scrollBefore = grid.fetchSemanticsNode().config[SemanticsProperties.VerticalScrollAxisRange].value()
|
||||
val selectedBefore = originalVm.selectedPhotos.value
|
||||
val totalBefore = originalVm.totalPrice.value
|
||||
var originalEntryId = ""
|
||||
compose.runOnIdle { originalEntryId = controller.currentBackStackEntry!!.id }
|
||||
// Two taps in one event cycle must produce only one pushed page.
|
||||
compose.onNodeWithText("查看最近照片").performTouchInput { doubleClick() }
|
||||
compose.waitUntil(10_000) { scopedResultVms.size == 2 && scopedResultVms[1].recentPhotosState.value == RecentPhotosState.READY }
|
||||
compose.onNodeWithText("最近照片").assertIsDisplayed()
|
||||
val recentVm = scopedResultVms[1]
|
||||
assertNotSame(originalVm, recentVm)
|
||||
assertTrue(recentVm.selectedPhotos.value.isEmpty())
|
||||
assertEquals(selectedBefore, originalVm.selectedPhotos.value)
|
||||
compose.onNodeWithText("查看最近照片").assertDoesNotExist()
|
||||
compose.runOnIdle {
|
||||
assertEquals(originalEntryId, controller.previousBackStackEntry!!.id)
|
||||
recentVm.toggleSelectAll()
|
||||
}
|
||||
compose.waitUntil(10_000) { recentVm.totalPrice.value == "4.00" }
|
||||
val verifyRequestsBeforeReturn = requests.count { it.url.encodedPath.endsWith("verify-result") }
|
||||
compose.runOnIdle { controller.popBackStack() }
|
||||
compose.onNodeWithText("人脸识别结果").assertIsDisplayed()
|
||||
compose.onNodeWithText("查看最近照片").assertIsDisplayed().assertIsEnabled()
|
||||
compose.runOnIdle {
|
||||
assertEquals(originalEntryId, controller.currentBackStackEntry!!.id)
|
||||
assertEquals(selectedBefore, originalVm.selectedPhotos.value)
|
||||
assertEquals(totalBefore, originalVm.totalPrice.value)
|
||||
}
|
||||
val scrollAfter = compose.onNode(hasScrollToIndexAction()).fetchSemanticsNode().config[SemanticsProperties.VerticalScrollAxisRange].value()
|
||||
assertEquals(scrollBefore, scrollAfter, 0.05f)
|
||||
assertEquals(verifyRequestsBeforeReturn, requests.count { it.url.encodedPath.endsWith("verify-result") })
|
||||
// Entry becomes available again after returning, and the next push gets fresh state.
|
||||
compose.onNodeWithText("查看最近照片").performClick()
|
||||
compose.waitUntil(10_000) { scopedResultVms.size == 3 && scopedResultVms[2].recentPhotosState.value == RecentPhotosState.READY }
|
||||
assertTrue(scopedResultVms[2].selectedPhotos.value.isEmpty())
|
||||
compose.onNodeWithText("查看最近照片").assertDoesNotExist()
|
||||
assertEquals(2, requests.count { it.url.encodedPath.endsWith("/api/photos/recent") })
|
||||
}
|
||||
|
||||
@Test fun failureButtonNavigatesOnceAndKeepsOriginalReturnPath() {
|
||||
lateinit var controller: NavHostController
|
||||
compose.setContent {
|
||||
controller = rememberNavController()
|
||||
LaunchedEffect(Unit) { nav.navigationEvents.collect { controller.handleNavigationEvent(it) } }
|
||||
NavHost(controller, startDestination = AppRoutes.HOME) {
|
||||
composable(AppRoutes.HOME) { Text("测试首页") }
|
||||
composable(AppRoutes.FACE_RECOGNITION) { FaceRecognitionScreen(viewModel = faceVm) }
|
||||
composable(AppRoutes.FACE_RESULT_PATTERN, arguments = listOf(
|
||||
navArgument("results") { type = NavType.StringType; defaultValue = "" },
|
||||
navArgument("source") { type = NavType.StringType; defaultValue = AppRoutes.FACE_RESULT_SOURCE },
|
||||
)) { entry ->
|
||||
FaceRecognitionResultScreen(source = entry.arguments?.getString("source")!!, viewModel = resultVm)
|
||||
}
|
||||
}
|
||||
}
|
||||
compose.runOnIdle { controller.navigate(AppRoutes.FACE_RECOGNITION) }
|
||||
compose.onNodeWithText("找不到自己?查看最近照片").assertDoesNotExist()
|
||||
compose.runOnIdle {
|
||||
val field = FaceRecognitionViewModel::class.java.getDeclaredField("_recognitionStatus").apply { isAccessible = true }
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(field.get(faceVm) as MutableStateFlow<RecognitionStatus>).value = RecognitionStatus.FAILED
|
||||
}
|
||||
compose.onNodeWithText("找不到自己?查看最近照片").assertIsDisplayed()
|
||||
screenshot("recent-photos-failure-button")
|
||||
compose.onNodeWithText("找不到自己?查看最近照片").performClick()
|
||||
compose.runOnIdle { faceVm.browseRecentPhotos() }
|
||||
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.EMPTY }
|
||||
compose.onNodeWithText("暂无可显示的最近照片").assertIsDisplayed()
|
||||
compose.runOnIdle {
|
||||
assertEquals(AppRoutes.RECENT_RESULT_SOURCE, controller.currentBackStackEntry?.arguments?.getString("source"))
|
||||
assertEquals(AppRoutes.HOME, controller.previousBackStackEntry?.destination?.route)
|
||||
// Verify the legacy route still resolves with source=face.
|
||||
controller.navigate("${AppRoutes.FACE_RECOGNITION_RESULT}?results=%5B%5D")
|
||||
assertEquals(AppRoutes.FACE_RESULT_SOURCE, controller.currentBackStackEntry?.arguments?.getString("source"))
|
||||
}
|
||||
assertEquals(1, requests.count { it.url.encodedPath.endsWith("/api/photos/recent") })
|
||||
}
|
||||
|
||||
@Test fun recentPhotosReuseSelectionPricingAndMockPaymentToPrintingRoute() {
|
||||
val image = Bitmap.createBitmap(160, 100, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.BLUE) }
|
||||
val file = File(context.cacheDir, "recent-photo-fixture.png")
|
||||
file.outputStream().use { image.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||
val url = file.toURI().toString()
|
||||
val second = File(context.cacheDir, "recent-photo-second.png")
|
||||
file.copyTo(second, overwrite = true)
|
||||
val secondUrl = second.toURI().toString()
|
||||
recentBody = """{"count":3,"results":[{"id":9,"thumbnail_oss_url":"$url","thumbnail_lan_url":"$url"},{"id":3,"thumbnail_oss_url":"$secondUrl","thumbnail_lan_url":"$secondUrl"},{"id":2,"thumbnail_oss_url":null,"thumbnail_lan_url":null}]}"""
|
||||
compose.setContent { FaceRecognitionResultScreen(source = AppRoutes.RECENT_RESULT_SOURCE, viewModel = resultVm) }
|
||||
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.READY }
|
||||
assertEquals(listOf(9, 3), resultVm.photoList.value.map { it.id })
|
||||
compose.onNodeWithText("全选").performClick()
|
||||
compose.waitUntil(10_000) { resultVm.totalPrice.value == "4.00" }
|
||||
assertEquals(2, resultVm.selectedPhotos.value.size)
|
||||
compose.onNodeWithText("支付4.00元").assertIsEnabled()
|
||||
screenshot("recent-photos-results")
|
||||
compose.onAllNodesWithText("点击预览")[0].performClick()
|
||||
compose.onNodeWithContentDescription("关闭").assertIsDisplayed().performClick()
|
||||
compose.runOnIdle { resultVm.togglePhotoSelection(resultVm.photoList.value[0].url) }
|
||||
assertEquals(1, resultVm.selectedPhotos.value.size)
|
||||
compose.runOnIdle { resultVm.toggleSelectAll() }
|
||||
assertEquals(2, resultVm.selectedPhotos.value.size)
|
||||
// Exercise the shared payment code against a fake response without opening polling UI.
|
||||
var payUrl: String? = null
|
||||
compose.runOnIdle { resultVm.getPayUrl { payUrl = it } }
|
||||
compose.waitUntil(10_000) { payUrl != null }
|
||||
assertEquals("https://example.test/mock-pay", payUrl)
|
||||
val field = FaceRecognitionResultViewModel::class.java.getDeclaredField("activePaymentOrderNumber").apply { isAccessible = true }
|
||||
assertEquals("MOCK-RECENT-1", field.get(resultVm))
|
||||
// Inspect printing navigation only; never compose the real printing page.
|
||||
var route: String? = null
|
||||
val job = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Main).let { scope ->
|
||||
scope.launchCollect(nav) { route = it }
|
||||
}
|
||||
compose.runOnIdle {
|
||||
val method = FaceRecognitionResultViewModel::class.java.getDeclaredMethod("handlePaymentSuccess", String::class.java, Integer::class.java, List::class.java, String::class.java).apply { isAccessible = true }
|
||||
method.invoke(resultVm, "MOCK-RECENT-1", 2, listOf(9, 3), "test")
|
||||
}
|
||||
compose.waitUntil(10_000) { route != null }
|
||||
assertTrue(route!!.startsWith("printing?"))
|
||||
assertTrue(route!!.contains("orderNumber=MOCK-RECENT-1"))
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test fun errorCanRetryIntoEmptyState() {
|
||||
recentCode = 403
|
||||
compose.setContent { FaceRecognitionResultScreen(source = AppRoutes.RECENT_RESULT_SOURCE, viewModel = resultVm) }
|
||||
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.ERROR }
|
||||
compose.onNodeWithText("照片加载失败,请重试").assertIsDisplayed()
|
||||
compose.onNodeWithText("支付0元").assertIsNotEnabled()
|
||||
recentCode = 200
|
||||
compose.onNodeWithText("重试").performClick()
|
||||
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.EMPTY }
|
||||
compose.onNodeWithText("暂无可显示的最近照片").assertIsDisplayed()
|
||||
assertEquals(2, requests.count { it.url.encodedPath.endsWith("/api/photos/recent") })
|
||||
}
|
||||
}
|
||||
|
||||
private fun kotlinx.coroutines.CoroutineScope.launchCollect(nav: AppNavigator, onRoute: (String) -> Unit) =
|
||||
launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
|
||||
nav.navigationEvents.collect { if (it is com.yzx.kiosk.navigation.NavigationEvent.NavigateTo) onRoute(it.route) }
|
||||
}
|
||||
+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()
|
||||
|
||||
@@ -42,9 +42,9 @@ class LocalAudioPlayService @Inject constructor(
|
||||
AppRoutes.HOME to R.raw.home_audio,
|
||||
AppRoutes.FACE_RECOGNITION to R.raw.face_recognition_page,
|
||||
AppRoutes.FACE_RECOGNITION_RESULT to R.raw.face_recognition_success_result_page,
|
||||
RECENT_PHOTOS_AUDIO_KEY to R.raw.recent_photos_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,
|
||||
// 特殊状态页面
|
||||
@@ -110,12 +110,12 @@ class LocalAudioPlayService @Inject constructor(
|
||||
return
|
||||
}
|
||||
|
||||
// 提取基础路由名称(去掉参数部分)
|
||||
val baseRoute = route.substringBefore("?")
|
||||
// 同一结果页根据入口选择播报,避免最近照片误播识别成功。
|
||||
val audioKey = localAudioKeyForRoute(route)
|
||||
|
||||
val audioResId = ROUTE_TO_AUDIO_MAP[baseRoute]
|
||||
val audioResId = ROUTE_TO_AUDIO_MAP[audioKey]
|
||||
if (audioResId == null) {
|
||||
LogUtils.d(TAG, "路由 $baseRoute 没有对应的音频文件")
|
||||
LogUtils.d(TAG, "路由音频 $audioKey 没有对应的音频文件")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.yzx.kiosk.audio
|
||||
|
||||
import com.yzx.kiosk.navigation.routes.AppRoutes
|
||||
import java.net.URLDecoder
|
||||
|
||||
internal const val RECENT_PHOTOS_AUDIO_KEY = "recent_photos_result"
|
||||
|
||||
/** Keep result-page audio tied to the entry source, not just the destination name. */
|
||||
internal fun localAudioKeyForRoute(route: String): String {
|
||||
val baseRoute = route.substringBefore("?")
|
||||
if (baseRoute != AppRoutes.FACE_RECOGNITION_RESULT) return baseRoute
|
||||
|
||||
val source = route.substringAfter("?", "").substringBefore("#")
|
||||
.split("&")
|
||||
.firstOrNull { it.substringBefore("=") == "source" }
|
||||
?.substringAfter("=", "")
|
||||
?.let { runCatching { URLDecoder.decode(it, "UTF-8") }.getOrNull() }
|
||||
return if (source == AppRoutes.RECENT_RESULT_SOURCE) RECENT_PHOTOS_AUDIO_KEY else baseRoute
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import androidx.compose.animation.core.tween
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.navArgument
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
@@ -17,7 +19,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
|
||||
@@ -141,7 +142,13 @@ fun AppNavHost(
|
||||
}
|
||||
|
||||
// 人脸识别结果页面
|
||||
composable(route = "${AppRoutes.FACE_RECOGNITION_RESULT}?results={results}") { backStackEntry ->
|
||||
composable(
|
||||
route = AppRoutes.FACE_RESULT_PATTERN,
|
||||
arguments = listOf(
|
||||
navArgument("results") { type = NavType.StringType; defaultValue = "" },
|
||||
navArgument("source") { type = NavType.StringType; defaultValue = AppRoutes.FACE_RESULT_SOURCE },
|
||||
),
|
||||
) { backStackEntry ->
|
||||
val resultsParam = backStackEntry.arguments?.getString("results") ?: ""
|
||||
val results = if (resultsParam.isNotEmpty()) {
|
||||
try {
|
||||
@@ -154,48 +161,9 @@ fun AppNavHost(
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
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
|
||||
FaceRecognitionResultScreen(
|
||||
results = results,
|
||||
source = backStackEntry.arguments?.getString("source") ?: AppRoutes.FACE_RESULT_SOURCE,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,30 @@ 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 FACE_RESULT_SOURCE = "face"
|
||||
const val RECENT_RESULT_SOURCE = "recent"
|
||||
const val FACE_RESULT_PATTERN = "$FACE_RECOGNITION_RESULT?results={results}&source={source}"
|
||||
|
||||
fun buildRecentPhotosRoute(): String = "$FACE_RECOGNITION_RESULT?source=$RECENT_RESULT_SOURCE"
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -104,6 +106,17 @@ object NetworkModule {
|
||||
}
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@Named(CLIENT_FACE)
|
||||
fun provideFaceOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
|
||||
.apply {
|
||||
if (BuildConfig.DEBUG) {
|
||||
addInterceptor(DebugNetworkLoggingInterceptor("face-search"))
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
private fun buildRetrofit(
|
||||
baseUrl: String,
|
||||
okHttpClient: OkHttpClient,
|
||||
@@ -131,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(
|
||||
|
||||
@@ -4,17 +4,24 @@ import com.yzx.kiosk.network.model.response.FaceSearchResponse
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.GET
|
||||
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 {
|
||||
@GET
|
||||
suspend fun recentPhotos(
|
||||
@Url url: String,
|
||||
@Header("Authorization") authorization: String,
|
||||
): Response<FaceSearchResponse>
|
||||
|
||||
@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,22 @@
|
||||
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 /{sn}/api/v3/search endpoint semantics even if configuration
|
||||
// accidentally contains a path or query string.
|
||||
.encodedPath("/")
|
||||
.query(null)
|
||||
.fragment(null)
|
||||
.addPathSegment(deviceSn)
|
||||
.addPathSegment("api")
|
||||
.addPathSegment("v3")
|
||||
.addPathSegment("search")
|
||||
.build()
|
||||
.toString()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.yzx.kiosk.network.service
|
||||
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
|
||||
internal fun buildRecentPhotosUrl(baseUrl: String, deviceSn: String): String {
|
||||
require(deviceSn.isNotBlank()) { "Face box SN is empty" }
|
||||
val parsedBaseUrl = baseUrl.trim().toHttpUrlOrNull()
|
||||
?: throw IllegalArgumentException("Invalid recent photos base URL")
|
||||
|
||||
// The recent-photos endpoint is unversioned and independent of the face-search API version.
|
||||
return parsedBaseUrl.newBuilder()
|
||||
.encodedPath("/")
|
||||
.query(null)
|
||||
.fragment(null)
|
||||
.addPathSegment(deviceSn)
|
||||
.addPathSegment("api")
|
||||
.addPathSegment("photos")
|
||||
.addPathSegment("recent")
|
||||
.build()
|
||||
.toString()
|
||||
}
|
||||
@@ -42,6 +42,11 @@ 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
|
||||
@@ -274,10 +279,20 @@ class PrinterService(
|
||||
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 = printSize.width,
|
||||
expectedHeight = printSize.height
|
||||
expectedWidth = expectedBmpWidth,
|
||||
expectedHeight = expectedBmpHeight
|
||||
)
|
||||
LogUtils.d(
|
||||
TAG,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,9 @@ 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.platform.LocalLifecycleOwner
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -38,6 +41,8 @@ import coil.compose.SubcomposeAsyncImage
|
||||
import coil.compose.SubcomposeAsyncImageContent
|
||||
import coil.compose.SubcomposeAsyncImageScope
|
||||
import coil.request.ImageRequest
|
||||
import com.yzx.kiosk.navigation.routes.AppRoutes
|
||||
import com.yzx.kiosk.ui.face.viewmodel.RecentPhotosState
|
||||
import com.yzx.kiosk.R
|
||||
import com.yzx.kiosk.component.appbar.AppTitleBar
|
||||
import com.yzx.kiosk.component.appbar.FaceBarNoStatusBarPadding
|
||||
@@ -55,21 +60,35 @@ import kotlinx.coroutines.flow.collect
|
||||
@Composable
|
||||
fun FaceRecognitionResultScreen(
|
||||
results: List<FaceSearchResult> = emptyList(),
|
||||
source: String = AppRoutes.FACE_RESULT_SOURCE,
|
||||
viewModel: FaceRecognitionResultViewModel = hiltViewModel()
|
||||
) {
|
||||
// 如果传入了results列表,初始化图片列表
|
||||
LaunchedEffect(results) {
|
||||
if (results.isNotEmpty()) {
|
||||
LaunchedEffect(source, results) {
|
||||
if (source == AppRoutes.RECENT_RESULT_SOURCE) {
|
||||
viewModel.loadRecentPhotos()
|
||||
} else if (results.isNotEmpty()) {
|
||||
viewModel.initPhotoList(results)
|
||||
}
|
||||
}
|
||||
|
||||
val recentPhotosState by viewModel.recentPhotosState.collectAsState()
|
||||
val photoList by viewModel.photoList.collectAsState()
|
||||
val selectedPhotos by viewModel.selectedPhotos.collectAsState()
|
||||
val pricePerPhoto by viewModel.pricePerPhoto.collectAsState()
|
||||
val totalPrice by viewModel.totalPrice.collectAsState()
|
||||
val imageLoadStates by viewModel.imageLoadStates.collectAsState()
|
||||
|
||||
var isOpeningRecentPhotos by remember { mutableStateOf(false) }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME) isOpeningRecentPhotos = false
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
val selectedCount = selectedPhotos.size
|
||||
val isAllSelected = photoList.isNotEmpty() && selectedPhotos.size == photoList.size
|
||||
|
||||
@@ -94,7 +113,7 @@ fun FaceRecognitionResultScreen(
|
||||
Column {
|
||||
AppTitleBar(
|
||||
backgroundColor = Color.White,
|
||||
title = "人脸识别结果",
|
||||
title = if (source == AppRoutes.RECENT_RESULT_SOURCE) "最近照片" else "人脸识别结果",
|
||||
isShowBackIcon = true,
|
||||
onBackClick = { viewModel.navigateBack() },
|
||||
isShowButtonLine = false
|
||||
@@ -180,49 +199,93 @@ fun FaceRecognitionResultScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// 全选行
|
||||
// Keep selection and navigation as separate click targets in the same toolbar.
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.clickable { viewModel.toggleSelectAll() },
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// 复选框
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(if (isAllSelected) Color(0xFF0073FF) else Color.White)
|
||||
.border(
|
||||
width = 2.dp,
|
||||
color = if (isAllSelected) Color(0xFF0073FF) else Color(0xFFCCCCCC),
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
Row(
|
||||
modifier = Modifier.weight(1f).heightIn(min = 48.dp)
|
||||
.clickable(enabled = photoList.isNotEmpty()) { viewModel.toggleSelectAll() },
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (isAllSelected) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
// 复选框
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(if (isAllSelected) Color(0xFF0073FF) else Color.White)
|
||||
.border(
|
||||
width = 2.dp,
|
||||
color = if (isAllSelected) Color(0xFF0073FF) else Color(0xFFCCCCCC),
|
||||
shape = RoundedCornerShape(4.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
|
||||
)
|
||||
}
|
||||
if (source != AppRoutes.RECENT_RESULT_SOURCE) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (!isOpeningRecentPhotos) {
|
||||
isOpeningRecentPhotos = true
|
||||
viewModel.browseRecentPhotos()
|
||||
}
|
||||
},
|
||||
enabled = !isOpeningRecentPhotos,
|
||||
modifier = Modifier.heightIn(min = 48.dp),
|
||||
contentPadding = PaddingValues(horizontal = 6.dp),
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFF2878CE)),
|
||||
) {
|
||||
Text("查看最近照片", fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("›", fontSize = 20.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(18.dp))
|
||||
|
||||
Text(
|
||||
text = "全选",
|
||||
fontSize = 24.sp,
|
||||
color = Color.Black
|
||||
)
|
||||
Text(
|
||||
text = "(共${photoList.size}张)",
|
||||
fontSize = 24.sp,
|
||||
color = Color.Black
|
||||
)
|
||||
if (source == AppRoutes.RECENT_RESULT_SOURCE && recentPhotosState != RecentPhotosState.READY) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
if (recentPhotosState == RecentPhotosState.IDLE || recentPhotosState == RecentPhotosState.LOADING) {
|
||||
CircularProgressIndicator(Modifier.size(32.dp))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
Text(
|
||||
text = when (recentPhotosState) {
|
||||
RecentPhotosState.ERROR -> "照片加载失败,请重试"
|
||||
RecentPhotosState.EMPTY -> "暂无可显示的最近照片"
|
||||
else -> "正在加载照片…"
|
||||
},
|
||||
fontSize = 18.sp,
|
||||
color = Color(0xFF666666),
|
||||
)
|
||||
if (recentPhotosState == RecentPhotosState.ERROR) {
|
||||
TextButton(onClick = viewModel::retryRecentPhotos) { Text("重试", fontSize = 18.sp) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 图片瀑布流列表
|
||||
|
||||
@@ -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(
|
||||
@@ -91,7 +87,8 @@ fun FaceRecognitionScreen(
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 48.dp, vertical = 36.dp)
|
||||
.padding(horizontal = 48.dp)
|
||||
.padding(top = 36.dp)
|
||||
.aspectRatio(0.92f),
|
||||
shape = RoundedCornerShape(36.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = Color.Black)
|
||||
@@ -103,6 +100,9 @@ fun FaceRecognitionScreen(
|
||||
onImageCaptureReady = { imageCapture ->
|
||||
viewModel.setImageCapture(imageCapture)
|
||||
},
|
||||
onImageCaptureReleased = { imageCapture ->
|
||||
viewModel.clearImageCapture(imageCapture)
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
@@ -201,7 +201,7 @@ fun FaceRecognitionScreen(
|
||||
}
|
||||
}
|
||||
// 拍照按钮(5秒倒计时期间也显示,识别失败时显示"重新拍照",其他情况显示"拍照")
|
||||
if (!isRecognizing) {
|
||||
if (!isRecognizing && !isCaptureInProgress) {
|
||||
Button(
|
||||
onClick = {
|
||||
if (recognitionStatus == RecognitionStatus.FAILED) {
|
||||
@@ -234,7 +234,22 @@ fun FaceRecognitionScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(36.dp))
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().height(72.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (recognitionStatus == RecognitionStatus.FAILED) {
|
||||
TextButton(
|
||||
onClick = viewModel::browseRecentPhotos,
|
||||
modifier = Modifier.heightIn(min = 48.dp),
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFF2878CE)),
|
||||
) {
|
||||
Text("找不到自己?查看最近照片", fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("›", fontSize = 20.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 倒计时提示
|
||||
Text(
|
||||
@@ -309,50 +324,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)
|
||||
|
||||
val previewView = remember(context) { PreviewView(context) }
|
||||
val preview = remember { Preview.Builder().build() }
|
||||
val imageCapture = remember {
|
||||
ImageCapture.Builder()
|
||||
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
|
||||
.build()
|
||||
}
|
||||
|
||||
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()
|
||||
.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
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(ctx))
|
||||
|
||||
previewView
|
||||
},
|
||||
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()
|
||||
}
|
||||
}, executor)
|
||||
|
||||
onDispose {
|
||||
disposed = true
|
||||
currentOnImageCaptureReleased(imageCapture)
|
||||
preview.setSurfaceProvider(null)
|
||||
cameraProvider?.unbind(preview, imageCapture)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+88
-47
@@ -4,10 +4,13 @@ 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.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.service.FaceSearchService
|
||||
import com.yzx.kiosk.network.service.buildRecentPhotosUrl
|
||||
import com.yzx.kiosk.network.repository.NetWorkRepository
|
||||
import com.yzx.kiosk.network.result.asResult
|
||||
import com.yzx.kiosk.ui.upload.viewmodel.FileMapData
|
||||
@@ -42,12 +45,39 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
navigator: AppNavigator,
|
||||
appState: AppState,
|
||||
private val netWorkRepository: NetWorkRepository,
|
||||
private val faceSearchService: FaceSearchService,
|
||||
private val webSocketService: WebSocketService,
|
||||
private val appStoreDataSource: AppStoreDataSource,
|
||||
) : BaseViewModel(
|
||||
navigator = navigator,
|
||||
appState = appState
|
||||
) {
|
||||
private val recentLoader = RecentPhotosLoader(viewModelScope) {
|
||||
val baseUrl = if (appStoreDataSource.getUseLan()) {
|
||||
appStoreDataSource.getBindBoxLanUrl()
|
||||
} else {
|
||||
appStoreDataSource.getBindBoxUrl()
|
||||
}
|
||||
val response = faceSearchService.recentPhotos(
|
||||
buildRecentPhotosUrl(baseUrl, appStoreDataSource.getBindBoxSn()),
|
||||
"Bearer ${appStoreDataSource.getBindBoxApiToken()}",
|
||||
)
|
||||
check(response.isSuccessful) { "Recent photos HTTP ${response.code()}" }
|
||||
val body = checkNotNull(response.body()) { "Empty recent photos response" }
|
||||
applyPhotoList(body.results.orEmpty())
|
||||
_photoList.value.isNotEmpty()
|
||||
}
|
||||
val recentPhotosState = recentLoader.state
|
||||
|
||||
fun browseRecentPhotos() {
|
||||
// Deliberately push even though the destination pattern is the same: the two pages
|
||||
// need separate back-stack entries and ViewModels. Do not use launchSingleTop/popUpTo.
|
||||
toPage(AppRoutes.buildRecentPhotosRoute())
|
||||
}
|
||||
|
||||
fun loadRecentPhotos() = recentLoader.load()
|
||||
fun retryRecentPhotos() = recentLoader.load(retry = true)
|
||||
|
||||
// 原始结果列表(用于获取图片id)
|
||||
private var originalResults: List<FaceSearchResult> = emptyList()
|
||||
|
||||
@@ -123,46 +153,53 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
/**
|
||||
* 初始化图片列表(从人脸识别结果)
|
||||
*/
|
||||
private var faceResultsInitialized = false
|
||||
|
||||
fun initPhotoList(results: List<FaceSearchResult>) {
|
||||
viewModelScope.launch {
|
||||
// 保存原始结果列表
|
||||
originalResults = results
|
||||
urlToIdMap.clear()
|
||||
// Returning from a pushed recent-photos page must retain selection, pricing and image state.
|
||||
if (faceResultsInitialized) return
|
||||
faceResultsInitialized = true
|
||||
viewModelScope.launch { applyPhotoList(results) }
|
||||
}
|
||||
|
||||
// 先使用默认宽高比创建列表
|
||||
val initialPhotos = results.mapNotNull { result ->
|
||||
// 使用 thumbnail_oss_url
|
||||
val imageUrl = if (appStoreDataSource.getUseLan()) result.thumbnailLanUrl else result.thumbnailOssUrl
|
||||
if (imageUrl.isNullOrEmpty()) {
|
||||
LogUtils.i("FaceRecognitionResultViewModel", "图片URL为空,跳过")
|
||||
null
|
||||
} else {
|
||||
val trimmedUrl = imageUrl.trim()
|
||||
// 建立URL到ID的映射
|
||||
urlToIdMap[trimmedUrl] = result.id
|
||||
PhotoData(
|
||||
id = result.id, // 使用图片ID
|
||||
url = trimmedUrl,
|
||||
aspectRatio = 1f // 默认1:1,后续会更新为真实宽高比
|
||||
)
|
||||
}
|
||||
private suspend fun applyPhotoList(results: List<FaceSearchResult>) {
|
||||
// 保存原始结果列表
|
||||
originalResults = results
|
||||
urlToIdMap.clear()
|
||||
|
||||
// 先使用默认宽高比创建列表
|
||||
val initialPhotos = results.mapNotNull { result ->
|
||||
// 使用 thumbnail_oss_url
|
||||
val imageUrl = if (appStoreDataSource.getUseLan()) result.thumbnailLanUrl else result.thumbnailOssUrl
|
||||
if (imageUrl.isNullOrEmpty()) {
|
||||
LogUtils.i("FaceRecognitionResultViewModel", "图片URL为空,跳过")
|
||||
null
|
||||
} else {
|
||||
val trimmedUrl = imageUrl.trim()
|
||||
// 建立URL到ID的映射
|
||||
urlToIdMap[trimmedUrl] = result.id
|
||||
PhotoData(
|
||||
id = result.id, // 使用图片ID
|
||||
url = trimmedUrl,
|
||||
aspectRatio = 1f // 默认1:1,后续会更新为真实宽高比
|
||||
)
|
||||
}
|
||||
|
||||
if (initialPhotos.isEmpty()) {
|
||||
LogUtils.i("FaceRecognitionResultViewModel", "没有有效的图片URL")
|
||||
_photoList.value = emptyList()
|
||||
_imageLoadStates.value = emptyMap()
|
||||
return@launch
|
||||
}
|
||||
|
||||
_photoList.value = initialPhotos
|
||||
// 初始化加载状态
|
||||
_imageLoadStates.value = initialPhotos.associate { it.url to ImageLoadState() }
|
||||
|
||||
// 页面初始化时调用验证接口(空列表,type: 2),获取默认价格
|
||||
verifyResult(emptyList())
|
||||
LogUtils.d(TAG, "图片列表初始化完成,共 ${initialPhotos.size} 张,宽高比将在图片加载成功后逐张更新")
|
||||
}
|
||||
|
||||
if (initialPhotos.isEmpty()) {
|
||||
LogUtils.i("FaceRecognitionResultViewModel", "没有有效的图片URL")
|
||||
_photoList.value = emptyList()
|
||||
_imageLoadStates.value = emptyMap()
|
||||
return
|
||||
}
|
||||
|
||||
_photoList.value = initialPhotos
|
||||
// 初始化加载状态
|
||||
_imageLoadStates.value = initialPhotos.associate { it.url to ImageLoadState() }
|
||||
|
||||
// 页面初始化时调用验证接口(空列表,type: 2),获取默认价格
|
||||
verifyResult(emptyList())
|
||||
LogUtils.d(TAG, "图片列表初始化完成,共 ${initialPhotos.size} 张,宽高比将在图片加载成功后逐张更新")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -214,6 +251,7 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
* 切换全选状态
|
||||
*/
|
||||
fun toggleSelectAll() {
|
||||
if (_photoList.value.isEmpty()) return
|
||||
val allUrls = _photoList.value.map { it.url }.toSet()
|
||||
val newSelected = if (_selectedPhotos.value.size == allUrls.size) {
|
||||
// 当前全选,取消全选
|
||||
@@ -480,17 +518,26 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
stopPayStatusPolling()
|
||||
_payQrCodeUrl.value = null
|
||||
_dismissPayDialogEvents.tryEmit(Unit)
|
||||
navigateToPaySuccess(orderNumber, captureType, imageIds)
|
||||
navigateToPrinting(orderNumber, captureType, imageIds)
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到支付成功页面
|
||||
* 支付成功后直接跳转到打印页面
|
||||
* @param orderNumber 订单号
|
||||
* @param captureType 抓拍类型
|
||||
* @param imageIds 图片ID数组(从支付成功消息中获取)
|
||||
*/
|
||||
private fun navigateToPaySuccess(orderNumber: String?, captureType: Int?, imageIds: List<Int>) {
|
||||
private fun navigateToPrinting(orderNumber: String?, captureType: Int?, imageIds: List<Int>) {
|
||||
viewModelScope.launch {
|
||||
if (orderNumber.isNullOrBlank() || captureType == null) {
|
||||
LogUtils.e(
|
||||
TAG,
|
||||
"支付成功消息订单信息不完整 - orderNumber: $orderNumber, captureType: $captureType"
|
||||
)
|
||||
ToastUtils.show("支付成功,但订单信息不完整")
|
||||
return@launch
|
||||
}
|
||||
|
||||
// 根据 imageIds 从 originalResults 中获取对应的 FaceSearchResult
|
||||
val selectedResults = originalResults.filter { imageIds.contains(it.id) }
|
||||
|
||||
@@ -514,19 +561,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,12 +13,15 @@ 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.network.interceptor.DebugNetworkLoggingInterceptor
|
||||
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
|
||||
@@ -36,15 +36,9 @@ 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 retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import javax.inject.Inject
|
||||
|
||||
enum class RecognitionStatus {
|
||||
@@ -59,7 +53,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
|
||||
@@ -91,9 +88,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
|
||||
@@ -109,6 +113,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始倒计时
|
||||
*/
|
||||
@@ -120,7 +133,7 @@ class FaceRecognitionViewModel @Inject constructor(
|
||||
_countdown.value = _countdown.value - 1
|
||||
}
|
||||
// 倒计时结束,自动返回首页
|
||||
toPage(com.yzx.kiosk.navigation.routes.AppRoutes.HOME)
|
||||
closeAllExcept(AppRoutes.HOME)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +167,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()
|
||||
@@ -167,80 +186,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
|
||||
)
|
||||
}
|
||||
|
||||
// 创建输出选项
|
||||
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)
|
||||
}
|
||||
|
||||
override fun onError(exception: ImageCaptureException) {
|
||||
LogUtils.e(TAG, "拍照失败: ${exception.message}")
|
||||
ToastUtils.show("拍照失败")
|
||||
}
|
||||
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) {
|
||||
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) {
|
||||
// 页面跳转或 ViewModel 销毁时的正常协程取消,不向用户报错
|
||||
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
|
||||
@@ -251,16 +314,10 @@ class FaceRecognitionViewModel @Inject constructor(
|
||||
val thresholdValue = 0.45f
|
||||
val thresholdBody = thresholdValue.toString().toRequestBody("text/plain".toMediaType())
|
||||
|
||||
// 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:")
|
||||
@@ -268,33 +325,15 @@ class FaceRecognitionViewModel @Inject constructor(
|
||||
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, "==========================================")
|
||||
|
||||
// Debug 环境记录请求与响应;图片等大文件只记录元数据,不读取文件内容
|
||||
val client = OkHttpClient.Builder()
|
||||
.apply {
|
||||
if (BuildConfig.DEBUG) {
|
||||
addInterceptor(DebugNetworkLoggingInterceptor("face-search"))
|
||||
}
|
||||
}
|
||||
.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,
|
||||
// 不传日期,由 V3 服务端使用上海时区当天;人数及人脸大小也使用服务端默认值。
|
||||
indexDate = null
|
||||
)
|
||||
|
||||
@@ -349,7 +388,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)
|
||||
@@ -369,82 +408,20 @@ 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
|
||||
)
|
||||
private var isOpeningRecentPhotos = false
|
||||
|
||||
// 加载原始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 browseRecentPhotos() {
|
||||
if (_recognitionStatus.value != RecognitionStatus.FAILED || isOpeningRecentPhotos) return
|
||||
isOpeningRecentPhotos = true
|
||||
countdownJob?.cancel()
|
||||
autoCaptureJob?.cancel()
|
||||
toPage(
|
||||
AppRoutes.buildRecentPhotosRoute(),
|
||||
NavOptions.Builder()
|
||||
.setPopUpTo(AppRoutes.FACE_RECOGNITION, inclusive = true)
|
||||
.setLaunchSingleTop(true)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
fun privatePolicy() {
|
||||
@@ -476,9 +453,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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.yzx.kiosk.ui.face.viewmodel
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class RecentPhotosState { IDLE, LOADING, READY, EMPTY, ERROR }
|
||||
|
||||
/** One request per result-page lifetime; retries are explicit and cancellation propagates. */
|
||||
internal class RecentPhotosLoader(
|
||||
private val scope: CoroutineScope,
|
||||
private val fetchAndApply: suspend () -> Boolean,
|
||||
) {
|
||||
private val mutableState = MutableStateFlow(RecentPhotosState.IDLE)
|
||||
val state = mutableState.asStateFlow()
|
||||
private var job: Job? = null
|
||||
|
||||
fun load(retry: Boolean = false) {
|
||||
if (job?.isActive == true) return
|
||||
if (mutableState.value != RecentPhotosState.IDLE && !(retry && mutableState.value == RecentPhotosState.ERROR)) return
|
||||
mutableState.value = RecentPhotosState.LOADING
|
||||
job = scope.launch {
|
||||
try {
|
||||
mutableState.value = if (fetchAndApply()) RecentPhotosState.READY else RecentPhotosState.EMPTY
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
mutableState.value = RecentPhotosState.ERROR
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,26 @@
|
||||
package com.yzx.kiosk.audio
|
||||
|
||||
import com.yzx.kiosk.navigation.routes.AppRoutes
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class LocalAudioRouteTest {
|
||||
@Test fun `browse recent photos selects neutral audio`() {
|
||||
assertEquals(RECENT_PHOTOS_AUDIO_KEY, localAudioKeyForRoute(AppRoutes.buildRecentPhotosRoute()))
|
||||
assertEquals(RECENT_PHOTOS_AUDIO_KEY, localAudioKeyForRoute("face_recognition_result?results=%5B%5D&source=recent"))
|
||||
assertEquals(RECENT_PHOTOS_AUDIO_KEY, localAudioKeyForRoute("face_recognition_result?source=recent&results=%5B%5D"))
|
||||
assertEquals(RECENT_PHOTOS_AUDIO_KEY, localAudioKeyForRoute("face_recognition_result?source=%72ecent"))
|
||||
}
|
||||
|
||||
@Test fun `face and legacy routes keep success audio`() {
|
||||
for (route in listOf("face_recognition_result", "face_recognition_result?results=%5B%5D", "face_recognition_result?source=face", "face_recognition_result?source=unknown", "face_recognition_result?source=%")) {
|
||||
assertEquals(AppRoutes.FACE_RECOGNITION_RESULT, localAudioKeyForRoute(route))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `photo data cannot change source and unrelated pages keep their audio`() {
|
||||
assertEquals(AppRoutes.FACE_RECOGNITION_RESULT, localAudioKeyForRoute("face_recognition_result?results=source%3Drecent"))
|
||||
assertEquals(AppRoutes.HOME, localAudioKeyForRoute("home?source=recent"))
|
||||
assertEquals("face_recognition_failed", localAudioKeyForRoute("face_recognition_failed"))
|
||||
}
|
||||
}
|
||||
@@ -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,83 @@
|
||||
package com.yzx.kiosk.network.service
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
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/v3/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 `v3 request preserves auth and leaves optional settings to server`() = runBlocking {
|
||||
val client = OkHttpClient.Builder().addInterceptor { chain ->
|
||||
val request = chain.request()
|
||||
assertEquals("POST", request.method)
|
||||
assertEquals("https://example.test/BOX-1/api/v3/search", request.url.toString())
|
||||
assertEquals("Bearer test-token", request.header("Authorization"))
|
||||
val body = request.body as MultipartBody
|
||||
assertEquals(MultipartBody.FORM, body.type)
|
||||
assertEquals(
|
||||
listOf("form-data; name=\"image\"; filename=\"group.jpg\"", "form-data; name=\"threshold\""),
|
||||
body.parts.map { it.headers?.get("Content-Disposition") },
|
||||
)
|
||||
okhttp3.Response.Builder()
|
||||
.request(request)
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(200)
|
||||
.message("OK")
|
||||
.body("""{"count":0,"results":[],"detected_count":8,"searched_count":6,"truncated":true,"min_face_size":80,"max_faces":6}"""
|
||||
.toResponseBody("application/json".toMediaType()))
|
||||
.build()
|
||||
}.build()
|
||||
val service = Retrofit.Builder()
|
||||
.baseUrl("https://example.test/")
|
||||
.client(client)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
.create(FaceSearchService::class.java)
|
||||
val response = service.searchFace(
|
||||
url = buildFaceSearchUrl("https://example.test", "BOX-1"),
|
||||
authorization = "Bearer test-token",
|
||||
image = MultipartBody.Part.createFormData("image", "group.jpg", "test-image".toRequestBody("image/jpeg".toMediaType())),
|
||||
threshold = "0.45".toRequestBody("text/plain".toMediaType()),
|
||||
indexDate = null,
|
||||
)
|
||||
assertEquals(0, response.body()?.count)
|
||||
assertEquals(emptyList<Any>(), response.body()?.results)
|
||||
}
|
||||
|
||||
@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,46 @@
|
||||
package com.yzx.kiosk.network.service
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
|
||||
class RecentPhotosServiceTest {
|
||||
@Test fun `recent url replaces configured path and encodes sn`() {
|
||||
assertEquals("https://example.test/BOX%2F01/api/photos/recent",
|
||||
buildRecentPhotosUrl("https://example.test/old?seconds=20#fragment", "BOX/01"))
|
||||
assertThrows(IllegalArgumentException::class.java) { buildRecentPhotosUrl("invalid", "BOX") }
|
||||
assertThrows(IllegalArgumentException::class.java) { buildRecentPhotosUrl("https://example.test", "") }
|
||||
}
|
||||
|
||||
@Test fun `get uses bearer without optional query parameters and preserves response order`() = runBlocking {
|
||||
val bodies = listOf(
|
||||
"""{"count":0,"results":[]}""",
|
||||
"""{"count":2,"results":[{"id":9,"thumbnail_oss_url":null,"thumbnail_lan_url":"http://local/9.jpg","similarity":0},{"id":3,"thumbnail_oss_url":"https://oss/3.jpg","thumbnail_lan_url":null}]}""",
|
||||
)
|
||||
for (body in bodies) {
|
||||
val client = OkHttpClient.Builder().addInterceptor { chain ->
|
||||
val request = chain.request()
|
||||
assertEquals("GET", request.method)
|
||||
assertEquals("https://example.test/BOX/api/photos/recent", request.url.toString())
|
||||
assertNull(request.url.query)
|
||||
assertEquals("Bearer test-token", request.header("Authorization"))
|
||||
assertNull(request.body)
|
||||
Response.Builder().request(request).protocol(Protocol.HTTP_1_1).code(200).message("OK")
|
||||
.body(body.toResponseBody("application/json".toMediaType())).build()
|
||||
}.build()
|
||||
val service = Retrofit.Builder().baseUrl("https://example.test/").client(client)
|
||||
.addConverterFactory(GsonConverterFactory.create()).build().create(FaceSearchService::class.java)
|
||||
val response = service.recentPhotos(buildRecentPhotosUrl("https://example.test", "BOX"), "Bearer test-token").body()!!
|
||||
if (response.count == 0) assertTrue(response.results!!.isEmpty()) else {
|
||||
assertEquals(listOf(9, 3), response.results!!.map { it.id })
|
||||
assertNull(response.results[0].thumbnailOssUrl)
|
||||
assertNull(response.results[1].thumbnailLanUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,62 @@
|
||||
package com.yzx.kiosk.ui.face.viewmodel
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class RecentPhotosLoaderTest {
|
||||
@Test fun `successful load is not repeated by recomposition`() = runBlocking {
|
||||
var calls = 0
|
||||
val loader = RecentPhotosLoader(this) { calls++; true }
|
||||
loader.load()
|
||||
loader.load()
|
||||
yield()
|
||||
loader.load()
|
||||
assertEquals(1, calls)
|
||||
assertEquals(RecentPhotosState.READY, loader.state.value)
|
||||
}
|
||||
|
||||
@Test fun `filtered empty result has an empty state`() = runBlocking {
|
||||
val loader = RecentPhotosLoader(this) { false }
|
||||
loader.load()
|
||||
yield()
|
||||
assertEquals(RecentPhotosState.EMPTY, loader.state.value)
|
||||
}
|
||||
|
||||
@Test fun `http and network failures require explicit retry`() = runBlocking {
|
||||
for (failure in listOf(IllegalStateException("HTTP 401"), IllegalStateException("HTTP 403"), java.io.IOException())) {
|
||||
var calls = 0
|
||||
val loader = RecentPhotosLoader(this) {
|
||||
calls++
|
||||
if (calls == 1) throw failure
|
||||
true
|
||||
}
|
||||
loader.load()
|
||||
yield()
|
||||
assertEquals(RecentPhotosState.ERROR, loader.state.value)
|
||||
loader.load()
|
||||
assertEquals(1, calls)
|
||||
loader.load(retry = true)
|
||||
loader.load(retry = true)
|
||||
yield()
|
||||
assertEquals(2, calls)
|
||||
assertEquals(RecentPhotosState.READY, loader.state.value)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `leaving the page cancels request without applying data`() = runBlocking {
|
||||
val parent = Job()
|
||||
var applied = false
|
||||
var cancelled = false
|
||||
val loader = RecentPhotosLoader(CoroutineScope(coroutineContext + parent)) {
|
||||
try { delay(Long.MAX_VALUE); applied = true; true }
|
||||
finally { cancelled = true }
|
||||
}
|
||||
loader.load()
|
||||
yield()
|
||||
parent.cancelAndJoin()
|
||||
assertTrue(cancelled)
|
||||
assertFalse(applied)
|
||||
assertNotEquals(RecentPhotosState.ERROR, loader.state.value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# 最近照片入口验证
|
||||
|
||||
2026-09-08,JDK 17,KIOSK_1080x1920(1080×1920,280dpi,Android 17)。
|
||||
|
||||
- Debug 构建通过;24 项 JVM 单元测试通过,其中新增 6 项。
|
||||
- `RecentPhotosFlowTest` 的 3 项模拟器测试全部通过。
|
||||
- 页面验证覆盖:仅失败时显示入口、点击进入 recent 模式、阻止重复跳转、保持首页返回栈、旧 results 路由默认使用 face 模式。
|
||||
- 模拟响应覆盖:空结果、403 后重试、有效照片按服务端顺序展示、无缩略图地址的照片过滤、预览、单选/全选、计价、获取模拟支付链接以及支付成功后的打印导航参数。
|
||||
- JVM 测试另外覆盖 URL/SN 编码、GET/Bearer、不发送查询参数、401/403/网络失败、重复请求防护、页面作用域取消。
|
||||
- 所有照片和订单 HTTP 请求均被测试拦截器替换为模拟响应;只校验打印导航,不打开打印页面、不创建真实订单、不连接打印机。未进行真实后端端到端联调。
|
||||
- 页面测试临时使用 example.test 和测试 Token,结束时恢复原照片盒配置。测试通过已有 Hilt 单例 Provider 获取 WebSocket 依赖而不启动主页面,未建立测试 WebSocket 连接。
|
||||
- Espresso 测试依赖升级至 3.7.0,解决旧版本在 Android 17 上调用已移除 InputManager 方法的问题;应用运行依赖不受影响。
|
||||
|
||||
## 截图
|
||||
|
||||
- [失败页按钮](failure-button.png):状态由测试设置,相机区域是测试时的黑色画面;按钮位于卡片与倒计时之间,底部布局保持原位置。
|
||||
- [最近照片结果页](results.png):蓝色图片为本地测试素材,价格及支付数据为模拟数据。
|
||||
|
||||
## 复现
|
||||
|
||||
使用 JDK 17,通过 Gradle 执行:
|
||||
|
||||
```text
|
||||
:app:assembleDebug :app:testDebugUnitTest
|
||||
:app:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.yzx.kiosk.ui.face.RecentPhotosFlowTest
|
||||
```
|
||||
|
||||
仅在测试模拟器上运行 instrumentation 测试。截图保存在目标应用 external files 目录;手动执行 `am instrument` 后可取回,Gradle 托管测试可能在结束时卸载测试应用并清理截图。
|
||||
|
||||
## 最近照片入口语音修正(2026-09-08)
|
||||
|
||||
- 新资源:`app/src/main/res/raw/recent_photos_result_page.wav`。
|
||||
- 从原成功语音的 2.160 秒处裁切。此处位于开场结束后的静音区间(约 1.634–2.289 秒),保留约 129 毫秒的起始停顿,后续音频样本完全不变。
|
||||
- 新音频长 8.890667 秒,48 kHz、16 位 PCM、双声道 WAV。原文件 SHA-256 保持为 `c9146f08eaa02bacac0cc8b574ccad829ae4457e22c3b553f2fd3ad8111b7dc1`。
|
||||
- 使用本地 Whisper 转写交叉核对前后内容:新文件从选片提示开始,没有“太棒啦,识别成功啦”开场;后续放大预览、支付按钮和打印提示均保留。模型转写存在个别同音字误差,裁切边界同时通过静音检测和 PCM 样本比对确认。未上传音频。
|
||||
- `LocalAudioPlayService` 根据路由 `source=recent` 选择新资源;face、旧入口及其他页面保留原映射。不新增播放触发点。
|
||||
- 最终 Debug 构建与 27 项 JVM 单元测试通过;`LocalAudioPlaybackTest` 在 KIOSK_1080x1920 上通过,验证新旧入口资源选择、实际进入播放状态及重复调用不重置媒体项。
|
||||
|
||||
## 正常识别结果页增加浏览入口(2026-09-08)
|
||||
|
||||
- 在全选行右侧增加「浏览全部照片 ›」文字按钮,16sp、蓝色、无背景/边框,点击区域至少 48dp。全选与浏览是独立点击区域。
|
||||
- 仅普通识别结果页显示,recent 页面隐藏。导航采用普通 push(不使用 singleTop/popUpTo),让相同 destination 的两次入栈拥有独立 ViewModel。
|
||||
- 原识别结果只初始化一次,避免返回时重新初始化照片尺寸、加载状态和价格;网格继续使用 Compose 自带的可保存滚动状态。
|
||||
- 按钮在导航期间阻止连续点击,回到原页时恢复可点击。
|
||||
- 最终 Debug 构建、27 项 JVM 单元测试通过;模拟器上 4 项 RecentPhotosFlowTest 和 1 项 LocalAudioPlaybackTest 全部通过。
|
||||
- 新测试验证:双击只 push 一次、新旧页面 VM 和选择独立、返回保留原 entry/选择/金额/滚动位置且不重复计价初始化、返回后可再次打开新页、recent 隐藏按钮及每次新入栈重新加载。
|
||||
- 计价使用按选择数量计算的模拟响应,无真实订单和打印操作。
|
||||
- [实际布局截图](result-toolbar-button.png) 使用 30 张蓝色测试图片展示工具栏位置和长列表场景。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
+3
-5
@@ -92,7 +92,7 @@ sequenceDiagram
|
||||
K->>API: get-pay-url 获取支付二维码
|
||||
User->>API: 手机支付
|
||||
WS-->>K: code=5 支付成功及订单信息
|
||||
K-->>User: 支付成功,确认要打印的照片
|
||||
K-->>User: 支付成功,直接进入打印页
|
||||
K->>P: 逐张下载、处理并打印
|
||||
K->>API: print-notify 上报每张结果
|
||||
K->>API: print-complete 上报订单完成
|
||||
@@ -104,7 +104,6 @@ sequenceDiagram
|
||||
首页
|
||||
-> 上传照片二维码页 UploadPhotoScreen
|
||||
-> 照片选择页 PhotoSelectScreen
|
||||
-> 支付成功页 PaySuccessScreen
|
||||
-> 打印页 PrintingScreen
|
||||
-> 自动返回首页
|
||||
```
|
||||
@@ -112,8 +111,7 @@ sequenceDiagram
|
||||
核心类:
|
||||
|
||||
- [`UploadPhotoViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/UploadPhotoViewModel.kt):加载上传二维码,监听扫码和文件列表事件
|
||||
- [`PhotoSelectViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PhotoSelectViewModel.kt):选片、计价、获取支付二维码
|
||||
- [`PaySuccessViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PaySuccessViewModel.kt):支付后再次确认打印照片
|
||||
- [`PhotoSelectViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PhotoSelectViewModel.kt):选片、计价、获取支付二维码,并在支付成功后直接进入打印页
|
||||
- [`PrintingViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PrintingViewModel.kt):逐张下载、打印、记录并上报结果
|
||||
|
||||
服务端通过 `file_map` 同时下发 OSS 和局域网地址。客户端根据设置中的“使用 LAN”开关选择 `*_lan_url` 或 `*_oss_url`。
|
||||
@@ -130,7 +128,7 @@ sequenceDiagram
|
||||
4. 服务根据人脸相似度返回当天匹配的照片。
|
||||
5. 终端展示缩略图,游客选择照片。
|
||||
6. 客户端请求后端计算价格并生成支付二维码。
|
||||
7. WebSocket 收到支付成功事件后进入支付成功页和打印页。
|
||||
7. WebSocket 或 HTTP 轮询确认支付成功后直接进入打印页。
|
||||
|
||||
主要代码:
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ coreKtx = "1.15.0"
|
||||
|
||||
junit = "4.13.2"
|
||||
junitVersion = "1.2.1"
|
||||
espressoCore = "3.6.1"
|
||||
espressoCore = "3.7.0"
|
||||
composeBom = "2025.05.01"
|
||||
constraintlayoutCompose = "1.1.1"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user