diff --git a/.gitignore b/.gitignore index ecb1d3c..70c4e50 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,4 @@ lint/tmp/ /app/src/androidTest/java/com/jiaoguanyi/appstore/ /adb /app/proguard-rules.pro +/.codebuddy/ \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index b890c74..5fe092b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -29,7 +29,7 @@ android { ndk { //选择要添加的对应 cpu 类型的 .so 库。 - abiFilters 'arm64-v8a' + abiFilters 'arm64-v8a', 'armeabi-v7a' // 还可以添加 'armeabi', 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64', 'mips', 'mips64' } @@ -736,13 +736,25 @@ dependencies { implementation files('libs/BaiduTraceSDK_v3_1_10.jar') + // 基础注解库 + implementation "androidx.annotation:annotation:1.10.0" + // 包含 OptIn 实验性 Lint 规则检测的核心库 + implementation "androidx.annotation:annotation-experimental:1.1.0" implementation 'androidx.recyclerview:recyclerview:1.2.1' implementation 'androidx.cardview:cardview:1.0.0' implementation 'androidx.multidex:multidex:2.0.1' implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0' - implementation 'androidx.appcompat:appcompat:1.3.1' implementation 'androidx.constraintlayout:constraintlayout:2.0.4' + implementation "androidx.lifecycle:lifecycle-service:2.3.1" + + // CameraX 核心库及生命周期绑定 + def camerax_version = "1.0.0" // 请根据实际情况选择稳定版本 + implementation "androidx.camera:camera-core:${camerax_version}" + implementation "androidx.camera:camera-camera2:${camerax_version}" + implementation "androidx.camera:camera-lifecycle:${camerax_version}" + implementation "androidx.camera:camera-view:1.0.0-alpha23" + testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' @@ -776,7 +788,8 @@ dependencies { //Google implementation 'com.google.code.gson:gson:2.9.0' implementation 'com.google.zxing:core:3.5.0' -// implementation 'com.google.mlkit:face-detection:16.1.7' + implementation 'com.google.mlkit:face-detection:16.1.5' + //图片加载框架 implementation 'com.github.bumptech.glide:glide:4.13.2' annotationProcessor 'com.github.bumptech.glide:compiler:4.13.2' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index baee31b..23338b0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,6 +11,10 @@ + + + + @@ -36,7 +40,6 @@ - @@ -92,6 +95,8 @@ + + @@ -168,6 +173,7 @@ android:requestLegacyExternalStorage="true" android:supportsRtl="true" android:theme="@style/AppTheme"> + @@ -183,7 +189,6 @@ android:icon="@drawable/com_system_huyan" android:label="护眼助手" android:launchMode="singleInstance" - android:screenOrientation="userLandscape" android:theme="@style/AppTheme"> @@ -233,7 +238,26 @@ android:launchMode="singleTask" android:screenOrientation="behind" android:theme="@style/DialogCloseOnTouchOutside" /> + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/aoleyun/sn/activity/CameraDetectionActivity.java b/app/src/main/java/com/aoleyun/sn/activity/CameraDetectionActivity.java new file mode 100644 index 0000000..5b27512 --- /dev/null +++ b/app/src/main/java/com/aoleyun/sn/activity/CameraDetectionActivity.java @@ -0,0 +1,253 @@ +package com.aoleyun.sn.activity; + +import android.Manifest; +import android.content.pm.PackageManager; +import android.graphics.PointF; +import android.media.Image; +import android.os.Bundle; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.OptIn; +import androidx.appcompat.app.AppCompatActivity; +import androidx.camera.core.CameraSelector; +import androidx.camera.core.ExperimentalGetImage; +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.app.ActivityCompat; +import androidx.core.content.ContextCompat; + +import com.aoleyun.sn.R; +import com.aoleyun.sn.detection.DistanceReminderManager; +import com.aoleyun.sn.detection.ShakeDetectionManager; +import com.aoleyun.sn.view.FaceOverlayView; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.mlkit.vision.common.InputImage; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class CameraDetectionActivity extends AppCompatActivity { + + private static final String TAG = "CameraInputImage"; + private static final int PERMISSION_REQUEST_CAMERA = 1001; + + private ListenableFuture cameraProviderFuture; + private ExecutorService cameraExecutor; + private PreviewView previewView; + private FaceOverlayView faceOverlay; + private android.widget.TextView tvStepInfo; + private int currentImageWidth; + private int currentImageHeight; + + public static boolean isShowing = false; + + private DistanceReminderManager reminderManager; + private ShakeDetectionManager shakeDetectionManager; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + // 如果你需要预览界面,可以使用 xml 里的 PreviewView,这里仅演示后台获取数据流 + setContentView(R.layout.activity_camera_detection); + previewView = findViewById(R.id.previewView); + faceOverlay = findViewById(R.id.faceOverlay); + tvStepInfo = findViewById(R.id.tvStepInfo); + + // 初始化用于处理图像分析的单线程线程池,避免阻塞主线程 + cameraExecutor = Executors.newSingleThreadExecutor(); + + // 检查并请求相机权限 + if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) + == PackageManager.PERMISSION_GRANTED) { + startCamera(); + } else { + ActivityCompat.requestPermissions(this, + new String[]{Manifest.permission.CAMERA}, PERMISSION_REQUEST_CAMERA); + } + + reminderManager = new DistanceReminderManager(new DistanceReminderManager.OnDistanceAlertListener() { + @Override + public void onTooClose() { + // 触发提醒:弹出 Activity、播放提示音或让屏幕变模糊 + WarningDialogActivity.start(CameraDetectionActivity.this, WarningDialogActivity.TYPE_DISTANCE); + } + + @Override + public void onDistanceUpdate(double distance, PointF leftEye, PointF rightEye) { + // 传给 Overlay 进行比例换算 + faceOverlay.updateData(distance, leftEye, rightEye, currentImageWidth, currentImageHeight); + } + }); + + // 初始化抖动(走路)检测 + shakeDetectionManager = new ShakeDetectionManager(this, new ShakeDetectionManager.OnShakeListener() { + @Override + public void onShakeDetected() { + // 边走路边看平板提醒 + WarningDialogActivity.start(CameraDetectionActivity.this, WarningDialogActivity.TYPE_SHAKE); + } + + @Override + public void onStatusUpdate(String info) { + runOnUiThread(() -> { + if (tvStepInfo != null) { + tvStepInfo.setText(info); + } + }); + } + }); + } + + /** + * 初始化并启动 CameraX + */ + private void startCamera() { + cameraProviderFuture = ProcessCameraProvider.getInstance(this); + cameraProviderFuture.addListener(() -> { + try { + ProcessCameraProvider cameraProvider = cameraProviderFuture.get(); + bindCameraUseCases(cameraProvider); + } catch (ExecutionException | InterruptedException e) { + Log.e(TAG, "初始化 CameraProvider 失败", e); + } + }, ContextCompat.getMainExecutor(this)); + } + + /** + * 配置并绑定相机用例 + */ + private void bindCameraUseCases(@NonNull ProcessCameraProvider cameraProvider) { + // 1. 选择【前置摄像头】 + CameraSelector cameraSelector = new CameraSelector.Builder() + .requireLensFacing(CameraSelector.LENS_FACING_FRONT) + .build(); + + // 2. 创建预览用例 + Preview preview = new Preview.Builder().build(); + preview.setSurfaceProvider(previewView.getSurfaceProvider()); + + // 3. 创建图像分析用例(ImageAnalysis) + ImageAnalysis imageAnalysis = new ImageAnalysis.Builder() + // 设置背压策略:如果图像处理太慢,丢弃旧帧,保留最新的一帧(防积压爆内存) + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + // 建议设置输出的目标分辨率(例如 480x640 或 720x1280),对于人脸/距离检测绰绰有余,且能提升运行速度 + .setTargetResolution(new android.util.Size(640, 480)) + .build(); + + // 3. 设置分析器,在这里把每一帧转换为 InputImage + imageAnalysis.setAnalyzer(cameraExecutor, new ImageAnalysis.Analyzer() { + private long lastAnalyzedTimestamp = 0L; + + @OptIn(markerClass = ExperimentalGetImage.class) + @Override + public void analyze(@NonNull ImageProxy imageProxy) { + // 【性能调优】如果是防近视提醒,不需要一秒分析30次。 + // 这里限制为每 1000 毫秒(1秒)才执行一次算法 + long currentTimestamp = System.currentTimeMillis(); + if (currentTimestamp - lastAnalyzedTimestamp < 1000) { + imageProxy.close(); // 必须关闭,否则不会收到下一帧 + return; + } + lastAnalyzedTimestamp = currentTimestamp; + + // 从 imageProxy 中提取原生的 android.media.Image + Image mediaImage = imageProxy.getImage(); + if (mediaImage != null) { + // 获取图像的旋转角度(前置摄像头和设备方向有关,ML Kit 需要这个角度来正确旋转识别) + int rotationDegrees = imageProxy.getImageInfo().getRotationDegrees(); + + // 【核心代码】将原生 Image 转换为 ML Kit 的 InputImage + InputImage inputImage = InputImage.fromMediaImage(mediaImage, rotationDegrees); + + // 此时已经成功拿到 InputImage,可以传给你的算法或 ML Kit 识别器 + // 【优化】传入 imageProxy 以便在异步任务完成后关闭它 + processInputImage(inputImage, imageProxy); + } else { + // 如果获取不到 Image,也需要关闭 imageProxy + imageProxy.close(); + } + } + }); + + try { + // 在绑定前先解绑所有用例 + cameraProvider.unbindAll(); + + // 将前置摄像头、预览和图像分析用例绑定到当前 Activity 的生命周期 + cameraProvider.bindToLifecycle(this, cameraSelector, preview, imageAnalysis); + + } catch (Exception e) { + Log.e(TAG, "绑定相机用例失败", e); + } + } + + /** + * 业务处理方法 + */ + private void processInputImage(InputImage inputImage, ImageProxy imageProxy) { + // 在这里调用你的人脸检测或者瞳距计算算法 + // Log.d(TAG, "成功获取到 InputImage,尺寸: " + inputImage.getWidth() + "x" + inputImage.getHeight()); + + // 记录当前图像尺寸,供 UI 绘制缩放使用 + // 重要:InputImage 的 getWidth/Height 返回的是原始 buffer 尺寸, + // 而 ML Kit 的坐标是基于旋转后的。如果是 90/270 度旋转,宽高需要对调。 + int rotation = imageProxy.getImageInfo().getRotationDegrees(); + if (rotation == 90 || rotation == 270) { + currentImageWidth = inputImage.getHeight(); + currentImageHeight = inputImage.getWidth(); + } else { + currentImageWidth = inputImage.getWidth(); + currentImageHeight = inputImage.getHeight(); + } + + // 比如传入上一问提到的人脸识别: + // 【关键修复】在 ML Kit 任务完成后再关闭 imageProxy,避免 Internal error (ModelResource.zza) + reminderManager.checkDistance(inputImage) + .addOnCompleteListener(task -> imageProxy.close()); + } + + @Override + protected void onStart() { + super.onStart(); + isShowing = true; + if (shakeDetectionManager != null) { + shakeDetectionManager.start(); + } + } + + @Override + protected void onStop() { + super.onStop(); + isShowing = false; + if (shakeDetectionManager != null) { + shakeDetectionManager.stop(); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + // 关闭线程池,防止内存泄漏 + if (cameraExecutor != null) { + cameraExecutor.shutdown(); + } + } + + // 处理权限回调 + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (requestCode == PERMISSION_REQUEST_CAMERA) { + if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + startCamera(); + } else { + WarningDialogActivity.start(this, WarningDialogActivity.TYPE_DISTANCE, "需要相机权限来检测距离"); + } + } + } +} diff --git a/app/src/main/java/com/aoleyun/sn/activity/EyeProtectionActivity.java b/app/src/main/java/com/aoleyun/sn/activity/EyeProtectionActivity.java index 95a348b..31fd6d2 100644 --- a/app/src/main/java/com/aoleyun/sn/activity/EyeProtectionActivity.java +++ b/app/src/main/java/com/aoleyun/sn/activity/EyeProtectionActivity.java @@ -1,20 +1,28 @@ package com.aoleyun.sn.activity; +import android.content.Intent; import android.provider.Settings; import android.view.View; -import androidx.constraintlayout.widget.ConstraintLayout; import androidx.databinding.DataBindingUtil; +import com.aoleyun.sn.BuildConfig; import com.aoleyun.sn.R; import com.aoleyun.sn.base.BaseDataBindingActivity; -import com.aoleyun.sn.base.mvp.BaseMvpActivity; +import com.aoleyun.sn.comm.CommonConfig; import com.aoleyun.sn.databinding.ActivityEyeProtectionBinding; +import com.aoleyun.sn.detection.SensorCheckUtil; +import com.aoleyun.sn.service.DetectionService; +import com.aoleyun.sn.service.ShakeDetectionService; import com.aoleyun.sn.view.ToggleButton; import com.hjq.toast.Toaster; +import com.tencent.mmkv.MMKV; public class EyeProtectionActivity extends BaseDataBindingActivity { + private MMKV mMMKV = MMKV.mmkvWithID(CommonConfig.MMKV_ID, MMKV.MULTI_PROCESS_MODE); + + private ActivityEyeProtectionBinding mBinding; @@ -26,6 +34,7 @@ public class EyeProtectionActivity extends BaseDataBindingActivity { @Override protected void initDataBinding() { mBinding = DataBindingUtil.setContentView(this, R.layout.activity_eye_protection); + mBinding.setClick(new BtnClick()); } @Override @@ -65,6 +74,30 @@ public class EyeProtectionActivity extends BaseDataBindingActivity { } }); + mBinding.toggleButton6.setOnToggleChanged(new ToggleButton.OnToggleChanged() { + @Override + public void onToggle(boolean on) { + mMMKV.encode(CommonConfig.SHAKE_REMINDER, on ? 1 : 0); + if (on) { + startService(new Intent(EyeProtectionActivity.this, ShakeDetectionService.class)); + } else { + stopService(new Intent(EyeProtectionActivity.this, ShakeDetectionService.class)); + } + } + }); + + mBinding.toggleButton7.setOnToggleChanged(new ToggleButton.OnToggleChanged() { + @Override + public void onToggle(boolean on) { + mMMKV.encode(CommonConfig.EYE_TO_SCREEN_DISTANCE_REMINDER, on ? 1 : 0); + if (on) { + startService(new Intent(EyeProtectionActivity.this, DetectionService.class)); + } else { + stopService(new Intent(EyeProtectionActivity.this, DetectionService.class)); + } + } + }); + mBinding.clExit.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { @@ -94,5 +127,29 @@ public class EyeProtectionActivity extends BaseDataBindingActivity { } else { mBinding.toggleButton2.setToggleOff(); } + + int shakeReminder = mMMKV.decodeInt(CommonConfig.SHAKE_REMINDER, 0); + if (shakeReminder == 1) { + mBinding.toggleButton6.setToggleOn(); + } else { + mBinding.toggleButton6.setToggleOff(); + } + + int reminder = mMMKV.decodeInt(CommonConfig.EYE_TO_SCREEN_DISTANCE_REMINDER, 0); + if (reminder == 1) { + mBinding.toggleButton7.setToggleOn(); + } else { + mBinding.toggleButton7.setToggleOff(); + } + } + + public class BtnClick { + public void onDistanceClick(View view) { + if (BuildConfig.DEBUG) { + if (!SensorCheckUtil.hasProximitySensor(EyeProtectionActivity.this)) { + startActivity(new Intent(EyeProtectionActivity.this, CameraDetectionActivity.class)); + } + } + } } } diff --git a/app/src/main/java/com/aoleyun/sn/activity/WarningDialogActivity.java b/app/src/main/java/com/aoleyun/sn/activity/WarningDialogActivity.java new file mode 100644 index 0000000..eee3e12 --- /dev/null +++ b/app/src/main/java/com/aoleyun/sn/activity/WarningDialogActivity.java @@ -0,0 +1,85 @@ +package com.aoleyun.sn.activity; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; + +import com.aoleyun.sn.R; + +public class WarningDialogActivity extends AppCompatActivity { + + public static final String EXTRA_TYPE = "extra_type"; + public static final String EXTRA_MESSAGE = "extra_message"; + public static final int TYPE_DISTANCE = 0; + public static final int TYPE_SHAKE = 1; + + private final Handler handler = new Handler(Looper.getMainLooper()); + private final Runnable finishRunnable = this::finish; + + public static void start(Context context, int type) { + start(context, type, null); + } + + public static void start(Context context, int type, String message) { + Intent intent = new Intent(context, WarningDialogActivity.class); + intent.putExtra(EXTRA_TYPE, type); + if (message != null) { + intent.putExtra(EXTRA_MESSAGE, message); + } + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP); + context.startActivity(intent); + } + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_warning_dialog); + + findViewById(R.id.iv_close).setOnClickListener(v -> finish()); + findViewById(R.id.btn_confirm).setOnClickListener(v -> finish()); + + updateUI(getIntent()); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + updateUI(intent); + } + + private void updateUI(Intent intent) { + int type = intent.getIntExtra(EXTRA_TYPE, TYPE_DISTANCE); + String customMessage = intent.getStringExtra(EXTRA_MESSAGE); + + ImageView ivIcon = findViewById(R.id.iv_warning_icon); + TextView tvTitle = findViewById(R.id.tv_title); + TextView tvMessage = findViewById(R.id.tv_message); + + if (type == TYPE_SHAKE) { + ivIcon.setImageResource(R.drawable.ic_warning_shake); + tvTitle.setText("检测到抖动"); + tvMessage.setText(customMessage != null ? customMessage : "设备正在抖动,请确认是否为误触"); + } else { + ivIcon.setImageResource(R.drawable.ic_warning); + tvTitle.setText("距离太近"); + tvMessage.setText(customMessage != null ? customMessage : "请保持安全距离,保护您的视力"); + } + + handler.removeCallbacks(finishRunnable); + handler.postDelayed(finishRunnable, 3000); + } + + @Override + public void finish() { + super.finish(); + overridePendingTransition(0, android.R.anim.fade_out); + } +} diff --git a/app/src/main/java/com/aoleyun/sn/activity/main/MainViewModel.java b/app/src/main/java/com/aoleyun/sn/activity/main/MainViewModel.java index 05e82e5..60faea9 100644 --- a/app/src/main/java/com/aoleyun/sn/activity/main/MainViewModel.java +++ b/app/src/main/java/com/aoleyun/sn/activity/main/MainViewModel.java @@ -198,17 +198,13 @@ public class MainViewModel extends BaseViewModel> checkDistance(InputImage image) { + return detector.process(image) + .addOnSuccessListener(faces -> { + boolean faceFound = false; + for (Face face : faces) { + // 获取左眼和右眼的关键点 + FaceLandmark leftEye = face.getLandmark(FaceLandmark.LEFT_EYE); + FaceLandmark rightEye = face.getLandmark(FaceLandmark.RIGHT_EYE); + + if (leftEye != null && rightEye != null) { + faceFound = true; + PointF leftEyePos = leftEye.getPosition(); + PointF rightEyePos = rightEye.getPosition(); + + // 计算两眼之间的像素距离 (勾股定理) + double eyeDistance = Math.sqrt( + Math.pow(rightEyePos.x - leftEyePos.x, 2) + + Math.pow(rightEyePos.y - leftEyePos.y, 2) + ); + + Log.e(TAG, "checkDistance: 当前瞳距像素值: " + eyeDistance + ", 图片分辨率: " + image.getWidth() + "x" + image.getHeight()); + + if (listener != null) { + listener.onDistanceUpdate(eyeDistance, leftEyePos, rightEyePos); + } + + // 计算归一化距离。 + // 【优化】为了解决横屏判断距离比竖屏短的问题,我们使用图像高度(image.getHeight())作为基准。 + // 在大多数传感器上,横屏时的高度 FOV 与竖屏时的宽度像素比例更接近, + // 使用高度作为分母可以有效补偿横竖屏切换时的像素密度差异,使判断距离趋于一致。 + double normalizedDistance = eyeDistance / image.getHeight(); + + Log.e(TAG, "checkDistance: 归一化距离 (基于高度): " + normalizedDistance + ", 阈值: " + TOO_CLOSE_RATIO_THRESHOLD); + + // 如果归一化距离大于阈值,说明离得太近了 + if (normalizedDistance > TOO_CLOSE_RATIO_THRESHOLD) { + if (listener != null) { + listener.onTooClose(); + } + } + } + } + if (!faceFound && listener != null) { + listener.onDistanceUpdate(0, null, null); + } + }) + .addOnFailureListener(e -> Log.e(TAG, "人脸检测失败", e)); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aoleyun/sn/detection/SensorCheckUtil.java b/app/src/main/java/com/aoleyun/sn/detection/SensorCheckUtil.java new file mode 100644 index 0000000..8af703f --- /dev/null +++ b/app/src/main/java/com/aoleyun/sn/detection/SensorCheckUtil.java @@ -0,0 +1,42 @@ +package com.aoleyun.sn.detection; + +import android.content.Context; +import android.hardware.Sensor; +import android.hardware.SensorManager; +import android.util.Log; + +public class SensorCheckUtil { + + /** + * 检测设备是否有距离感应器 + * + * @param context 上下文 + * @return true 有距离感应器;false 没有 + */ + public static boolean hasProximitySensor(Context context) { + // 1. 获取系统的传感器管理器 + SensorManager sensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE); + + if (sensorManager == null) { + return false; + } + + // 2. 获取默认的距离传感器 + Sensor proximitySensor = sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY); + + // 3. 判断是否为空 + if (proximitySensor != null) { + Log.e("SensorCheck", "找到距离感应器: " + proximitySensor.getName()); + + String name = proximitySensor.getName(); // 传感器名称 + String vendor = proximitySensor.getVendor(); // 厂商 + float maxRange = proximitySensor.getMaximumRange(); // 最大测量距离(通常厘米为单位,很多只有 0.0 或 5.0) + + Log.e("SensorDetails", "名称: " + name + ", 厂家: " + vendor + ", 最大范围: " + maxRange); + return true; + } else { + Log.e("SensorCheck", "此设备没有距离感应器硬件"); + return false; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aoleyun/sn/detection/ShakeDetectionManager.java b/app/src/main/java/com/aoleyun/sn/detection/ShakeDetectionManager.java new file mode 100644 index 0000000..f85aefc --- /dev/null +++ b/app/src/main/java/com/aoleyun/sn/detection/ShakeDetectionManager.java @@ -0,0 +1,227 @@ +package com.aoleyun.sn.detection; + +import android.content.Context; +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.hardware.SensorEventListener; +import android.hardware.SensorManager; +import android.os.Build; +import android.util.Log; + +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; +import java.util.Queue; + +/** + * 抖动检测管理类(用于实现边走路边看平板的护眼提醒) + * 优先使用系统步数检测器(Step Detector),若硬件不支持则自动回退到线性加速度方差算法。 + */ +public class ShakeDetectionManager implements SensorEventListener { + private static final String TAG = "ShakeDetection"; + private SensorManager sensorManager; + private Sensor stepSensor; + private Sensor accelSensor; + private OnShakeListener listener; + + // --- 方案一:步数检测相关参数 --- + private final List stepTimestamps = new ArrayList<>(); + // 判定为“正在走路”的阈值:5秒内走了 4 步以上 + private static final long STEP_TIME_WINDOW = 5000; + private static final int STEP_THRESHOLD = 4; + + // --- 方案二:加速度方差相关参数 --- + private final Queue magnitudeWindow = new LinkedList<>(); + // 采样窗口大小:1.5 秒大约需要 25 个样本 + private static final int VARIANCE_WINDOW_SIZE = 25; + // 提高方差阈值:正常的拿起放下抖动通常在 1.0~1.8 之间,我们将阈值提高到 2.0 + private static final float VARIANCE_THRESHOLD = 2.0f; + // 增加评估频率控制:每 400ms 评估一次方差 + private long lastVarianceEvalTime = 0; + private static final long VARIANCE_EVAL_INTERVAL = 400; + // 持续检测到抖动的评估次数:连续 4 次评估达标才判定(400ms * 4 = 1.6秒的持续抖动) + private int consecutiveShakeCount = 0; + private static final int CONSECUTIVE_THRESHOLD = 4; + + public interface OnShakeListener { + /** + * 当检测到持续抖动(疑似走路)时回调 + */ + void onShakeDetected(); + + /** + * 实时步数或抖动强度更新 + * + * @param info 描述信息(如 "步数: 3" 或 "抖动方差: 1.5") + */ + default void onStatusUpdate(String info) { + } + } + + public ShakeDetectionManager(Context context, OnShakeListener listener) { + this.listener = listener; + sensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE); + if (sensorManager != null) { + // 1. 尝试获取步数检测传感器 (最精准、最省电) + stepSensor = sensorManager.getDefaultSensor(Sensor.TYPE_STEP_DETECTOR); + if (stepSensor != null) { + Log.d(TAG, "检测到硬件步数检测器: " + stepSensor.getName()); + } else { + Log.d(TAG, "无硬件步数检测器: "); + } + + // 2. 获取线性加速度计作为备选 (排除重力干扰) + accelSensor = sensorManager.getDefaultSensor(Sensor.TYPE_LINEAR_ACCELERATION); + if (accelSensor != null) { + Log.d(TAG, "检测到线性加速度计: " + accelSensor.getName()); + } else { + // 如果没有线性加速度计,降级使用普通加速度计 + accelSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER); + if (accelSensor != null) { + Log.d(TAG, "未检测到线性加速度计,降级使用普通加速度计: " + accelSensor.getName()); + } + } + } + + if (stepSensor == null && accelSensor == null) { + Log.e(TAG, "错误:该设备不支持任何步数或加速度传感器,抖动检测功能将失效。"); + } + } + + public void start() { + if (sensorManager == null) return; + + // 优先使用步数检测 + if (stepSensor != null) { + Log.i(TAG, "启动检测:优先模式 [步数检测器]"); + sensorManager.registerListener(this, stepSensor, SensorManager.SENSOR_DELAY_NORMAL); + } else if (accelSensor != null) { + Log.i(TAG, "启动检测:回退模式 [加速度方差算法]"); + sensorManager.registerListener(this, accelSensor, SensorManager.SENSOR_DELAY_UI); + } + } + + public void stop() { + Log.d(TAG, "停止检测,重置状态。"); + if (sensorManager != null) { + sensorManager.unregisterListener(this); + } + stepTimestamps.clear(); + magnitudeWindow.clear(); + consecutiveShakeCount = 0; + } + + @Override + public void onSensorChanged(SensorEvent event) { + int sensorType = event.sensor.getType(); + + if (sensorType == Sensor.TYPE_STEP_DETECTOR) { + handleStepDetection(); + } else if (sensorType == Sensor.TYPE_LINEAR_ACCELERATION || sensorType == Sensor.TYPE_ACCELEROMETER) { + handleAccelDetection(event); + } + } + + /** + * 方案一:基于步数检测器的算法 + */ + private void handleStepDetection() { + long currentTime = System.currentTimeMillis(); + stepTimestamps.add(currentTime); + + // 清除超出时间窗口的历史数据 + long windowStartTime = currentTime - STEP_TIME_WINDOW; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stepTimestamps.removeIf(timestamp -> timestamp < windowStartTime); + } else { + // 兼容低版本 + while (!stepTimestamps.isEmpty() && stepTimestamps.get(0) < windowStartTime) { + stepTimestamps.remove(0); + } + } + + Log.v(TAG, "Step Detector: 检测到步伐,当前窗口步数=" + stepTimestamps.size()); + if (listener != null) { + listener.onStatusUpdate("窗口步数: " + stepTimestamps.size()); + } + + // 如果在窗口内连续检测到足够步数 + if (stepTimestamps.size() >= STEP_THRESHOLD) { + Log.w(TAG, "检测到用户正在走路 (步数模式)"); + triggerCallback(); + stepTimestamps.clear(); + } + } + + /** + * 方案二:基于加速度方差的算法 + */ + private void handleAccelDetection(SensorEvent event) { + float x = event.values[0]; + float y = event.values[1]; + float z = event.values[2]; + + // 计算合加速度模长 + float magnitude; + if (event.sensor.getType() == Sensor.TYPE_LINEAR_ACCELERATION) { + magnitude = (float) Math.sqrt(x * x + y * y + z * z); + } else { + // 普通加速度计减去重力近似值 + magnitude = (float) Math.abs(Math.sqrt(x * x + y * y + z * z) - 9.8); + } + + magnitudeWindow.add(magnitude); + if (magnitudeWindow.size() > VARIANCE_WINDOW_SIZE) { + magnitudeWindow.poll(); + } + + // 窗口填满后按固定频率计算方差 + long currentTime = System.currentTimeMillis(); + if (magnitudeWindow.size() == VARIANCE_WINDOW_SIZE && (currentTime - lastVarianceEvalTime > VARIANCE_EVAL_INTERVAL)) { + lastVarianceEvalTime = currentTime; + double variance = calculateVariance(magnitudeWindow); + + if (variance > VARIANCE_THRESHOLD) { + consecutiveShakeCount++; + String info = String.format("抖动方差: %.2f, 连续计数: %d", variance, consecutiveShakeCount); + Log.d(TAG, info); + if (listener != null) { + listener.onStatusUpdate(info); + } + if (consecutiveShakeCount >= CONSECUTIVE_THRESHOLD) { + Log.w(TAG, "检测到用户正在走路 (加速度方差模式)"); + triggerCallback(); + consecutiveShakeCount = 0; + magnitudeWindow.clear(); + } + } else { + // 没达到阈值时,清空计数,要求必须是连续的抖动 + consecutiveShakeCount = 0; + } + } + } + + private double calculateVariance(Queue queue) { + double sum = 0; + for (float val : queue) { + sum += val; + } + double mean = sum / queue.size(); + + double temp = 0; + for (float val : queue) { + temp += (val - mean) * (val - mean); + } + return temp / queue.size(); + } + + private void triggerCallback() { + if (listener != null) { + listener.onShakeDetected(); + } + } + + @Override + public void onAccuracyChanged(Sensor sensor, int accuracy) { + } +} diff --git a/app/src/main/java/com/aoleyun/sn/service/DetectionService.java b/app/src/main/java/com/aoleyun/sn/service/DetectionService.java new file mode 100644 index 0000000..ff2912c --- /dev/null +++ b/app/src/main/java/com/aoleyun/sn/service/DetectionService.java @@ -0,0 +1,316 @@ +package com.aoleyun.sn.service; + +import android.content.Context; +import android.content.Intent; +import android.graphics.PointF; +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.hardware.SensorEventListener; +import android.hardware.SensorManager; +import android.media.Image; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.view.OrientationEventListener; +import android.view.Surface; +import android.view.WindowManager; + +import androidx.annotation.NonNull; +import androidx.annotation.OptIn; +import androidx.camera.core.CameraSelector; +import androidx.camera.core.ExperimentalGetImage; +import androidx.camera.core.ImageAnalysis; +import androidx.camera.core.ImageProxy; +import androidx.camera.lifecycle.ProcessCameraProvider; +import androidx.core.content.ContextCompat; +import androidx.lifecycle.LifecycleService; + +import com.aoleyun.sn.activity.CameraDetectionActivity; +import com.aoleyun.sn.activity.WarningDialogActivity; +import com.aoleyun.sn.detection.DistanceReminderManager; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.mlkit.vision.common.InputImage; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * 后台检测服务,用于持续监控人眼距离 + * 移植自 CameraDetectionActivity + */ +public class DetectionService extends LifecycleService { + private static final String TAG = "DetectionService"; + + private ListenableFuture cameraProviderFuture; + private ExecutorService cameraExecutor; + private DistanceReminderManager reminderManager; + private ImageAnalysis imageAnalysis; + private OrientationEventListener orientationEventListener; + + private SensorManager sensorManager; + private Sensor proximitySensor; + + private long lastAnalyzedTimestamp = 0L; + private final Handler watchdogHandler = new Handler(Looper.getMainLooper()); + private static final long WATCHDOG_INTERVAL = 10000; // 10秒检测一次 + + @Override + public void onCreate() { + super.onCreate(); + Log.d(TAG, "onCreate"); + + // 初始化用于处理图像分析的单线程线程池 + cameraExecutor = Executors.newSingleThreadExecutor(); + + // 初始化距离检测管理器 + reminderManager = new DistanceReminderManager(new DistanceReminderManager.OnDistanceAlertListener() { + @Override + public void onTooClose() { + // 触发提醒 + WarningDialogActivity.start(DetectionService.this, WarningDialogActivity.TYPE_DISTANCE); + } + + @Override + public void onDistanceUpdate(double distance, PointF leftEye, PointF rightEye) { + // 后台服务通常不需要更新 UI Overlay,如果需要可以将数据通过 LiveEventBus 或广播发送出去 + } + }); + + // 启动相机(系统应用假设已获得权限) + startCamera(); + + // 监听屏幕旋转,优化检测准确性 + initOrientationListener(); + + // 启动看门狗,定时检查摄像头状态 + startWatchdog(); + + // 初始化传感器检测 + initProximitySensor(); + } + + private void initProximitySensor() { + sensorManager = (SensorManager) getSystemService(Context.SENSOR_SERVICE); + if (sensorManager != null) { + proximitySensor = sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY); + if (proximitySensor != null) { + sensorManager.registerListener(proximityListener, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL); + Log.d(TAG, "已注册距离传感器监听"); + } else { + Log.d(TAG, "未找到距离传感器"); + } + } + } + + private final SensorEventListener proximityListener = new SensorEventListener() { + @Override + public void onSensorChanged(SensorEvent event) { + if (CameraDetectionActivity.isShowing) { + return; + } + if (event.sensor.getType() == Sensor.TYPE_PROXIMITY) { + float distance = event.values[0]; + // 大多数接近传感器是二值的(0或5),或者返回实际厘米数 + // 这里判断如果小于最大范围则认为过近 + if (distance < proximitySensor.getMaximumRange()) { + Log.d(TAG, "距离传感器检测到距离过近: " + distance); +// WarningDialogActivity.start(DetectionService.this, WarningDialogActivity.TYPE_DISTANCE); + } + } + } + + @Override + public void onAccuracyChanged(Sensor sensor, int accuracy) { + } + }; + + private void initOrientationListener() { + orientationEventListener = new OrientationEventListener(this) { + @Override + public void onOrientationChanged(int orientation) { + if (orientation == ORIENTATION_UNKNOWN) return; + + // 将角度转换为 Surface.ROTATION + int rotation; + if (orientation >= 45 && orientation < 135) { + rotation = Surface.ROTATION_270; + } else if (orientation >= 135 && orientation < 225) { + rotation = Surface.ROTATION_180; + } else if (orientation >= 225 && orientation < 315) { + rotation = Surface.ROTATION_90; + } else { + rotation = Surface.ROTATION_0; + } + + if (imageAnalysis != null) { + imageAnalysis.setTargetRotation(rotation); + } + } + }; + orientationEventListener.enable(); + } + + private void startCamera() { + cameraProviderFuture = ProcessCameraProvider.getInstance(this); + cameraProviderFuture.addListener(() -> { + try { + ProcessCameraProvider cameraProvider = cameraProviderFuture.get(); + bindCameraUseCases(cameraProvider); + } catch (ExecutionException | InterruptedException e) { + Log.e(TAG, "初始化 CameraProvider 失败", e); + } + }, ContextCompat.getMainExecutor(this)); + } + + private void bindCameraUseCases(@NonNull ProcessCameraProvider cameraProvider) { + if (CameraDetectionActivity.isShowing) { + Log.d(TAG, "CameraDetectionActivity 正在显示,跳过后台相机绑定"); + return; + } + // 绑定时更新时间戳,给予缓冲期 + lastAnalyzedTimestamp = System.currentTimeMillis(); + + // 1. 选择【前置摄像头】 + CameraSelector cameraSelector = new CameraSelector.Builder() + .requireLensFacing(CameraSelector.LENS_FACING_FRONT) + .build(); + + // 2. 创建图像分析用例(无需 Preview 用例以节省资源) + ImageAnalysis.Builder builder = new ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .setTargetResolution(new android.util.Size(640, 480)); + + // 设置初始旋转方向 + WindowManager windowManager = (WindowManager) getSystemService(Context.WINDOW_SERVICE); + if (windowManager != null) { + builder.setTargetRotation(windowManager.getDefaultDisplay().getRotation()); + } + + imageAnalysis = builder.build(); + + // 3. 设置分析器 + imageAnalysis.setAnalyzer(cameraExecutor, new ImageAnalysis.Analyzer() { + @OptIn(markerClass = ExperimentalGetImage.class) + @Override + public void analyze(@NonNull ImageProxy imageProxy) { + // 限制频率,每秒执行一次算法 + long currentTimestamp = System.currentTimeMillis(); + if (currentTimestamp - DetectionService.this.lastAnalyzedTimestamp < 1000) { + imageProxy.close(); + return; + } + DetectionService.this.lastAnalyzedTimestamp = currentTimestamp; + + Image mediaImage = imageProxy.getImage(); + if (mediaImage != null) { + int rotationDegrees = imageProxy.getImageInfo().getRotationDegrees(); + InputImage inputImage = InputImage.fromMediaImage(mediaImage, rotationDegrees); + + // 调用检测逻辑 + reminderManager.checkDistance(inputImage) + .addOnCompleteListener(task -> imageProxy.close()); + } else { + imageProxy.close(); + } + } + }); + + try { + // 在绑定前先解绑所有用例 + cameraProvider.unbindAll(); + // 将图像分析用例绑定到 LifecycleService 的生命周期 + cameraProvider.bindToLifecycle(this, cameraSelector, imageAnalysis); + Log.d(TAG, "相机用例绑定成功"); + } catch (Exception e) { + Log.e(TAG, "绑定相机用例失败", e); + } + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + super.onStartCommand(intent, flags, startId); + return START_STICKY; + } + + @Override + public void onDestroy() { + super.onDestroy(); + Log.d(TAG, "onDestroy"); + if (cameraExecutor != null) { + cameraExecutor.shutdown(); + } + if (orientationEventListener != null) { + orientationEventListener.disable(); + } + if (sensorManager != null) { + sensorManager.unregisterListener(proximityListener); + } + stopWatchdog(); + } + + private void startWatchdog() { + watchdogHandler.postDelayed(watchdogRunnable, WATCHDOG_INTERVAL); + } + + private void stopWatchdog() { + watchdogHandler.removeCallbacks(watchdogRunnable); + } + + private final Runnable watchdogRunnable = new Runnable() { + @Override + public void run() { + checkCameraStatus(); + watchdogHandler.postDelayed(this, WATCHDOG_INTERVAL); + } + }; + + /** + * 定时检查摄像头状态,如果被抢占或未正常运行则尝试重新绑定 + */ + private void checkCameraStatus() { + if (CameraDetectionActivity.isShowing) { + // 如果检测界面正在显示,后台服务主动释放摄像头,避免抢占 + releaseCameraIfBound(); + return; + } + + if (cameraProviderFuture == null || !cameraProviderFuture.isDone()) { + return; + } + + try { + ProcessCameraProvider cameraProvider = cameraProviderFuture.get(); + if (cameraProvider == null) return; + + boolean isBound = false; + if (imageAnalysis != null) { + isBound = cameraProvider.isBound(imageAnalysis); + } + + long now = System.currentTimeMillis(); + // 如果未绑定,或者已绑定但超过 15 秒没有收到图像帧(说明摄像头可能被抢占或异常) + if (!isBound || (now - lastAnalyzedTimestamp > 15000)) { + Log.w(TAG, "检测到摄像头未运行或可能被抢占,尝试重新绑定... isBound=" + isBound); + bindCameraUseCases(cameraProvider); + } + } catch (Exception e) { + Log.e(TAG, "检查摄像头状态失败", e); + } + } + + private void releaseCameraIfBound() { + if (cameraProviderFuture == null || !cameraProviderFuture.isDone()) { + return; + } + try { + ProcessCameraProvider cameraProvider = cameraProviderFuture.get(); + if (imageAnalysis != null && cameraProvider.isBound(imageAnalysis)) { + Log.d(TAG, "检测到 CameraDetectionActivity 正在运行,释放后台摄像头绑定"); + cameraProvider.unbind(imageAnalysis); + } + } catch (Exception e) { + Log.e(TAG, "释放摄像头失败", e); + } + } +} diff --git a/app/src/main/java/com/aoleyun/sn/service/ShakeDetectionService.java b/app/src/main/java/com/aoleyun/sn/service/ShakeDetectionService.java new file mode 100644 index 0000000..49352c3 --- /dev/null +++ b/app/src/main/java/com/aoleyun/sn/service/ShakeDetectionService.java @@ -0,0 +1,50 @@ +package com.aoleyun.sn.service; + +import android.app.Service; +import android.content.Intent; +import android.os.IBinder; +import android.util.Log; + +import androidx.annotation.Nullable; + +import com.aoleyun.sn.activity.WarningDialogActivity; +import com.aoleyun.sn.detection.ShakeDetectionManager; + +public class ShakeDetectionService extends Service { + private static final String TAG = "ShakeDetectionService"; + private ShakeDetectionManager shakeDetectionManager; + + @Override + public void onCreate() { + super.onCreate(); + Log.e(TAG, "onCreate: "); + shakeDetectionManager = new ShakeDetectionManager(this, new ShakeDetectionManager.OnShakeListener() { + @Override + public void onShakeDetected() { + // 后台提醒 + WarningDialogActivity.start(ShakeDetectionService.this, WarningDialogActivity.TYPE_SHAKE); + } + }); + shakeDetectionManager.start(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + return START_STICKY; + } + + @Override + public void onDestroy() { + super.onDestroy(); + Log.e(TAG, "onDestroy: "); + if (shakeDetectionManager != null) { + shakeDetectionManager.stop(); + } + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } +} diff --git a/app/src/main/java/com/aoleyun/sn/service/main/MainService.java b/app/src/main/java/com/aoleyun/sn/service/main/MainService.java index dd1a386..195e9d1 100644 --- a/app/src/main/java/com/aoleyun/sn/service/main/MainService.java +++ b/app/src/main/java/com/aoleyun/sn/service/main/MainService.java @@ -49,10 +49,13 @@ import com.aoleyun.sn.bean.ScreenLockState; import com.aoleyun.sn.comm.CommonConfig; import com.aoleyun.sn.comm.JGYActions; import com.aoleyun.sn.comm.PackageNames; +import com.aoleyun.sn.detection.SensorCheckUtil; import com.aoleyun.sn.gson.GsonUtils; import com.aoleyun.sn.hook.AoleyunActivityController; import com.aoleyun.sn.network.NetInterfaceManager; import com.aoleyun.sn.rlog.LogDBManager; +import com.aoleyun.sn.service.DetectionService; +import com.aoleyun.sn.service.ShakeDetectionService; import com.aoleyun.sn.utils.ApkUtils; import com.aoleyun.sn.utils.CacheUtils; import com.aoleyun.sn.utils.ForegroundAppUtil; @@ -481,6 +484,21 @@ public class MainService extends BaseService implements NetworkUtils.OnNetworkSt if (TextUtils.isEmpty(allowAppList)) { Settings.System.putString(getContentResolver(), CommonConfig.SEEWO_APP_SOURCE_INSTALL_WHITELIST, String.join(",", JgyUtils.DEFAULT_APP_SOURCE_WHITE_LIST)); } + + + int reminder = mMMKV.decodeInt(CommonConfig.EYE_TO_SCREEN_DISTANCE_REMINDER, 0); + Log.e(TAG, "onCreate: reminder = " + reminder); + if (reminder == 1) { + if (!SensorCheckUtil.hasProximitySensor(this)) { + startService(new Intent(this, DetectionService.class)); + } + } + + int shakeReminder = mMMKV.decodeInt(CommonConfig.SHAKE_REMINDER, 0); + Log.e(TAG, "onCreate: shakeReminder = " + shakeReminder); + if (shakeReminder == 1) { + startService(new Intent(this, ShakeDetectionService.class)); + } } private void initLiveData() { diff --git a/app/src/main/java/com/aoleyun/sn/service/main/MainServiceModel.java b/app/src/main/java/com/aoleyun/sn/service/main/MainServiceModel.java index 5a1927b..f107cd9 100644 --- a/app/src/main/java/com/aoleyun/sn/service/main/MainServiceModel.java +++ b/app/src/main/java/com/aoleyun/sn/service/main/MainServiceModel.java @@ -238,15 +238,13 @@ public class MainServiceModel extends ViewModel { } public void checkTestUpdate() { - if (!JgyUtils.getInstance().tagEmpty()) { - NetInterfaceManager.getInstance() - .checkTestUpdate(getLifecycle(), new NetInterfaceManager.onCompleteCallback() { - @Override - public void onComplete() { + NetInterfaceManager.getInstance() + .checkTestUpdate(getLifecycle(), new NetInterfaceManager.onCompleteCallback() { + @Override + public void onComplete() { - } - }); - } + } + }); } public void getDeveloper() { diff --git a/app/src/main/java/com/aoleyun/sn/view/FaceOverlayView.java b/app/src/main/java/com/aoleyun/sn/view/FaceOverlayView.java new file mode 100644 index 0000000..f100501 --- /dev/null +++ b/app/src/main/java/com/aoleyun/sn/view/FaceOverlayView.java @@ -0,0 +1,95 @@ +package com.aoleyun.sn.view; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.PointF; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.Nullable; + +import java.util.Locale; + +public class FaceOverlayView extends View { + private Paint pointPaint; + private Paint textPaint; + private PointF leftEye; + private PointF rightEye; + private double distance; + private int imageWidth; + private int imageHeight; + + public FaceOverlayView(Context context) { + super(context); + init(); + } + + public FaceOverlayView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(); + } + + private void init() { + pointPaint = new Paint(); + pointPaint.setColor(Color.RED); + pointPaint.setStyle(Paint.Style.FILL); + pointPaint.setStrokeWidth(10f); + + textPaint = new Paint(); + textPaint.setColor(Color.YELLOW); + textPaint.setTextSize(60f); + textPaint.setFakeBoldText(true); + textPaint.setShadowLayer(5, 0, 0, Color.BLACK); + } + + public void updateData(double distance, PointF leftEye, PointF rightEye, int imageWidth, int imageHeight) { + this.distance = distance; + this.leftEye = leftEye; + this.rightEye = rightEye; + this.imageWidth = imageWidth; + this.imageHeight = imageHeight; + postInvalidate(); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + if (leftEye == null || rightEye == null || imageWidth <= 0 || imageHeight <= 0) { + canvas.drawText("未检测到人脸", 50, 100, textPaint); + return; + } + + // 1. 获取 View 的尺寸 + float viewWidth = getWidth(); + float viewHeight = getHeight(); + + // 2. 计算缩放比例 (适配 PreviewView 的 FILL_CENTER 逻辑) + // FILL_CENTER:取宽高缩放比例中较大的一个,使图像填满 View 且不拉伸,多出的部分被裁剪 + float scale = Math.max(viewWidth / imageWidth, viewHeight / imageHeight); + + // 3. 计算偏移量(由居中裁剪产生) + float postScaleWidth = imageWidth * scale; + float postScaleHeight = imageHeight * scale; + float offsetX = (viewWidth - postScaleWidth) / 2f; + float offsetY = (viewHeight - postScaleHeight) / 2f; + + // 4. 坐标映射并处理镜像 + // CameraX 的 PreviewView 显示前置摄像头时默认是镜像的 + // 映射公式: viewX = viewWidth - (imageX * scale + offsetX) + float lx = viewWidth - (leftEye.x * scale + offsetX); + float ly = leftEye.y * scale + offsetY; + float rx = viewWidth - (rightEye.x * scale + offsetX); + float ry = rightEye.y * scale + offsetY; + + // 绘制眼睛点位 + canvas.drawCircle(lx, ly, 15, pointPaint); + canvas.drawCircle(rx, ry, 15, pointPaint); + + // 绘制连线,更直观 + canvas.drawLine(lx, ly, rx, ry, pointPaint); + + canvas.drawText(String.format(Locale.getDefault(), "瞳距: %.2f", distance), 50, 100, textPaint); + } +} diff --git a/app/src/main/java/com/aoleyun/sn/view/ToggleButton.java b/app/src/main/java/com/aoleyun/sn/view/ToggleButton.java index 76d9c5a..ecc64db 100644 --- a/app/src/main/java/com/aoleyun/sn/view/ToggleButton.java +++ b/app/src/main/java/com/aoleyun/sn/view/ToggleButton.java @@ -28,11 +28,11 @@ public class ToggleButton extends View { /** * 开启颜色 */ - private int onColor = Color.parseColor("#00d56b"); + private int onColor = Color.parseColor("#3a56e1"); /** * 关闭颜色 */ - private int offBorderColor = Color.parseColor("#e7e4e4"); + private int offBorderColor = Color.parseColor("#727272"); /** * 灰色带颜色 */ @@ -56,7 +56,7 @@ public class ToggleButton extends View { /** * 边框大小 */ - private int borderWidth = 2; + private int borderWidth = 3; /** * 垂直中心 */ diff --git a/app/src/main/res/drawable/bg_warning_btn_blue.xml b/app/src/main/res/drawable/bg_warning_btn_blue.xml new file mode 100644 index 0000000..9d65829 --- /dev/null +++ b/app/src/main/res/drawable/bg_warning_btn_blue.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_warning.xml b/app/src/main/res/drawable/ic_warning.xml new file mode 100644 index 0000000..792b674 --- /dev/null +++ b/app/src/main/res/drawable/ic_warning.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_warning_shake.xml b/app/src/main/res/drawable/ic_warning_shake.xml new file mode 100644 index 0000000..9b50a35 --- /dev/null +++ b/app/src/main/res/drawable/ic_warning_shake.xml @@ -0,0 +1,24 @@ + + + + diff --git a/app/src/main/res/layout/activity_eye_protection.xml b/app/src/main/res/layout-land/activity_eye_protection.xml similarity index 57% rename from app/src/main/res/layout/activity_eye_protection.xml rename to app/src/main/res/layout-land/activity_eye_protection.xml index ab50e54..2506e93 100644 --- a/app/src/main/res/layout/activity_eye_protection.xml +++ b/app/src/main/res/layout-land/activity_eye_protection.xml @@ -6,6 +6,9 @@ + - - - + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent"> + + + + + + - - - + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout-port/activity_eye_protection.xml b/app/src/main/res/layout-port/activity_eye_protection.xml new file mode 100644 index 0000000..f9f74fa --- /dev/null +++ b/app/src/main/res/layout-port/activity_eye_protection.xml @@ -0,0 +1,426 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_camera_detection.xml b/app/src/main/res/layout/activity_camera_detection.xml new file mode 100644 index 0000000..e9ee065 --- /dev/null +++ b/app/src/main/res/layout/activity_camera_detection.xml @@ -0,0 +1,35 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_warning_dialog.xml b/app/src/main/res/layout/activity_warning_dialog.xml new file mode 100644 index 0000000..0f6ef08 --- /dev/null +++ b/app/src/main/res/layout/activity_warning_dialog.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index c0cd961..50e4b39 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -95,4 +95,28 @@ true true + + + + + + + + + + + + + + + +