add sources
This commit is contained in:
40
app/build.gradle.kts
Normal file
40
app/build.gradle.kts
Normal file
@@ -0,0 +1,40 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.studentfaceregistry"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.example.studentfaceregistry"
|
||||
minSdk = 24
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.activity:activity-ktx:1.9.3")
|
||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||
implementation("androidx.camera:camera-camera2:1.4.0")
|
||||
implementation("androidx.camera:camera-lifecycle:1.4.0")
|
||||
implementation("androidx.camera:camera-view:1.4.0")
|
||||
implementation("androidx.core:core-ktx:1.13.1")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.6")
|
||||
implementation("com.google.mlkit:face-detection:16.1.7")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0")
|
||||
implementation("org.tensorflow:tensorflow-lite:2.16.1")
|
||||
}
|
||||
31
app/src/main/AndroidManifest.xml
Normal file
31
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="true" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
4
app/src/main/assets/README.md
Normal file
4
app/src/main/assets/README.md
Normal file
@@ -0,0 +1,4 @@
|
||||
Place a FaceNet-compatible TensorFlow Lite model here as facenet.tflite.
|
||||
|
||||
Expected input: [1, 160, 160, 3].
|
||||
Expected output: a float embedding vector.
|
||||
BIN
app/src/main/assets/facenet_512.tflite
Normal file
BIN
app/src/main/assets/facenet_512.tflite
Normal file
Binary file not shown.
BIN
app/src/main/assets/facenet_512_int_quantized.tflite
Normal file
BIN
app/src/main/assets/facenet_512_int_quantized.tflite
Normal file
Binary file not shown.
BIN
app/src/main/assets/facenet_int_quantized.tflite
Normal file
BIN
app/src/main/assets/facenet_int_quantized.tflite
Normal file
Binary file not shown.
BIN
app/src/main/assets/mask_detector.tflite
Normal file
BIN
app/src/main/assets/mask_detector.tflite
Normal file
Binary file not shown.
@@ -0,0 +1,63 @@
|
||||
package com.example.studentfaceregistry
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.ImageFormat
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Rect
|
||||
import android.graphics.YuvImage
|
||||
import android.media.Image
|
||||
import androidx.camera.core.ImageProxy
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlin.math.min
|
||||
|
||||
object ImageUtils {
|
||||
fun imageProxyToBitmap(imageProxy: ImageProxy): Bitmap {
|
||||
val image = imageProxy.image ?: error("ImageProxy does not contain an image.")
|
||||
val nv21 = yuv420ToNv21(image)
|
||||
val yuvImage = YuvImage(nv21, ImageFormat.NV21, image.width, image.height, null)
|
||||
val output = ByteArrayOutputStream()
|
||||
yuvImage.compressToJpeg(Rect(0, 0, image.width, image.height), 90, output)
|
||||
val bitmap = BitmapFactory.decodeByteArray(output.toByteArray(), 0, output.size())
|
||||
val matrix = Matrix().apply {
|
||||
postRotate(imageProxy.imageInfo.rotationDegrees.toFloat())
|
||||
}
|
||||
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
||||
}
|
||||
|
||||
private fun yuv420ToNv21(image: Image): ByteArray {
|
||||
val width = image.width
|
||||
val height = image.height
|
||||
val ySize = width * height
|
||||
val chromaSize = width * height / 4
|
||||
val nv21 = ByteArray(ySize + chromaSize * 2)
|
||||
copyPlane(image.planes[0], width, height, nv21, 0, 1)
|
||||
copyPlane(image.planes[2], width / 2, height / 2, nv21, ySize, 2)
|
||||
copyPlane(image.planes[1], width / 2, height / 2, nv21, ySize + 1, 2)
|
||||
return nv21
|
||||
}
|
||||
|
||||
private fun copyPlane(
|
||||
plane: Image.Plane,
|
||||
width: Int,
|
||||
height: Int,
|
||||
output: ByteArray,
|
||||
offset: Int,
|
||||
outputPixelStride: Int
|
||||
) {
|
||||
val buffer = plane.buffer
|
||||
val row = ByteArray(plane.rowStride)
|
||||
var outputIndex = offset
|
||||
for (rowIndex in 0 until height) {
|
||||
val bytesToRead = min(plane.rowStride, buffer.remaining())
|
||||
buffer.get(row, 0, bytesToRead)
|
||||
for (colIndex in 0 until width) {
|
||||
val inputIndex = colIndex * plane.pixelStride
|
||||
if (inputIndex < bytesToRead && outputIndex < output.size) {
|
||||
output[outputIndex] = row[inputIndex]
|
||||
}
|
||||
outputIndex += outputPixelStride
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package com.example.studentfaceregistry
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Typeface
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.widget.Button
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageProxy
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.example.studentfaceregistry.data.StudentRepository
|
||||
import com.example.studentfaceregistry.data.Student
|
||||
import com.example.studentfaceregistry.face.FaceMatcher
|
||||
import com.example.studentfaceregistry.face.FaceProcessor
|
||||
import com.example.studentfaceregistry.ui.DetectionUi
|
||||
import com.example.studentfaceregistry.ui.OverlayView
|
||||
import com.example.studentfaceregistry.upload.UploadServer
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
private lateinit var previewView: PreviewView
|
||||
private lateinit var overlayView: OverlayView
|
||||
private lateinit var statusText: TextView
|
||||
private lateinit var countText: TextView
|
||||
private var processor: FaceProcessor? = null
|
||||
private var uploadServer: UploadServer? = null
|
||||
|
||||
private val repository by lazy { StudentRepository(this) }
|
||||
private val matcher = FaceMatcher()
|
||||
private val cameraExecutor = Executors.newSingleThreadExecutor()
|
||||
private var students: List<Student> = emptyList()
|
||||
private var recognitionEnabled = true
|
||||
private var analyzing = false
|
||||
|
||||
private val permissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { grants ->
|
||||
if (grants[Manifest.permission.CAMERA] == true) startCamera() else toast("需要相机权限才能识别学生身份。")
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(createContentView())
|
||||
processor = runCatching { FaceProcessor(this) }
|
||||
.onFailure {
|
||||
statusText.text = "缺少模型文件:app/src/main/assets/facenet.tflite"
|
||||
toast("请先放入 facenet.tflite 模型。")
|
||||
}
|
||||
.getOrNull()
|
||||
uploadServer = UploadServer(this, repository) { processor }.also { it.start() }
|
||||
|
||||
lifecycleScope.launch {
|
||||
repository.students.collectLatest { list ->
|
||||
students = list
|
||||
countText.text = "已入库 ${list.size} 名学生"
|
||||
}
|
||||
}
|
||||
|
||||
statusText.text = "电脑浏览器打开上传地址来批量导入头像"
|
||||
if (hasCameraPermission()) startCamera() else permissionLauncher.launch(arrayOf(Manifest.permission.CAMERA))
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
processor?.close()
|
||||
uploadServer?.close()
|
||||
cameraExecutor.shutdown()
|
||||
}
|
||||
|
||||
private fun createContentView(): View {
|
||||
val root = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setBackgroundColor(0xFFF8FAFC.toInt())
|
||||
}
|
||||
val header = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(32, 28, 32, 20)
|
||||
}
|
||||
header.addView(TextView(this).apply {
|
||||
text = "学生人脸识别"
|
||||
textSize = 24f
|
||||
setTextColor(0xFF0F172A.toInt())
|
||||
})
|
||||
countText = TextView(this).apply {
|
||||
text = "已入库 0 名学生"
|
||||
textSize = 15f
|
||||
setTextColor(0xFF475569.toInt())
|
||||
}
|
||||
statusText = TextView(this).apply {
|
||||
text = "点击上传地址,用电脑浏览器批量上传头像"
|
||||
textSize = 22f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setTextColor(0xFF0F172A.toInt())
|
||||
setPadding(0, 12, 0, 0)
|
||||
}
|
||||
header.addView(countText)
|
||||
header.addView(statusText)
|
||||
|
||||
val cameraFrame = FrameLayout(this).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f)
|
||||
}
|
||||
previewView = PreviewView(this).apply {
|
||||
scaleType = PreviewView.ScaleType.FILL_CENTER
|
||||
}
|
||||
overlayView = OverlayView(this)
|
||||
cameraFrame.addView(previewView, FrameLayout.LayoutParams(-1, -1))
|
||||
cameraFrame.addView(overlayView, FrameLayout.LayoutParams(-1, -1))
|
||||
|
||||
val controls = LinearLayout(this).apply {
|
||||
gravity = Gravity.CENTER
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
setPadding(20, 20, 20, 28)
|
||||
}
|
||||
controls.addView(Button(this).apply {
|
||||
text = "上传地址"
|
||||
setOnClickListener {
|
||||
statusText.text = uploadServer?.accessUrl() ?: "上传服务未启动"
|
||||
}
|
||||
})
|
||||
controls.addView(Button(this).apply {
|
||||
text = "暂停识别"
|
||||
setOnClickListener {
|
||||
recognitionEnabled = !recognitionEnabled
|
||||
text = if (recognitionEnabled) "暂停识别" else "开始识别"
|
||||
statusText.text = if (recognitionEnabled) "正在识别" else "识别已暂停"
|
||||
}
|
||||
})
|
||||
root.addView(header)
|
||||
root.addView(cameraFrame)
|
||||
root.addView(controls)
|
||||
return root
|
||||
}
|
||||
|
||||
private fun startCamera() {
|
||||
val providerFuture = ProcessCameraProvider.getInstance(this)
|
||||
providerFuture.addListener({
|
||||
val cameraProvider = providerFuture.get()
|
||||
val preview = Preview.Builder().build().also {
|
||||
it.setSurfaceProvider(previewView.surfaceProvider)
|
||||
}
|
||||
val analysis = ImageAnalysis.Builder()
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
.also { it.setAnalyzer(cameraExecutor) { image -> analyze(image) } }
|
||||
cameraProvider.unbindAll()
|
||||
cameraProvider.bindToLifecycle(this, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
|
||||
}, ContextCompat.getMainExecutor(this))
|
||||
}
|
||||
|
||||
private fun analyze(image: ImageProxy) {
|
||||
val faceProcessor = processor
|
||||
if (!recognitionEnabled || analyzing || faceProcessor == null) {
|
||||
image.close()
|
||||
return
|
||||
}
|
||||
analyzing = true
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val bitmap = withContext(Dispatchers.Default) { ImageUtils.imageProxyToBitmap(image) }
|
||||
val faces = faceProcessor.detectFaces(bitmap)
|
||||
val detections = faces.map { face ->
|
||||
val embedding = faceProcessor.embedFace(bitmap, face)
|
||||
DetectionUi(face.boundingBox, matcher.findNearest(embedding, students))
|
||||
}
|
||||
overlayView.update(detections, bitmap.width, bitmap.height)
|
||||
statusText.text = detections.firstOrNull()?.result?.student?.let {
|
||||
"识别到:${it.name}(${it.studentNo})"
|
||||
} ?: if (detections.isEmpty()) "未检测到人脸" else "检测到未入库人脸"
|
||||
} catch (_: Exception) {
|
||||
statusText.text = "识别暂不可用,请确认模型文件和相机画面。"
|
||||
} finally {
|
||||
analyzing = false
|
||||
image.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasCameraPermission(): Boolean {
|
||||
return ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun toast(message: String) {
|
||||
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.example.studentfaceregistry.data
|
||||
|
||||
data class Student(
|
||||
val id: Long = 0,
|
||||
val studentNo: String,
|
||||
val name: String,
|
||||
val photoUri: String,
|
||||
val embedding: FloatArray,
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.example.studentfaceregistry.data
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
class StudentRepository(context: Context) {
|
||||
private val storageFile = File(context.filesDir, "students.json")
|
||||
private val _students = MutableStateFlow(load())
|
||||
val students: StateFlow<List<Student>> = _students
|
||||
|
||||
fun add(student: Student) {
|
||||
val next = _students.value.toMutableList().apply { add(student) }
|
||||
.sortedByDescending { it.createdAt }
|
||||
_students.value = next
|
||||
save(next)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
_students.value = emptyList()
|
||||
save(emptyList())
|
||||
}
|
||||
|
||||
private fun load(): List<Student> {
|
||||
if (!storageFile.exists()) return emptyList()
|
||||
val raw = storageFile.readText()
|
||||
if (raw.isBlank()) return emptyList()
|
||||
val array = JSONArray(raw)
|
||||
return buildList {
|
||||
for (index in 0 until array.length()) {
|
||||
val obj = array.getJSONObject(index)
|
||||
add(
|
||||
Student(
|
||||
id = obj.optLong("id", index.toLong()),
|
||||
studentNo = obj.getString("studentNo"),
|
||||
name = obj.getString("name"),
|
||||
photoUri = obj.getString("photoUri"),
|
||||
embedding = decodeEmbedding(obj.getJSONArray("embedding")),
|
||||
createdAt = obj.optLong("createdAt", System.currentTimeMillis())
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun save(students: List<Student>) {
|
||||
val array = JSONArray()
|
||||
students.forEach { student ->
|
||||
array.put(
|
||||
JSONObject().apply {
|
||||
put("id", student.id)
|
||||
put("studentNo", student.studentNo)
|
||||
put("name", student.name)
|
||||
put("photoUri", student.photoUri)
|
||||
put("createdAt", student.createdAt)
|
||||
put("embedding", JSONArray(student.embedding.toList()))
|
||||
}
|
||||
)
|
||||
}
|
||||
storageFile.writeText(array.toString())
|
||||
}
|
||||
|
||||
private fun decodeEmbedding(array: JSONArray): FloatArray {
|
||||
return FloatArray(array.length()) { index -> array.getDouble(index).toFloat() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.example.studentfaceregistry.face
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import org.tensorflow.lite.Interpreter
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import kotlin.math.sqrt
|
||||
|
||||
class FaceEmbedder(context: Context) : AutoCloseable {
|
||||
private val interpreter: Interpreter
|
||||
private val inputSize = 160
|
||||
private val outputSize: Int
|
||||
|
||||
init {
|
||||
val modelBytes = context.assets.open("facenet.tflite").use { input ->
|
||||
input.readBytes()
|
||||
}
|
||||
interpreter = Interpreter(ByteBuffer.allocateDirect(modelBytes.size).apply {
|
||||
order(ByteOrder.nativeOrder())
|
||||
put(modelBytes)
|
||||
rewind()
|
||||
})
|
||||
outputSize = interpreter.getOutputTensor(0).shape().last()
|
||||
}
|
||||
|
||||
fun embed(face: Bitmap): FloatArray {
|
||||
val resized = Bitmap.createScaledBitmap(face, inputSize, inputSize, true)
|
||||
val input = ByteBuffer
|
||||
.allocateDirect(inputSize * inputSize * 3 * Float.SIZE_BYTES)
|
||||
.order(ByteOrder.nativeOrder())
|
||||
val pixels = IntArray(inputSize * inputSize)
|
||||
resized.getPixels(pixels, 0, inputSize, 0, 0, inputSize, inputSize)
|
||||
pixels.forEach { color ->
|
||||
input.putFloat((((color shr 16) and 0xFF) - 127.5f) / 128f)
|
||||
input.putFloat((((color shr 8) and 0xFF) - 127.5f) / 128f)
|
||||
input.putFloat(((color and 0xFF) - 127.5f) / 128f)
|
||||
}
|
||||
input.rewind()
|
||||
val output = Array(1) { FloatArray(outputSize) }
|
||||
interpreter.run(input, output)
|
||||
return l2Normalize(output[0])
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
interpreter.close()
|
||||
}
|
||||
|
||||
private fun l2Normalize(values: FloatArray): FloatArray {
|
||||
var sum = 0f
|
||||
values.forEach { sum += it * it }
|
||||
val norm = sqrt(sum.coerceAtLeast(1e-12f))
|
||||
return FloatArray(values.size) { index -> values[index] / norm }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.example.studentfaceregistry.face
|
||||
|
||||
import com.example.studentfaceregistry.data.Student
|
||||
import kotlin.math.sqrt
|
||||
|
||||
class FaceMatcher(private val threshold: Float = 0.95f) {
|
||||
fun findNearest(embedding: FloatArray, students: List<Student>): RecognitionResult {
|
||||
var nearest: Student? = null
|
||||
var nearestDistance = Float.MAX_VALUE
|
||||
students.forEach { student ->
|
||||
val distance = euclidean(embedding, student.embedding)
|
||||
if (distance < nearestDistance) {
|
||||
nearest = student
|
||||
nearestDistance = distance
|
||||
}
|
||||
}
|
||||
return if (nearest != null && nearestDistance <= threshold) {
|
||||
RecognitionResult(nearest, nearestDistance)
|
||||
} else {
|
||||
RecognitionResult(null, nearestDistance)
|
||||
}
|
||||
}
|
||||
|
||||
private fun euclidean(a: FloatArray, b: FloatArray): Float {
|
||||
var sum = 0f
|
||||
for (index in 0 until minOf(a.size, b.size)) {
|
||||
val diff = a[index] - b[index]
|
||||
sum += diff * diff
|
||||
}
|
||||
return sqrt(sum)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.example.studentfaceregistry.face
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Rect
|
||||
import com.google.mlkit.vision.common.InputImage
|
||||
import com.google.mlkit.vision.face.Face
|
||||
import com.google.mlkit.vision.face.FaceDetection
|
||||
import com.google.mlkit.vision.face.FaceDetectorOptions
|
||||
import kotlinx.coroutines.tasks.await
|
||||
|
||||
class FaceProcessor(context: Context) : AutoCloseable {
|
||||
private val embedder = FaceEmbedder(context)
|
||||
private val detector = FaceDetection.getClient(
|
||||
FaceDetectorOptions.Builder()
|
||||
.setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_FAST)
|
||||
.setMinFaceSize(0.08f)
|
||||
.enableTracking()
|
||||
.build()
|
||||
)
|
||||
|
||||
suspend fun embedSingleFace(bitmap: Bitmap): FloatArray {
|
||||
val faces = detectFaces(bitmap)
|
||||
require(faces.isNotEmpty()) { "图片中未检测到人脸。" }
|
||||
val largestFace = faces.maxBy { face ->
|
||||
face.boundingBox.width() * face.boundingBox.height()
|
||||
}
|
||||
return embedFace(bitmap, largestFace)
|
||||
}
|
||||
|
||||
suspend fun detectFaces(bitmap: Bitmap): List<Face> {
|
||||
return detector.process(InputImage.fromBitmap(bitmap, 0)).await()
|
||||
}
|
||||
|
||||
fun embedFace(bitmap: Bitmap, face: Face): FloatArray {
|
||||
return embedder.embed(cropFace(bitmap, face.boundingBox))
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
detector.close()
|
||||
embedder.close()
|
||||
}
|
||||
|
||||
private fun cropFace(bitmap: Bitmap, box: Rect): Bitmap {
|
||||
val padding = (maxOf(box.width(), box.height()) * 0.18f).toInt()
|
||||
val left = (box.left - padding).coerceAtLeast(0)
|
||||
val top = (box.top - padding).coerceAtLeast(0)
|
||||
val right = (box.right + padding).coerceAtMost(bitmap.width)
|
||||
val bottom = (box.bottom + padding).coerceAtMost(bitmap.height)
|
||||
return Bitmap.createBitmap(bitmap, left, top, right - left, bottom - top)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.example.studentfaceregistry.face
|
||||
|
||||
import com.example.studentfaceregistry.data.Student
|
||||
|
||||
data class RecognitionResult(
|
||||
val student: Student?,
|
||||
val distance: Float
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.example.studentfaceregistry.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import com.example.studentfaceregistry.face.RecognitionResult
|
||||
|
||||
class OverlayView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : View(context, attrs) {
|
||||
private val boxPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.rgb(37, 99, 235)
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = 5f
|
||||
}
|
||||
private val labelBackgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.argb(220, 15, 23, 42)
|
||||
style = Paint.Style.FILL
|
||||
}
|
||||
private val labelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.WHITE
|
||||
textSize = 52f
|
||||
typeface = android.graphics.Typeface.DEFAULT_BOLD
|
||||
}
|
||||
private var detections: List<DetectionUi> = emptyList()
|
||||
private var imageWidth = 1
|
||||
private var imageHeight = 1
|
||||
|
||||
fun update(items: List<DetectionUi>, width: Int, height: Int) {
|
||||
detections = items
|
||||
imageWidth = width.coerceAtLeast(1)
|
||||
imageHeight = height.coerceAtLeast(1)
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
detections.forEach { item ->
|
||||
val rect = mapRect(item.bounds)
|
||||
canvas.drawRect(rect.left, rect.top, rect.right, rect.bottom, boxPaint)
|
||||
val label = item.label
|
||||
val labelWidth = labelPaint.measureText(label)
|
||||
val top = (rect.top - 68f).coerceAtLeast(0f)
|
||||
canvas.drawRect(rect.left, top, rect.left + labelWidth + 32f, top + 64f, labelBackgroundPaint)
|
||||
canvas.drawText(label, rect.left + 16f, top + 48f, labelPaint)
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapRect(rect: Rect): android.graphics.RectF {
|
||||
val scaleX = width.toFloat() / imageWidth
|
||||
val scaleY = height.toFloat() / imageHeight
|
||||
return android.graphics.RectF(
|
||||
width - rect.right * scaleX,
|
||||
rect.top * scaleY,
|
||||
width - rect.left * scaleX,
|
||||
rect.bottom * scaleY
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class DetectionUi(
|
||||
val bounds: Rect,
|
||||
val result: RecognitionResult
|
||||
) {
|
||||
val label: String
|
||||
get() = result.student?.let { "${it.name} ${it.studentNo}" }
|
||||
?: if (result.distance == Float.MAX_VALUE) "未入库" else "未匹配 %.2f".format(result.distance)
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
package com.example.studentfaceregistry.upload
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import com.example.studentfaceregistry.data.Student
|
||||
import com.example.studentfaceregistry.data.StudentRepository
|
||||
import com.example.studentfaceregistry.face.FaceProcessor
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.Inet4Address
|
||||
import java.net.NetworkInterface
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.zip.ZipInputStream
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
class UploadServer(
|
||||
private val context: Context,
|
||||
private val repository: StudentRepository,
|
||||
private val processorProvider: () -> FaceProcessor?
|
||||
) : AutoCloseable {
|
||||
private var serverSocket: ServerSocket? = null
|
||||
@Volatile private var running = false
|
||||
|
||||
fun start() {
|
||||
if (running) return
|
||||
running = true
|
||||
serverSocket = ServerSocket(8080)
|
||||
thread(name = "upload-server", isDaemon = true) {
|
||||
while (running) {
|
||||
val socket = try {
|
||||
serverSocket?.accept()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: break
|
||||
handle(socket)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun accessUrl(): String {
|
||||
val ip = currentIpAddress() ?: "127.0.0.1"
|
||||
return "http://$ip:8080"
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
running = false
|
||||
runCatching { serverSocket?.close() }
|
||||
}
|
||||
|
||||
private fun handle(socket: Socket) {
|
||||
thread(name = "upload-request", isDaemon = true) {
|
||||
socket.use { client ->
|
||||
val input = BufferedInputStream(client.getInputStream())
|
||||
val output = client.getOutputStream()
|
||||
val request = readHttpRequest(input)
|
||||
when {
|
||||
request.method == "GET" && request.path == "/" -> respondHtml(output)
|
||||
request.method == "POST" && request.path == "/upload" -> handleUpload(request, output)
|
||||
else -> respondText(output, 404, "Not found", "text/plain; charset=utf-8")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleUpload(request: HttpRequest, output: OutputStream) {
|
||||
val processor = processorProvider()
|
||||
if (processor == null) {
|
||||
respondText(output, 503, "Missing facenet.tflite model", "text/plain; charset=utf-8")
|
||||
return
|
||||
}
|
||||
val bodyText = String(request.body, StandardCharsets.UTF_8)
|
||||
var accepted = 0
|
||||
var rejected = 0
|
||||
val notices = mutableListOf<String>()
|
||||
|
||||
runCatching {
|
||||
val payload = JSONObject(bodyText)
|
||||
val zipBase64 = payload.optString("zipBase64", "")
|
||||
if (zipBase64.isNotBlank()) {
|
||||
val zipBytes = Base64.decode(zipBase64, Base64.DEFAULT)
|
||||
processZip(zipBytes, processor) { ok, name, error ->
|
||||
if (ok) {
|
||||
accepted += 1
|
||||
notices += "OK $name"
|
||||
} else {
|
||||
rejected += 1
|
||||
notices += "FAIL $name: $error"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val images = parseImages(payload)
|
||||
images.forEachIndexed { index, item ->
|
||||
processImage(item.fileName.ifBlank { "student_$index.jpg" }, item.base64, processor) { ok, name, error ->
|
||||
if (ok) {
|
||||
accepted += 1
|
||||
notices += "OK $name"
|
||||
} else {
|
||||
rejected += 1
|
||||
notices += "FAIL $name: $error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
respondText(output, 400, """{"accepted":0,"rejected":0,"message":${jsonString(it.message ?: "invalid request")}}""", "application/json; charset=utf-8")
|
||||
return
|
||||
}
|
||||
|
||||
val result = """
|
||||
{"accepted":$accepted,"rejected":$rejected,"message":${jsonString(notices.joinToString("\n"))}}
|
||||
""".trimIndent()
|
||||
respondText(output, 200, result, "application/json; charset=utf-8")
|
||||
}
|
||||
|
||||
private fun respondHtml(output: OutputStream) {
|
||||
val html = """
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>学生头像上传</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; margin: 24px; max-width: 920px; }
|
||||
input, button { font-size: 16px; padding: 10px; margin: 8px 0; }
|
||||
.hint { color: #555; line-height: 1.6; }
|
||||
pre { background: #f4f4f5; padding: 12px; white-space: pre-wrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>学生头像批量上传</h2>
|
||||
<p class="hint">在电脑上选择头像,点击上传后,手机会本地生成人脸特征并保存。文件名建议使用 学号_姓名.jpg。</p>
|
||||
<div>
|
||||
<label>上传文件夹</label><br>
|
||||
<input id="folderFiles" type="file" multiple webkitdirectory directory accept="image/*"><br>
|
||||
<button id="uploadFolderBtn">上传文件夹</button>
|
||||
</div>
|
||||
<div>
|
||||
<label>上传压缩包</label><br>
|
||||
<input id="zipFile" type="file" accept=".zip,application/zip"><br>
|
||||
<button id="uploadZipBtn">上传 zip</button>
|
||||
</div>
|
||||
<pre id="result"></pre>
|
||||
<script>
|
||||
const result = document.getElementById('result');
|
||||
document.getElementById('uploadFolderBtn').onclick = async () => {
|
||||
result.textContent = '上传中...';
|
||||
const files = document.getElementById('folderFiles').files;
|
||||
let ok = 0;
|
||||
let fail = 0;
|
||||
const logs = [];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
result.textContent = `上传中 ${'$'}{i + 1}/${'$'}{files.length}: ${'$'}{file.name}`;
|
||||
const base64 = await toBase64(file);
|
||||
const resp = await fetch('/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ images: [{ fileName: file.name, base64 }] })
|
||||
});
|
||||
const text = await resp.text();
|
||||
logs.push(text);
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
ok += parsed.accepted || 0;
|
||||
fail += parsed.rejected || 0;
|
||||
} catch (e) {
|
||||
fail += 1;
|
||||
}
|
||||
}
|
||||
result.textContent = `完成:成功 ${'$'}{ok},失败 ${'$'}{fail}\n\n` + logs.join('\n');
|
||||
};
|
||||
document.getElementById('uploadZipBtn').onclick = async () => {
|
||||
result.textContent = '上传中...';
|
||||
const file = document.getElementById('zipFile').files[0];
|
||||
if (!file) {
|
||||
result.textContent = '请选择 zip 文件';
|
||||
return;
|
||||
}
|
||||
const base64 = await toBase64(file);
|
||||
const resp = await fetch('/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ zipFileName: file.name, zipBase64: base64 })
|
||||
});
|
||||
result.textContent = await resp.text();
|
||||
};
|
||||
function toBase64(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result).split(',')[1]);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""".trimIndent()
|
||||
respondText(output, 200, html, "text/html; charset=utf-8")
|
||||
}
|
||||
|
||||
private fun parseImages(root: JSONObject): List<UploadedImage> {
|
||||
val array = root.optJSONArray("images") ?: org.json.JSONArray()
|
||||
return buildList {
|
||||
for (index in 0 until array.length()) {
|
||||
val item = array.optJSONObject(index) ?: continue
|
||||
add(
|
||||
UploadedImage(
|
||||
fileName = item.optString("fileName", "student_$index.jpg"),
|
||||
base64 = item.optString("base64", "")
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun processZip(
|
||||
zipBytes: ByteArray,
|
||||
processor: FaceProcessor,
|
||||
report: (ok: Boolean, name: String, error: String?) -> Unit
|
||||
) {
|
||||
ZipInputStream(ByteArrayInputStream(zipBytes)).use { zip ->
|
||||
var entry = zip.nextEntry
|
||||
while (entry != null) {
|
||||
val entryName = entry.name
|
||||
if (!entry.isDirectory && isImageFile(entryName)) {
|
||||
val imageBytes = zip.readBytes()
|
||||
processImageBytes(entryName, imageBytes, processor, report)
|
||||
}
|
||||
zip.closeEntry()
|
||||
entry = zip.nextEntry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun processImage(
|
||||
fileName: String,
|
||||
base64: String,
|
||||
processor: FaceProcessor,
|
||||
report: (ok: Boolean, name: String, error: String?) -> Unit
|
||||
) {
|
||||
runCatching {
|
||||
val imageBytes = Base64.decode(base64, Base64.DEFAULT)
|
||||
processImageBytes(fileName, imageBytes, processor, report)
|
||||
}.onFailure {
|
||||
report(false, fileName, it.message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun processImageBytes(
|
||||
fileName: String,
|
||||
imageBytes: ByteArray,
|
||||
processor: FaceProcessor,
|
||||
report: (ok: Boolean, name: String, error: String?) -> Unit
|
||||
) {
|
||||
runCatching {
|
||||
val bitmap = decodeBitmap(imageBytes)
|
||||
val embedding = runBlocking { processor.embedSingleFace(bitmap) }
|
||||
val (studentNo, name) = parseStudent(fileName)
|
||||
repository.add(
|
||||
Student(
|
||||
studentNo = studentNo,
|
||||
name = name,
|
||||
photoUri = "upload://$fileName",
|
||||
embedding = embedding
|
||||
)
|
||||
)
|
||||
report(true, fileName, null)
|
||||
}.onFailure {
|
||||
report(false, fileName, it.message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isImageFile(name: String): Boolean {
|
||||
val lower = name.lowercase()
|
||||
return lower.endsWith(".jpg") || lower.endsWith(".jpeg") || lower.endsWith(".png") || lower.endsWith(".webp")
|
||||
}
|
||||
|
||||
private fun decodeBitmap(imageBytes: ByteArray): android.graphics.Bitmap {
|
||||
val bounds = BitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = true
|
||||
}
|
||||
BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size, bounds)
|
||||
val maxSide = maxOf(bounds.outWidth, bounds.outHeight).coerceAtLeast(1)
|
||||
var sampleSize = 1
|
||||
while (maxSide / sampleSize > 1280) {
|
||||
sampleSize *= 2
|
||||
}
|
||||
val options = BitmapFactory.Options().apply {
|
||||
inSampleSize = sampleSize
|
||||
inPreferredConfig = android.graphics.Bitmap.Config.RGB_565
|
||||
}
|
||||
return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size, options)
|
||||
?: error("Unable to decode image")
|
||||
}
|
||||
|
||||
private fun parseStudent(filename: String): Pair<String, String> {
|
||||
val base = filename.substringBeforeLast(".")
|
||||
val parts = base.split(Regex("[_\\-\\s]+"), limit = 2).map { it.trim() }
|
||||
return if (parts.size == 2 && parts[0].isNotBlank() && parts[1].isNotBlank()) {
|
||||
parts[0] to parts[1]
|
||||
} else {
|
||||
base to base
|
||||
}
|
||||
}
|
||||
|
||||
private fun readHttpRequest(input: BufferedInputStream): HttpRequest {
|
||||
val headerBytes = ByteArrayOutputStream()
|
||||
var previous = -1
|
||||
var current = -1
|
||||
while (true) {
|
||||
current = input.read()
|
||||
if (current == -1) break
|
||||
headerBytes.write(current)
|
||||
if (previous == '\r'.code && current == '\n'.code) {
|
||||
val bytes = headerBytes.toByteArray()
|
||||
if (bytes.size >= 4 &&
|
||||
bytes[bytes.size - 4] == '\r'.code.toByte() &&
|
||||
bytes[bytes.size - 3] == '\n'.code.toByte() &&
|
||||
bytes[bytes.size - 2] == '\r'.code.toByte() &&
|
||||
bytes[bytes.size - 1] == '\n'.code.toByte()
|
||||
) {
|
||||
break
|
||||
}
|
||||
}
|
||||
previous = current
|
||||
}
|
||||
val headerText = headerBytes.toString(StandardCharsets.ISO_8859_1.name())
|
||||
val lines = headerText.split("\r\n").filter { it.isNotBlank() }
|
||||
val requestLine = lines.firstOrNull().orEmpty().split(" ")
|
||||
val method = requestLine.getOrNull(0).orEmpty()
|
||||
val path = requestLine.getOrNull(1).orEmpty()
|
||||
val contentLength = lines.firstOrNull { it.startsWith("Content-Length:", true) }
|
||||
?.substringAfter(":")?.trim()?.toIntOrNull() ?: 0
|
||||
val body = readExactly(input, contentLength)
|
||||
return HttpRequest(method, path, body)
|
||||
}
|
||||
|
||||
private fun readExactly(input: BufferedInputStream, length: Int): ByteArray {
|
||||
val output = ByteArrayOutputStream(length)
|
||||
var remaining = length
|
||||
val buffer = ByteArray(8192)
|
||||
while (remaining > 0) {
|
||||
val read = input.read(buffer, 0, minOf(buffer.size, remaining))
|
||||
if (read <= 0) break
|
||||
output.write(buffer, 0, read)
|
||||
remaining -= read
|
||||
}
|
||||
return output.toByteArray()
|
||||
}
|
||||
|
||||
private fun respondText(output: OutputStream, code: Int, text: String, contentType: String) {
|
||||
val bytes = text.toByteArray(StandardCharsets.UTF_8)
|
||||
val head = buildString {
|
||||
append("HTTP/1.1 ")
|
||||
append(code)
|
||||
append(" ")
|
||||
append(statusText(code))
|
||||
append("\r\nContent-Type: ")
|
||||
append(contentType)
|
||||
append("\r\nContent-Length: ")
|
||||
append(bytes.size)
|
||||
append("\r\nConnection: close\r\n\r\n")
|
||||
}.toByteArray(StandardCharsets.ISO_8859_1)
|
||||
output.write(head)
|
||||
output.write(bytes)
|
||||
output.flush()
|
||||
}
|
||||
|
||||
private fun statusText(code: Int): String = when (code) {
|
||||
200 -> "OK"
|
||||
404 -> "Not Found"
|
||||
503 -> "Service Unavailable"
|
||||
else -> "OK"
|
||||
}
|
||||
|
||||
private fun currentIpAddress(): String? {
|
||||
return NetworkInterface.getNetworkInterfaces().toList()
|
||||
.flatMap { it.inetAddresses.toList() }
|
||||
.filterIsInstance<Inet4Address>()
|
||||
.firstOrNull { !it.isLoopbackAddress }
|
||||
?.hostAddress
|
||||
}
|
||||
|
||||
private fun jsonString(value: String): String = JSONObject.quote(value)
|
||||
}
|
||||
|
||||
private data class HttpRequest(
|
||||
val method: String,
|
||||
val path: String,
|
||||
val body: ByteArray
|
||||
)
|
||||
|
||||
private data class UploadedImage(
|
||||
val fileName: String,
|
||||
val base64: String
|
||||
)
|
||||
4
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
4
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#1D4ED8" />
|
||||
</shape>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_background" />
|
||||
</adaptive-icon>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_background" />
|
||||
</adaptive-icon>
|
||||
4
app/src/main/res/values/strings.xml
Normal file
4
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">学生人脸识别</string>
|
||||
</resources>
|
||||
8
app/src/main/res/values/styles.xml
Normal file
8
app/src/main/res/values/styles.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
|
||||
<item name="android:fontFamily">sans</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
<item name="android:colorAccent">#2563EB</item>
|
||||
</style>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user