Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added 1.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added 2.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ dependencies {
// ViewModel and LiveData for modern UI development
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-process:2.7.0")
implementation("androidx.activity:activity-ktx:1.9.0")

// Kable for Bluetooth LE
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import com.google.android.material.color.DynamicColorsOptions
* - 当 app_settings.monet_color_enabled 为 true(默认)时,对所有 Activity 应用动态色彩 overlay,
* colorPrimary/colorSurface 等主题色由系统根据壁纸动态生成(需 Android 12+,低版本自动回退到主题固定色)。
* - 关闭时使用 themes.xml/colors.xml 中定义的固定品牌色。
*
* 退出应用隐藏后台:
* - 由 [com.example.heart_rate_monitor_mobile.ui.BaseActivity] 统一处理。
*/
class HeartRateApp : Application() {
override fun onCreate() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,22 @@ class FloatingWindowService : Service() {
override fun onBind(intent: Intent?): IBinder = binder

/**
* 处理通知栏「关闭触摸穿透」按钮触发的 startService 调用。
* 处理完毕后调用 stopSelf(startId) 清理 start 请求;
* 若 Activity 仍绑定本服务则服务不会被销毁,行为与纯绑定模式一致。
* 处理两类 startService 调用:
* 1. 通知栏「关闭触摸穿透」按钮(ACTION_DISABLE_TOUCH_THROUGH):一次性动作,处理完即
* stopSelf(startId) 释放本次 start 请求;若 Activity 仍绑定本服务则服务不会被销毁。
* 2. showWindow() 中的无 action 保活 start:使服务在 Activity 解绑(如开启"退出应用隐藏
* 后台"后按 HOME 触发 finishAffinity)后仍能存活,悬浮窗持续显示。hideWindow() 时
* stopSelf() 释放该保活 start。
*/
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_DISABLE_TOUCH_THROUGH -> disableTouchThrough()
ACTION_DISABLE_TOUCH_THROUGH -> {
disableTouchThrough()
stopSelf(startId)
}
// 无 action:showWindow 保活 start,不释放
}
stopSelf(startId)
return START_NOT_STICKY
return START_STICKY
}

private lateinit var windowManager: WindowManager
Expand Down Expand Up @@ -138,6 +144,8 @@ class FloatingWindowService : Service() {
windowManager.addView(binding.root, layoutParams)
isWindowShown = true
updateWindowAppearance()
// 提升为 started 服务,使悬浮窗在 Activity 解绑(如开启"退出应用隐藏后台"后按 HOME 触发 finishAffinity)后仍能存活
startService(Intent(this, FloatingWindowService::class.java))
} catch (e: Exception) {
// Handle exception
}
Expand All @@ -158,6 +166,8 @@ class FloatingWindowService : Service() {
try {
windowManager.removeView(binding.root)
isWindowShown = false
// 释放 showWindow 时的 start 保活;若仍被绑定则服务继续存活
stopSelf()
} catch (e: Exception) {
// Handle exception
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.example.heart_rate_monitor_mobile.service.posture

import org.json.JSONArray
import org.json.JSONObject

/**
Expand All @@ -20,31 +21,45 @@ data class PostureFeatures(
/**
* 姿态校准数据。
*
* 包含静坐、站立两种姿态的特征,以及运动判定阈值。
* 序列化为 JSON 存储于 SharedPreferences key [posture_calibration_data]。
* 每种姿态可采集多个样本([sittingSamples]/[standingSamples]),以应对不同体位
* (如手机放口袋、握在手中、置于桌面等不同朝向)。实时检测时取与当前窗口欧氏距离
* 最小的样本参与判定。
*
* 实时检测时,计算当前窗口特征,用欧氏距离与校准样本匹配:
* - 距离 < [MATCH_THRESHOLD] 才判定为对应姿态,否则返回 UNKNOWN(避免睡眠误报)
* 序列化为 JSON 存储于 SharedPreferences key [posture_calibration_data]。
* 新格式使用 `sitting_samples`/`standing_samples` 数组;旧格式(单对象 `sitting`/`standing`)
* 在 [fromJson] 中自动兼容,解析为单元素列表。
*/
data class PostureCalibration(
val sitting: PostureFeatures?,
val standing: PostureFeatures?,
val sittingSamples: List<PostureFeatures>,
val standingSamples: List<PostureFeatures>,
val motionThreshold: Float = 1.5f,
val calibratedAt: Long = 0L
) {
/** 静坐和站立均已采集才算校准完成 */
fun isComplete(): Boolean = sitting != null && standing != null
/** 兼容旧用法:取首个静坐样本(无则 null) */
val sitting: PostureFeatures? get() = sittingSamples.firstOrNull()

/** 兼容旧用法:取首个站立样本(无则 null) */
val standing: PostureFeatures? get() = standingSamples.firstOrNull()

/** 静坐和站立均至少有一个样本才算校准完成 */
fun isComplete(): Boolean = sittingSamples.isNotEmpty() && standingSamples.isNotEmpty()

/** 序列化为 JSON 字符串 */
fun toJson(): String {
val obj = JSONObject()
obj.put("motion_threshold", motionThreshold)
obj.put("calibrated_at", calibratedAt)
sitting?.let { obj.put("sitting", featuresToJson(it)) }
standing?.let { obj.put("standing", featuresToJson(it)) }
obj.put("sitting_samples", featuresListToJson(sittingSamples))
obj.put("standing_samples", featuresListToJson(standingSamples))
return obj.toString()
}

private fun featuresListToJson(list: List<PostureFeatures>): JSONArray {
val arr = JSONArray()
for (f in list) arr.put(featuresToJson(f))
return arr
}

private fun featuresToJson(f: PostureFeatures): JSONObject = JSONObject().apply {
put("mean_x", f.meanX)
put("mean_y", f.meanY)
Expand All @@ -57,14 +72,16 @@ data class PostureCalibration(
/** 欧氏距离匹配阈值(m/s²),距离小于此值才判定为对应姿态 */
const val MATCH_THRESHOLD = 5.0f

/** 从 JSON 字符串反序列化,解析失败返回 null */
/** 从 JSON 字符串反序列化,解析失败返回 null。兼容旧单对象格式。 */
fun fromJson(json: String?): PostureCalibration? {
if (json.isNullOrBlank()) return null
return try {
val obj = JSONObject(json)
val sitting = parseSamples(obj, "sitting_samples", "sitting")
val standing = parseSamples(obj, "standing_samples", "standing")
PostureCalibration(
sitting = obj.optJSONObject("sitting")?.let { parseFeatures(it) },
standing = obj.optJSONObject("standing")?.let { parseFeatures(it) },
sittingSamples = sitting,
standingSamples = standing,
motionThreshold = obj.optDouble("motion_threshold", 1.5).toFloat(),
calibratedAt = obj.optLong("calibrated_at", 0L)
)
Expand All @@ -73,6 +90,23 @@ data class PostureCalibration(
}
}

/**
* 解析某姿态的样本列表。
* 优先读取新数组字段 [arrayKey];若不存在则回退到旧单对象字段 [legacyKey],
* 包装为单元素列表,保证旧数据平滑迁移。
*/
private fun parseSamples(obj: JSONObject, arrayKey: String, legacyKey: String): List<PostureFeatures> {
obj.optJSONArray(arrayKey)?.let { arr ->
val list = mutableListOf<PostureFeatures>()
for (i in 0 until arr.length()) {
arr.optJSONObject(i)?.let { list.add(parseFeatures(it)) }
}
return list
}
obj.optJSONObject(legacyKey)?.let { return listOf(parseFeatures(it)) }
return emptyList()
}

private fun parseFeatures(o: JSONObject): PostureFeatures = PostureFeatures(
meanX = o.optDouble("mean_x", 0.0).toFloat(),
meanY = o.optDouble("mean_y", 0.0).toFloat(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,19 @@ import kotlin.math.sqrt
/**
* 姿态检测器。
*
* 接收加速度传感器样本,维护一个滑动窗口,每 ~500ms 调用 [classify] 输出当前姿态。
* 接收加速度传感器样本,维护一个滑动窗口,每 ~200ms 调用 [classify] 输出当前姿态。
*
* 算法分两步:
* 1. 运动判定:加速度模长标准差 > motionThreshold → EXERCISE
* 2. 静坐/站立区分:计算窗口各轴均值与校准样本的欧氏距离,距离最小且 < MATCH_THRESHOLD 者胜出;
* 两者距离都大 → UNKNOWN(非校准姿态,如躺下睡眠,不触发预警)
* 1. 运动判定:加速度模长标准差(stdMag)超过 [EXERCISE_SHAKE_THRESHOLD] 视为"幅度较大的晃动";
* 仅当大幅晃动**连续持续 [EXERCISE_SUSTAINED_MS](3 秒)以上**才识别为 EXERCISE。
* 短暂晃动(调整姿势、拿取手机等)不会触发,避免误判。
* 2. 静坐/站立区分:计算窗口各轴均值,与校准样本列表中每个样本求欧氏距离,取最小者;
* 最小距离 < [PostureCalibration.MATCH_THRESHOLD] 且小于另一姿态者胜出;
* 两者距离都大 → UNKNOWN(非校准姿态,如躺下睡眠,不触发预警)。
* 多样本支持不同体位(口袋、手持、桌面等)。
*
* 滞回防抖:最近 5 次分类投票,票数 >= 3 才切换 stablePosture,避免边界抖动。
* 运动姿态因已通过持续时长判定,直接写入 stablePosture 不经滞回。
*/
class PostureDetector {

Expand All @@ -27,6 +32,9 @@ class PostureDetector {
private val recentClassifications = ArrayDeque<PostureType>(5)
private var stablePosture = PostureType.UNKNOWN

/** 大幅晃动持续计时起点(0 表示当前未处于大幅晃动状态) */
private var largeMotionStartMs = 0L

/** 设置校准数据(SharedPreferences 变化时热更新) */
fun setCalibration(cal: PostureCalibration?) {
calibration = cal
Expand All @@ -51,7 +59,7 @@ class PostureDetector {

/**
* 对当前窗口进行姿态分类。
* 每 ~500ms 调用一次,不必每样本调。
* 每 ~200ms 调用一次,不必每样本调。
*/
fun classify(): PostureType {
val cal = calibration
Expand All @@ -65,17 +73,33 @@ class PostureDetector {
val meanY = sampleBuffer.map { it[1] }.average().toFloat()
val meanZ = sampleBuffer.map { it[2] }.average().toFloat()
val stdMag = computeStd(magnitudeBuffer)

// 第一步:运动判定
if (stdMag > cal.motionThreshold) {
return updateStable(PostureType.EXERCISE)
val now = System.currentTimeMillis()

// 第一步:运动判定 —— 大幅晃动需持续 3 秒以上才识别为运动
if (stdMag > EXERCISE_SHAKE_THRESHOLD) {
if (largeMotionStartMs == 0L) {
largeMotionStartMs = now
}
// 持续时长达标 → 直接识别为运动(持续时长已提供稳定性,不经滞回防抖)
if (now - largeMotionStartMs >= EXERCISE_SUSTAINED_MS) {
stablePosture = PostureType.EXERCISE
recentClassifications.clear()
return stablePosture
}
// 大幅晃动尚未持续 3 秒,保持当前稳定姿态,不立即切换
return stablePosture
} else {
// 晃动停止,重置持续计时
largeMotionStartMs = 0L
}

// 第二步:静坐/站立欧氏距离匹配
val sit = cal.sitting!!
val stand = cal.standing!!
val distSit = euclidean(meanX, meanY, meanZ, sit.meanX, sit.meanY, sit.meanZ)
val distStand = euclidean(meanX, meanY, meanZ, stand.meanX, stand.meanY, stand.meanZ)
// 第二步:静坐/站立欧氏距离匹配(多样本取最小距离,应对不同体位)
val distSit = cal.sittingSamples.minOfOrNull {
euclidean(meanX, meanY, meanZ, it.meanX, it.meanY, it.meanZ)
} ?: Float.MAX_VALUE
val distStand = cal.standingSamples.minOfOrNull {
euclidean(meanX, meanY, meanZ, it.meanX, it.meanY, it.meanZ)
} ?: Float.MAX_VALUE

val candidate = when {
distSit < PostureCalibration.MATCH_THRESHOLD && distSit < distStand -> PostureType.SITTING
Expand All @@ -94,6 +118,7 @@ class PostureDetector {
magnitudeBuffer.clear()
recentClassifications.clear()
stablePosture = PostureType.UNKNOWN
largeMotionStartMs = 0L
}

/** 滞回防抖:候选加入最近 5 次记录,票数 >= 3 才更新稳定姿态 */
Expand Down Expand Up @@ -123,4 +148,15 @@ class PostureDetector {
val dz = z1 - z2
return sqrt(dx * dx + dy * dy + dz * dz)
}

companion object {
/**
* 大幅晃动阈值(加速度模长标准差,m/s²)。
* 高于此值视为"幅度较大的晃动"。设置在基线噪声之上,避免轻微移动误判为运动。
*/
private const val EXERCISE_SHAKE_THRESHOLD = 2.5f

/** 大幅晃动需连续持续的时长(毫秒),达此值才识别为运动姿态。 */
private const val EXERCISE_SUSTAINED_MS = 3000L
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.example.heart_rate_monitor_mobile.ui

import android.app.ActivityManager
import android.content.Context
import androidx.appcompat.app.AppCompatActivity

/**
* 所有 Activity 的基类。
*
* "退出应用隐藏后台"功能:
* - 通过 companion object 跟踪当前处于 started 状态的 Activity 数量。
* - 当最后一个 Activity 进入 onStop(数量归零,应用真正退到后台)且开关开启时,
* 通过 [ActivityManager.AppTask.setExcludeFromRecents] 将当前任务从「最近任务」列表隐藏,
* **不销毁任何 Activity**,因此:
* - 任意页面退出都能可靠隐藏(不依赖 root activity)
* - 重新进入时保留退出前的页面状态(不会强制跳回首页)
* - 应用回到前台([onStart])时恢复 excludeFromRecents=false。
* - [suppressHideForExternalLaunch]:启动系统设置、浏览器等外部 Activity 前标记,
* 防止退出到外部页面时误触发。在 [onStart] 中自动复位。
*/
open class BaseActivity : AppCompatActivity() {

companion object {
/** 当前处于 started 状态的 Activity 数量(仅主线程访问) */
private var startedCount = 0

/**
* 启动外部 Activity(系统设置、浏览器等)前设为 true,
* 阻止 onStop 中的 hide 误触发。在下次 onStart 自动复位。
*/
@JvmStatic
var suppressHideForExternalLaunch = false
}

override fun onStart() {
super.onStart()
startedCount++
suppressHideForExternalLaunch = false
// 回到前台:恢复最近任务可见
setExcludeFromRecentsFlag(false)
}

override fun onStop() {
super.onStop()
startedCount--
if (startedCount <= 0
&& !suppressHideForExternalLaunch
&& isHideFromRecentsEnabled()
) {
// 应用退到后台:从最近任务隐藏(不销毁 Activity,保留页面状态)
setExcludeFromRecentsFlag(true)
}
}

private fun setExcludeFromRecentsFlag(exclude: Boolean) {
try {
val am = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val myTaskId = taskId
for (task in am.appTasks) {
if (task.taskInfo.id == myTaskId) {
task.setExcludeFromRecents(exclude)
break
}
}
} catch (_: Exception) { }
}

private fun isHideFromRecentsEnabled(): Boolean {
return getSharedPreferences("app_settings", Context.MODE_PRIVATE)
.getBoolean("hide_from_recents_enabled", false)
}
}
Loading
Loading