diff --git a/app/src/androidTest/java/com/yzx/kiosk/audio/LocalAudioPlaybackTest.kt b/app/src/androidTest/java/com/yzx/kiosk/audio/LocalAudioPlaybackTest.kt new file mode 100644 index 0000000..d16083b --- /dev/null +++ b/app/src/androidTest/java/com/yzx/kiosk/audio/LocalAudioPlaybackTest.kt @@ -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) + } +} diff --git a/app/src/androidTest/java/com/yzx/kiosk/ui/face/RecentPhotosFlowTest.kt b/app/src/androidTest/java/com/yzx/kiosk/ui/face/RecentPhotosFlowTest.kt new file mode 100644 index 0000000..11a37b0 --- /dev/null +++ b/app/src/androidTest/java/com/yzx/kiosk/ui/face/RecentPhotosFlowTest.kt @@ -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() + private lateinit var faceVm: FaceRecognitionViewModel + private lateinit var nav: AppNavigator + private var restoreConfig: (() -> Unit)? = null + private val requests = CopyOnWriteArrayList() + @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 create(modelClass: Class): 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).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) } + } diff --git a/app/src/main/java/com/yzx/kiosk/audio/LocalAudioPlayService.kt b/app/src/main/java/com/yzx/kiosk/audio/LocalAudioPlayService.kt index 08de517..ac7cf06 100644 --- a/app/src/main/java/com/yzx/kiosk/audio/LocalAudioPlayService.kt +++ b/app/src/main/java/com/yzx/kiosk/audio/LocalAudioPlayService.kt @@ -42,6 +42,7 @@ 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.PRINTING to R.raw.printing_page, @@ -109,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 } diff --git a/app/src/main/java/com/yzx/kiosk/audio/LocalAudioRoute.kt b/app/src/main/java/com/yzx/kiosk/audio/LocalAudioRoute.kt new file mode 100644 index 0000000..e47e3a4 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/audio/LocalAudioRoute.kt @@ -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 +} diff --git a/app/src/main/java/com/yzx/kiosk/navigation/AppNavHost.kt b/app/src/main/java/com/yzx/kiosk/navigation/AppNavHost.kt index 7c64007..97ff5a9 100644 --- a/app/src/main/java/com/yzx/kiosk/navigation/AppNavHost.kt +++ b/app/src/main/java/com/yzx/kiosk/navigation/AppNavHost.kt @@ -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 @@ -140,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 { @@ -153,7 +161,10 @@ fun AppNavHost( } else { emptyList() } - FaceRecognitionResultScreen(results = results) + FaceRecognitionResultScreen( + results = results, + source = backStackEntry.arguments?.getString("source") ?: AppRoutes.FACE_RESULT_SOURCE, + ) } // 打印中页面 diff --git a/app/src/main/java/com/yzx/kiosk/navigation/routes/AppRoutes.kt b/app/src/main/java/com/yzx/kiosk/navigation/routes/AppRoutes.kt index 2d82cd3..db70bcd 100644 --- a/app/src/main/java/com/yzx/kiosk/navigation/routes/AppRoutes.kt +++ b/app/src/main/java/com/yzx/kiosk/navigation/routes/AppRoutes.kt @@ -12,6 +12,12 @@ object AppRoutes { 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( diff --git a/app/src/main/java/com/yzx/kiosk/network/service/FaceSearchService.kt b/app/src/main/java/com/yzx/kiosk/network/service/FaceSearchService.kt index 3fcdc57..ddbfdad 100644 --- a/app/src/main/java/com/yzx/kiosk/network/service/FaceSearchService.kt +++ b/app/src/main/java/com/yzx/kiosk/network/service/FaceSearchService.kt @@ -4,6 +4,7 @@ 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 @@ -11,6 +12,12 @@ import retrofit2.http.Part import retrofit2.http.Url interface FaceSearchService { + @GET + suspend fun recentPhotos( + @Url url: String, + @Header("Authorization") authorization: String, + ): Response + @Multipart @POST suspend fun searchFace( diff --git a/app/src/main/java/com/yzx/kiosk/network/service/FaceSearchUrl.kt b/app/src/main/java/com/yzx/kiosk/network/service/FaceSearchUrl.kt index 64302af..ac8e2ba 100644 --- a/app/src/main/java/com/yzx/kiosk/network/service/FaceSearchUrl.kt +++ b/app/src/main/java/com/yzx/kiosk/network/service/FaceSearchUrl.kt @@ -8,13 +8,14 @@ internal fun buildFaceSearchUrl(baseUrl: String, deviceSn: String): String { ?: throw IllegalArgumentException("Invalid face search base URL") return parsedBaseUrl.newBuilder() - // Preserve the existing /{sn}/api/search endpoint semantics even if configuration + // 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() diff --git a/app/src/main/java/com/yzx/kiosk/network/service/RecentPhotosUrl.kt b/app/src/main/java/com/yzx/kiosk/network/service/RecentPhotosUrl.kt new file mode 100644 index 0000000..9e641ae --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/service/RecentPhotosUrl.kt @@ -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() +} diff --git a/app/src/main/java/com/yzx/kiosk/ui/face/view/FaceRecognitionResultScreen.kt b/app/src/main/java/com/yzx/kiosk/ui/face/view/FaceRecognitionResultScreen.kt index db0f156..8e2a4a8 100644 --- a/app/src/main/java/com/yzx/kiosk/ui/face/view/FaceRecognitionResultScreen.kt +++ b/app/src/main/java/com/yzx/kiosk/ui/face/view/FaceRecognitionResultScreen.kt @@ -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 = 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) } + } + } + } } // 图片瀑布流列表 diff --git a/app/src/main/java/com/yzx/kiosk/ui/face/view/FaceRecognitionScreen.kt b/app/src/main/java/com/yzx/kiosk/ui/face/view/FaceRecognitionScreen.kt index 925a4b2..529e2aa 100644 --- a/app/src/main/java/com/yzx/kiosk/ui/face/view/FaceRecognitionScreen.kt +++ b/app/src/main/java/com/yzx/kiosk/ui/face/view/FaceRecognitionScreen.kt @@ -87,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) @@ -233,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( diff --git a/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionResultViewModel.kt b/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionResultViewModel.kt index 3fcc1be..189173b 100644 --- a/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionResultViewModel.kt +++ b/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionResultViewModel.kt @@ -9,6 +9,8 @@ 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 @@ -43,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 = emptyList() @@ -124,46 +153,53 @@ class FaceRecognitionResultViewModel @Inject constructor( /** * 初始化图片列表(从人脸识别结果) */ + private var faceResultsInitialized = false + fun initPhotoList(results: List) { - 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) { + // 保存原始结果列表 + 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} 张,宽高比将在图片加载成功后逐张更新") } /** @@ -215,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) { // 当前全选,取消全选 diff --git a/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionViewModel.kt b/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionViewModel.kt index 90931fb..867a74d 100644 --- a/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionViewModel.kt +++ b/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionViewModel.kt @@ -39,8 +39,6 @@ import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.toRequestBody import java.io.File -import java.text.SimpleDateFormat -import java.util.* import javax.inject.Inject enum class RecognitionStatus { @@ -316,9 +314,6 @@ 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()) // Authorization header val authorization = "Bearer $faceSearchToken" @@ -330,7 +325,6 @@ 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, "==========================================") // 调用接口 @@ -339,6 +333,7 @@ class FaceRecognitionViewModel @Inject constructor( authorization = authorization, image = imagePart, threshold = thresholdBody, + // 不传日期,由 V3 服务端使用上海时区当天;人数及人脸大小也使用服务端默认值。 indexDate = null ) @@ -413,6 +408,22 @@ class FaceRecognitionViewModel @Inject constructor( } } + private var isOpeningRecentPhotos = false + + 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() { val args = mapOf( AgreementRoutes.AGREEMENT_URL to BuildConfig.BASE_URL + "/pages/oscar/private-policy.html", diff --git a/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/RecentPhotosLoader.kt b/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/RecentPhotosLoader.kt new file mode 100644 index 0000000..8201f05 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/RecentPhotosLoader.kt @@ -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 + } + } + } +} diff --git a/app/src/main/res/raw/recent_photos_result_page.wav b/app/src/main/res/raw/recent_photos_result_page.wav new file mode 100644 index 0000000..b8e789f Binary files /dev/null and b/app/src/main/res/raw/recent_photos_result_page.wav differ diff --git a/app/src/test/java/com/yzx/kiosk/audio/LocalAudioRouteTest.kt b/app/src/test/java/com/yzx/kiosk/audio/LocalAudioRouteTest.kt new file mode 100644 index 0000000..a8ad038 --- /dev/null +++ b/app/src/test/java/com/yzx/kiosk/audio/LocalAudioRouteTest.kt @@ -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")) + } +} diff --git a/app/src/test/java/com/yzx/kiosk/network/service/FaceSearchUrlTest.kt b/app/src/test/java/com/yzx/kiosk/network/service/FaceSearchUrlTest.kt index cd8cd13..332d3aa 100644 --- a/app/src/test/java/com/yzx/kiosk/network/service/FaceSearchUrlTest.kt +++ b/app/src/test/java/com/yzx/kiosk/network/service/FaceSearchUrlTest.kt @@ -1,5 +1,12 @@ 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 @@ -10,7 +17,7 @@ class FaceSearchUrlTest { @Test fun `url uses configured host and safely encoded sn`() { assertEquals( - "http://192.168.1.10/BOX%2F01/api/search", + "http://192.168.1.10/BOX%2F01/api/v3/search", buildFaceSearchUrl("http://192.168.1.10/old/path?unused=true", "BOX/01"), ) } @@ -25,6 +32,45 @@ class FaceSearchUrlTest { } } + @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(), response.body()?.results) + } + @Test fun `retrofit accepts multipart post with dynamic url`() { Retrofit.Builder() diff --git a/app/src/test/java/com/yzx/kiosk/network/service/RecentPhotosServiceTest.kt b/app/src/test/java/com/yzx/kiosk/network/service/RecentPhotosServiceTest.kt new file mode 100644 index 0000000..e58635d --- /dev/null +++ b/app/src/test/java/com/yzx/kiosk/network/service/RecentPhotosServiceTest.kt @@ -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) + } + } + } +} diff --git a/app/src/test/java/com/yzx/kiosk/ui/face/viewmodel/RecentPhotosLoaderTest.kt b/app/src/test/java/com/yzx/kiosk/ui/face/viewmodel/RecentPhotosLoaderTest.kt new file mode 100644 index 0000000..c1b726d --- /dev/null +++ b/app/src/test/java/com/yzx/kiosk/ui/face/viewmodel/RecentPhotosLoaderTest.kt @@ -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) + } +} diff --git a/docs/verification/recent-photos/README.md b/docs/verification/recent-photos/README.md new file mode 100644 index 0000000..6bdcf6f --- /dev/null +++ b/docs/verification/recent-photos/README.md @@ -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 张蓝色测试图片展示工具栏位置和长列表场景。 diff --git a/docs/verification/recent-photos/failure-button.png b/docs/verification/recent-photos/failure-button.png new file mode 100644 index 0000000..5d84dd2 Binary files /dev/null and b/docs/verification/recent-photos/failure-button.png differ diff --git a/docs/verification/recent-photos/result-toolbar-button.png b/docs/verification/recent-photos/result-toolbar-button.png new file mode 100644 index 0000000..e8cec05 Binary files /dev/null and b/docs/verification/recent-photos/result-toolbar-button.png differ diff --git a/docs/verification/recent-photos/results.png b/docs/verification/recent-photos/results.png new file mode 100644 index 0000000..5ad251d Binary files /dev/null and b/docs/verification/recent-photos/results.png differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 824e841..6436252 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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"