package expo.modules.facedetection import android.graphics.Bitmap import android.graphics.BitmapFactory import android.util.Base64 import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition import expo.modules.kotlin.Promise import expo.modules.kotlin.exception.CodedException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class ExpoFaceDetectionModule : Module() { private val manager: FaceDetectionManager by lazy { FaceDetectionManager.getInstance(appContext.reactContext!!) } override fun definition() = ModuleDefinition { Name("ExpoFaceDetection") // ========== Face Detection ========== AsyncFunction("detectFaces") { imageBase64: String, cropFaces: Boolean -> val bitmap = decodeBase64ToBitmap(imageBase64) val result = manager.detectFaces(bitmap, cropFaces) mapOf( "faces" to result.faces.map { face -> mapOf( "box" to face.box, "landmarks" to face.landmarks, "confidence" to face.confidence, "croppedFace" to face.croppedFace?.let { encodeBitmapToBase64(it) } ) }, "faceCount" to result.faceCount, "hasFaces" to result.hasFaces, "processingTimeMs" to result.processingTimeMs, "frameWidth" to result.frameWidth, "frameHeight" to result.frameHeight ) } AsyncFunction("detectLargestFace") { imageBase64: String -> val bitmap = decodeBase64ToBitmap(imageBase64) val face = manager.detectLargestFace(bitmap) face?.let { mapOf( "box" to it.box, "landmarks" to it.landmarks, "confidence" to it.confidence ) } } // ========== Liveness Detection ========== AsyncFunction("checkLiveness") { imageBase64: String -> val bitmap = decodeBase64ToBitmap(imageBase64) val result = manager.checkLiveness(bitmap) mapOf( "faceDetected" to result.faceDetected, "isLive" to result.isLive, "livenessScore" to result.livenessScore, "sharpness" to result.sharpness, "isSharp" to result.isSharp, "faceBox" to result.faceBox, "confidence" to result.confidence, "processingTimeMs" to result.processingTimeMs, "errorMessage" to result.errorMessage ) } // ========== Face Registration (Embedding Extraction) ========== AsyncFunction("extractEmbedding") { imageBase64: String -> val bitmap = decodeBase64ToBitmap(imageBase64) val result = manager.extractEmbedding(bitmap) mapOf( "success" to result.success, "embedding" to result.embedding, "faceBox" to result.faceBox, "processingTimeMs" to result.processingTimeMs, "errorMessage" to result.errorMessage ) } AsyncFunction("registerFace") { frontBase64: String, leftBase64: String, rightBase64: String -> val frontBitmap = decodeBase64ToBitmap(frontBase64) val leftBitmap = decodeBase64ToBitmap(leftBase64) val rightBitmap = decodeBase64ToBitmap(rightBase64) val result = manager.registerFace(frontBitmap, leftBitmap, rightBitmap) mapOf( "success" to result.success, "embedding" to result.embedding, "faceBox" to result.faceBox, "processingTimeMs" to result.processingTimeMs, "errorMessage" to result.errorMessage ) } // ========== Face Matching ========== Function("setTargetEmbedding") { embedding: List -> val floatArray = embedding.map { it.toFloat() }.toFloatArray() manager.setTargetEmbedding(floatArray) } Function("hasTarget") { manager.hasTarget() } Function("clearTarget") { manager.clearTarget() } AsyncFunction("processFrame") { imageBase64: String -> val bitmap = decodeBase64ToBitmap(imageBase64) val result = manager.processFrame(bitmap) mapOf( "faceDetected" to result.faceDetected, "isMatch" to result.isMatch, "confidence" to result.confidence, "distance" to result.distance, "faceBox" to result.faceBox, "processingTimeMs" to result.processingTimeMs, "errorMessage" to result.errorMessage ) } // ========== Threshold Setters ========== Function("setMinFaceRatio") { ratio: Double -> manager.setMinFaceRatio(ratio.toFloat()) } Function("getMinFaceRatio") { manager.getMinFaceRatio() } Function("setDetectionConfidenceThreshold") { threshold: Double -> manager.setDetectionConfidenceThreshold(threshold.toFloat()) } Function("getDetectionConfidenceThreshold") { manager.getDetectionConfidenceThreshold() } Function("setLivenessThreshold") { threshold: Double -> manager.setLivenessThreshold(threshold.toFloat()) } Function("getLivenessThreshold") { manager.getLivenessThreshold() } Function("setSharpnessThreshold") { threshold: Double -> manager.setSharpnessThreshold(threshold.toFloat()) } Function("getSharpnessThreshold") { manager.getSharpnessThreshold() } Function("setMatchThreshold") { threshold: Double -> manager.setMatchThreshold(threshold.toFloat()) } Function("getMatchThreshold") { manager.getMatchThreshold() } // ========== Native Camera View ========== View(ExpoFaceDetectionView::class) { Events( "onMatchResult", "onFaceDetected", "onError", "onEnrollmentCapture", "onEnrollmentComplete", "onEnrollmentStatus" ) // Mode: "matching" or "enrollment" Prop("mode") { view: ExpoFaceDetectionView, mode: String -> view.setMode(mode) } Prop("enableMatching") { view: ExpoFaceDetectionView, enabled: Boolean -> view.setMatchingEnabled(enabled) } Prop("enableLiveness") { view: ExpoFaceDetectionView, enabled: Boolean -> view.setLivenessEnabled(enabled) } Prop("targetEmbedding") { view: ExpoFaceDetectionView, embedding: List? -> val TAG = "VIEW_PROP_DEBUG" if (embedding != null) { android.util.Log.d(TAG, "=== TARGET EMBEDDING PROP RECEIVED ===") android.util.Log.d(TAG, "Embedding size: ${embedding.size}") android.util.Log.d(TAG, "First 5 values (Double): [${embedding.take(5).joinToString(", ") { "%.6f".format(it) }}]") val floatArray = embedding.map { d -> d.toFloat() }.toFloatArray() android.util.Log.d(TAG, "First 5 values (Float): [${floatArray.take(5).joinToString(", ") { "%.6f".format(it) }}]") view.setTargetEmbedding(floatArray) } else { android.util.Log.d(TAG, "=== TARGET EMBEDDING PROP IS NULL ===") } } Prop("matchThreshold") { view: ExpoFaceDetectionView, threshold: Double -> view.setMatchThreshold(threshold.toFloat()) } Prop("cameraFacing") { view: ExpoFaceDetectionView, facing: String -> view.setCameraFacing(facing) } // Enrollment mode props Prop("capturePhoto") { view: ExpoFaceDetectionView, capture: Boolean -> view.setCapturePhoto(capture) } Prop("resetEnrollment") { view: ExpoFaceDetectionView, reset: Boolean -> view.setResetEnrollment(reset) } } } // ========== Utility Functions ========== private fun decodeBase64ToBitmap(base64String: String): Bitmap { val cleanBase64 = if (base64String.contains(",")) { base64String.substringAfter(",") } else { base64String } val decodedBytes = Base64.decode(cleanBase64, Base64.DEFAULT) return BitmapFactory.decodeByteArray(decodedBytes, 0, decodedBytes.size) ?: throw CodedException("DECODE_ERROR", "Failed to decode base64 image", null) } private fun encodeBitmapToBase64(bitmap: Bitmap): String { val outputStream = java.io.ByteArrayOutputStream() bitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream) val byteArray = outputStream.toByteArray() return Base64.encodeToString(byteArray, Base64.NO_WRAP) } }