feat: 新增护眼距离和行走抖动检测提醒功能

This commit is contained in:
2026-07-30 09:36:10 +08:00
parent 132000b4e1
commit 621ebd64d7
25 changed files with 2112 additions and 76 deletions

View File

@@ -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<ProcessCameraProvider> 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, "需要相机权限来检测距离");
}
}
}
}

View File

@@ -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));
}
}
}
}
}

View File

@@ -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);
}
}

View File

@@ -198,17 +198,13 @@ public class MainViewModel extends BaseViewModel<ActivityMainBinding, ActivityEv
}
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() {
}
});
} else {
Log.e(TAG, "checkTestUpdate: tag is Empty");
}
}
});
}
public void getDefaultDesktop() {

View File

@@ -196,4 +196,9 @@ public class CommonConfig {
/*系统设置菜单选项*/
public final static String AOLE_SETTINGS_DISALLOW = "aole_settings_disallow";
public final static String EYE_TO_SCREEN_DISTANCE_REMINDER = "aole_eye_to_screen_distance_reminder";
public final static String SHAKE_REMINDER = "aole_shake_reminder";
}

View File

@@ -0,0 +1,97 @@
package com.aoleyun.sn.detection;
import android.graphics.PointF;
import android.util.Log;
import com.google.android.gms.tasks.Task;
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.FaceDetector;
import com.google.mlkit.vision.face.FaceDetectorOptions;
import com.google.mlkit.vision.face.FaceLandmark;
import java.util.List;
public class DistanceReminderManager {
private static final String TAG = "DistanceReminder";
// 【核心阈值】两眼间距离占图片长边的比例。
// 之前 640x480 分辨率下阈值为 220 像素,220/640 ≈ 0.34
private static final double TOO_CLOSE_RATIO_THRESHOLD = 0.23;
private FaceDetector detector;
private OnDistanceAlertListener listener;
public interface OnDistanceAlertListener {
void onTooClose();
void onDistanceUpdate(double distance, PointF leftEye, PointF rightEye);
}
public DistanceReminderManager(OnDistanceAlertListener listener) {
this.listener = listener;
// 配置ML Kit:开启关键点检测(Landmark)
FaceDetectorOptions options = new FaceDetectorOptions.Builder()
.setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_FAST) // 追求速度
.setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_ALL) // 必须开启关键点
.build();
this.detector = FaceDetection.getClient(options);
}
/**
* 检测人脸距离的方法(由相机帧回调定期调用,例如每秒检测2-3次)
*
* @return 返回 Task 供调用者管理生命周期(例如关闭 ImageProxy)
*/
public Task<List<Face>> 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));
}
}

View File

@@ -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;
}
}
}

View File

@@ -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<Long> stepTimestamps = new ArrayList<>();
// 判定为“正在走路”的阈值:5秒内走了 4 步以上
private static final long STEP_TIME_WINDOW = 5000;
private static final int STEP_THRESHOLD = 4;
// --- 方案二:加速度方差相关参数 ---
private final Queue<Float> 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<Float> 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) {
}
}

View File

@@ -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<ProcessCameraProvider> 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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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() {

View File

@@ -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() {

View File

@@ -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);
}
}

View File

@@ -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;
/**
* 垂直中心
*/