Author SHA1 Message Date
lujiuyin 712f6154bc chore: bump version to 1.1.4 2026-09-14 13:36:06 +08:00
lujiuyin 1d66bd18d1 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.
2026-09-08 15:48:42 +08:00
lujiuyin 63944db6fb fix: optimize face recognition resource lifecycle 2026-08-25 13:42:09 +08:00
34 changed files with 1559 additions and 294 deletions
+2 -2
View File
@@ -89,8 +89,8 @@ android {
applicationId = "com.yzx.kiosk" applicationId = "com.yzx.kiosk"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 23 versionCode = 24
versionName = "1.1.3" versionName = "1.1.4"
//multiDexEnabled = true //multiDexEnabled = true
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -0,0 +1,52 @@
package com.yzx.kiosk.audio
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.test.platform.app.InstrumentationRegistry
import com.yzx.kiosk.R
import com.yzx.kiosk.navigation.routes.AppRoutes
import org.junit.Assert.*
import org.junit.Test
import java.util.concurrent.atomic.AtomicInteger
class LocalAudioPlaybackTest {
@Test fun recentAndFaceEntriesSwitchAudioWithoutDuplicatePlayback() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
lateinit var service: LocalAudioPlayService
lateinit var player: ExoPlayer
val transitions = AtomicInteger()
instrumentation.runOnMainSync {
service = LocalAudioPlayService(instrumentation.targetContext)
player = LocalAudioPlayService::class.java.getDeclaredField("exoPlayer").apply { isAccessible = true }.get(service) as ExoPlayer
player.addListener(object : Player.Listener {
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { transitions.incrementAndGet() }
})
}
try {
instrumentation.runOnMainSync { service.playByRoute(AppRoutes.buildRecentPhotosRoute()) }
awaitPlaying(service)
assertEquals(R.raw.recent_photos_result_page, service.currentResId.value)
assertEquals(1, transitions.get())
instrumentation.runOnMainSync { service.playByRoute(AppRoutes.buildRecentPhotosRoute()) }
instrumentation.waitForIdleSync()
assertEquals("Repeated recent entry must not reset the media item", 1, transitions.get())
instrumentation.runOnMainSync { service.playByRoute("${AppRoutes.FACE_RECOGNITION_RESULT}?results=%5B%5D&source=face") }
awaitPlaying(service)
assertEquals(R.raw.face_recognition_success_result_page, service.currentResId.value)
assertEquals(2, transitions.get())
instrumentation.runOnMainSync { service.playByRoute(AppRoutes.FACE_RECOGNITION_RESULT) }
instrumentation.waitForIdleSync()
assertEquals("Legacy face route uses the same success clip", 2, transitions.get())
} finally {
instrumentation.runOnMainSync { service.release() }
}
}
private fun awaitPlaying(service: LocalAudioPlayService) {
val deadline = System.currentTimeMillis() + 5000
while (!service.isPlaying.value && System.currentTimeMillis() < deadline) Thread.sleep(20)
assertTrue("Local audio should enter playback", service.isPlaying.value)
}
}
@@ -0,0 +1,312 @@
package com.yzx.kiosk.ui.face
import android.graphics.Bitmap
import android.graphics.Color
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.ui.semantics.SemanticsProperties
import com.yzx.kiosk.network.model.response.FaceSearchResult
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.*
import androidx.navigation.navArgument
import androidx.test.platform.app.InstrumentationRegistry
import com.google.gson.Gson
import com.yzx.kiosk.audio.LocalAudioPlayService
import com.yzx.kiosk.navigation.AppNavigator
import com.yzx.kiosk.navigation.handleNavigationEvent
import com.yzx.kiosk.navigation.routes.AppRoutes
import com.yzx.kiosk.network.repository.NetWorkRepository
import com.yzx.kiosk.network.service.*
import com.yzx.kiosk.ui.face.resource.FaceCaptureFileStore
import com.yzx.kiosk.ui.face.resource.FaceThumbnailDecoder
import com.yzx.kiosk.ui.face.view.*
import com.yzx.kiosk.ui.face.viewmodel.*
import com.yzx.kiosk.websocket.WebSocketService
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableStateFlow
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.*
import org.junit.Assert.*
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.io.File
import java.util.concurrent.CopyOnWriteArrayList
/** All photo/order HTTP requests use an in-process interceptor; no orders or prints are submitted. */
class RecentPhotosFlowTest {
@get:Rule val compose = createComposeRule()
private val context get() = InstrumentationRegistry.getInstrumentation().targetContext
private lateinit var resultVm: FaceRecognitionResultViewModel
private lateinit var createResultVm: () -> FaceRecognitionResultViewModel
private val scopedResultVms = mutableListOf<FaceRecognitionResultViewModel>()
private lateinit var faceVm: FaceRecognitionViewModel
private lateinit var nav: AppNavigator
private var restoreConfig: (() -> Unit)? = null
private val requests = CopyOnWriteArrayList<Request>()
@Volatile private var recentCode = 200
@Volatile private var recentBody = """{"count":0,"results":[]}"""
@Before fun setup() {
val app = context.applicationContext as com.yzx.kiosk.App
// Access the existing singleton without launching MainActivity (which connects live services).
val component = (app as dagger.hilt.internal.GeneratedComponentManager<*>).generatedComponent()
val providerField = component.javaClass.getDeclaredField("webSocketServiceProvider").apply { isAccessible = true }
val socket = (providerField.get(component) as javax.inject.Provider<*>).get() as WebSocketService
val store = app.appStoreDataSource
val oldRemote = store.getBindBoxUrl()
val oldLan = store.getBindBoxLanUrl()
val oldSn = store.getBindBoxSn()
val oldToken = store.getBindBoxApiToken()
restoreConfig = {
store.saveBindBoxUrl(oldRemote)
store.saveBindBoxLanUrl(oldLan)
store.saveBindBoxSn(oldSn)
store.saveBindBoxApiToken(oldToken)
}
store.saveBindBoxUrl("https://example.test")
store.saveBindBoxLanUrl("https://example.test")
store.saveBindBoxSn("MOCK-BOX")
store.saveBindBoxApiToken("test-token")
val client = OkHttpClient.Builder().addInterceptor { chain ->
val req = chain.request()
requests.add(req)
val recent = req.url.encodedPath.endsWith("/api/photos/recent")
val body = when {
recent -> recentBody
req.url.encodedPath.endsWith("verify-result") -> {
val buffer = okio.Buffer()
req.body!!.writeTo(buffer)
val ids = Gson().fromJson(buffer.readUtf8(), com.google.gson.JsonObject::class.java).getAsJsonArray("image_id")
val amount = "${ids.size() * 2}.00"
"""{"code":100000,"data":{"price_image":"2.00","amount":"$amount"}}"""
}
req.url.encodedPath.endsWith("get-pay-url") -> """{"code":100000,"data":{"url":"https://example.test/mock-pay","order_number":"MOCK-RECENT-1"}}"""
else -> """{"count":0,"results":[]}"""
}
Response.Builder().request(req).protocol(Protocol.HTTP_1_1).code(if (recent) recentCode else 200)
.message("Mock").body(body.toResponseBody("application/json".toMediaType())).build()
}.build()
val retrofit = Retrofit.Builder().baseUrl("https://example.test/").client(client)
.addConverterFactory(GsonConverterFactory.create()).build()
val service = retrofit.create(FaceSearchService::class.java)
val repository = NetWorkRepository(retrofit.create(NetworkService::class.java), retrofit.create(UploadService::class.java), Gson())
compose.runOnUiThread {
nav = AppNavigator()
createResultVm = { FaceRecognitionResultViewModel(nav, app.appState, repository, service, socket, app.appStoreDataSource) }
resultVm = createResultVm()
faceVm = FaceRecognitionViewModel(nav, app.appState, app.appStoreDataSource, LocalAudioPlayService(context), service, FaceCaptureFileStore(context), FaceThumbnailDecoder())
}
}
@After fun cleanup() {
compose.runOnUiThread {
if (::resultVm.isInitialized) resultVm.viewModelScope.cancel()
if (::faceVm.isInitialized) faceVm.viewModelScope.cancel()
scopedResultVms.forEach { it.viewModelScope.cancel() }
restoreConfig?.invoke()
}
}
private fun screenshot(name: String) {
val image = compose.onRoot().captureToImage().asAndroidBitmap()
File(context.getExternalFilesDir(null), "$name.png").outputStream().use { image.compress(Bitmap.CompressFormat.PNG, 100, it) }
}
@Test fun resultToolbarPushesIndependentPageAndRestoresSelectionAndScroll() {
val bitmap = Bitmap.createBitmap(160, 100, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.BLUE) }
val faceResults = (1..30).map { id ->
val file = File(context.cacheDir, "toolbar-photo-$id.png")
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
val url = file.toURI().toString()
FaceSearchResult(id, url, url, url, url)
}
recentBody = Gson().toJson(mapOf("count" to 2, "results" to faceResults.takeLast(2)))
lateinit var controller: NavHostController
val factory = object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
createResultVm().also { scopedResultVms.add(it) } as T
}
compose.setContent {
controller = rememberNavController()
LaunchedEffect(Unit) { nav.navigationEvents.collect { controller.handleNavigationEvent(it) } }
NavHost(controller, startDestination = AppRoutes.HOME) {
composable(AppRoutes.HOME) { Text("测试首页") }
composable(AppRoutes.FACE_RESULT_PATTERN, arguments = listOf(
navArgument("results") { type = NavType.StringType; defaultValue = "" },
navArgument("source") { type = NavType.StringType; defaultValue = AppRoutes.FACE_RESULT_SOURCE },
)) { entry ->
val vm: FaceRecognitionResultViewModel = viewModel(viewModelStoreOwner = entry, factory = factory)
val source = entry.arguments?.getString("source")!!
FaceRecognitionResultScreen(
results = if (source == AppRoutes.FACE_RESULT_SOURCE) faceResults else emptyList(),
source = source,
viewModel = vm,
)
}
}
}
compose.runOnIdle { controller.navigate(AppRoutes.FACE_RECOGNITION_RESULT) }
compose.waitUntil(10_000) { scopedResultVms.firstOrNull()?.photoList?.value?.size == 30 }
compose.onNodeWithText("人脸识别结果").assertIsDisplayed()
val originalVm = scopedResultVms.first()
compose.runOnIdle { originalVm.togglePhotoSelection(faceResults[0].thumbnailOssUrl!!) }
compose.waitUntil(10_000) { originalVm.totalPrice.value == "2.00" }
compose.onNodeWithText("查看最近照片").assertIsDisplayed().assertIsEnabled()
screenshot("face-results-browse-button")
val grid = compose.onNode(hasScrollToIndexAction())
grid.performScrollToIndex(12)
compose.waitForIdle()
val scrollBefore = grid.fetchSemanticsNode().config[SemanticsProperties.VerticalScrollAxisRange].value()
val selectedBefore = originalVm.selectedPhotos.value
val totalBefore = originalVm.totalPrice.value
var originalEntryId = ""
compose.runOnIdle { originalEntryId = controller.currentBackStackEntry!!.id }
// Two taps in one event cycle must produce only one pushed page.
compose.onNodeWithText("查看最近照片").performTouchInput { doubleClick() }
compose.waitUntil(10_000) { scopedResultVms.size == 2 && scopedResultVms[1].recentPhotosState.value == RecentPhotosState.READY }
compose.onNodeWithText("最近照片").assertIsDisplayed()
val recentVm = scopedResultVms[1]
assertNotSame(originalVm, recentVm)
assertTrue(recentVm.selectedPhotos.value.isEmpty())
assertEquals(selectedBefore, originalVm.selectedPhotos.value)
compose.onNodeWithText("查看最近照片").assertDoesNotExist()
compose.runOnIdle {
assertEquals(originalEntryId, controller.previousBackStackEntry!!.id)
recentVm.toggleSelectAll()
}
compose.waitUntil(10_000) { recentVm.totalPrice.value == "4.00" }
val verifyRequestsBeforeReturn = requests.count { it.url.encodedPath.endsWith("verify-result") }
compose.runOnIdle { controller.popBackStack() }
compose.onNodeWithText("人脸识别结果").assertIsDisplayed()
compose.onNodeWithText("查看最近照片").assertIsDisplayed().assertIsEnabled()
compose.runOnIdle {
assertEquals(originalEntryId, controller.currentBackStackEntry!!.id)
assertEquals(selectedBefore, originalVm.selectedPhotos.value)
assertEquals(totalBefore, originalVm.totalPrice.value)
}
val scrollAfter = compose.onNode(hasScrollToIndexAction()).fetchSemanticsNode().config[SemanticsProperties.VerticalScrollAxisRange].value()
assertEquals(scrollBefore, scrollAfter, 0.05f)
assertEquals(verifyRequestsBeforeReturn, requests.count { it.url.encodedPath.endsWith("verify-result") })
// Entry becomes available again after returning, and the next push gets fresh state.
compose.onNodeWithText("查看最近照片").performClick()
compose.waitUntil(10_000) { scopedResultVms.size == 3 && scopedResultVms[2].recentPhotosState.value == RecentPhotosState.READY }
assertTrue(scopedResultVms[2].selectedPhotos.value.isEmpty())
compose.onNodeWithText("查看最近照片").assertDoesNotExist()
assertEquals(2, requests.count { it.url.encodedPath.endsWith("/api/photos/recent") })
}
@Test fun failureButtonNavigatesOnceAndKeepsOriginalReturnPath() {
lateinit var controller: NavHostController
compose.setContent {
controller = rememberNavController()
LaunchedEffect(Unit) { nav.navigationEvents.collect { controller.handleNavigationEvent(it) } }
NavHost(controller, startDestination = AppRoutes.HOME) {
composable(AppRoutes.HOME) { Text("测试首页") }
composable(AppRoutes.FACE_RECOGNITION) { FaceRecognitionScreen(viewModel = faceVm) }
composable(AppRoutes.FACE_RESULT_PATTERN, arguments = listOf(
navArgument("results") { type = NavType.StringType; defaultValue = "" },
navArgument("source") { type = NavType.StringType; defaultValue = AppRoutes.FACE_RESULT_SOURCE },
)) { entry ->
FaceRecognitionResultScreen(source = entry.arguments?.getString("source")!!, viewModel = resultVm)
}
}
}
compose.runOnIdle { controller.navigate(AppRoutes.FACE_RECOGNITION) }
compose.onNodeWithText("找不到自己?查看最近照片").assertDoesNotExist()
compose.runOnIdle {
val field = FaceRecognitionViewModel::class.java.getDeclaredField("_recognitionStatus").apply { isAccessible = true }
@Suppress("UNCHECKED_CAST")
(field.get(faceVm) as MutableStateFlow<RecognitionStatus>).value = RecognitionStatus.FAILED
}
compose.onNodeWithText("找不到自己?查看最近照片").assertIsDisplayed()
screenshot("recent-photos-failure-button")
compose.onNodeWithText("找不到自己?查看最近照片").performClick()
compose.runOnIdle { faceVm.browseRecentPhotos() }
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.EMPTY }
compose.onNodeWithText("暂无可显示的最近照片").assertIsDisplayed()
compose.runOnIdle {
assertEquals(AppRoutes.RECENT_RESULT_SOURCE, controller.currentBackStackEntry?.arguments?.getString("source"))
assertEquals(AppRoutes.HOME, controller.previousBackStackEntry?.destination?.route)
// Verify the legacy route still resolves with source=face.
controller.navigate("${AppRoutes.FACE_RECOGNITION_RESULT}?results=%5B%5D")
assertEquals(AppRoutes.FACE_RESULT_SOURCE, controller.currentBackStackEntry?.arguments?.getString("source"))
}
assertEquals(1, requests.count { it.url.encodedPath.endsWith("/api/photos/recent") })
}
@Test fun recentPhotosReuseSelectionPricingAndMockPaymentToPrintingRoute() {
val image = Bitmap.createBitmap(160, 100, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.BLUE) }
val file = File(context.cacheDir, "recent-photo-fixture.png")
file.outputStream().use { image.compress(Bitmap.CompressFormat.PNG, 100, it) }
val url = file.toURI().toString()
val second = File(context.cacheDir, "recent-photo-second.png")
file.copyTo(second, overwrite = true)
val secondUrl = second.toURI().toString()
recentBody = """{"count":3,"results":[{"id":9,"thumbnail_oss_url":"$url","thumbnail_lan_url":"$url"},{"id":3,"thumbnail_oss_url":"$secondUrl","thumbnail_lan_url":"$secondUrl"},{"id":2,"thumbnail_oss_url":null,"thumbnail_lan_url":null}]}"""
compose.setContent { FaceRecognitionResultScreen(source = AppRoutes.RECENT_RESULT_SOURCE, viewModel = resultVm) }
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.READY }
assertEquals(listOf(9, 3), resultVm.photoList.value.map { it.id })
compose.onNodeWithText("全选").performClick()
compose.waitUntil(10_000) { resultVm.totalPrice.value == "4.00" }
assertEquals(2, resultVm.selectedPhotos.value.size)
compose.onNodeWithText("支付4.00元").assertIsEnabled()
screenshot("recent-photos-results")
compose.onAllNodesWithText("点击预览")[0].performClick()
compose.onNodeWithContentDescription("关闭").assertIsDisplayed().performClick()
compose.runOnIdle { resultVm.togglePhotoSelection(resultVm.photoList.value[0].url) }
assertEquals(1, resultVm.selectedPhotos.value.size)
compose.runOnIdle { resultVm.toggleSelectAll() }
assertEquals(2, resultVm.selectedPhotos.value.size)
// Exercise the shared payment code against a fake response without opening polling UI.
var payUrl: String? = null
compose.runOnIdle { resultVm.getPayUrl { payUrl = it } }
compose.waitUntil(10_000) { payUrl != null }
assertEquals("https://example.test/mock-pay", payUrl)
val field = FaceRecognitionResultViewModel::class.java.getDeclaredField("activePaymentOrderNumber").apply { isAccessible = true }
assertEquals("MOCK-RECENT-1", field.get(resultVm))
// Inspect printing navigation only; never compose the real printing page.
var route: String? = null
val job = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Main).let { scope ->
scope.launchCollect(nav) { route = it }
}
compose.runOnIdle {
val method = FaceRecognitionResultViewModel::class.java.getDeclaredMethod("handlePaymentSuccess", String::class.java, Integer::class.java, List::class.java, String::class.java).apply { isAccessible = true }
method.invoke(resultVm, "MOCK-RECENT-1", 2, listOf(9, 3), "test")
}
compose.waitUntil(10_000) { route != null }
assertTrue(route!!.startsWith("printing?"))
assertTrue(route!!.contains("orderNumber=MOCK-RECENT-1"))
job.cancel()
}
@Test fun errorCanRetryIntoEmptyState() {
recentCode = 403
compose.setContent { FaceRecognitionResultScreen(source = AppRoutes.RECENT_RESULT_SOURCE, viewModel = resultVm) }
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.ERROR }
compose.onNodeWithText("照片加载失败,请重试").assertIsDisplayed()
compose.onNodeWithText("支付0元").assertIsNotEnabled()
recentCode = 200
compose.onNodeWithText("重试").performClick()
compose.waitUntil(10_000) { resultVm.recentPhotosState.value == RecentPhotosState.EMPTY }
compose.onNodeWithText("暂无可显示的最近照片").assertIsDisplayed()
assertEquals(2, requests.count { it.url.encodedPath.endsWith("/api/photos/recent") })
}
}
private fun kotlinx.coroutines.CoroutineScope.launchCollect(nav: AppNavigator, onRoute: (String) -> Unit) =
launch(start = kotlinx.coroutines.CoroutineStart.UNDISPATCHED) {
nav.navigationEvents.collect { if (it is com.yzx.kiosk.navigation.NavigationEvent.NavigateTo) onRoute(it.route) }
}
@@ -0,0 +1,98 @@
package com.yzx.kiosk.ui.face.resource
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import androidx.exifinterface.media.ExifInterface
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
@RunWith(AndroidJUnit4::class)
class FaceThumbnailDecoderInstrumentedTest {
private lateinit var testDirectory: File
@Before
fun setUp() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
testDirectory = File(context.cacheDir, "face_thumbnail_test").apply {
deleteRecursively()
mkdirs()
}
}
@After
fun tearDown() {
testDirectory.deleteRecursively()
}
@Test
fun largeImagesAreBoundedAndAllExifOrientationsAreHandled() {
val orientations = listOf(
ExifInterface.ORIENTATION_NORMAL to false,
ExifInterface.ORIENTATION_FLIP_HORIZONTAL to false,
ExifInterface.ORIENTATION_ROTATE_180 to false,
ExifInterface.ORIENTATION_FLIP_VERTICAL to false,
ExifInterface.ORIENTATION_TRANSPOSE to true,
ExifInterface.ORIENTATION_ROTATE_90 to true,
ExifInterface.ORIENTATION_TRANSVERSE to true,
ExifInterface.ORIENTATION_ROTATE_270 to true,
)
val decoder = FaceThumbnailDecoder()
orientations.forEachIndexed { index, (orientation, swapsDimensions) ->
val imageFile = File(testDirectory, "orientation_$index.jpg")
createLargeJpeg(imageFile)
ExifInterface(imageFile).apply {
setAttribute(ExifInterface.TAG_ORIENTATION, orientation.toString())
saveAttributes()
}
val decoded = decoder.decode(imageFile.absolutePath)
assertNotNull(decoded)
decoded!!
assertFalse(decoded.isRecycled)
assertTrue(maxOf(decoded.width, decoded.height) <= FaceThumbnailDecoder.MAX_PREVIEW_EDGE_PX)
if (swapsDimensions) {
assertEquals(360, decoded.width)
assertEquals(720, decoded.height)
} else {
assertEquals(720, decoded.width)
assertEquals(360, decoded.height)
}
decoded.recycle()
}
}
@Test
fun captureFilesAreCreatedInOwnedDirectoryAndDeletedIdempotently() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val fileStore = FaceCaptureFileStore(context)
val captureFile = fileStore.createCaptureFile()
assertTrue(captureFile.exists())
assertEquals(FaceCaptureFileStore.CAPTURE_DIRECTORY, captureFile.parentFile?.name)
fileStore.delete(captureFile)
fileStore.delete(captureFile)
assertFalse(captureFile.exists())
}
private fun createLargeJpeg(file: File) {
val bitmap = Bitmap.createBitmap(1600, 800, Bitmap.Config.ARGB_8888)
Canvas(bitmap).drawColor(Color.MAGENTA)
file.outputStream().use { output ->
assertTrue(bitmap.compress(Bitmap.CompressFormat.JPEG, 95, output))
}
bitmap.recycle()
}
}
+14
View File
@@ -12,6 +12,7 @@ import com.luck.picture.lib.basic.PictureSelectorSupporterActivity
import com.luck.picture.lib.basic.PictureSelectorTransparentActivity import com.luck.picture.lib.basic.PictureSelectorTransparentActivity
import com.yzx.kiosk.datastore.AppState import com.yzx.kiosk.datastore.AppState
import com.yzx.kiosk.datastore.AppStoreDataSource import com.yzx.kiosk.datastore.AppStoreDataSource
import com.yzx.kiosk.ui.face.resource.FaceCaptureFileStore
import com.yzx.kiosk.utils.LogUtils import com.yzx.kiosk.utils.LogUtils
import com.yzx.kiosk.utils.MMKVUtils import com.yzx.kiosk.utils.MMKVUtils
import com.yzx.kiosk.utils.NavigationBarUtil import com.yzx.kiosk.utils.NavigationBarUtil
@@ -42,6 +43,9 @@ class App : Application() {
@Inject @Inject
lateinit var appStoreDataSource: AppStoreDataSource lateinit var appStoreDataSource: AppStoreDataSource
@Inject
lateinit var faceCaptureFileStore: FaceCaptureFileStore
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
instance = this instance = this
@@ -59,6 +63,16 @@ class App : Application() {
LogUtils.init(BuildConfig.DEBUG) LogUtils.init(BuildConfig.DEBUG)
MMKVUtils.init(this) MMKVUtils.init(this)
runCatching { faceCaptureFileStore.cleanupOrphans() }
.onSuccess { deletedFaceCaptures ->
if (deletedFaceCaptures > 0) {
LogUtils.i("App", "Cleaned $deletedFaceCaptures orphaned face capture file(s)")
}
}
.onFailure { error ->
LogUtils.e("App", "Failed to clean orphaned face captures: ${error.message}")
}
appState.initialize() appState.initialize()
initCoil() initCoil()
@@ -42,6 +42,7 @@ class LocalAudioPlayService @Inject constructor(
AppRoutes.HOME to R.raw.home_audio, AppRoutes.HOME to R.raw.home_audio,
AppRoutes.FACE_RECOGNITION to R.raw.face_recognition_page, AppRoutes.FACE_RECOGNITION to R.raw.face_recognition_page,
AppRoutes.FACE_RECOGNITION_RESULT to R.raw.face_recognition_success_result_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.UPLOAD_PHOTO to R.raw.scan_qrcode_upload_page,
AppRoutes.PHOTO_SELECT to R.raw.upload_unpaid_result_page, AppRoutes.PHOTO_SELECT to R.raw.upload_unpaid_result_page,
AppRoutes.PRINTING to R.raw.printing_page, AppRoutes.PRINTING to R.raw.printing_page,
@@ -109,12 +110,12 @@ class LocalAudioPlayService @Inject constructor(
return 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) { if (audioResId == null) {
LogUtils.d(TAG, "路由 $baseRoute 没有对应的音频文件") LogUtils.d(TAG, "路由音频 $audioKey 没有对应的音频文件")
return 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.navigation.NavType
import androidx.navigation.navArgument
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState 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 resultsParam = backStackEntry.arguments?.getString("results") ?: ""
val results = if (resultsParam.isNotEmpty()) { val results = if (resultsParam.isNotEmpty()) {
try { try {
@@ -153,7 +161,10 @@ fun AppNavHost(
} else { } else {
emptyList() 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 = "face_recognition"
const val FACE_RECOGNITION_RESULT = "face_recognition_result" 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" const val AGREEMENT = "agreement"
fun buildPrintingRoute( fun buildPrintingRoute(
@@ -28,8 +28,10 @@ object NetworkModule {
private const val CLIENT_DEFAULT = "defaultOkHttpClient" private const val CLIENT_DEFAULT = "defaultOkHttpClient"
const val CLIENT_UPLOAD = "uploadOkHttpClient" const val CLIENT_UPLOAD = "uploadOkHttpClient"
const val CLIENT_DOWNLOAD = "downloadOkHttpClient" const val CLIENT_DOWNLOAD = "downloadOkHttpClient"
const val CLIENT_FACE = "faceOkHttpClient"
const val RETROFIT_DEFAULT = "defaultRetrofit" const val RETROFIT_DEFAULT = "defaultRetrofit"
const val RETROFIT_UPLOAD = "uploadRetrofit" const val RETROFIT_UPLOAD = "uploadRetrofit"
const val RETROFIT_FACE = "faceRetrofit"
const val TRANSFRTVIEWMODEL = "transferViewModel" const val TRANSFRTVIEWMODEL = "transferViewModel"
@Provides @Provides
@@ -104,6 +106,17 @@ object NetworkModule {
} }
.build() .build()
@Provides
@Singleton
@Named(CLIENT_FACE)
fun provideFaceOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
.apply {
if (BuildConfig.DEBUG) {
addInterceptor(DebugNetworkLoggingInterceptor("face-search"))
}
}
.build()
private fun buildRetrofit( private fun buildRetrofit(
baseUrl: String, baseUrl: String,
okHttpClient: OkHttpClient, okHttpClient: OkHttpClient,
@@ -131,4 +144,13 @@ object NetworkModule {
gson: Gson, gson: Gson,
@Named(BASE_URL) baseUrl: String @Named(BASE_URL) baseUrl: String
): Retrofit = buildRetrofit(baseUrl, okHttpClient, gson) ): Retrofit = buildRetrofit(baseUrl, okHttpClient, gson)
@Provides
@Singleton
@Named(RETROFIT_FACE)
fun provideFaceRetrofit(
@Named(CLIENT_FACE) okHttpClient: OkHttpClient,
gson: Gson,
@Named(BASE_URL) baseUrl: String,
): Retrofit = buildRetrofit(baseUrl, okHttpClient, gson)
} }
@@ -4,6 +4,7 @@ import android.content.Context
import com.yzx.kiosk.audio.AudioPlayService import com.yzx.kiosk.audio.AudioPlayService
import com.yzx.kiosk.network.service.NetworkService import com.yzx.kiosk.network.service.NetworkService
import com.yzx.kiosk.network.service.UploadService import com.yzx.kiosk.network.service.UploadService
import com.yzx.kiosk.network.service.FaceSearchService
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@@ -11,6 +12,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import com.yzx.kiosk.network.di.NetworkModule.RETROFIT_DEFAULT import com.yzx.kiosk.network.di.NetworkModule.RETROFIT_DEFAULT
import com.yzx.kiosk.network.di.NetworkModule.RETROFIT_UPLOAD import com.yzx.kiosk.network.di.NetworkModule.RETROFIT_UPLOAD
import com.yzx.kiosk.network.di.NetworkModule.RETROFIT_FACE
import retrofit2.Retrofit import retrofit2.Retrofit
import javax.inject.Named import javax.inject.Named
import javax.inject.Singleton import javax.inject.Singleton
@@ -31,6 +33,12 @@ object ServiceModule {
@Named(RETROFIT_UPLOAD) retrofit: Retrofit @Named(RETROFIT_UPLOAD) retrofit: Retrofit
): UploadService = retrofit.create(UploadService::class.java) ): UploadService = retrofit.create(UploadService::class.java)
@Provides
@Singleton
fun provideFaceSearchService(
@Named(RETROFIT_FACE) retrofit: Retrofit,
): FaceSearchService = retrofit.create(FaceSearchService::class.java)
@Provides @Provides
@Singleton @Singleton
fun provideAudioPlayService( fun provideAudioPlayService(
@@ -4,17 +4,24 @@ import com.yzx.kiosk.network.model.response.FaceSearchResponse
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody import okhttp3.RequestBody
import retrofit2.Response import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Header import retrofit2.http.Header
import retrofit2.http.Multipart import retrofit2.http.Multipart
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Part import retrofit2.http.Part
import retrofit2.http.Path import retrofit2.http.Url
interface FaceSearchService { interface FaceSearchService {
@GET
suspend fun recentPhotos(
@Url url: String,
@Header("Authorization") authorization: String,
): Response<FaceSearchResponse>
@Multipart @Multipart
@POST("/{sn}/api/search") @POST
suspend fun searchFace( suspend fun searchFace(
@Path("sn") sn: String, @Url url: String,
@Header("Authorization") authorization: String, @Header("Authorization") authorization: String,
@Part image: MultipartBody.Part, @Part image: MultipartBody.Part,
@Part("threshold") threshold: RequestBody?, @Part("threshold") threshold: RequestBody?,
@@ -0,0 +1,22 @@
package com.yzx.kiosk.network.service
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
internal fun buildFaceSearchUrl(baseUrl: String, deviceSn: String): String {
require(deviceSn.isNotBlank()) { "Face box SN is empty" }
val parsedBaseUrl = baseUrl.trim().toHttpUrlOrNull()
?: throw IllegalArgumentException("Invalid face search base URL")
return parsedBaseUrl.newBuilder()
// Preserve the /{sn}/api/v3/search endpoint semantics even if configuration
// accidentally contains a path or query string.
.encodedPath("/")
.query(null)
.fragment(null)
.addPathSegment(deviceSn)
.addPathSegment("api")
.addPathSegment("v3")
.addPathSegment("search")
.build()
.toString()
}
@@ -0,0 +1,21 @@
package com.yzx.kiosk.network.service
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
internal fun buildRecentPhotosUrl(baseUrl: String, deviceSn: String): String {
require(deviceSn.isNotBlank()) { "Face box SN is empty" }
val parsedBaseUrl = baseUrl.trim().toHttpUrlOrNull()
?: throw IllegalArgumentException("Invalid recent photos base URL")
// The recent-photos endpoint is unversioned and independent of the face-search API version.
return parsedBaseUrl.newBuilder()
.encodedPath("/")
.query(null)
.fragment(null)
.addPathSegment(deviceSn)
.addPathSegment("api")
.addPathSegment("photos")
.addPathSegment("recent")
.build()
.toString()
}
@@ -0,0 +1,19 @@
package com.yzx.kiosk.ui.face.resource
import java.util.concurrent.atomic.AtomicLong
internal class CaptureGate {
private val nextToken = AtomicLong(0L)
private val activeToken = AtomicLong(NO_TOKEN)
fun tryAcquire(): Long? {
val token = nextToken.incrementAndGet()
return if (activeToken.compareAndSet(NO_TOKEN, token)) token else null
}
fun release(token: Long): Boolean = activeToken.compareAndSet(token, NO_TOKEN)
companion object {
private const val NO_TOKEN = 0L
}
}
@@ -0,0 +1,65 @@
package com.yzx.kiosk.ui.face.resource
import android.content.Context
import com.yzx.kiosk.utils.LogUtils
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
/** Owns the short-lived files created for face-search requests. */
@Singleton
class FaceCaptureFileStore @Inject constructor(
@ApplicationContext private val context: Context,
) {
fun createCaptureFile(): File {
val directory = File(context.cacheDir, CAPTURE_DIRECTORY)
check(directory.exists() || directory.mkdirs()) {
"Unable to create face capture cache directory"
}
return File.createTempFile(CAPTURE_PREFIX, CAPTURE_SUFFIX, directory)
}
fun delete(file: File?) {
if (file == null || !file.exists()) return
if (!file.delete()) {
LogUtils.e(TAG, "Failed to delete face capture file: ${file.name}")
}
}
/**
* A capture cannot remain in use across a process restart, so every owned file is orphaned
* when the application starts. Legacy root-cache captures are removed during migration too.
*/
fun cleanupOrphans(): Int = cleanupOrphans(context.cacheDir)
companion object {
private const val TAG = "FaceCaptureFileStore"
internal const val CAPTURE_DIRECTORY = "face_recognition"
internal const val CAPTURE_PREFIX = "capture_"
internal const val CAPTURE_SUFFIX = ".jpg"
internal const val LEGACY_PREFIX = "IMG_"
internal fun cleanupOrphans(cacheDirectory: File): Int {
var deletedCount = 0
val captureDirectory = File(cacheDirectory, CAPTURE_DIRECTORY)
captureDirectory.listFiles()?.forEach { file ->
if (file.deleteRecursively()) deletedCount++
}
cacheDirectory.listFiles()?.forEach { file ->
if (
file.isFile &&
file.name.startsWith(LEGACY_PREFIX) &&
file.name.endsWith(CAPTURE_SUFFIX, ignoreCase = true) &&
file.delete()
) {
deletedCount++
}
}
return deletedCount
}
}
}
@@ -0,0 +1,96 @@
package com.yzx.kiosk.ui.face.resource
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import androidx.core.graphics.scale
import androidx.exifinterface.media.ExifInterface
import com.yzx.kiosk.utils.LogUtils
import javax.inject.Inject
class FaceThumbnailDecoder @Inject constructor() {
fun decode(imagePath: String): Bitmap? {
var ownedBitmap: Bitmap? = null
return try {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(imagePath, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
val options = BitmapFactory.Options().apply {
inPreferredConfig = Bitmap.Config.ARGB_8888
inSampleSize = calculateInSampleSize(
bounds.outWidth,
bounds.outHeight,
MAX_PREVIEW_EDGE_PX,
)
}
ownedBitmap = BitmapFactory.decodeFile(imagePath, options) ?: return null
val orientation = ExifInterface(imagePath).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL,
)
val oriented = transformForExif(ownedBitmap, orientation)
if (oriented !== ownedBitmap) {
ownedBitmap.recycle()
ownedBitmap = oriented
}
val scaled = scaleDown(ownedBitmap, MAX_PREVIEW_EDGE_PX)
if (scaled !== ownedBitmap) {
ownedBitmap.recycle()
}
ownedBitmap = null
scaled
} catch (e: Exception) {
ownedBitmap?.let { if (!it.isRecycled) it.recycle() }
LogUtils.e(TAG, "Failed to decode face preview: ${e.message}")
null
}
}
private fun transformForExif(source: Bitmap, orientation: Int): Bitmap {
val matrix = Matrix()
when (orientation) {
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.setScale(-1f, 1f)
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.setRotate(180f)
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.setScale(1f, -1f)
ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.setRotate(90f)
matrix.postScale(-1f, 1f)
}
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.setRotate(90f)
ExifInterface.ORIENTATION_TRANSVERSE -> {
matrix.setRotate(270f)
matrix.postScale(-1f, 1f)
}
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.setRotate(270f)
else -> return source
}
return Bitmap.createBitmap(source, 0, 0, source.width, source.height, matrix, true)
}
private fun scaleDown(source: Bitmap, maxEdgePx: Int): Bitmap {
val currentMaxEdge = maxOf(source.width, source.height)
if (currentMaxEdge <= maxEdgePx) return source
val scale = maxEdgePx.toFloat() / currentMaxEdge
val targetWidth = (source.width * scale).toInt().coerceAtLeast(1)
val targetHeight = (source.height * scale).toInt().coerceAtLeast(1)
return source.scale(targetWidth, targetHeight)
}
companion object {
private const val TAG = "FaceThumbnailDecoder"
const val MAX_PREVIEW_EDGE_PX = 720
internal fun calculateInSampleSize(width: Int, height: Int, maxEdgePx: Int): Int {
if (width <= 0 || height <= 0 || maxEdgePx <= 0) return 1
var sampleSize = 1
while (maxOf(width / (sampleSize * 2), height / (sampleSize * 2)) > maxEdgePx) {
sampleSize *= 2
}
return sampleSize
}
}
}
@@ -26,6 +26,9 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext 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.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -38,6 +41,8 @@ import coil.compose.SubcomposeAsyncImage
import coil.compose.SubcomposeAsyncImageContent import coil.compose.SubcomposeAsyncImageContent
import coil.compose.SubcomposeAsyncImageScope import coil.compose.SubcomposeAsyncImageScope
import coil.request.ImageRequest 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.R
import com.yzx.kiosk.component.appbar.AppTitleBar import com.yzx.kiosk.component.appbar.AppTitleBar
import com.yzx.kiosk.component.appbar.FaceBarNoStatusBarPadding import com.yzx.kiosk.component.appbar.FaceBarNoStatusBarPadding
@@ -55,21 +60,35 @@ import kotlinx.coroutines.flow.collect
@Composable @Composable
fun FaceRecognitionResultScreen( fun FaceRecognitionResultScreen(
results: List<FaceSearchResult> = emptyList(), results: List<FaceSearchResult> = emptyList(),
source: String = AppRoutes.FACE_RESULT_SOURCE,
viewModel: FaceRecognitionResultViewModel = hiltViewModel() viewModel: FaceRecognitionResultViewModel = hiltViewModel()
) { ) {
// 如果传入了results列表,初始化图片列表 // 如果传入了results列表,初始化图片列表
LaunchedEffect(results) { LaunchedEffect(source, results) {
if (results.isNotEmpty()) { if (source == AppRoutes.RECENT_RESULT_SOURCE) {
viewModel.loadRecentPhotos()
} else if (results.isNotEmpty()) {
viewModel.initPhotoList(results) viewModel.initPhotoList(results)
} }
} }
val recentPhotosState by viewModel.recentPhotosState.collectAsState()
val photoList by viewModel.photoList.collectAsState() val photoList by viewModel.photoList.collectAsState()
val selectedPhotos by viewModel.selectedPhotos.collectAsState() val selectedPhotos by viewModel.selectedPhotos.collectAsState()
val pricePerPhoto by viewModel.pricePerPhoto.collectAsState() val pricePerPhoto by viewModel.pricePerPhoto.collectAsState()
val totalPrice by viewModel.totalPrice.collectAsState() val totalPrice by viewModel.totalPrice.collectAsState()
val imageLoadStates by viewModel.imageLoadStates.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 selectedCount = selectedPhotos.size
val isAllSelected = photoList.isNotEmpty() && selectedPhotos.size == photoList.size val isAllSelected = photoList.isNotEmpty() && selectedPhotos.size == photoList.size
@@ -94,7 +113,7 @@ fun FaceRecognitionResultScreen(
Column { Column {
AppTitleBar( AppTitleBar(
backgroundColor = Color.White, backgroundColor = Color.White,
title = "人脸识别结果", title = if (source == AppRoutes.RECENT_RESULT_SOURCE) "最近照片" else "人脸识别结果",
isShowBackIcon = true, isShowBackIcon = true,
onBackClick = { viewModel.navigateBack() }, onBackClick = { viewModel.navigateBack() },
isShowButtonLine = false isShowButtonLine = false
@@ -180,49 +199,93 @@ fun FaceRecognitionResultScreen(
} }
} }
// 全选行 // Keep selection and navigation as separate click targets in the same toolbar.
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
.fillMaxWidth() verticalAlignment = Alignment.CenterVertically,
.padding(horizontal = 16.dp, vertical = 8.dp)
.clickable { viewModel.toggleSelectAll() },
verticalAlignment = Alignment.CenterVertically
) { ) {
// 复选框 Row(
Box( modifier = Modifier.weight(1f).heightIn(min = 48.dp)
modifier = Modifier .clickable(enabled = photoList.isNotEmpty()) { viewModel.toggleSelectAll() },
.size(24.dp) verticalAlignment = Alignment.CenterVertically,
.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( Box(
imageVector = Icons.Default.Check, modifier = Modifier
contentDescription = null, .size(24.dp)
tint = Color.White, .clip(RoundedCornerShape(6.dp))
modifier = Modifier.size(24.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)) if (source == AppRoutes.RECENT_RESULT_SOURCE && recentPhotosState != RecentPhotosState.READY) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text( Column(horizontalAlignment = Alignment.CenterHorizontally) {
text = "全选", if (recentPhotosState == RecentPhotosState.IDLE || recentPhotosState == RecentPhotosState.LOADING) {
fontSize = 24.sp, CircularProgressIndicator(Modifier.size(32.dp))
color = Color.Black Spacer(Modifier.height(16.dp))
) }
Text( Text(
text = "(共${photoList.size}张)", text = when (recentPhotosState) {
fontSize = 24.sp, RecentPhotosState.ERROR -> "照片加载失败,请重试"
color = Color.Black RecentPhotosState.EMPTY -> "暂无可显示的最近照片"
) else -> "正在加载照片…"
},
fontSize = 18.sp,
color = Color(0xFF666666),
)
if (recentPhotosState == RecentPhotosState.ERROR) {
TextButton(onClick = viewModel::retryRecentPhotos) { Text("重试", fontSize = 18.sp) }
}
}
}
} }
// 图片瀑布流列表 // 图片瀑布流列表
@@ -2,8 +2,6 @@ package com.yzx.kiosk.ui.face.view
import android.Manifest import android.Manifest
import android.graphics.Bitmap import android.graphics.Bitmap
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.camera.core.* import androidx.camera.core.*
import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
@@ -39,9 +37,6 @@ import com.yzx.kiosk.component.scaffold.AppScaffold
import com.yzx.kiosk.ui.common.view.FullScreenMode import com.yzx.kiosk.ui.common.view.FullScreenMode
import com.yzx.kiosk.ui.face.viewmodel.FaceRecognitionViewModel import com.yzx.kiosk.ui.face.viewmodel.FaceRecognitionViewModel
import com.yzx.kiosk.ui.face.viewmodel.RecognitionStatus import com.yzx.kiosk.ui.face.viewmodel.RecognitionStatus
import java.util.concurrent.Executor
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class)
@Composable @Composable
@@ -56,6 +51,7 @@ fun FaceRecognitionScreen(
val recognitionStatus by viewModel.recognitionStatus.collectAsState() val recognitionStatus by viewModel.recognitionStatus.collectAsState()
val capturedImage by viewModel.capturedImage.collectAsState() val capturedImage by viewModel.capturedImage.collectAsState()
val isRecognizing by viewModel.isRecognizing.collectAsState() val isRecognizing by viewModel.isRecognizing.collectAsState()
val isCaptureInProgress by viewModel.isCaptureInProgress.collectAsState()
// 相机权限 // 相机权限
val permissionsState = rememberMultiplePermissionsState( val permissionsState = rememberMultiplePermissionsState(
@@ -91,7 +87,8 @@ fun FaceRecognitionScreen(
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 48.dp, vertical = 36.dp) .padding(horizontal = 48.dp)
.padding(top = 36.dp)
.aspectRatio(0.92f), .aspectRatio(0.92f),
shape = RoundedCornerShape(36.dp), shape = RoundedCornerShape(36.dp),
colors = CardDefaults.cardColors(containerColor = Color.Black) colors = CardDefaults.cardColors(containerColor = Color.Black)
@@ -103,6 +100,9 @@ fun FaceRecognitionScreen(
onImageCaptureReady = { imageCapture -> onImageCaptureReady = { imageCapture ->
viewModel.setImageCapture(imageCapture) viewModel.setImageCapture(imageCapture)
}, },
onImageCaptureReleased = { imageCapture ->
viewModel.clearImageCapture(imageCapture)
},
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
@@ -201,7 +201,7 @@ fun FaceRecognitionScreen(
} }
} }
// 拍照按钮(5秒倒计时期间也显示,识别失败时显示"重新拍照",其他情况显示"拍照") // 拍照按钮(5秒倒计时期间也显示,识别失败时显示"重新拍照",其他情况显示"拍照")
if (!isRecognizing) { if (!isRecognizing && !isCaptureInProgress) {
Button( Button(
onClick = { onClick = {
if (recognitionStatus == RecognitionStatus.FAILED) { if (recognitionStatus == RecognitionStatus.FAILED) {
@@ -234,7 +234,22 @@ fun FaceRecognitionScreen(
} }
} }
Spacer(modifier = Modifier.height(36.dp)) Box(
modifier = Modifier.fillMaxWidth().height(72.dp),
contentAlignment = Alignment.Center,
) {
if (recognitionStatus == RecognitionStatus.FAILED) {
TextButton(
onClick = viewModel::browseRecentPhotos,
modifier = Modifier.heightIn(min = 48.dp),
colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFF2878CE)),
) {
Text("找不到自己?查看最近照片", fontSize = 16.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.width(8.dp))
Text("›", fontSize = 20.sp)
}
}
}
// 倒计时提示 // 倒计时提示
Text( Text(
@@ -309,50 +324,62 @@ fun FaceRecognitionScreen(
@Composable @Composable
fun CameraPreview( fun CameraPreview(
onImageCaptureReady: (ImageCapture) -> Unit, onImageCaptureReady: (ImageCapture) -> Unit,
onImageCaptureReleased: (ImageCapture) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val currentOnImageCaptureReady by rememberUpdatedState(onImageCaptureReady)
val currentOnImageCaptureReleased by rememberUpdatedState(onImageCaptureReleased)
val previewView = remember(context) { PreviewView(context) }
val preview = remember { Preview.Builder().build() }
val imageCapture = remember {
ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
.build()
}
AndroidView( AndroidView(
factory = { ctx -> factory = { previewView },
val previewView = PreviewView(ctx)
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
// 创建预览
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
// 创建图像捕获
val imageCapture = ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
.build()
// 通知ViewModel ImageCapture已准备好
onImageCaptureReady(imageCapture)
// 绑定到生命周期
val cameraSelector = CameraSelector.DEFAULT_FRONT_CAMERA
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
lifecycleOwner,
cameraSelector,
preview,
imageCapture
)
} catch (e: Exception) {
e.printStackTrace()
}
}, ContextCompat.getMainExecutor(ctx))
previewView
},
modifier = modifier modifier = modifier
) )
DisposableEffect(context, lifecycleOwner, preview, imageCapture) {
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
val executor = ContextCompat.getMainExecutor(context)
var cameraProvider: ProcessCameraProvider? = null
var disposed = false
preview.setSurfaceProvider(previewView.surfaceProvider)
cameraProviderFuture.addListener({
try {
val resolvedProvider = cameraProviderFuture.get()
if (disposed) {
resolvedProvider.unbind(preview, imageCapture)
return@addListener
}
cameraProvider = resolvedProvider
resolvedProvider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_FRONT_CAMERA,
preview,
imageCapture,
)
currentOnImageCaptureReady(imageCapture)
} catch (e: Exception) {
e.printStackTrace()
}
}, executor)
onDispose {
disposed = true
currentOnImageCaptureReleased(imageCapture)
preview.setSurfaceProvider(null)
cameraProvider?.unbind(preview, imageCapture)
}
}
} }
@@ -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.PaySuccessMessageRequest
import com.yzx.kiosk.network.model.request.VerifyResultRequest import com.yzx.kiosk.network.model.request.VerifyResultRequest
import com.yzx.kiosk.network.model.response.FaceSearchResult 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.repository.NetWorkRepository
import com.yzx.kiosk.network.result.asResult import com.yzx.kiosk.network.result.asResult
import com.yzx.kiosk.ui.upload.viewmodel.FileMapData import com.yzx.kiosk.ui.upload.viewmodel.FileMapData
@@ -43,12 +45,39 @@ class FaceRecognitionResultViewModel @Inject constructor(
navigator: AppNavigator, navigator: AppNavigator,
appState: AppState, appState: AppState,
private val netWorkRepository: NetWorkRepository, private val netWorkRepository: NetWorkRepository,
private val faceSearchService: FaceSearchService,
private val webSocketService: WebSocketService, private val webSocketService: WebSocketService,
private val appStoreDataSource: AppStoreDataSource, private val appStoreDataSource: AppStoreDataSource,
) : BaseViewModel( ) : BaseViewModel(
navigator = navigator, navigator = navigator,
appState = appState 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) // 原始结果列表(用于获取图片id)
private var originalResults: List<FaceSearchResult> = emptyList() private var originalResults: List<FaceSearchResult> = emptyList()
@@ -124,46 +153,53 @@ class FaceRecognitionResultViewModel @Inject constructor(
/** /**
* 初始化图片列表(从人脸识别结果) * 初始化图片列表(从人脸识别结果)
*/ */
private var faceResultsInitialized = false
fun initPhotoList(results: List<FaceSearchResult>) { fun initPhotoList(results: List<FaceSearchResult>) {
viewModelScope.launch { // Returning from a pushed recent-photos page must retain selection, pricing and image state.
// 保存原始结果列表 if (faceResultsInitialized) return
originalResults = results faceResultsInitialized = true
urlToIdMap.clear() viewModelScope.launch { applyPhotoList(results) }
}
// 先使用默认宽高比创建列表 private suspend fun applyPhotoList(results: List<FaceSearchResult>) {
val initialPhotos = results.mapNotNull { result -> // 保存原始结果列表
// 使用 thumbnail_oss_url originalResults = results
val imageUrl = if (appStoreDataSource.getUseLan()) result.thumbnailLanUrl else result.thumbnailOssUrl urlToIdMap.clear()
if (imageUrl.isNullOrEmpty()) {
LogUtils.i("FaceRecognitionResultViewModel", "图片URL为空,跳过") // 先使用默认宽高比创建列表
null val initialPhotos = results.mapNotNull { result ->
} else { // 使用 thumbnail_oss_url
val trimmedUrl = imageUrl.trim() val imageUrl = if (appStoreDataSource.getUseLan()) result.thumbnailLanUrl else result.thumbnailOssUrl
// 建立URL到ID的映射 if (imageUrl.isNullOrEmpty()) {
urlToIdMap[trimmedUrl] = result.id LogUtils.i("FaceRecognitionResultViewModel", "图片URL为空,跳过")
PhotoData( null
id = result.id, // 使用图片ID } else {
url = trimmedUrl, val trimmedUrl = imageUrl.trim()
aspectRatio = 1f // 默认1:1,后续会更新为真实宽高比 // 建立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() { fun toggleSelectAll() {
if (_photoList.value.isEmpty()) return
val allUrls = _photoList.value.map { it.url }.toSet() val allUrls = _photoList.value.map { it.url }.toSet()
val newSelected = if (_selectedPhotos.value.size == allUrls.size) { val newSelected = if (_selectedPhotos.value.size == allUrls.size) {
// 当前全选,取消全选 // 当前全选,取消全选
@@ -1,10 +1,7 @@
package com.yzx.kiosk.ui.face.viewmodel package com.yzx.kiosk.ui.face.viewmodel
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import androidx.camera.core.ImageCapture import androidx.camera.core.ImageCapture
import androidx.exifinterface.media.ExifInterface
import androidx.camera.core.ImageCaptureException import androidx.camera.core.ImageCaptureException
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -16,12 +13,15 @@ import com.yzx.kiosk.datastore.AppStoreDataSource
import com.google.gson.Gson import com.google.gson.Gson
import com.yzx.kiosk.network.model.response.FaceSearchResponse import com.yzx.kiosk.network.model.response.FaceSearchResponse
import com.yzx.kiosk.network.service.FaceSearchService import com.yzx.kiosk.network.service.FaceSearchService
import com.yzx.kiosk.network.service.buildFaceSearchUrl
import com.yzx.kiosk.App import com.yzx.kiosk.App
import com.yzx.kiosk.BuildConfig import com.yzx.kiosk.BuildConfig
import com.yzx.kiosk.network.interceptor.DebugNetworkLoggingInterceptor
import com.yzx.kiosk.navigation.AppNavigator import com.yzx.kiosk.navigation.AppNavigator
import com.yzx.kiosk.navigation.routes.AppRoutes import com.yzx.kiosk.navigation.routes.AppRoutes
import com.yzx.kiosk.ui.setting.navigation.AgreementRoutes import com.yzx.kiosk.ui.setting.navigation.AgreementRoutes
import com.yzx.kiosk.ui.face.resource.CaptureGate
import com.yzx.kiosk.ui.face.resource.FaceCaptureFileStore
import com.yzx.kiosk.ui.face.resource.FaceThumbnailDecoder
import com.yzx.kiosk.utils.LogUtils import com.yzx.kiosk.utils.LogUtils
import com.yzx.kiosk.utils.ToastUtils import com.yzx.kiosk.utils.ToastUtils
import java.net.URLEncoder import java.net.URLEncoder
@@ -36,15 +36,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.io.File import java.io.File
import java.text.SimpleDateFormat
import java.util.*
import javax.inject.Inject import javax.inject.Inject
enum class RecognitionStatus { enum class RecognitionStatus {
@@ -59,7 +53,10 @@ class FaceRecognitionViewModel @Inject constructor(
navigator: AppNavigator, navigator: AppNavigator,
appState: AppState, appState: AppState,
private val appStoreDataSource: AppStoreDataSource, private val appStoreDataSource: AppStoreDataSource,
private val localAudioPlayService: LocalAudioPlayService private val localAudioPlayService: LocalAudioPlayService,
private val faceSearchService: FaceSearchService,
private val faceCaptureFileStore: FaceCaptureFileStore,
private val faceThumbnailDecoder: FaceThumbnailDecoder,
) : BaseViewModel( ) : BaseViewModel(
navigator = navigator, navigator = navigator,
appState = appState appState = appState
@@ -91,9 +88,16 @@ class FaceRecognitionViewModel @Inject constructor(
private val _isRecognizing = MutableStateFlow(false) private val _isRecognizing = MutableStateFlow(false)
val isRecognizing: StateFlow<Boolean> = _isRecognizing.asStateFlow() val isRecognizing: StateFlow<Boolean> = _isRecognizing.asStateFlow()
private val _isCaptureInProgress = MutableStateFlow(false)
val isCaptureInProgress: StateFlow<Boolean> = _isCaptureInProgress.asStateFlow()
// 倒计时Job // 倒计时Job
private var countdownJob: kotlinx.coroutines.Job? = null private var countdownJob: kotlinx.coroutines.Job? = null
private var autoCaptureJob: kotlinx.coroutines.Job? = null private var autoCaptureJob: kotlinx.coroutines.Job? = null
private val captureGate = CaptureGate()
private var activeCaptureToken: Long? = null
private var activeCaptureFile: File? = null
private var isCleared = false
// ImageCapture实例 // ImageCapture实例
private var imageCapture: ImageCapture? = null private var imageCapture: ImageCapture? = null
@@ -109,6 +113,15 @@ class FaceRecognitionViewModel @Inject constructor(
this.imageCapture = imageCapture this.imageCapture = imageCapture
} }
fun clearImageCapture(imageCapture: ImageCapture) {
if (this.imageCapture === imageCapture) {
this.imageCapture = null
activeCaptureToken?.let { captureToken ->
finishCaptureFile(activeCaptureFile, captureToken)
}
}
}
/** /**
* 开始倒计时 * 开始倒计时
*/ */
@@ -120,7 +133,7 @@ class FaceRecognitionViewModel @Inject constructor(
_countdown.value = _countdown.value - 1 _countdown.value = _countdown.value - 1
} }
// 倒计时结束,自动返回首页 // 倒计时结束,自动返回首页
toPage(com.yzx.kiosk.navigation.routes.AppRoutes.HOME) closeAllExcept(AppRoutes.HOME)
} }
} }
@@ -154,6 +167,12 @@ class FaceRecognitionViewModel @Inject constructor(
* 手动拍照 * 手动拍照
*/ */
fun takePhoto() { fun takePhoto() {
if (_isRecognizing.value) return
val captureToken = captureGate.tryAcquire() ?: return
activeCaptureToken = captureToken
_isCaptureInProgress.value = true
// 如果正在自动拍照倒计时,先取消 // 如果正在自动拍照倒计时,先取消
if (_captureCountdown.value > 0) { if (_captureCountdown.value > 0) {
cancelAutoCapture() cancelAutoCapture()
@@ -167,80 +186,124 @@ class FaceRecognitionViewModel @Inject constructor(
} }
val capture = imageCapture ?: run { val capture = imageCapture ?: run {
releaseCaptureGate(captureToken)
ToastUtils.show("相机未准备好") ToastUtils.show("相机未准备好")
return return
} }
viewModelScope.launch { viewModelScope.launch {
// 创建输出文件 // 创建输出文件
val photoFile = withContext(Dispatchers.IO) { var photoFile: File? = null
File.createTempFile( try {
"IMG_${SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())}", photoFile = withContext(Dispatchers.IO) {
".jpg", faceCaptureFileStore.createCaptureFile()
App.instance.cacheDir
)
}
// 创建输出选项
val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
// 拍照
capture.takePicture(
outputOptions,
ContextCompat.getMainExecutor(App.instance),
object : ImageCapture.OnImageSavedCallback {
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
// 加载图片为Bitmap,并处理EXIF方向
val bitmap = loadBitmapWithExifOrientation(photoFile.absolutePath)
_capturedImage.value = bitmap
// 直接调用搜索接口
searchFaceDirectly(photoFile)
}
override fun onError(exception: ImageCaptureException) {
LogUtils.e(TAG, "拍照失败: ${exception.message}")
ToastUtils.show("拍照失败")
}
} }
) if (
isCleared ||
imageCapture !== capture ||
activeCaptureToken != captureToken
) {
faceCaptureFileStore.delete(photoFile)
releaseCaptureGate(captureToken)
return@launch
}
activeCaptureFile = photoFile
val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
capture.takePicture(
outputOptions,
ContextCompat.getMainExecutor(App.instance),
object : ImageCapture.OnImageSavedCallback {
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
if (
isCleared ||
imageCapture !== capture ||
activeCaptureToken != captureToken
) {
finishCaptureFile(photoFile, captureToken)
return
}
recognizeSavedPhoto(photoFile, captureToken)
}
override fun onError(exception: ImageCaptureException) {
LogUtils.e(TAG, "拍照失败: ${exception.message}")
finishCaptureFile(photoFile, captureToken)
if (!isCleared && imageCapture === capture) {
ToastUtils.show("拍照失败")
}
}
},
)
} catch (e: CancellationException) {
finishCaptureFile(photoFile, captureToken)
throw e
} catch (e: Exception) {
LogUtils.e(TAG, "创建人脸拍照文件失败: ${e.message}")
finishCaptureFile(photoFile, captureToken)
ToastUtils.show("拍照失败")
}
} }
} }
/** private fun recognizeSavedPhoto(photoFile: File, captureToken: Long) {
* 直接调用人脸搜索接口
*/
private fun searchFaceDirectly(photoFile: File) {
viewModelScope.launch { viewModelScope.launch {
_isRecognizing.value = true _isRecognizing.value = true
_recognitionStatus.value = RecognitionStatus.RECOGNIZING _recognitionStatus.value = RecognitionStatus.RECOGNIZING
// 播放识别中音频 releaseCaptureGate(captureToken)
localAudioPlayService.playByRoute("face_recognition_recognizing")
// 播放识别中音频
localAudioPlayService.playByRoute("face_recognition_recognizing") localAudioPlayService.playByRoute("face_recognition_recognizing")
try { try {
// 直接调用人脸识别接口 _capturedImage.value = withContext(Dispatchers.IO) {
faceThumbnailDecoder.decode(photoFile.absolutePath)
}
searchFace(photoFile) searchFace(photoFile)
} catch (e: CancellationException) { } catch (e: CancellationException) {
// 页面跳转或 ViewModel 销毁时的正常协程取消,不向用户报错
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
LogUtils.e(TAG, "处理失败: ${e.message}") LogUtils.e(TAG, "处理失败: ${e.message}")
ToastUtils.show("处理失败: ${e.message}") ToastUtils.show("处理失败: ${e.message}")
_isRecognizing.value = false _isRecognizing.value = false
_recognitionStatus.value = RecognitionStatus.FAILED _recognitionStatus.value = RecognitionStatus.FAILED
} finally {
finishCaptureFile(photoFile, captureToken)
} }
} }
} }
private fun releaseCaptureGate(captureToken: Long) {
if (captureGate.release(captureToken)) {
if (activeCaptureToken == captureToken) {
activeCaptureToken = null
}
_isCaptureInProgress.value = false
}
}
private fun finishCaptureFile(photoFile: File?, captureToken: Long) {
faceCaptureFileStore.delete(photoFile)
if (activeCaptureFile === photoFile) {
activeCaptureFile = null
}
releaseCaptureGate(captureToken)
}
/** /**
* 搜索人脸 * 搜索人脸
*/ */
private suspend fun searchFace(imageFile: File) = withContext(Dispatchers.IO) { private suspend fun searchFace(imageFile: File) = withContext(Dispatchers.IO) {
try { try {
val FACE_SEARCH_BASE_URL = if (appStoreDataSource.getUseLan()) appStoreDataSource.getBindBoxLanUrl() else appStoreDataSource.getBindBoxUrl() val faceSearchBaseUrl = if (appStoreDataSource.getUseLan()) {
val FACE_SEARCH_TOKEN = appStoreDataSource.getBindBoxApiToken() appStoreDataSource.getBindBoxLanUrl()
} else {
appStoreDataSource.getBindBoxUrl()
}
val faceSearchToken = appStoreDataSource.getBindBoxApiToken()
val requestUrl = buildFaceSearchUrl(
baseUrl = faceSearchBaseUrl,
deviceSn = appStoreDataSource.getBindBoxSn(),
)
// 创建MultipartBody // 创建MultipartBody
@@ -251,16 +314,10 @@ class FaceRecognitionViewModel @Inject constructor(
val thresholdValue = 0.45f val thresholdValue = 0.45f
val thresholdBody = thresholdValue.toString().toRequestBody("text/plain".toMediaType()) val thresholdBody = thresholdValue.toString().toRequestBody("text/plain".toMediaType())
// index_date 参数:当前日期,格式 YYYYMMDD
val dateFormat = SimpleDateFormat("yyyyMMdd", Locale.getDefault())
val indexDate = dateFormat.format(Date())
val indexDateBody = indexDate.toRequestBody("text/plain".toMediaType())
// Authorization header // Authorization header
val authorization = "Bearer $FACE_SEARCH_TOKEN" val authorization = "Bearer $faceSearchToken"
// 打印请求信息 // 打印请求信息
val requestUrl = "$FACE_SEARCH_BASE_URL/api/search"
LogUtils.d(TAG, "========== 人脸识别接口请求信息 ==========") LogUtils.d(TAG, "========== 人脸识别接口请求信息 ==========")
LogUtils.d(TAG, "URL: $requestUrl") LogUtils.d(TAG, "URL: $requestUrl")
LogUtils.d(TAG, "Headers:") LogUtils.d(TAG, "Headers:")
@@ -268,33 +325,15 @@ class FaceRecognitionViewModel @Inject constructor(
LogUtils.d(TAG, "Parameters:") LogUtils.d(TAG, "Parameters:")
LogUtils.d(TAG, "image: ${imageFile.name} (${imageFile.length()} bytes)") LogUtils.d(TAG, "image: ${imageFile.name} (${imageFile.length()} bytes)")
LogUtils.d(TAG, "threshold: $thresholdValue") LogUtils.d(TAG, "threshold: $thresholdValue")
LogUtils.d(TAG, "index_date: $indexDate")
LogUtils.d(TAG, "==========================================") LogUtils.d(TAG, "==========================================")
// Debug 环境记录请求与响应;图片等大文件只记录元数据,不读取文件内容
val client = OkHttpClient.Builder()
.apply {
if (BuildConfig.DEBUG) {
addInterceptor(DebugNetworkLoggingInterceptor("face-search"))
}
}
.build()
// 创建Retrofit实例
val retrofit = Retrofit.Builder()
.baseUrl("$FACE_SEARCH_BASE_URL/")
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
val service = retrofit.create(FaceSearchService::class.java)
// 调用接口 // 调用接口
val response = service.searchFace( val response = faceSearchService.searchFace(
sn = appStoreDataSource.getBindBoxSn(), url = requestUrl,
authorization = authorization, authorization = authorization,
image = imagePart, image = imagePart,
threshold = thresholdBody, threshold = thresholdBody,
// 不传日期,由 V3 服务端使用上海时区当天;人数及人脸大小也使用服务端默认值。
indexDate = null indexDate = null
) )
@@ -369,82 +408,20 @@ class FaceRecognitionViewModel @Inject constructor(
} }
} }
/** private var isOpeningRecentPhotos = false
* 加载Bitmap并处理EXIF方向信息
* 解决不同设备拍照后图片方向不正确的问题
*/
private fun loadBitmapWithExifOrientation(imagePath: String): Bitmap? {
return try {
// 先读取EXIF方向信息
val exif = ExifInterface(imagePath)
val orientation = exif.getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL
)
// 加载原始Bitmap fun browseRecentPhotos() {
val bitmap = BitmapFactory.decodeFile(imagePath) ?: return null if (_recognitionStatus.value != RecognitionStatus.FAILED || isOpeningRecentPhotos) return
isOpeningRecentPhotos = true
// 根据EXIF方向旋转Bitmap countdownJob?.cancel()
val rotationDegrees = when (orientation) { autoCaptureJob?.cancel()
ExifInterface.ORIENTATION_ROTATE_90 -> 90f toPage(
ExifInterface.ORIENTATION_ROTATE_180 -> 180f AppRoutes.buildRecentPhotosRoute(),
ExifInterface.ORIENTATION_ROTATE_270 -> 270f NavOptions.Builder()
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> { .setPopUpTo(AppRoutes.FACE_RECOGNITION, inclusive = true)
// 水平翻转 .setLaunchSingleTop(true)
val matrix = Matrix() .build(),
matrix.setScale(-1f, 1f) )
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
// 垂直翻转
val matrix = Matrix()
matrix.setScale(1f, -1f)
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
ExifInterface.ORIENTATION_TRANSPOSE -> {
// 转置(旋转90度+水平翻转)
val matrix = Matrix()
matrix.setRotate(90f)
matrix.postScale(-1f, 1f)
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
ExifInterface.ORIENTATION_TRANSVERSE -> {
// 横向(旋转270度+水平翻转)
val matrix = Matrix()
matrix.setRotate(270f)
matrix.postScale(-1f, 1f)
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
else -> 0f // 不需要旋转
}
// 如果需要旋转
if (rotationDegrees != 0f) {
val matrix = Matrix()
matrix.postRotate(rotationDegrees)
val rotatedBitmap = Bitmap.createBitmap(
bitmap,
0,
0,
bitmap.width,
bitmap.height,
matrix,
true
)
// 回收原始bitmap
if (rotatedBitmap != bitmap) {
bitmap.recycle()
}
rotatedBitmap
} else {
bitmap
}
} catch (e: Exception) {
LogUtils.e(TAG, "加载图片并处理EXIF方向失败: ${e.message}")
// 如果处理失败,返回原始Bitmap
BitmapFactory.decodeFile(imagePath)
}
} }
fun privatePolicy() { fun privatePolicy() {
@@ -476,9 +453,15 @@ class FaceRecognitionViewModel @Inject constructor(
} }
override fun onCleared() { override fun onCleared() {
super.onCleared() isCleared = true
countdownJob?.cancel() countdownJob?.cancel()
autoCaptureJob?.cancel() autoCaptureJob?.cancel()
imageCapture = null
_capturedImage.value = null
faceCaptureFileStore.delete(activeCaptureFile)
activeCaptureFile = null
activeCaptureToken?.let(::releaseCaptureGate)
super.onCleared()
} }
} }
@@ -0,0 +1,35 @@
package com.yzx.kiosk.ui.face.viewmodel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
enum class RecentPhotosState { IDLE, LOADING, READY, EMPTY, ERROR }
/** One request per result-page lifetime; retries are explicit and cancellation propagates. */
internal class RecentPhotosLoader(
private val scope: CoroutineScope,
private val fetchAndApply: suspend () -> Boolean,
) {
private val mutableState = MutableStateFlow(RecentPhotosState.IDLE)
val state = mutableState.asStateFlow()
private var job: Job? = null
fun load(retry: Boolean = false) {
if (job?.isActive == true) return
if (mutableState.value != RecentPhotosState.IDLE && !(retry && mutableState.value == RecentPhotosState.ERROR)) return
mutableState.value = RecentPhotosState.LOADING
job = scope.launch {
try {
mutableState.value = if (fetchAndApply()) RecentPhotosState.READY else RecentPhotosState.EMPTY
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
mutableState.value = RecentPhotosState.ERROR
}
}
}
}
Binary file not shown.
@@ -0,0 +1,26 @@
package com.yzx.kiosk.audio
import com.yzx.kiosk.navigation.routes.AppRoutes
import org.junit.Assert.assertEquals
import org.junit.Test
class LocalAudioRouteTest {
@Test fun `browse recent photos selects neutral audio`() {
assertEquals(RECENT_PHOTOS_AUDIO_KEY, localAudioKeyForRoute(AppRoutes.buildRecentPhotosRoute()))
assertEquals(RECENT_PHOTOS_AUDIO_KEY, localAudioKeyForRoute("face_recognition_result?results=%5B%5D&source=recent"))
assertEquals(RECENT_PHOTOS_AUDIO_KEY, localAudioKeyForRoute("face_recognition_result?source=recent&results=%5B%5D"))
assertEquals(RECENT_PHOTOS_AUDIO_KEY, localAudioKeyForRoute("face_recognition_result?source=%72ecent"))
}
@Test fun `face and legacy routes keep success audio`() {
for (route in listOf("face_recognition_result", "face_recognition_result?results=%5B%5D", "face_recognition_result?source=face", "face_recognition_result?source=unknown", "face_recognition_result?source=%")) {
assertEquals(AppRoutes.FACE_RECOGNITION_RESULT, localAudioKeyForRoute(route))
}
}
@Test fun `photo data cannot change source and unrelated pages keep their audio`() {
assertEquals(AppRoutes.FACE_RECOGNITION_RESULT, localAudioKeyForRoute("face_recognition_result?results=source%3Drecent"))
assertEquals(AppRoutes.HOME, localAudioKeyForRoute("home?source=recent"))
assertEquals("face_recognition_failed", localAudioKeyForRoute("face_recognition_failed"))
}
}
@@ -0,0 +1,83 @@
package com.yzx.kiosk.network.service
import kotlinx.coroutines.runBlocking
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Test
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
class FaceSearchUrlTest {
@Test
fun `url uses configured host and safely encoded sn`() {
assertEquals(
"http://192.168.1.10/BOX%2F01/api/v3/search",
buildFaceSearchUrl("http://192.168.1.10/old/path?unused=true", "BOX/01"),
)
}
@Test
fun `invalid configuration is rejected`() {
assertThrows(IllegalArgumentException::class.java) {
buildFaceSearchUrl("not-a-url", "BOX-1")
}
assertThrows(IllegalArgumentException::class.java) {
buildFaceSearchUrl("http://192.168.1.10", "")
}
}
@Test
fun `v3 request preserves auth and leaves optional settings to server`() = runBlocking {
val client = OkHttpClient.Builder().addInterceptor { chain ->
val request = chain.request()
assertEquals("POST", request.method)
assertEquals("https://example.test/BOX-1/api/v3/search", request.url.toString())
assertEquals("Bearer test-token", request.header("Authorization"))
val body = request.body as MultipartBody
assertEquals(MultipartBody.FORM, body.type)
assertEquals(
listOf("form-data; name=\"image\"; filename=\"group.jpg\"", "form-data; name=\"threshold\""),
body.parts.map { it.headers?.get("Content-Disposition") },
)
okhttp3.Response.Builder()
.request(request)
.protocol(Protocol.HTTP_1_1)
.code(200)
.message("OK")
.body("""{"count":0,"results":[],"detected_count":8,"searched_count":6,"truncated":true,"min_face_size":80,"max_faces":6}"""
.toResponseBody("application/json".toMediaType()))
.build()
}.build()
val service = Retrofit.Builder()
.baseUrl("https://example.test/")
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(FaceSearchService::class.java)
val response = service.searchFace(
url = buildFaceSearchUrl("https://example.test", "BOX-1"),
authorization = "Bearer test-token",
image = MultipartBody.Part.createFormData("image", "group.jpg", "test-image".toRequestBody("image/jpeg".toMediaType())),
threshold = "0.45".toRequestBody("text/plain".toMediaType()),
indexDate = null,
)
assertEquals(0, response.body()?.count)
assertEquals(emptyList<Any>(), response.body()?.results)
}
@Test
fun `retrofit accepts multipart post with dynamic url`() {
Retrofit.Builder()
.baseUrl("https://example.test/")
.addConverterFactory(GsonConverterFactory.create())
.validateEagerly(true)
.build()
.create(FaceSearchService::class.java)
}
}
@@ -0,0 +1,46 @@
package com.yzx.kiosk.network.service
import kotlinx.coroutines.runBlocking
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.*
import org.junit.Test
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
class RecentPhotosServiceTest {
@Test fun `recent url replaces configured path and encodes sn`() {
assertEquals("https://example.test/BOX%2F01/api/photos/recent",
buildRecentPhotosUrl("https://example.test/old?seconds=20#fragment", "BOX/01"))
assertThrows(IllegalArgumentException::class.java) { buildRecentPhotosUrl("invalid", "BOX") }
assertThrows(IllegalArgumentException::class.java) { buildRecentPhotosUrl("https://example.test", "") }
}
@Test fun `get uses bearer without optional query parameters and preserves response order`() = runBlocking {
val bodies = listOf(
"""{"count":0,"results":[]}""",
"""{"count":2,"results":[{"id":9,"thumbnail_oss_url":null,"thumbnail_lan_url":"http://local/9.jpg","similarity":0},{"id":3,"thumbnail_oss_url":"https://oss/3.jpg","thumbnail_lan_url":null}]}""",
)
for (body in bodies) {
val client = OkHttpClient.Builder().addInterceptor { chain ->
val request = chain.request()
assertEquals("GET", request.method)
assertEquals("https://example.test/BOX/api/photos/recent", request.url.toString())
assertNull(request.url.query)
assertEquals("Bearer test-token", request.header("Authorization"))
assertNull(request.body)
Response.Builder().request(request).protocol(Protocol.HTTP_1_1).code(200).message("OK")
.body(body.toResponseBody("application/json".toMediaType())).build()
}.build()
val service = Retrofit.Builder().baseUrl("https://example.test/").client(client)
.addConverterFactory(GsonConverterFactory.create()).build().create(FaceSearchService::class.java)
val response = service.recentPhotos(buildRecentPhotosUrl("https://example.test", "BOX"), "Bearer test-token").body()!!
if (response.count == 0) assertTrue(response.results!!.isEmpty()) else {
assertEquals(listOf(9, 3), response.results!!.map { it.id })
assertNull(response.results[0].thumbnailOssUrl)
assertNull(response.results[1].thumbnailLanUrl)
}
}
}
}
@@ -0,0 +1,50 @@
package com.yzx.kiosk.ui.face.resource
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.File
import java.nio.file.Files
class FaceCaptureFileStoreTest {
private lateinit var cacheDirectory: File
@Before
fun setUp() {
cacheDirectory = Files.createTempDirectory("face-capture-test").toFile()
}
@After
fun tearDown() {
cacheDirectory.deleteRecursively()
}
@Test
fun `cleanup removes owned and legacy captures only`() {
val captureDirectory = File(
cacheDirectory,
FaceCaptureFileStore.CAPTURE_DIRECTORY,
).apply { mkdirs() }
val ownedCapture = File(captureDirectory, "capture_1.jpg").apply { writeText("face") }
val legacyCapture = File(cacheDirectory, "IMG_20260825.jpg").apply { writeText("face") }
val unrelatedJpeg = File(cacheDirectory, "holiday.jpg").apply { writeText("keep") }
val similarLegacyFile = File(cacheDirectory, "IMG_notes.txt").apply { writeText("keep") }
val deleted = FaceCaptureFileStore.cleanupOrphans(cacheDirectory)
assertEquals(2, deleted)
assertFalse(ownedCapture.exists())
assertFalse(legacyCapture.exists())
assertTrue(unrelatedJpeg.exists())
assertTrue(similarLegacyFile.exists())
}
@Test
fun `cleanup is idempotent`() {
assertEquals(0, FaceCaptureFileStore.cleanupOrphans(cacheDirectory))
assertEquals(0, FaceCaptureFileStore.cleanupOrphans(cacheDirectory))
}
}
@@ -0,0 +1,32 @@
package com.yzx.kiosk.ui.face.resource
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class FaceResourceHelpersTest {
@Test
fun `sample size bounds large source before final scale`() {
assertEquals(4, FaceThumbnailDecoder.calculateInSampleSize(4000, 3000, 720))
assertEquals(1, FaceThumbnailDecoder.calculateInSampleSize(1280, 720, 720))
assertEquals(1, FaceThumbnailDecoder.calculateInSampleSize(0, 0, 720))
}
@Test
fun `capture gate permits only one active capture`() {
val gate = CaptureGate()
val firstToken = gate.tryAcquire()
assertTrue(firstToken != null)
assertEquals(null, gate.tryAcquire())
assertTrue(gate.release(firstToken!!))
val secondToken = gate.tryAcquire()
assertTrue(secondToken != null)
assertFalse(gate.release(firstToken))
assertEquals(null, gate.tryAcquire())
assertTrue(gate.release(secondToken!!))
}
}
@@ -0,0 +1,62 @@
package com.yzx.kiosk.ui.face.viewmodel
import kotlinx.coroutines.*
import org.junit.Assert.*
import org.junit.Test
class RecentPhotosLoaderTest {
@Test fun `successful load is not repeated by recomposition`() = runBlocking {
var calls = 0
val loader = RecentPhotosLoader(this) { calls++; true }
loader.load()
loader.load()
yield()
loader.load()
assertEquals(1, calls)
assertEquals(RecentPhotosState.READY, loader.state.value)
}
@Test fun `filtered empty result has an empty state`() = runBlocking {
val loader = RecentPhotosLoader(this) { false }
loader.load()
yield()
assertEquals(RecentPhotosState.EMPTY, loader.state.value)
}
@Test fun `http and network failures require explicit retry`() = runBlocking {
for (failure in listOf(IllegalStateException("HTTP 401"), IllegalStateException("HTTP 403"), java.io.IOException())) {
var calls = 0
val loader = RecentPhotosLoader(this) {
calls++
if (calls == 1) throw failure
true
}
loader.load()
yield()
assertEquals(RecentPhotosState.ERROR, loader.state.value)
loader.load()
assertEquals(1, calls)
loader.load(retry = true)
loader.load(retry = true)
yield()
assertEquals(2, calls)
assertEquals(RecentPhotosState.READY, loader.state.value)
}
}
@Test fun `leaving the page cancels request without applying data`() = runBlocking {
val parent = Job()
var applied = false
var cancelled = false
val loader = RecentPhotosLoader(CoroutineScope(coroutineContext + parent)) {
try { delay(Long.MAX_VALUE); applied = true; true }
finally { cancelled = true }
}
loader.load()
yield()
parent.cancelAndJoin()
assertTrue(cancelled)
assertFalse(applied)
assertNotEquals(RecentPhotosState.ERROR, loader.state.value)
}
}
+48
View File
@@ -0,0 +1,48 @@
# 最近照片入口验证
2026-09-08,JDK 17,KIOSK_1080x1920(1080×1920,280dpi,Android 17)。
- Debug 构建通过;24 项 JVM 单元测试通过,其中新增 6 项。
- `RecentPhotosFlowTest` 的 3 项模拟器测试全部通过。
- 页面验证覆盖:仅失败时显示入口、点击进入 recent 模式、阻止重复跳转、保持首页返回栈、旧 results 路由默认使用 face 模式。
- 模拟响应覆盖:空结果、403 后重试、有效照片按服务端顺序展示、无缩略图地址的照片过滤、预览、单选/全选、计价、获取模拟支付链接以及支付成功后的打印导航参数。
- JVM 测试另外覆盖 URL/SN 编码、GET/Bearer、不发送查询参数、401/403/网络失败、重复请求防护、页面作用域取消。
- 所有照片和订单 HTTP 请求均被测试拦截器替换为模拟响应;只校验打印导航,不打开打印页面、不创建真实订单、不连接打印机。未进行真实后端端到端联调。
- 页面测试临时使用 example.test 和测试 Token,结束时恢复原照片盒配置。测试通过已有 Hilt 单例 Provider 获取 WebSocket 依赖而不启动主页面,未建立测试 WebSocket 连接。
- Espresso 测试依赖升级至 3.7.0,解决旧版本在 Android 17 上调用已移除 InputManager 方法的问题;应用运行依赖不受影响。
## 截图
- [失败页按钮](failure-button.png):状态由测试设置,相机区域是测试时的黑色画面;按钮位于卡片与倒计时之间,底部布局保持原位置。
- [最近照片结果页](results.png):蓝色图片为本地测试素材,价格及支付数据为模拟数据。
## 复现
使用 JDK 17,通过 Gradle 执行:
```text
:app:assembleDebug :app:testDebugUnitTest
:app:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.yzx.kiosk.ui.face.RecentPhotosFlowTest
```
仅在测试模拟器上运行 instrumentation 测试。截图保存在目标应用 external files 目录;手动执行 `am instrument` 后可取回,Gradle 托管测试可能在结束时卸载测试应用并清理截图。
## 最近照片入口语音修正(2026-09-08)
- 新资源:`app/src/main/res/raw/recent_photos_result_page.wav`。
- 从原成功语音的 2.160 秒处裁切。此处位于开场结束后的静音区间(约 1.634–2.289 秒),保留约 129 毫秒的起始停顿,后续音频样本完全不变。
- 新音频长 8.890667 秒,48 kHz、16 位 PCM、双声道 WAV。原文件 SHA-256 保持为 `c9146f08eaa02bacac0cc8b574ccad829ae4457e22c3b553f2fd3ad8111b7dc1`。
- 使用本地 Whisper 转写交叉核对前后内容:新文件从选片提示开始,没有“太棒啦,识别成功啦”开场;后续放大预览、支付按钮和打印提示均保留。模型转写存在个别同音字误差,裁切边界同时通过静音检测和 PCM 样本比对确认。未上传音频。
- `LocalAudioPlayService` 根据路由 `source=recent` 选择新资源;face、旧入口及其他页面保留原映射。不新增播放触发点。
- 最终 Debug 构建与 27 项 JVM 单元测试通过;`LocalAudioPlaybackTest` 在 KIOSK_1080x1920 上通过,验证新旧入口资源选择、实际进入播放状态及重复调用不重置媒体项。
## 正常识别结果页增加浏览入口(2026-09-08)
- 在全选行右侧增加「浏览全部照片 ›」文字按钮,16sp、蓝色、无背景/边框,点击区域至少 48dp。全选与浏览是独立点击区域。
- 仅普通识别结果页显示,recent 页面隐藏。导航采用普通 push(不使用 singleTop/popUpTo),让相同 destination 的两次入栈拥有独立 ViewModel。
- 原识别结果只初始化一次,避免返回时重新初始化照片尺寸、加载状态和价格;网格继续使用 Compose 自带的可保存滚动状态。
- 按钮在导航期间阻止连续点击,回到原页时恢复可点击。
- 最终 Debug 构建、27 项 JVM 单元测试通过;模拟器上 4 项 RecentPhotosFlowTest 和 1 项 LocalAudioPlaybackTest 全部通过。
- 新测试验证:双击只 push 一次、新旧页面 VM 和选择独立、返回保留原 entry/选择/金额/滚动位置且不重复计价初始化、返回后可再次打开新页、recent 隐藏按钮及每次新入栈重新加载。
- 计价使用按选择数量计算的模拟响应,无真实订单和打印操作。
- [实际布局截图](result-toolbar-button.png) 使用 30 张蓝色测试图片展示工具栏位置和长列表场景。
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

+1 -1
View File
@@ -6,7 +6,7 @@ coreKtx = "1.15.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.2.1" junitVersion = "1.2.1"
espressoCore = "3.6.1" espressoCore = "3.7.0"
composeBom = "2025.05.01" composeBom = "2025.05.01"
constraintlayoutCompose = "1.1.1" constraintlayoutCompose = "1.1.1"