From 63944db6fbdeb83dadc944f65e9795c60345d664 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Tue, 25 Aug 2026 13:42:09 +0800 Subject: [PATCH] fix: optimize face recognition resource lifecycle --- .../FaceThumbnailDecoderInstrumentedTest.kt | 98 ++++++ app/src/main/java/com/yzx/kiosk/App.kt | 16 +- .../com/yzx/kiosk/network/di/NetworkModule.kt | 22 ++ .../com/yzx/kiosk/network/di/ServiceModule.kt | 10 +- .../network/service/FaceSearchService.kt | 6 +- .../kiosk/network/service/FaceSearchUrl.kt | 21 ++ .../yzx/kiosk/ui/face/resource/CaptureGate.kt | 19 ++ .../ui/face/resource/FaceCaptureFileStore.kt | 65 ++++ .../ui/face/resource/FaceThumbnailDecoder.kt | 96 ++++++ .../ui/face/view/FaceRecognitionScreen.kt | 97 +++--- .../viewmodel/FaceRecognitionViewModel.kt | 282 ++++++++---------- .../network/service/FaceSearchUrlTest.kt | 37 +++ .../face/resource/FaceCaptureFileStoreTest.kt | 50 ++++ .../face/resource/FaceResourceHelpersTest.kt | 32 ++ 14 files changed, 648 insertions(+), 203 deletions(-) create mode 100644 app/src/androidTest/java/com/yzx/kiosk/ui/face/resource/FaceThumbnailDecoderInstrumentedTest.kt create mode 100644 app/src/main/java/com/yzx/kiosk/network/service/FaceSearchUrl.kt create mode 100644 app/src/main/java/com/yzx/kiosk/ui/face/resource/CaptureGate.kt create mode 100644 app/src/main/java/com/yzx/kiosk/ui/face/resource/FaceCaptureFileStore.kt create mode 100644 app/src/main/java/com/yzx/kiosk/ui/face/resource/FaceThumbnailDecoder.kt create mode 100644 app/src/test/java/com/yzx/kiosk/network/service/FaceSearchUrlTest.kt create mode 100644 app/src/test/java/com/yzx/kiosk/ui/face/resource/FaceCaptureFileStoreTest.kt create mode 100644 app/src/test/java/com/yzx/kiosk/ui/face/resource/FaceResourceHelpersTest.kt diff --git a/app/src/androidTest/java/com/yzx/kiosk/ui/face/resource/FaceThumbnailDecoderInstrumentedTest.kt b/app/src/androidTest/java/com/yzx/kiosk/ui/face/resource/FaceThumbnailDecoderInstrumentedTest.kt new file mode 100644 index 0000000..f91c8a3 --- /dev/null +++ b/app/src/androidTest/java/com/yzx/kiosk/ui/face/resource/FaceThumbnailDecoderInstrumentedTest.kt @@ -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() + } +} diff --git a/app/src/main/java/com/yzx/kiosk/App.kt b/app/src/main/java/com/yzx/kiosk/App.kt index 3954d5b..8b2c8b8 100644 --- a/app/src/main/java/com/yzx/kiosk/App.kt +++ b/app/src/main/java/com/yzx/kiosk/App.kt @@ -12,6 +12,7 @@ import com.luck.picture.lib.basic.PictureSelectorSupporterActivity import com.luck.picture.lib.basic.PictureSelectorTransparentActivity import com.yzx.kiosk.datastore.AppState import com.yzx.kiosk.datastore.AppStoreDataSource +import com.yzx.kiosk.ui.face.resource.FaceCaptureFileStore import com.yzx.kiosk.utils.LogUtils import com.yzx.kiosk.utils.MMKVUtils import com.yzx.kiosk.utils.NavigationBarUtil @@ -42,6 +43,9 @@ class App : Application() { @Inject lateinit var appStoreDataSource: AppStoreDataSource + @Inject + lateinit var faceCaptureFileStore: FaceCaptureFileStore + override fun onCreate() { super.onCreate() instance = this @@ -59,6 +63,16 @@ class App : Application() { LogUtils.init(BuildConfig.DEBUG) MMKVUtils.init(this) + runCatching { faceCaptureFileStore.cleanupOrphans() } + .onSuccess { deletedFaceCaptures -> + if (deletedFaceCaptures > 0) { + LogUtils.i("App", "Cleaned $deletedFaceCaptures orphaned face capture file(s)") + } + } + .onFailure { error -> + LogUtils.e("App", "Failed to clean orphaned face captures: ${error.message}") + } + appState.initialize() initCoil() @@ -120,4 +134,4 @@ class App : Application() { }) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/yzx/kiosk/network/di/NetworkModule.kt b/app/src/main/java/com/yzx/kiosk/network/di/NetworkModule.kt index 18d64dd..5626123 100644 --- a/app/src/main/java/com/yzx/kiosk/network/di/NetworkModule.kt +++ b/app/src/main/java/com/yzx/kiosk/network/di/NetworkModule.kt @@ -28,8 +28,10 @@ object NetworkModule { private const val CLIENT_DEFAULT = "defaultOkHttpClient" const val CLIENT_UPLOAD = "uploadOkHttpClient" const val CLIENT_DOWNLOAD = "downloadOkHttpClient" + const val CLIENT_FACE = "faceOkHttpClient" const val RETROFIT_DEFAULT = "defaultRetrofit" const val RETROFIT_UPLOAD = "uploadRetrofit" + const val RETROFIT_FACE = "faceRetrofit" const val TRANSFRTVIEWMODEL = "transferViewModel" @Provides @@ -104,6 +106,17 @@ object NetworkModule { } .build() + @Provides + @Singleton + @Named(CLIENT_FACE) + fun provideFaceOkHttpClient(): OkHttpClient = OkHttpClient.Builder() + .apply { + if (BuildConfig.DEBUG) { + addInterceptor(DebugNetworkLoggingInterceptor("face-search")) + } + } + .build() + private fun buildRetrofit( baseUrl: String, okHttpClient: OkHttpClient, @@ -131,4 +144,13 @@ object NetworkModule { gson: Gson, @Named(BASE_URL) baseUrl: String ): Retrofit = buildRetrofit(baseUrl, okHttpClient, gson) + + @Provides + @Singleton + @Named(RETROFIT_FACE) + fun provideFaceRetrofit( + @Named(CLIENT_FACE) okHttpClient: OkHttpClient, + gson: Gson, + @Named(BASE_URL) baseUrl: String, + ): Retrofit = buildRetrofit(baseUrl, okHttpClient, gson) } diff --git a/app/src/main/java/com/yzx/kiosk/network/di/ServiceModule.kt b/app/src/main/java/com/yzx/kiosk/network/di/ServiceModule.kt index 82a4f08..2ee9c95 100644 --- a/app/src/main/java/com/yzx/kiosk/network/di/ServiceModule.kt +++ b/app/src/main/java/com/yzx/kiosk/network/di/ServiceModule.kt @@ -4,6 +4,7 @@ import android.content.Context import com.yzx.kiosk.audio.AudioPlayService import com.yzx.kiosk.network.service.NetworkService import com.yzx.kiosk.network.service.UploadService +import com.yzx.kiosk.network.service.FaceSearchService import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -11,6 +12,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import com.yzx.kiosk.network.di.NetworkModule.RETROFIT_DEFAULT import com.yzx.kiosk.network.di.NetworkModule.RETROFIT_UPLOAD +import com.yzx.kiosk.network.di.NetworkModule.RETROFIT_FACE import retrofit2.Retrofit import javax.inject.Named import javax.inject.Singleton @@ -30,6 +32,12 @@ object ServiceModule { fun provideUploadService( @Named(RETROFIT_UPLOAD) retrofit: Retrofit ): UploadService = retrofit.create(UploadService::class.java) + + @Provides + @Singleton + fun provideFaceSearchService( + @Named(RETROFIT_FACE) retrofit: Retrofit, + ): FaceSearchService = retrofit.create(FaceSearchService::class.java) @Provides @Singleton @@ -42,4 +50,4 @@ object ServiceModule { fun provideLocalAudioPlayService( @ApplicationContext context: Context ): com.yzx.kiosk.audio.LocalAudioPlayService = com.yzx.kiosk.audio.LocalAudioPlayService(context) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/yzx/kiosk/network/service/FaceSearchService.kt b/app/src/main/java/com/yzx/kiosk/network/service/FaceSearchService.kt index b960aa7..3fcdc57 100644 --- a/app/src/main/java/com/yzx/kiosk/network/service/FaceSearchService.kt +++ b/app/src/main/java/com/yzx/kiosk/network/service/FaceSearchService.kt @@ -8,13 +8,13 @@ import retrofit2.http.Header import retrofit2.http.Multipart import retrofit2.http.POST import retrofit2.http.Part -import retrofit2.http.Path +import retrofit2.http.Url interface FaceSearchService { @Multipart - @POST("/{sn}/api/search") + @POST suspend fun searchFace( - @Path("sn") sn: String, + @Url url: String, @Header("Authorization") authorization: String, @Part image: MultipartBody.Part, @Part("threshold") threshold: RequestBody?, diff --git a/app/src/main/java/com/yzx/kiosk/network/service/FaceSearchUrl.kt b/app/src/main/java/com/yzx/kiosk/network/service/FaceSearchUrl.kt new file mode 100644 index 0000000..64302af --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/network/service/FaceSearchUrl.kt @@ -0,0 +1,21 @@ +package com.yzx.kiosk.network.service + +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +internal fun buildFaceSearchUrl(baseUrl: String, deviceSn: String): String { + require(deviceSn.isNotBlank()) { "Face box SN is empty" } + val parsedBaseUrl = baseUrl.trim().toHttpUrlOrNull() + ?: throw IllegalArgumentException("Invalid face search base URL") + + return parsedBaseUrl.newBuilder() + // Preserve the existing /{sn}/api/search endpoint semantics even if configuration + // accidentally contains a path or query string. + .encodedPath("/") + .query(null) + .fragment(null) + .addPathSegment(deviceSn) + .addPathSegment("api") + .addPathSegment("search") + .build() + .toString() +} diff --git a/app/src/main/java/com/yzx/kiosk/ui/face/resource/CaptureGate.kt b/app/src/main/java/com/yzx/kiosk/ui/face/resource/CaptureGate.kt new file mode 100644 index 0000000..d5e7dd5 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/face/resource/CaptureGate.kt @@ -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 + } +} diff --git a/app/src/main/java/com/yzx/kiosk/ui/face/resource/FaceCaptureFileStore.kt b/app/src/main/java/com/yzx/kiosk/ui/face/resource/FaceCaptureFileStore.kt new file mode 100644 index 0000000..6a3a19f --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/face/resource/FaceCaptureFileStore.kt @@ -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 + } + } +} diff --git a/app/src/main/java/com/yzx/kiosk/ui/face/resource/FaceThumbnailDecoder.kt b/app/src/main/java/com/yzx/kiosk/ui/face/resource/FaceThumbnailDecoder.kt new file mode 100644 index 0000000..6638383 --- /dev/null +++ b/app/src/main/java/com/yzx/kiosk/ui/face/resource/FaceThumbnailDecoder.kt @@ -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 + } + } +} diff --git a/app/src/main/java/com/yzx/kiosk/ui/face/view/FaceRecognitionScreen.kt b/app/src/main/java/com/yzx/kiosk/ui/face/view/FaceRecognitionScreen.kt index 978efd9..925a4b2 100644 --- a/app/src/main/java/com/yzx/kiosk/ui/face/view/FaceRecognitionScreen.kt +++ b/app/src/main/java/com/yzx/kiosk/ui/face/view/FaceRecognitionScreen.kt @@ -2,8 +2,6 @@ package com.yzx.kiosk.ui.face.view import android.Manifest import android.graphics.Bitmap -import android.view.ViewGroup -import android.widget.FrameLayout import androidx.camera.core.* import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.view.PreviewView @@ -39,9 +37,6 @@ import com.yzx.kiosk.component.scaffold.AppScaffold import com.yzx.kiosk.ui.common.view.FullScreenMode import com.yzx.kiosk.ui.face.viewmodel.FaceRecognitionViewModel import com.yzx.kiosk.ui.face.viewmodel.RecognitionStatus -import java.util.concurrent.Executor -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine @OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) @Composable @@ -56,6 +51,7 @@ fun FaceRecognitionScreen( val recognitionStatus by viewModel.recognitionStatus.collectAsState() val capturedImage by viewModel.capturedImage.collectAsState() val isRecognizing by viewModel.isRecognizing.collectAsState() + val isCaptureInProgress by viewModel.isCaptureInProgress.collectAsState() // 相机权限 val permissionsState = rememberMultiplePermissionsState( @@ -103,6 +99,9 @@ fun FaceRecognitionScreen( onImageCaptureReady = { imageCapture -> viewModel.setImageCapture(imageCapture) }, + onImageCaptureReleased = { imageCapture -> + viewModel.clearImageCapture(imageCapture) + }, modifier = Modifier.fillMaxSize() ) @@ -201,7 +200,7 @@ fun FaceRecognitionScreen( } } // 拍照按钮(5秒倒计时期间也显示,识别失败时显示"重新拍照",其他情况显示"拍照") - if (!isRecognizing) { + if (!isRecognizing && !isCaptureInProgress) { Button( onClick = { if (recognitionStatus == RecognitionStatus.FAILED) { @@ -309,50 +308,62 @@ fun FaceRecognitionScreen( @Composable fun CameraPreview( onImageCaptureReady: (ImageCapture) -> Unit, + onImageCaptureReleased: (ImageCapture) -> Unit, modifier: Modifier = Modifier ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current + val currentOnImageCaptureReady by rememberUpdatedState(onImageCaptureReady) + val currentOnImageCaptureReleased by rememberUpdatedState(onImageCaptureReleased) + + val previewView = remember(context) { PreviewView(context) } + val preview = remember { Preview.Builder().build() } + val imageCapture = remember { + ImageCapture.Builder() + .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) + .build() + } AndroidView( - factory = { ctx -> - val previewView = PreviewView(ctx) - val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx) - - cameraProviderFuture.addListener({ - val cameraProvider = cameraProviderFuture.get() - - // 创建预览 - val preview = Preview.Builder().build().also { - it.setSurfaceProvider(previewView.surfaceProvider) - } - - // 创建图像捕获 - val imageCapture = ImageCapture.Builder() - .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) - .build() - - // 通知ViewModel ImageCapture已准备好 - onImageCaptureReady(imageCapture) - - // 绑定到生命周期 - val cameraSelector = CameraSelector.DEFAULT_FRONT_CAMERA - try { - cameraProvider.unbindAll() - cameraProvider.bindToLifecycle( - lifecycleOwner, - cameraSelector, - preview, - imageCapture - ) - } catch (e: Exception) { - e.printStackTrace() - } - }, ContextCompat.getMainExecutor(ctx)) - - previewView - }, + factory = { previewView }, modifier = modifier ) + + DisposableEffect(context, lifecycleOwner, preview, imageCapture) { + val cameraProviderFuture = ProcessCameraProvider.getInstance(context) + val executor = ContextCompat.getMainExecutor(context) + var cameraProvider: ProcessCameraProvider? = null + var disposed = false + + preview.setSurfaceProvider(previewView.surfaceProvider) + + cameraProviderFuture.addListener({ + try { + val resolvedProvider = cameraProviderFuture.get() + if (disposed) { + resolvedProvider.unbind(preview, imageCapture) + return@addListener + } + + cameraProvider = resolvedProvider + resolvedProvider.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_FRONT_CAMERA, + preview, + imageCapture, + ) + currentOnImageCaptureReady(imageCapture) + } catch (e: Exception) { + e.printStackTrace() + } + }, executor) + + onDispose { + disposed = true + currentOnImageCaptureReleased(imageCapture) + preview.setSurfaceProvider(null) + cameraProvider?.unbind(preview, imageCapture) + } + } } diff --git a/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionViewModel.kt b/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionViewModel.kt index 6581295..90931fb 100644 --- a/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionViewModel.kt +++ b/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionViewModel.kt @@ -1,10 +1,7 @@ package com.yzx.kiosk.ui.face.viewmodel import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.Matrix import androidx.camera.core.ImageCapture -import androidx.exifinterface.media.ExifInterface import androidx.camera.core.ImageCaptureException import androidx.core.content.ContextCompat import androidx.lifecycle.viewModelScope @@ -16,12 +13,15 @@ import com.yzx.kiosk.datastore.AppStoreDataSource import com.google.gson.Gson import com.yzx.kiosk.network.model.response.FaceSearchResponse import com.yzx.kiosk.network.service.FaceSearchService +import com.yzx.kiosk.network.service.buildFaceSearchUrl import com.yzx.kiosk.App import com.yzx.kiosk.BuildConfig -import com.yzx.kiosk.network.interceptor.DebugNetworkLoggingInterceptor import com.yzx.kiosk.navigation.AppNavigator import com.yzx.kiosk.navigation.routes.AppRoutes import com.yzx.kiosk.ui.setting.navigation.AgreementRoutes +import com.yzx.kiosk.ui.face.resource.CaptureGate +import com.yzx.kiosk.ui.face.resource.FaceCaptureFileStore +import com.yzx.kiosk.ui.face.resource.FaceThumbnailDecoder import com.yzx.kiosk.utils.LogUtils import com.yzx.kiosk.utils.ToastUtils import java.net.URLEncoder @@ -36,12 +36,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType import okhttp3.MultipartBody -import okhttp3.OkHttpClient -import okhttp3.RequestBody import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.toRequestBody -import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory import java.io.File import java.text.SimpleDateFormat import java.util.* @@ -59,7 +55,10 @@ class FaceRecognitionViewModel @Inject constructor( navigator: AppNavigator, appState: AppState, private val appStoreDataSource: AppStoreDataSource, - private val localAudioPlayService: LocalAudioPlayService + private val localAudioPlayService: LocalAudioPlayService, + private val faceSearchService: FaceSearchService, + private val faceCaptureFileStore: FaceCaptureFileStore, + private val faceThumbnailDecoder: FaceThumbnailDecoder, ) : BaseViewModel( navigator = navigator, appState = appState @@ -90,10 +89,17 @@ class FaceRecognitionViewModel @Inject constructor( // 是否正在识别 private val _isRecognizing = MutableStateFlow(false) val isRecognizing: StateFlow = _isRecognizing.asStateFlow() + + private val _isCaptureInProgress = MutableStateFlow(false) + val isCaptureInProgress: StateFlow = _isCaptureInProgress.asStateFlow() // 倒计时Job private var countdownJob: kotlinx.coroutines.Job? = null private var autoCaptureJob: kotlinx.coroutines.Job? = null + private val captureGate = CaptureGate() + private var activeCaptureToken: Long? = null + private var activeCaptureFile: File? = null + private var isCleared = false // ImageCapture实例 private var imageCapture: ImageCapture? = null @@ -108,6 +114,15 @@ class FaceRecognitionViewModel @Inject constructor( fun setImageCapture(imageCapture: ImageCapture) { this.imageCapture = imageCapture } + + fun clearImageCapture(imageCapture: ImageCapture) { + if (this.imageCapture === imageCapture) { + this.imageCapture = null + activeCaptureToken?.let { captureToken -> + finishCaptureFile(activeCaptureFile, captureToken) + } + } + } /** * 开始倒计时 @@ -120,7 +135,7 @@ class FaceRecognitionViewModel @Inject constructor( _countdown.value = _countdown.value - 1 } // 倒计时结束,自动返回首页 - toPage(com.yzx.kiosk.navigation.routes.AppRoutes.HOME) + closeAllExcept(AppRoutes.HOME) } } @@ -154,6 +169,12 @@ class FaceRecognitionViewModel @Inject constructor( * 手动拍照 */ fun takePhoto() { + if (_isRecognizing.value) return + val captureToken = captureGate.tryAcquire() ?: return + + activeCaptureToken = captureToken + _isCaptureInProgress.value = true + // 如果正在自动拍照倒计时,先取消 if (_captureCountdown.value > 0) { cancelAutoCapture() @@ -167,71 +188,107 @@ class FaceRecognitionViewModel @Inject constructor( } val capture = imageCapture ?: run { + releaseCaptureGate(captureToken) ToastUtils.show("相机未准备好") return } viewModelScope.launch { // 创建输出文件 - val photoFile = withContext(Dispatchers.IO) { - File.createTempFile( - "IMG_${SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())}", - ".jpg", - App.instance.cacheDir - ) - } - - // 创建输出选项 - val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build() - - // 拍照 - capture.takePicture( - outputOptions, - ContextCompat.getMainExecutor(App.instance), - object : ImageCapture.OnImageSavedCallback { - override fun onImageSaved(output: ImageCapture.OutputFileResults) { - // 加载图片为Bitmap,并处理EXIF方向 - val bitmap = loadBitmapWithExifOrientation(photoFile.absolutePath) - _capturedImage.value = bitmap - // 直接调用搜索接口 - searchFaceDirectly(photoFile) - } - - override fun onError(exception: ImageCaptureException) { - LogUtils.e(TAG, "拍照失败: ${exception.message}") - ToastUtils.show("拍照失败") - } + var photoFile: File? = null + try { + photoFile = withContext(Dispatchers.IO) { + faceCaptureFileStore.createCaptureFile() } - ) + if ( + isCleared || + imageCapture !== capture || + activeCaptureToken != captureToken + ) { + faceCaptureFileStore.delete(photoFile) + releaseCaptureGate(captureToken) + return@launch + } + activeCaptureFile = photoFile + + val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build() + capture.takePicture( + outputOptions, + ContextCompat.getMainExecutor(App.instance), + object : ImageCapture.OnImageSavedCallback { + override fun onImageSaved(output: ImageCapture.OutputFileResults) { + if ( + isCleared || + imageCapture !== capture || + activeCaptureToken != captureToken + ) { + finishCaptureFile(photoFile, captureToken) + return + } + recognizeSavedPhoto(photoFile, captureToken) + } + + override fun onError(exception: ImageCaptureException) { + LogUtils.e(TAG, "拍照失败: ${exception.message}") + finishCaptureFile(photoFile, captureToken) + if (!isCleared && imageCapture === capture) { + ToastUtils.show("拍照失败") + } + } + }, + ) + } catch (e: CancellationException) { + finishCaptureFile(photoFile, captureToken) + throw e + } catch (e: Exception) { + LogUtils.e(TAG, "创建人脸拍照文件失败: ${e.message}") + finishCaptureFile(photoFile, captureToken) + ToastUtils.show("拍照失败") + } } } - - /** - * 直接调用人脸搜索接口 - */ - private fun searchFaceDirectly(photoFile: File) { + + private fun recognizeSavedPhoto(photoFile: File, captureToken: Long) { viewModelScope.launch { _isRecognizing.value = true _recognitionStatus.value = RecognitionStatus.RECOGNIZING - // 播放识别中音频 + releaseCaptureGate(captureToken) localAudioPlayService.playByRoute("face_recognition_recognizing") - // 播放识别中音频 - localAudioPlayService.playByRoute("face_recognition_recognizing") - + try { - // 直接调用人脸识别接口 + _capturedImage.value = withContext(Dispatchers.IO) { + faceThumbnailDecoder.decode(photoFile.absolutePath) + } searchFace(photoFile) } catch (e: CancellationException) { - // 页面跳转或 ViewModel 销毁时的正常协程取消,不向用户报错 throw e } catch (e: Exception) { LogUtils.e(TAG, "处理失败: ${e.message}") ToastUtils.show("处理失败: ${e.message}") _isRecognizing.value = false _recognitionStatus.value = RecognitionStatus.FAILED + } finally { + finishCaptureFile(photoFile, captureToken) } } } + + private fun releaseCaptureGate(captureToken: Long) { + if (captureGate.release(captureToken)) { + if (activeCaptureToken == captureToken) { + activeCaptureToken = null + } + _isCaptureInProgress.value = false + } + } + + private fun finishCaptureFile(photoFile: File?, captureToken: Long) { + faceCaptureFileStore.delete(photoFile) + if (activeCaptureFile === photoFile) { + activeCaptureFile = null + } + releaseCaptureGate(captureToken) + } /** * 搜索人脸 @@ -239,8 +296,16 @@ class FaceRecognitionViewModel @Inject constructor( private suspend fun searchFace(imageFile: File) = withContext(Dispatchers.IO) { try { - val FACE_SEARCH_BASE_URL = if (appStoreDataSource.getUseLan()) appStoreDataSource.getBindBoxLanUrl() else appStoreDataSource.getBindBoxUrl() - val FACE_SEARCH_TOKEN = appStoreDataSource.getBindBoxApiToken() + val faceSearchBaseUrl = if (appStoreDataSource.getUseLan()) { + appStoreDataSource.getBindBoxLanUrl() + } else { + appStoreDataSource.getBindBoxUrl() + } + val faceSearchToken = appStoreDataSource.getBindBoxApiToken() + val requestUrl = buildFaceSearchUrl( + baseUrl = faceSearchBaseUrl, + deviceSn = appStoreDataSource.getBindBoxSn(), + ) // 创建MultipartBody @@ -254,13 +319,10 @@ class FaceRecognitionViewModel @Inject constructor( // index_date 参数:当前日期,格式 YYYYMMDD val dateFormat = SimpleDateFormat("yyyyMMdd", Locale.getDefault()) val indexDate = dateFormat.format(Date()) - val indexDateBody = indexDate.toRequestBody("text/plain".toMediaType()) - // Authorization header - val authorization = "Bearer $FACE_SEARCH_TOKEN" + val authorization = "Bearer $faceSearchToken" // 打印请求信息 - val requestUrl = "$FACE_SEARCH_BASE_URL/api/search" LogUtils.d(TAG, "========== 人脸识别接口请求信息 ==========") LogUtils.d(TAG, "URL: $requestUrl") LogUtils.d(TAG, "Headers:") @@ -271,27 +333,9 @@ class FaceRecognitionViewModel @Inject constructor( LogUtils.d(TAG, "index_date: $indexDate") LogUtils.d(TAG, "==========================================") - // Debug 环境记录请求与响应;图片等大文件只记录元数据,不读取文件内容 - val client = OkHttpClient.Builder() - .apply { - if (BuildConfig.DEBUG) { - addInterceptor(DebugNetworkLoggingInterceptor("face-search")) - } - } - .build() - - // 创建Retrofit实例 - val retrofit = Retrofit.Builder() - .baseUrl("$FACE_SEARCH_BASE_URL/") - .client(client) - .addConverterFactory(GsonConverterFactory.create()) - .build() - - val service = retrofit.create(FaceSearchService::class.java) - // 调用接口 - val response = service.searchFace( - sn = appStoreDataSource.getBindBoxSn(), + val response = faceSearchService.searchFace( + url = requestUrl, authorization = authorization, image = imagePart, threshold = thresholdBody, @@ -369,84 +413,6 @@ class FaceRecognitionViewModel @Inject constructor( } } - /** - * 加载Bitmap并处理EXIF方向信息 - * 解决不同设备拍照后图片方向不正确的问题 - */ - private fun loadBitmapWithExifOrientation(imagePath: String): Bitmap? { - return try { - // 先读取EXIF方向信息 - val exif = ExifInterface(imagePath) - val orientation = exif.getAttributeInt( - ExifInterface.TAG_ORIENTATION, - ExifInterface.ORIENTATION_NORMAL - ) - - // 加载原始Bitmap - val bitmap = BitmapFactory.decodeFile(imagePath) ?: return null - - // 根据EXIF方向旋转Bitmap - val rotationDegrees = when (orientation) { - ExifInterface.ORIENTATION_ROTATE_90 -> 90f - ExifInterface.ORIENTATION_ROTATE_180 -> 180f - ExifInterface.ORIENTATION_ROTATE_270 -> 270f - ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> { - // 水平翻转 - val matrix = Matrix() - matrix.setScale(-1f, 1f) - return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) - } - ExifInterface.ORIENTATION_FLIP_VERTICAL -> { - // 垂直翻转 - val matrix = Matrix() - matrix.setScale(1f, -1f) - return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) - } - ExifInterface.ORIENTATION_TRANSPOSE -> { - // 转置(旋转90度+水平翻转) - val matrix = Matrix() - matrix.setRotate(90f) - matrix.postScale(-1f, 1f) - return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) - } - ExifInterface.ORIENTATION_TRANSVERSE -> { - // 横向(旋转270度+水平翻转) - val matrix = Matrix() - matrix.setRotate(270f) - matrix.postScale(-1f, 1f) - return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) - } - else -> 0f // 不需要旋转 - } - - // 如果需要旋转 - if (rotationDegrees != 0f) { - val matrix = Matrix() - matrix.postRotate(rotationDegrees) - val rotatedBitmap = Bitmap.createBitmap( - bitmap, - 0, - 0, - bitmap.width, - bitmap.height, - matrix, - true - ) - // 回收原始bitmap - if (rotatedBitmap != bitmap) { - bitmap.recycle() - } - rotatedBitmap - } else { - bitmap - } - } catch (e: Exception) { - LogUtils.e(TAG, "加载图片并处理EXIF方向失败: ${e.message}") - // 如果处理失败,返回原始Bitmap - BitmapFactory.decodeFile(imagePath) - } - } - fun privatePolicy() { val args = mapOf( AgreementRoutes.AGREEMENT_URL to BuildConfig.BASE_URL + "/pages/oscar/private-policy.html", @@ -476,9 +442,15 @@ class FaceRecognitionViewModel @Inject constructor( } override fun onCleared() { - super.onCleared() + isCleared = true countdownJob?.cancel() autoCaptureJob?.cancel() + imageCapture = null + _capturedImage.value = null + faceCaptureFileStore.delete(activeCaptureFile) + activeCaptureFile = null + activeCaptureToken?.let(::releaseCaptureGate) + super.onCleared() } } diff --git a/app/src/test/java/com/yzx/kiosk/network/service/FaceSearchUrlTest.kt b/app/src/test/java/com/yzx/kiosk/network/service/FaceSearchUrlTest.kt new file mode 100644 index 0000000..cd8cd13 --- /dev/null +++ b/app/src/test/java/com/yzx/kiosk/network/service/FaceSearchUrlTest.kt @@ -0,0 +1,37 @@ +package com.yzx.kiosk.network.service + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory + +class FaceSearchUrlTest { + @Test + fun `url uses configured host and safely encoded sn`() { + assertEquals( + "http://192.168.1.10/BOX%2F01/api/search", + buildFaceSearchUrl("http://192.168.1.10/old/path?unused=true", "BOX/01"), + ) + } + + @Test + fun `invalid configuration is rejected`() { + assertThrows(IllegalArgumentException::class.java) { + buildFaceSearchUrl("not-a-url", "BOX-1") + } + assertThrows(IllegalArgumentException::class.java) { + buildFaceSearchUrl("http://192.168.1.10", "") + } + } + + @Test + fun `retrofit accepts multipart post with dynamic url`() { + Retrofit.Builder() + .baseUrl("https://example.test/") + .addConverterFactory(GsonConverterFactory.create()) + .validateEagerly(true) + .build() + .create(FaceSearchService::class.java) + } +} diff --git a/app/src/test/java/com/yzx/kiosk/ui/face/resource/FaceCaptureFileStoreTest.kt b/app/src/test/java/com/yzx/kiosk/ui/face/resource/FaceCaptureFileStoreTest.kt new file mode 100644 index 0000000..242e81b --- /dev/null +++ b/app/src/test/java/com/yzx/kiosk/ui/face/resource/FaceCaptureFileStoreTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/com/yzx/kiosk/ui/face/resource/FaceResourceHelpersTest.kt b/app/src/test/java/com/yzx/kiosk/ui/face/resource/FaceResourceHelpersTest.kt new file mode 100644 index 0000000..ce09e56 --- /dev/null +++ b/app/src/test/java/com/yzx/kiosk/ui/face/resource/FaceResourceHelpersTest.kt @@ -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!!)) + } +}