feat(device-interaction): 加速度计抬手亮屏功能

REQ-20260430-0026

- 新增 AccelerometerWakeController 接口 + FiseAccelerometerWake 实现
- 双模式策略:方案D(WRIST_TILT)优先,不支持自动降级方案C(Z轴变化趋势)
- 方案C防误触发:检测Z轴从低(<3)到高(≥6)的变化趋势,非简单阈值
- NFC打卡时 pause/resume 暂停检测,防止贴卡姿势误触发
- 熄屏交系统SCREEN_OFF_TIMEOUT管理,加速度计只管亮屏
- DeviceModule 增加 DI 绑定
- MainActivity 增加 start/stop 生命周期管理
- PunchViewModel 增加 NFC 开关时 pause/resume 调用

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
dongliang
2026-04-30 16:54:05 +09:30
parent 08aae2065e
commit e3f6ac3c97
5 changed files with 258 additions and 3 deletions

View File

@@ -9,6 +9,7 @@ import androidx.appcompat.app.AppCompatActivity
import com.xiaoqu.watch.databinding.ActivityMainBinding import com.xiaoqu.watch.databinding.ActivityMainBinding
import com.xiaoqu.watch.event.AppEvent import com.xiaoqu.watch.event.AppEvent
import com.xiaoqu.watch.event.EventBus import com.xiaoqu.watch.event.EventBus
import com.xiaoqu.watch.device.sensor.AccelerometerWakeController
import com.xiaoqu.watch.service.manager.NotificationManager import com.xiaoqu.watch.service.manager.NotificationManager
import com.xiaoqu.watch.service.manager.SystemStateMonitor import com.xiaoqu.watch.service.manager.SystemStateMonitor
import com.xiaoqu.watch.ui.widget.NotificationBannerView import com.xiaoqu.watch.ui.widget.NotificationBannerView
@@ -34,6 +35,8 @@ class MainActivity : AppCompatActivity() {
@Inject lateinit var systemStateMonitor: SystemStateMonitor @Inject lateinit var systemStateMonitor: SystemStateMonitor
@Inject lateinit var notificationManager: NotificationManager @Inject lateinit var notificationManager: NotificationManager
@Inject lateinit var eventBus: EventBus @Inject lateinit var eventBus: EventBus
/** 加速度计抬手亮屏控制器 */
@Inject lateinit var accelerometerWake: AccelerometerWakeController
lateinit var notificationBanner: NotificationBannerView lateinit var notificationBanner: NotificationBannerView
private val activityScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private val activityScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
@@ -59,6 +62,9 @@ class MainActivity : AppCompatActivity() {
// 注册系统状态监听(电量、蓝牙) // 注册系统状态监听(电量、蓝牙)
systemStateMonitor.register() systemStateMonitor.register()
// 启动加速度计抬手亮屏
accelerometerWake.start()
// 初始化通知横幅 // 初始化通知横幅
notificationBanner = binding.notificationBanner notificationBanner = binding.notificationBanner
@@ -70,6 +76,7 @@ class MainActivity : AppCompatActivity() {
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
accelerometerWake.stop()
systemStateMonitor.unregister() systemStateMonitor.unregister()
notificationBanner.destroy() notificationBanner.destroy()
} }

View File

@@ -0,0 +1,36 @@
package com.xiaoqu.watch.device.sensor
/**
* 加速度计抬手亮屏控制器接口
*
* 检测用户抬手动作,自动唤醒屏幕。
* 熄屏由系统 SCREEN_OFF_TIMEOUT 管理,本控制器只负责亮屏。
*
* 实现策略:
* - 优先使用 TYPE_WRIST_TILT_GESTURE方案D系统级手势识别
* - 不支持时降级为 TYPE_ACCELEROMETER Z轴变化趋势检测方案C
*
* 使用方式:
* - MainActivity.onCreate → start()
* - MainActivity.onDestroy → stop()
* - NFC 操作开始 → pause()(防止打卡时误熄屏)
* - NFC 操作结束 → resume()
*/
interface AccelerometerWakeController {
/** 开始监听传感器(注册 SensorEventListener */
fun start()
/** 停止监听(注销 SensorEventListener释放资源 */
fun stop()
/**
* 暂停亮屏检测
* NFC 操作中调用,防止贴卡姿势导致误熄屏。
* 覆盖四种 NFC 场景:考勤打卡、任务单个打卡、任务批量打卡、硬件开锁
*/
fun pause()
/** 恢复亮屏检测 */
fun resume()
}

View File

@@ -0,0 +1,194 @@
package com.xiaoqu.watch.device.sensor
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import com.xiaoqu.watch.device.screen.ScreenController
import dagger.hilt.android.qualifiers.ApplicationContext
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
/**
* FISE 手表加速度计抬手亮屏实现
*
* 双模式策略:
* - 方案DTYPE_WRIST_TILT_GESTUREtype=26系统级手势识别最省电最准确
* - 方案CTYPE_ACCELEROMETER Z轴变化趋势检测作为降级方案
*
* 方案C核心逻辑
* 检测Z轴从低值<3手臂下垂到高值≥6手臂抬起的变化趋势。
* 与旧版简单的Z≥5判断相比可防止
* - 手表平放桌面常亮Z一直≈9.8,无"低→高"变化)
* - 走路手臂摆动频繁亮灭Z波动但无持续趋势
*
* 参数来源baseline/04 — Z轴唤醒阈值≥5home.vue:122加迟滞调整为低<3/高≥6
*/
@Singleton
class FiseAccelerometerWake @Inject constructor(
@ApplicationContext private val context: Context,
private val screenController: ScreenController
) : AccelerometerWakeController, SensorEventListener {
companion object {
/** 方案CZ轴低位阈值手臂下垂判定 */
private const val Z_LOW_THRESHOLD = 3f
/** 方案CZ轴高位阈值手臂抬起判定来源 baseline/04 home.vue:122 原值5加迟滞调整为6 */
private const val Z_HIGH_THRESHOLD = 6f
/** 方案C滑动窗口大小前半+后半各3个采样SENSOR_DELAY_NORMAL下约1.2秒) */
private const val WINDOW_SIZE = 6
/** 方案C前半窗口大小 */
private const val HALF_WINDOW = WINDOW_SIZE / 2
}
private val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
/** 是否已暂停NFC操作中 */
private var paused = false
/** 是否使用方案DWRIST_TILT */
private var useWristTilt = false
/** 是否已启动 */
private var started = false
// === 方案CZ轴滑动窗口 ===
/** 固定大小的环形缓冲区存储最近的Z轴采样值 */
private val zWindow = FloatArray(WINDOW_SIZE)
/** 当前写入位置 */
private var windowIndex = 0
/** 窗口是否已填满至少6个采样后才开始判断 */
private var windowFilled = false
/**
* 开始监听传感器
* 优先尝试 WRIST_TILT方案D不支持则降级 ACCELEROMETER方案C
*/
override fun start() {
if (started) return
// 方案D优先尝试 TYPE_WRIST_TILT_GESTURE (type=26, API 20+)
val wristTilt = sensorManager.getDefaultSensor(26)
if (wristTilt != null) {
sensorManager.registerListener(this, wristTilt, SensorManager.SENSOR_DELAY_NORMAL)
useWristTilt = true
started = true
Timber.i("抬手亮屏: 使用方案DWRIST_TILT_GESTURE传感器")
return
}
// 方案C降级使用加速度计Z轴变化趋势检测
val accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
if (accelerometer != null) {
sensorManager.registerListener(this, accelerometer, SensorManager.SENSOR_DELAY_NORMAL)
useWristTilt = false
started = true
Timber.i("抬手亮屏: 使用方案C加速度计Z轴变化趋势WRIST_TILT不可用")
} else {
Timber.w("抬手亮屏: 无可用传感器,功能不可用")
}
}
/** 停止监听,释放传感器资源 */
override fun stop() {
if (!started) return
sensorManager.unregisterListener(this)
started = false
resetWindow()
Timber.d("抬手亮屏: 已停止")
}
/** 暂停检测NFC操作中调用 */
override fun pause() {
paused = true
Timber.d("抬手亮屏: 已暂停NFC操作中")
}
/** 恢复检测 */
override fun resume() {
paused = false
resetWindow() // 恢复时清空窗口,避免暂停期间积累的数据干扰判断
Timber.d("抬手亮屏: 已恢复")
}
// === SensorEventListener 回调 ===
override fun onSensorChanged(event: SensorEvent) {
if (paused) return
if (useWristTilt) {
// 方案D收到 WRIST_TILT 事件即为抬手动作
wakeScreenIfOff()
return
}
// 方案CZ轴变化趋势检测
detectWristRaise(event.values[2])
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {
// 精度变化无需处理
}
// === 方案C 核心逻辑 ===
/**
* Z轴变化趋势检测
* 用环形缓冲区存储最近6个Z轴采样值分前半(旧)和后半(新)各3个
* - 前3个均值 < 3手臂下垂状态
* - 后3个均值 ≥ 6手臂抬起状态
* 满足条件 = 检测到"抬手"动作
*/
private fun detectWristRaise(z: Float) {
// 写入环形缓冲区
zWindow[windowIndex % WINDOW_SIZE] = z
windowIndex++
// 窗口未填满,等待更多采样
if (!windowFilled) {
if (windowIndex >= WINDOW_SIZE) {
windowFilled = true
} else {
return
}
}
// 计算前半较旧的3个采样和后半较新的3个采样的均值
// 环形缓冲区中,当前写入位置的前 HALF_WINDOW 个是最新的,再前 HALF_WINDOW 个是较旧的
val currentPos = windowIndex % WINDOW_SIZE
var oldSum = 0f
var newSum = 0f
for (i in 0 until HALF_WINDOW) {
// 较旧的3个从 currentPos 往前数第 6,5,4 个位置
val oldIdx = (currentPos - WINDOW_SIZE + i + WINDOW_SIZE) % WINDOW_SIZE
// 较新的3个从 currentPos 往前数第 3,2,1 个位置
val newIdx = (currentPos - HALF_WINDOW + i + WINDOW_SIZE) % WINDOW_SIZE
oldSum += zWindow[oldIdx]
newSum += zWindow[newIdx]
}
val oldAvg = oldSum / HALF_WINDOW
val newAvg = newSum / HALF_WINDOW
// 判断:从低位(下垂)到高位(抬起)的变化趋势
if (oldAvg < Z_LOW_THRESHOLD && newAvg >= Z_HIGH_THRESHOLD) {
wakeScreenIfOff()
// 触发后清空窗口,防止连续触发
resetWindow()
}
}
/** 屏幕未亮时才发送亮屏指令,防止重复广播 */
private fun wakeScreenIfOff() {
if (!screenController.isScreenOn()) {
Timber.d("抬手亮屏: 检测到抬手,唤醒屏幕")
screenController.turnOn()
}
}
/** 重置滑动窗口 */
private fun resetWindow() {
windowIndex = 0
windowFilled = false
zWindow.fill(0f)
}
}

View File

@@ -4,6 +4,8 @@ import com.xiaoqu.watch.device.nfc.FiseNfcController
import com.xiaoqu.watch.device.nfc.NfcController import com.xiaoqu.watch.device.nfc.NfcController
import com.xiaoqu.watch.device.screen.FiseScreenController import com.xiaoqu.watch.device.screen.FiseScreenController
import com.xiaoqu.watch.device.screen.ScreenController import com.xiaoqu.watch.device.screen.ScreenController
import com.xiaoqu.watch.device.sensor.AccelerometerWakeController
import com.xiaoqu.watch.device.sensor.FiseAccelerometerWake
import com.xiaoqu.watch.device.sensor.FiseVibrationController import com.xiaoqu.watch.device.sensor.FiseVibrationController
import com.xiaoqu.watch.device.sensor.VibrationController import com.xiaoqu.watch.device.sensor.VibrationController
import dagger.Binds import dagger.Binds
@@ -35,4 +37,9 @@ abstract class DeviceModule {
@Binds @Binds
@Singleton @Singleton
abstract fun bindVibrationController(impl: FiseVibrationController): VibrationController abstract fun bindVibrationController(impl: FiseVibrationController): VibrationController
/** 加速度计抬手亮屏方案D(WRIST_TILT)优先降级方案C(Z轴趋势) */
@Binds
@Singleton
abstract fun bindAccelerometerWake(impl: FiseAccelerometerWake): AccelerometerWakeController
} }

View File

@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.xiaoqu.watch.device.nfc.NfcController import com.xiaoqu.watch.device.nfc.NfcController
import com.xiaoqu.watch.device.screen.ScreenController import com.xiaoqu.watch.device.screen.ScreenController
import com.xiaoqu.watch.device.sensor.AccelerometerWakeController
import com.xiaoqu.watch.device.sensor.VibrationController import com.xiaoqu.watch.device.sensor.VibrationController
import com.xiaoqu.watch.device.sensor.VibrationDefaults import com.xiaoqu.watch.device.sensor.VibrationDefaults
import com.xiaoqu.watch.network.ApiResult import com.xiaoqu.watch.network.ApiResult
@@ -29,7 +30,8 @@ class PunchViewModel @Inject constructor(
private val punchApi: PunchApi, private val punchApi: PunchApi,
private val nfcController: NfcController, private val nfcController: NfcController,
private val vibrationController: VibrationController, private val vibrationController: VibrationController,
private val screenController: ScreenController private val screenController: ScreenController,
private val accelerometerWake: AccelerometerWakeController
) : ViewModel() { ) : ViewModel() {
companion object { companion object {
@@ -96,7 +98,10 @@ class PunchViewModel @Inject constructor(
Timber.d("考勤: 开始NFC打卡, punchType=$punchType") Timber.d("考勤: 开始NFC打卡, punchType=$punchType")
_uiState.update { it.copy(isNfcScanning = true, scanningPunchType = punchType) } _uiState.update { it.copy(isNfcScanning = true, scanningPunchType = punchType) }
// 1. 开启 NFC + 音效 // 1. 暂停加速度计亮屏检测(防止贴卡姿势误触发熄屏)
accelerometerWake.pause()
// 2. 开启 NFC + 音效
nfcController.open() nfcController.open()
playFeedback(PLAN_NFC_OPEN) playFeedback(PLAN_NFC_OPEN)
@@ -134,6 +139,8 @@ class PunchViewModel @Inject constructor(
// 关闭 NFC 硬件(不播关闭音效,成功/失败音效由 API 结果决定) // 关闭 NFC 硬件(不播关闭音效,成功/失败音效由 API 结果决定)
nfcController.stopScan() nfcController.stopScan()
nfcController.close() nfcController.close()
// 恢复加速度计亮屏检测
accelerometerWake.resume()
// 清除倒计时 // 清除倒计时
_uiState.update { it.copy(nfcCountdown = 0) } _uiState.update { it.copy(nfcCountdown = 0) }
@@ -237,11 +244,13 @@ class PunchViewModel @Inject constructor(
_uiState.update { it.copy(punchResult = null, errorMessage = null) } _uiState.update { it.copy(punchResult = null, errorMessage = null) }
} }
/** 关闭 NFC 硬件 + 播放关闭音效 */ /** 关闭 NFC 硬件 + 播放关闭音效 + 恢复加速度计 */
private fun closeNfc() { private fun closeNfc() {
nfcController.stopScan() nfcController.stopScan()
nfcController.close() nfcController.close()
playFeedback(PLAN_NFC_CLOSE) playFeedback(PLAN_NFC_CLOSE)
// 恢复加速度计亮屏检测
accelerometerWake.resume()
} }
/** 播放震动+音效反馈 */ /** 播放震动+音效反馈 */
@@ -259,6 +268,8 @@ class PunchViewModel @Inject constructor(
if (nfcController.isOpen()) { if (nfcController.isOpen()) {
nfcController.stopScan() nfcController.stopScan()
nfcController.close() nfcController.close()
// 确保加速度计恢复(防止页面销毁时 NFC 未关闭导致永久暂停)
accelerometerWake.resume()
} }
} }
} }