feat: add recent photo browsing with source-aware titles and audio
Add recent-photo entry points to face recognition and its result page, preserve selection on back navigation, and reuse photo selection and payment flows. Switch face search to v3 with server defaults. Include cropped guidance audio, request and UI coverage, and verification artifacts.
This commit is contained in:
@@ -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) }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
// 打印中页面
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<FaceSearchResponse>
|
||||
|
||||
@Multipart
|
||||
@POST
|
||||
suspend fun searchFace(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 图片瀑布流列表
|
||||
|
||||
@@ -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(
|
||||
|
||||
+73
-36
@@ -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<FaceSearchResult> = emptyList()
|
||||
|
||||
@@ -124,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} 张,宽高比将在图片加载成功后逐张更新")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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) {
|
||||
// 当前全选,取消全选
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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"))
|
||||
}
|
||||
}
|
||||
@@ -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<Any>(), response.body()?.results)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retrofit accepts multipart post with dynamic url`() {
|
||||
Retrofit.Builder()
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user