fix: optimize face recognition resource lifecycle

This commit is contained in:
2026-08-25 13:42:09 +08:00
parent fb5d3b71df
commit 63944db6fb
14 changed files with 648 additions and 203 deletions
@@ -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()
@@ -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(
@@ -8,13 +8,13 @@ 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 {
@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,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()
}
@@ -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
}
}
}
@@ -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(
@@ -103,6 +99,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 +200,7 @@ fun FaceRecognitionScreen(
} }
} }
// 拍照按钮(5秒倒计时期间也显示,识别失败时显示"重新拍照",其他情况显示"拍照") // 拍照按钮(5秒倒计时期间也显示,识别失败时显示"重新拍照",其他情况显示"拍照")
if (!isRecognizing) { if (!isRecognizing && !isCaptureInProgress) {
Button( Button(
onClick = { onClick = {
if (recognitionStatus == RecognitionStatus.FAILED) { if (recognitionStatus == RecognitionStatus.FAILED) {
@@ -309,50 +308,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)
}
}
} }
@@ -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,12 +36,8 @@ 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.text.SimpleDateFormat
import java.util.* import java.util.*
@@ -59,7 +55,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 +90,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 +115,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 +135,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 +169,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 +188,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
@@ -254,13 +319,10 @@ class FaceRecognitionViewModel @Inject constructor(
// index_date 参数:当前日期,格式 YYYYMMDD // index_date 参数:当前日期,格式 YYYYMMDD
val dateFormat = SimpleDateFormat("yyyyMMdd", Locale.getDefault()) val dateFormat = SimpleDateFormat("yyyyMMdd", Locale.getDefault())
val indexDate = dateFormat.format(Date()) 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:")
@@ -271,27 +333,9 @@ class FaceRecognitionViewModel @Inject constructor(
LogUtils.d(TAG, "index_date: $indexDate") 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,
@@ -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() { fun privatePolicy() {
val args = mapOf( val args = mapOf(
AgreementRoutes.AGREEMENT_URL to BuildConfig.BASE_URL + "/pages/oscar/private-policy.html", AgreementRoutes.AGREEMENT_URL to BuildConfig.BASE_URL + "/pages/oscar/private-policy.html",
@@ -476,9 +442,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,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)
}
}
@@ -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!!))
}
}