Author SHA1 Message Date
lujiuyin 63944db6fb fix: optimize face recognition resource lifecycle 2026-08-25 13:42:09 +08:00
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.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()
@@ -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)
}
@@ -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
@@ -31,6 +33,12 @@ object ServiceModule {
@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
fun provideAudioPlayService(
@@ -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?,
@@ -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.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)
}
}
}
@@ -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
@@ -91,9 +90,16 @@ class FaceRecognitionViewModel @Inject constructor(
private val _isRecognizing = MutableStateFlow(false)
val isRecognizing: StateFlow<Boolean> = _isRecognizing.asStateFlow()
private val _isCaptureInProgress = MutableStateFlow(false)
val isCaptureInProgress: StateFlow<Boolean> = _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
@@ -109,6 +115,15 @@ class FaceRecognitionViewModel @Inject constructor(
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,80 +188,124 @@ 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
// 播放识别中音频
localAudioPlayService.playByRoute("face_recognition_recognizing")
// 播放识别中音频
releaseCaptureGate(captureToken)
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)
}
/**
* 搜索人脸
*/
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()
}
}
@@ -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!!))
}
}