package com.taf.attendance import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Matrix import android.util.Log import androidx.exifinterface.media.ExifInterface import com.taf.attendance.data.AttendanceRepository import com.google.mlkit.vision.common.InputImage import com.google.mlkit.vision.face.FaceDetection import com.google.mlkit.vision.face.FaceDetectorOptions import org.tensorflow.lite.Interpreter import java.nio.ByteBuffer import java.nio.ByteOrder import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class FaceAuth(private val context: Context, private val repo: AttendanceRepository) { private val detectorOptions = FaceDetectorOptions.Builder() .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_FAST) .setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_ALL) .setClassificationMode(FaceDetectorOptions.CLASSIFICATION_MODE_ALL) .build() private val faceDetector = FaceDetection.getClient(detectorOptions) private val interpreter: Interpreter by lazy { Interpreter(loadModelFile("mobile_facenet.tflite")) } private fun loadModelFile(modelName: String): ByteBuffer { val fd = context.assets.openFd(modelName) val input = java.io.FileInputStream(fd.fileDescriptor) val buffer = input.channel.map(java.nio.channels.FileChannel.MapMode.READ_ONLY, fd.startOffset, fd.declaredLength) input.close() fd.close() return buffer } private fun FloatArray.toPrefString(): String = joinToString(",") private fun String.toFloatArray(): FloatArray = if (isEmpty()) FloatArray(0) else split(",").map { it.toFloat() }.toFloatArray() private fun rotateBitmapIfNeeded(path: String, bitmap: Bitmap): Bitmap { val orientation = try { ExifInterface(path).getAttributeInt( ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL ) } catch (e: Exception) { ExifInterface.ORIENTATION_NORMAL } val rotation = when (orientation) { ExifInterface.ORIENTATION_ROTATE_90 -> 90f ExifInterface.ORIENTATION_ROTATE_180 -> 180f ExifInterface.ORIENTATION_ROTATE_270 -> 270f else -> 0f } return if (rotation != 0f) { val m = Matrix().apply { postRotate(rotation) } Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, m, true) } else { bitmap } } private suspend fun getEmbedding(path: String): FloatArray? = withContext(Dispatchers.Default) { val origBitmap = BitmapFactory.decodeFile(path) ?: return@withContext null val bitmap = rotateBitmapIfNeeded(path, origBitmap) val image = InputImage.fromBitmap(bitmap, 0) val faces = faceDetector.process(image).awaitResult() ?: return@withContext null if (faces.isEmpty()) return@withContext null val box = faces[0].boundingBox val faceBmp = Bitmap.createBitmap( bitmap, box.left.coerceAtLeast(0), box.top.coerceAtLeast(0), box.width().coerceAtMost(bitmap.width - box.left), box.height().coerceAtMost(bitmap.height - box.top) ) val inputBmp = Bitmap.createScaledBitmap(faceBmp, 112, 112, true) val buffer = ByteBuffer.allocateDirect(1 * 112 * 112 * 3 * 4).order(ByteOrder.nativeOrder()) for (y in 0 until 112) { for (x in 0 until 112) { val pixel = inputBmp.getPixel(x, y) val r = ((pixel shr 16 and 0xFF) - 127.5f) / 128f val g = ((pixel shr 8 and 0xFF) - 127.5f) / 128f val b = ((pixel and 0xFF) - 127.5f) / 128f buffer.putFloat(r) buffer.putFloat(g) buffer.putFloat(b) } } val outputDim = interpreter.getOutputTensor(0).shape()[1] val output = Array(1) { FloatArray(outputDim) } buffer.rewind() interpreter.run(buffer, output) output[0] } suspend fun isFaceRegistered(userId: String): Boolean = withContext(Dispatchers.IO) { repo.getUserFace(userId) != null } suspend fun saveFace(userId: String, path: String): Boolean = withContext(Dispatchers.Default) { try { val embed = getEmbedding(path) ?: return@withContext false val embedStr = embed.toPrefString() // Upload to backend first - this is mandatory for cross-device sync try { repo.uploadUserFace(userId, path, embedStr) } catch (e: Exception) { Log.e("FaceAuth", "Failed to upload face to backend", e) return@withContext false // Fail registration if upload fails } // Save locally only after successful backend upload repo.saveUserFace(userId, path, embedStr, synced = true) true } catch (e: Exception) { Log.e("FaceAuth", "Face registration failed", e) false } } private suspend fun getRegisteredEmbedding(userId: String): FloatArray? = repo.getUserFace(userId)?.embedding?.toFloatArray() /** * Verify that the provided photo matches the registered face. * * The photo is processed with ML Kit to extract the face region and then * passed through the bundled MobileFaceNet model. A cosine similarity check * compares the resulting embedding with the stored reference. */ suspend fun verifyFace(userId: String, photoPath: String): Boolean = withContext(Dispatchers.Default) { val registered = getRegisteredEmbedding(userId) ?: return@withContext false return@withContext try { val embed = getEmbedding(photoPath) ?: return@withContext false val score = cosineSimilarity(registered, embed) score > 0.5f } catch (e: Exception) { false } } /** * Perform a lightweight liveness check. * * The first frame is analysed with ML Kit and the result is considered * valid when a single face is present and both eyes are open with a * probability over 0.5. This is not as robust as a full blink detection but * prevents obvious spoofing with a static photo. */ suspend fun runLivenessCheck(frames: List): Boolean = withContext(Dispatchers.Default) { if (frames.isEmpty()) return@withContext true val image = InputImage.fromBitmap(frames[0], 0) val faces = faceDetector.process(image).awaitResult() ?: return@withContext false if (faces.isEmpty()) return@withContext false val face = faces[0] val eyesOpen = (face.leftEyeOpenProbability ?: 1f) > 0.5f && (face.rightEyeOpenProbability ?: 1f) > 0.5f eyesOpen } private fun cosineSimilarity(v1: FloatArray, v2: FloatArray): Float { if (v1.size != v2.size) return 0f var dot = 0f var mag1 = 0f var mag2 = 0f for (i in v1.indices) { dot += v1[i] * v2[i] mag1 += v1[i] * v1[i] mag2 += v2[i] * v2[i] } mag1 = kotlin.math.sqrt(mag1) mag2 = kotlin.math.sqrt(mag2) return dot / (mag1 * mag2) } }