android: refactor... stuff (4, see description)

hrm alerts
This commit is contained in:
Kavish Devar
2026-08-25 15:57:36 +05:30
parent e950aa010d
commit a68637ed7c
35 changed files with 863 additions and 328 deletions
+1 -1
View File
@@ -77,7 +77,7 @@ android {
defaultConfig {
applicationId = "me.kavishdevar.librepods"
targetSdk = 37
versionCode = 80
versionCode = 85
versionName = appVersionName
}
buildTypes {
@@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "b6034db24a2a1b8f4d424d89fcc4495f",
"identityHash": "721149fa5e2a13e82b73a83de312968a",
"entities": [
{
"tableName": "AppleEntity",
@@ -42,7 +42,7 @@
},
{
"tableName": "AppSettingsEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `nightMode` TEXT NOT NULL, `designSystem` TEXT NOT NULL, PRIMARY KEY(`id`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `nightMode` TEXT NOT NULL, `designSystem` TEXT NOT NULL, `useHighestRefreshRate` INTEGER NOT NULL, `debugMode` INTEGER NOT NULL, `bleScanMode` INTEGER NOT NULL, `bleReportDelay` INTEGER NOT NULL, `swipeAnywhereForBack` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
@@ -61,6 +61,36 @@
"columnName": "designSystem",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "useHighestRefreshRate",
"columnName": "useHighestRefreshRate",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "debugMode",
"columnName": "debugMode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bleScanMode",
"columnName": "bleScanMode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bleReportDelay",
"columnName": "bleReportDelay",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "swipeAnywhereForBack",
"columnName": "swipeAnywhereForBack",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
@@ -72,7 +102,7 @@
},
{
"tableName": "AppStateEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `hasCompletedOnboarding` INTEGER NOT NULL, `lastVersionShown` TEXT, `hasConnectedToAACP` INTEGER NOT NULL, `firstSuccessfulConnectionTime` INTEGER, `reviewPrompted` INTEGER NOT NULL, `timeUntilFOSSPremiumExpiry` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `isPremium` INTEGER NOT NULL, `hasCompletedOnboarding` INTEGER NOT NULL, `lastVersionShown` TEXT, `hasConnectedToAACP` INTEGER NOT NULL, `firstSuccessfulConnectionTime` INTEGER, `reviewPrompted` INTEGER NOT NULL, `timeUntilFOSSPremiumExpiry` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
@@ -80,6 +110,12 @@
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isPremium",
"columnName": "isPremium",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hasCompletedOnboarding",
"columnName": "hasCompletedOnboarding",
@@ -145,11 +181,41 @@
"appWidgetId"
]
}
},
{
"tableName": "HeartRateSampleEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `bpm` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bpm",
"columnName": "bpm",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b6034db24a2a1b8f4d424d89fcc4495f')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '721149fa5e2a13e82b73a83de312968a')"
]
}
}
}
@@ -0,0 +1,226 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "721149fa5e2a13e82b73a83de312968a",
"entities": [
{
"tableName": "AppleEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`macAddress` TEXT NOT NULL, `settings` BLOB NOT NULL, `metadata` BLOB NOT NULL, `cache` BLOB NOT NULL, PRIMARY KEY(`macAddress`))",
"fields": [
{
"fieldPath": "macAddress",
"columnName": "macAddress",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "settings",
"columnName": "settings",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "metadata",
"columnName": "metadata",
"affinity": "BLOB",
"notNull": true
},
{
"fieldPath": "cache",
"columnName": "cache",
"affinity": "BLOB",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"macAddress"
]
}
},
{
"tableName": "AppSettingsEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `nightMode` TEXT NOT NULL, `designSystem` TEXT NOT NULL, `overrideMaterialColor` INTEGER, `useHighestRefreshRate` INTEGER NOT NULL, `debugMode` INTEGER NOT NULL, `bleScanMode` INTEGER NOT NULL, `bleReportDelay` INTEGER NOT NULL, `swipeAnywhereForBack` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "nightMode",
"columnName": "nightMode",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "designSystem",
"columnName": "designSystem",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "overrideMaterialColor",
"columnName": "overrideMaterialColor",
"affinity": "INTEGER"
},
{
"fieldPath": "useHighestRefreshRate",
"columnName": "useHighestRefreshRate",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "debugMode",
"columnName": "debugMode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bleScanMode",
"columnName": "bleScanMode",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bleReportDelay",
"columnName": "bleReportDelay",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "swipeAnywhereForBack",
"columnName": "swipeAnywhereForBack",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "AppStateEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `isPremium` INTEGER NOT NULL, `hasCompletedOnboarding` INTEGER NOT NULL, `lastVersionShown` TEXT, `hasConnectedToAACP` INTEGER NOT NULL, `firstSuccessfulConnectionTime` INTEGER, `reviewPrompted` INTEGER NOT NULL, `timeUntilFOSSPremiumExpiry` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isPremium",
"columnName": "isPremium",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hasCompletedOnboarding",
"columnName": "hasCompletedOnboarding",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastVersionShown",
"columnName": "lastVersionShown",
"affinity": "TEXT"
},
{
"fieldPath": "hasConnectedToAACP",
"columnName": "hasConnectedToAACP",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "firstSuccessfulConnectionTime",
"columnName": "firstSuccessfulConnectionTime",
"affinity": "INTEGER"
},
{
"fieldPath": "reviewPrompted",
"columnName": "reviewPrompted",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timeUntilFOSSPremiumExpiry",
"columnName": "timeUntilFOSSPremiumExpiry",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
},
{
"tableName": "WidgetConfigEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appWidgetId` INTEGER NOT NULL, `macAddress` TEXT NOT NULL, PRIMARY KEY(`appWidgetId`))",
"fields": [
{
"fieldPath": "appWidgetId",
"columnName": "appWidgetId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "macAddress",
"columnName": "macAddress",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"appWidgetId"
]
}
},
{
"tableName": "HeartRateSampleEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `bpm` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bpm",
"columnName": "bpm",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '721149fa5e2a13e82b73a83de312968a')"
]
}
}
@@ -53,6 +53,7 @@ import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
import me.kavishdevar.librepods.bluetooth.aacp.types.RTBuddyDescriptor
import me.kavishdevar.librepods.bluetooth.aacp.types.SensorDataWxBuddyPayload
import me.kavishdevar.librepods.data.apple.BuddyState
import me.kavishdevar.librepods.data.audio.MicrophoneFrame
import me.kavishdevar.librepods.data.heartrate.HeartRateSample
import me.kavishdevar.librepods.devices.AppleDevice
@@ -64,6 +65,7 @@ import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.time.Clock
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
class AACPManager(private val device: AppleDevice) {
private val macParts = device.macAddress.value.split(":")
@@ -73,6 +75,28 @@ class AACPManager(private val device: AppleDevice) {
private val rtBuddyManager = RTBuddyManager(::sendPacket)
private suspend fun sendDelayedInitPackets() {
socket?.let {
while (!it.isConnected) {
Log.i(TAG, "waiting for connection...")
delay(500.milliseconds)
}
Log.i(TAG, "initializing connection")
connectService4()
delay(200.milliseconds)
sendSourceFeatureCapabilities()
delay(200.milliseconds)
sendNotificationRequest()
delay(200.milliseconds)
sendCountryCode()
delay(200.milliseconds)
sendRequestMagicKeys((MagicKeyType.IRK.value + MagicKeyType.ENC_KEY.value).toByte())
} ?: run {
Log.e(TAG, "socket is null after creation")
}
}
fun connect(): Boolean {
if (socket != null && socket!!.isConnected) {
Log.i(TAG, "Already connected")
@@ -99,19 +123,9 @@ class AACPManager(private val device: AppleDevice) {
}
CoroutineScope(Dispatchers.IO).launch {
while(!socket.isConnected) {
Log.i(TAG, "waiting for connection...")
delay(500.milliseconds)
}
Log.i(TAG, "initializing connection")
connectService4()
delay(200.milliseconds)
sendSourceFeatureCapabilities()
delay(200.milliseconds)
sendNotificationRequest()
delay(200.milliseconds)
sendRequestMagicKeys((MagicKeyType.IRK.value + MagicKeyType.ENC_KEY.value).toByte())
sendDelayedInitPackets()
delay(3.seconds) // just android stuff ig
sendDelayedInitPackets()
}
CoroutineScope(Dispatchers.IO).launch {
@@ -1031,6 +1045,8 @@ class AACPManager(private val device: AppleDevice) {
microphoneFrames = emptyList(),
controlStates = emptyMap(),
aacpPackets = emptyList(),
headTrackingState = BuddyState.INACTIVE,
hrmState = BuddyState.INACTIVE,
currentHeartRate = null
)
}
@@ -1053,36 +1069,6 @@ class AACPManager(private val device: AppleDevice) {
return sendPacket(packet)
}
fun parseCustomEqPacket(packet: ByteArray): CustomEq {
val data = packet.sliceArray(6 until packet.size)
if (data.size < 7) {
Log.e(TAG, "custom EQ packet length less than 7, returning default")
return CustomEq(1, 50, 50, 50)
}
val lengthLow = data[0].toInt() and 0xFF
val lengthHigh = data[1].toInt() and 0xFF
val length = (lengthHigh shl 8) or lengthLow
if (length != 5) {
Log.w(TAG, "parseCustomEqPacket: unexpected length ($length). parsing normally")
}
val state = data[3].toInt()
val low = data[4].toInt()
val mid = data[5].toInt()
val high = data[6].toInt()
return CustomEq(
state,
low,
mid,
high
)
}
fun requestMicrophoneStream(): Boolean {
val payload = byteArrayOf(
0x00, 0x00,
@@ -1183,16 +1169,18 @@ class AACPManager(private val device: AppleDevice) {
if (value and 0x8000 != 0) value - 0x10000 else value
}
if (device.state.value.headTrackingState != BuddyState.ACTIVE) {
device.updateState {
it.copy(
headTrackingState = BuddyState.ACTIVE
)
}
}
HeadTracking.addAccel(i16(device.settings.value.headGesturesVerticalOffset).toFloat(), i16(device.settings.value.headGesturesHorizontalOffset).toFloat())
}
SensorServiceType.HEARTRATE -> {
Log.d(
TAG,
"Received sensor data for service: ${data.command.service}, payload: ${data.command.payload.toByteArray().toHexString()}"
)
}
SensorServiceType.HEARTRATE,
SensorServiceType.HEARTRATEv2 -> {
val payload = data.command.payload.toByteArray()
val timestamp = Clock.System.now()
@@ -1208,10 +1196,10 @@ class AACPManager(private val device: AppleDevice) {
return
}
if (!device.state.value.hrmActive) {
if (device.state.value.hrmState != BuddyState.ACTIVE) {
device.updateState {
it.copy(
hrmActive = true
hrmState = BuddyState.ACTIVE
)
}
}
@@ -4,7 +4,6 @@ import me.kavishdevar.librepods.bluetooth.aacp.types.CustomEq
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
import me.kavishdevar.librepods.devices.PacketDestination
data class CustomEqPacket(
val customEq: CustomEq,
override val destination: PacketDestination
@@ -26,10 +25,10 @@ data class CustomEqPacket(
val length = payload[0].toInt()
require(length == 5) { "Invalid length for CustomEqPacket: $length" }
val state = payload[2].toInt()
val low = payload[3].toInt()
val mid = payload[4].toInt()
val high = payload[5].toInt()
val state = payload[3].toInt()
val low = payload[4].toInt()
val mid = payload[5].toInt()
val high = payload[6].toInt()
val customEq = CustomEq(state, low, mid, high)
@@ -3,7 +3,6 @@ package me.kavishdevar.librepods.data.apple
import kotlinx.serialization.Serializable
import me.kavishdevar.librepods.bluetooth.aacp.types.CapabilityEntry
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
import me.kavishdevar.librepods.bluetooth.aacp.types.CustomEq
import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType
@Serializable
@@ -11,5 +10,4 @@ data class AppleCache(
val capabilities: Set<CapabilityEntry> = emptySet(),
val magicKeys: Map<MagicKeyType, ByteArray> = emptyMap(),
val controlStates: Map<ControlCommandIdentifier, ByteArray> = emptyMap(),
val customEq: CustomEq = CustomEq(1, 50, 50, 50),
)
@@ -0,0 +1,7 @@
package me.kavishdevar.librepods.data.apple
enum class BuddyState {
INACTIVE,
WAITING,
ACTIVE
}
@@ -6,4 +6,3 @@ data class HeartRateSample (
val bpm: Int,
val timestamp: Instant
)
// TODO: implement records (don't have a better place to put this)
@@ -11,6 +11,10 @@ import me.kavishdevar.librepods.devices.AppleSettings
import kotlin.time.Instant
object Converters {
val cbor = Cbor {
ignoreUnknownKeys = true
}
@ColumnTypeConverter
fun macAddressToString(mac: MacAddress): String = mac.value
@@ -19,27 +23,27 @@ object Converters {
@ColumnTypeConverter
fun appleSettingsToBytes(settings: AppleSettings): ByteArray =
Cbor.encodeToByteArray(settings)
cbor.encodeToByteArray(settings)
@ColumnTypeConverter
fun bytesToAppleSettings(bytes: ByteArray): AppleSettings =
Cbor.decodeFromByteArray(bytes)
cbor.decodeFromByteArray(bytes)
@ColumnTypeConverter
fun appleMetadataToBytes(metadata: AppleMetadata): ByteArray =
Cbor.encodeToByteArray(metadata)
cbor.encodeToByteArray(metadata)
@ColumnTypeConverter
fun bytesToAppleMetadata(bytes: ByteArray): AppleMetadata =
Cbor.decodeFromByteArray(bytes)
cbor.decodeFromByteArray(bytes)
@ColumnTypeConverter
fun appleCacheToBytes(cache: AppleCache): ByteArray =
Cbor.encodeToByteArray(cache)
cbor.encodeToByteArray(cache)
@ColumnTypeConverter
fun bytesToAppleCache(bytes: ByteArray): AppleCache =
Cbor.decodeFromByteArray(bytes)
cbor.decodeFromByteArray(bytes)
@ColumnTypeConverter
fun kotlinInstantToLong(instant: Instant): Long =
@@ -1,15 +1,16 @@
package me.kavishdevar.librepods.database
import androidx.room3.AutoMigration
import androidx.room3.ColumnTypeConverters
import androidx.room3.Database
import androidx.room3.RoomDatabase
import me.kavishdevar.librepods.data.heartrate.HeartRateDao
import me.kavishdevar.librepods.database.app.AppSettingsDao
import me.kavishdevar.librepods.database.app.AppSettingsEntity
import me.kavishdevar.librepods.database.app.AppStateDao
import me.kavishdevar.librepods.database.app.AppStateEntity
import me.kavishdevar.librepods.database.apple.AppleDao
import me.kavishdevar.librepods.database.apple.AppleEntity
import me.kavishdevar.librepods.database.heartrate.HeartRateDao
import me.kavishdevar.librepods.database.heartrate.HeartRateSampleEntity
import me.kavishdevar.librepods.database.widget.WidgetConfigDao
import me.kavishdevar.librepods.database.widget.WidgetConfigEntity
@@ -23,7 +24,11 @@ import me.kavishdevar.librepods.database.widget.WidgetConfigEntity
WidgetConfigEntity::class,
HeartRateSampleEntity::class
],
version = 1,
version = 3,
autoMigrations = [
AutoMigration(from = 1, to = 2),
AutoMigration(from = 2, to = 3),
]
)
abstract class LibrePodsDatabase: RoomDatabase() {
abstract fun appleDao(): AppleDao
@@ -1,6 +1,7 @@
package me.kavishdevar.librepods.database.app
import android.bluetooth.le.ScanSettings
import androidx.compose.ui.graphics.Color
import androidx.room3.Entity
import androidx.room3.PrimaryKey
import me.kavishdevar.librepods.presentation.theme.DesignSystem
@@ -13,6 +14,8 @@ data class AppSettingsEntity(
val nightMode: NightTheme = NightTheme.System,
val designSystem: DesignSystem = DesignSystem.Material,
val overrideMaterialColor: Color? = null,
val useHighestRefreshRate: Boolean = false,
val debugMode: Boolean = false,
@@ -4,10 +4,10 @@ import android.util.Log
import androidx.room3.Dao
import androidx.room3.Query
import androidx.room3.Upsert
import me.kavishdevar.librepods.bluetooth.MacAddress
import me.kavishdevar.librepods.data.apple.AppleCache
import me.kavishdevar.librepods.devices.AppleMetadata
import me.kavishdevar.librepods.devices.AppleSettings
import me.kavishdevar.librepods.bluetooth.MacAddress
private const val TAG = "AppleDao"
@@ -26,18 +26,18 @@ interface AppleDao {
suspend fun updateSettings(macAddress: MacAddress, settings: AppleSettings) {
Log.d(TAG, "Updating settings for $macAddress: $settings")
val device = get(macAddress)?: AppleEntity(macAddress = macAddress, settings = settings, metadata = AppleMetadata(), cache = AppleCache())
upsert(device.copy(settings = settings))
upsert(device.copy(settings = settings))
}
suspend fun updateMetadata(macAddress: MacAddress, metadata: AppleMetadata) {
Log.d(TAG, "Updating metadata for $macAddress: $metadata")
val device = get(macAddress)?: AppleEntity(macAddress = macAddress, settings = AppleSettings(), metadata = metadata, cache = AppleCache())
upsert(device.copy(metadata = metadata))
upsert(device.copy(metadata = metadata))
}
suspend fun saveCache(macAddress: MacAddress, cache: AppleCache) {
Log.d(TAG, "Saving cache for $macAddress: $cache")
val device = get(macAddress)?: AppleEntity(macAddress = macAddress, settings = AppleSettings(), metadata = AppleMetadata(), cache = cache)
upsert(device.copy(cache = cache))
upsert(device.copy(cache = cache))
}
}
@@ -1,9 +1,8 @@
package me.kavishdevar.librepods.data.heartrate
package me.kavishdevar.librepods.database.heartrate
import androidx.room3.Dao
import androidx.room3.Insert
import androidx.room3.Query
import me.kavishdevar.librepods.database.heartrate.HeartRateSampleEntity
import kotlin.time.Instant
@Dao
@@ -24,6 +24,7 @@ import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
import me.kavishdevar.librepods.bluetooth.att.ATTHandle
import me.kavishdevar.librepods.bluetooth.att.ATTManager
import me.kavishdevar.librepods.data.StemAction
import me.kavishdevar.librepods.data.apple.BuddyState
import me.kavishdevar.librepods.utils.GestureDetector
import me.kavishdevar.librepods.utils.HeadTracking
import kotlin.time.Duration
@@ -224,7 +225,9 @@ class AppleDevice(
interval = _settings.value.headTrackingInterval
)
_state.update {
it.copy(headTrackingActive = true)
it.copy(
headTrackingState = BuddyState.WAITING
)
}
}
@@ -234,20 +237,25 @@ class AppleDevice(
interval = Duration.ZERO
)
_state.update {
it.copy(headTrackingActive = false)
it.copy(
headTrackingState = BuddyState.INACTIVE,
)
}
gestureDetector.stopDetection()
}
// TODO: multiple callbacks
fun detectHeadGestures(callback: (Boolean) -> Unit) {
if (!state.value.headTrackingActive) startHeadTracking()
if (!state.value.headTrackingActive) {
aacp.setSensorServiceReportInterval(
sensorServiceType = if (metadata.value.version3.first().digitToInt() >= 8) SensorServiceType.DEVMOTION6 else SensorServiceType.ACTIVITY,
interval = 40.milliseconds
)
if (state.value.headTrackingState != BuddyState.ACTIVE) startHeadTracking()
val started = gestureDetector.startDetection(HeadTracking.acceleration, callback)
if (!started) {
Log.w(TAG, "Failed to start gesture detection, probably because already running.")
}
gestureDetector.startDetection(HeadTracking.acceleration, callback)
}
fun stopHeadGestureDetection() {
gestureDetector.stopDetection()
stopHeadTracking()
}
fun setHeadGesturesEnabled(enabled: Boolean) {
@@ -287,19 +295,28 @@ class AppleDevice(
}
// AACPManager sets hrmActive true when a valid reading is received
fun startHr(): Boolean = aacp.setSensorServiceReportInterval(
sensorServiceType = if (metadata.value.version3.first().digitToInt() >= 8) SensorServiceType.HEARTRATE_COMMAND else SensorServiceType.HEARTRATE,
interval = 1.seconds
)
fun startHr(): Boolean {
val success = aacp.setSensorServiceReportInterval(
sensorServiceType = if (metadata.value.version3.first().digitToInt() >= 9) SensorServiceType.HEARTRATE_COMMAND else SensorServiceType.HEARTRATE,
interval = 1.seconds
)
if (success) {
_state.update {
it.copy(
hrmState = BuddyState.WAITING
)
}
}
return success
}
fun stopHr(): Boolean {
val success = aacp.setSensorServiceReportInterval(
sensorServiceType = if (metadata.value.version3.first().digitToInt() >= 8) SensorServiceType.HEARTRATE_COMMAND else SensorServiceType.HEARTRATE,
sensorServiceType = if (metadata.value.version3.first().digitToInt() >= 9) SensorServiceType.HEARTRATE_COMMAND else SensorServiceType.HEARTRATE,
interval = Duration.ZERO
)
if (state.value.hrmActive && success) _state.update {
if (state.value.hrmState != BuddyState.INACTIVE && success) _state.update {
it.copy(
hrmActive = false,
hrmState = BuddyState.INACTIVE,
currentHeartRate = null
)
}
@@ -1,5 +1,6 @@
package me.kavishdevar.librepods.devices
import androidx.annotation.IntRange
import kotlinx.serialization.Serializable
import me.kavishdevar.librepods.data.StemAction
import kotlin.time.Duration
@@ -38,6 +39,6 @@ data class AppleSettings(
val conversationalAwarenessVolume: Float = 43f,
val conversationalAwarenessReducedVolume: Float = 20f,
val hrAlertEnabled: Boolean = true,
val hrmAlertThreshold: Int = 120,
val hrmAlertEnabled: Boolean = false,
@IntRange(from = 120, to = 200) val hrmAlertThreshold: Int = 150,
): DeviceSettings
@@ -9,6 +9,7 @@ import me.kavishdevar.librepods.bluetooth.aacp.types.ConnectedDevice
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
import me.kavishdevar.librepods.bluetooth.aacp.types.CustomEq
import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType
import me.kavishdevar.librepods.data.apple.BuddyState
import me.kavishdevar.librepods.data.audio.MicrophoneFrame
import me.kavishdevar.librepods.data.audio.MicrophoneState
import me.kavishdevar.librepods.data.heartrate.HeartRateSample
@@ -32,8 +33,7 @@ data class AppleState(
val magicKeys: Map<MagicKeyType, ByteArray> = emptyMap(),
val headTrackingActive: Boolean = false,
val detectHeadGestures: Boolean = false,
val headTrackingState: BuddyState = BuddyState.INACTIVE,
val loudSoundReductionEnabled: Boolean = false,
val transparencyData: ByteArray = byteArrayOf(),
@@ -55,7 +55,7 @@ data class AppleState(
val headphoneAccomodationEnabledForPhone: Boolean = false,
val currentHeartRate: HeartRateSample? = null,
val hrmActive: Boolean = false,
val hrmState: BuddyState = BuddyState.INACTIVE,
val heartRateInterval: Duration = 1.seconds,
val aacpPackets: List<AACPPacket> = emptyList(),
@@ -115,6 +115,7 @@ class MainActivity : ComponentActivity() {
LibrePodsTheme(
designSystem = settings.designSystem,
overrideMaterialColor = settings.overrideMaterialColor,
darkTheme = darkTheme
) {
// For demo screenshots
@@ -65,7 +65,7 @@ class PrivacyPolicyActivity : ComponentActivity() {
StyledScaffold(
title = stringResource(R.string.privacy_policy),
navigateBack = null
navigateBack = { finish() }
) { topPadding, bottomPadding ->
Column {
Spacer(modifier = Modifier.height(topPadding))
@@ -67,11 +67,8 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asComposeRenderEffect
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.input.pointer.consumePositionChange
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
@@ -207,10 +204,10 @@ fun StyledScaffold(
val backdrop = rememberLayerBackdrop()
val bgColor = MaterialTheme.colorScheme.surfaceContainer
val density = LocalDensity.current
val screenWidthPx = with(density) {
LocalWindowInfo.current.containerDpSize.width.toPx()
}
// val density = LocalDensity.current
// val screenWidthPx = with(density) {
// LocalWindowInfo.current.containerDpSize.width.toPx()
// }
val isCurrentEntry = LocalIsCurrentEntry.current
val transitionProgress = LocalTransitionProgress.current
val sharedTransitionScope = LocalSharedTransitionScope.current
@@ -233,7 +230,7 @@ fun StyledScaffold(
val event = awaitPointerEvent()
event.changes.forEach { change ->
change.consumePositionChange()
change.consume()
}
} while (event.changes.any { it.pressed })
}
@@ -350,7 +347,7 @@ fun StyledScaffold(
val event = awaitPointerEvent()
event.changes.forEach { change ->
change.consumePositionChange()
change.consume()
}
} while (event.changes.any { it.pressed })
}
@@ -1,6 +1,5 @@
package me.kavishdevar.librepods.presentation.navigation
import android.annotation.SuppressLint
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.SharedTransitionLayout
@@ -11,7 +10,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.navigation3.runtime.NavBackStack
@@ -44,18 +42,31 @@ fun AppNavGraph(
val currentDevice: Device<*, *, *>? = devices[backStack.lastOrNull()?.let { (it as? DeviceScreen)?.macAddress }]
@SuppressLint("UnrememberedMutableState")
val currentConnectionState by (currentDevice as? AppleDevice)?.connectionState?.collectAsState()?: mutableStateOf(ConnectionState.DISCONNECTED)
LaunchedEffect(currentDevice) {
val device = currentDevice as? AppleDevice ?: return@LaunchedEffect
LaunchedEffect(currentConnectionState) {
if (currentConnectionState == ConnectionState.DISCONNECTED) {
while (
backStack.size > 1 &&
backStack.lastOrNull() is DeviceScreen
) {
val completed = CompletableDeferred<Unit>()
backRequests.send(completed)
completed.await()
var wasConnected = false
device.connectionState.collect { state ->
when (state) {
ConnectionState.CONNECTED -> {
wasConnected = true
}
ConnectionState.DISCONNECTED -> {
if (!wasConnected) return@collect
while (
backStack.size > 1 &&
backStack.lastOrNull() is DeviceScreen
) {
val completed = CompletableDeferred<Unit>()
backRequests.send(completed)
completed.await()
}
}
else -> Unit
}
}
}
@@ -169,6 +169,7 @@ private fun <T : Any> SwipeBackSceneContent(
PredictiveBackHandler { progressFlow ->
try {
progressFlow.collect { backEvent ->
if (previousEntry == null) return@collect
val progress = backEvent.progress
transitionProgress = -progress
@@ -1,11 +1,125 @@
package me.kavishdevar.librepods.presentation.navigation
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.BoundsTransform
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.layout.AlignmentLine
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope> {
error("LocalSharedTransitionScope not provided")
}
val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope> { error("No SharedTransitionScope provided") }
val LocalTransitionProgress = compositionLocalOf { 0f }
val LocalIsCurrentEntry = compositionLocalOf { false }
/*
*/
object DummySharedTransitionScope: SharedTransitionScope {
override val isTransitionActive: Boolean
get() = false
override fun Modifier.skipToLookaheadSize(enabled: () -> Boolean): Modifier = this
override fun Modifier.renderInSharedTransitionScopeOverlay(
zIndexInOverlay: Float,
renderInOverlay: () -> Boolean
): Modifier = this
override fun Modifier.sharedElement(
sharedContentState: SharedTransitionScope.SharedContentState,
animatedVisibilityScope: AnimatedVisibilityScope,
boundsTransform: BoundsTransform,
placeholderSize: SharedTransitionScope.PlaceholderSize,
renderInOverlayDuringTransition: Boolean,
zIndexInOverlay: Float,
clipInOverlayDuringTransition: SharedTransitionScope.OverlayClip
): Modifier = this
override fun Modifier.sharedBounds(
sharedContentState: SharedTransitionScope.SharedContentState,
animatedVisibilityScope: AnimatedVisibilityScope,
enter: EnterTransition,
exit: ExitTransition,
boundsTransform: BoundsTransform,
resizeMode: SharedTransitionScope.ResizeMode,
placeholderSize: SharedTransitionScope.PlaceholderSize,
renderInOverlayDuringTransition: Boolean,
zIndexInOverlay: Float,
clipInOverlayDuringTransition: SharedTransitionScope.OverlayClip
): Modifier = this
override fun Modifier.sharedElementWithCallerManagedVisibility(
sharedContentState: SharedTransitionScope.SharedContentState,
visible: Boolean,
boundsTransform: BoundsTransform,
placeholderSize: SharedTransitionScope.PlaceholderSize,
renderInOverlayDuringTransition: Boolean,
zIndexInOverlay: Float,
clipInOverlayDuringTransition: SharedTransitionScope.OverlayClip
): Modifier = this
override fun OverlayClip(clipShape: Shape): SharedTransitionScope.OverlayClip {
return object : SharedTransitionScope.OverlayClip {
override fun getClipPath(
sharedContentState: SharedTransitionScope.SharedContentState,
bounds: Rect,
layoutDirection: LayoutDirection,
density: Density
): Path? {
return null
}
}
}
override val Placeable.PlacementScope.lookaheadScopeCoordinates: LayoutCoordinates
get() = DummyLookaheadCoordinates
override fun LayoutCoordinates.toLookaheadCoordinates(): LayoutCoordinates {
return DummyLookaheadCoordinates
}
}
object DummyLookaheadCoordinates: LayoutCoordinates {
override val size: IntSize
get() = IntSize(0, 0)
override val providedAlignmentLines: Set<AlignmentLine>
get() = setOf()
override val parentLayoutCoordinates: LayoutCoordinates?
get() = null
override val parentCoordinates: LayoutCoordinates?
get() = null
override val isAttached: Boolean
get() = false
override fun windowToLocal(relativeToWindow: Offset): Offset = Offset(0f, 0f)
override fun localToWindow(relativeToLocal: Offset): Offset = Offset(0f, 0f)
override fun localToRoot(relativeToLocal: Offset): Offset = Offset(0f, 0f)
override fun localPositionOf(
sourceCoordinates: LayoutCoordinates,
relativeToSource: Offset
): Offset = Offset(0f, 0f)
override fun localBoundingBoxOf(
sourceCoordinates: LayoutCoordinates,
clipBounds: Boolean
): Rect = Rect(Offset(0f, 0f), Offset(0f, 0f))
override fun get(alignmentLine: AlignmentLine): Int = 0
}
@@ -51,6 +51,7 @@ import kotlinx.coroutines.flow.debounce
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
import me.kavishdevar.librepods.bluetooth.att.ATTHandle
import me.kavishdevar.librepods.data.apple.BuddyState
import me.kavishdevar.librepods.devices.AirPodsSpecs
import me.kavishdevar.librepods.devices.AppleSettings
import me.kavishdevar.librepods.devices.BaseCapability
@@ -302,6 +303,7 @@ fun AppleSettingsScreen(
}
if (baseCapabilities.contains(BaseCapability.HRM)) {
val showAlertDisabledMessage = state.hrmState != BuddyState.ACTIVE && settings.hrmAlertEnabled
item(key = "spacer_heart_rate") {
Spacer(modifier = Modifier.height(16.dp))
}
@@ -309,7 +311,8 @@ fun AppleSettingsScreen(
StyledListItem(
contentText = stringResource(R.string.heart_rate),
onClick = navigateToHeartRateScreen,
supportingText = state.currentHeartRate?.let { "${it.bpm} bpm" }
supportingText = if (showAlertDisabledMessage) stringResource(R.string.heart_rate_alerts_disabled_warning) else state.currentHeartRate?.let { "${it.bpm} bpm" },
orientation = if (showAlertDisabledMessage) StyledListItemOrientation.Vertical else StyledListItemOrientation.Horizontal
)
}
}
@@ -16,16 +16,10 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// this is absolutely unnecessary, why did I make this. a simple toggle would've sufficed
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.screens.apple
import android.graphics.Paint
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -47,7 +41,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconToggleButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
@@ -78,12 +71,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.apple.BuddyState
import me.kavishdevar.librepods.presentation.components.primitives.MaterialButtonStyle
import me.kavishdevar.librepods.presentation.components.primitives.StyledButton
import me.kavishdevar.librepods.presentation.components.primitives.StyledIconButton
@@ -96,13 +88,10 @@ import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
import me.kavishdevar.librepods.utils.HeadTracking
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
@Composable
fun HeadTrackingScreen(
viewModel: AppleViewModel,
@@ -111,6 +100,7 @@ fun HeadTrackingScreen(
) {
val uiState by viewModel.uiState.collectAsState()
val state = uiState.state
val settings = uiState.settings
DisposableEffect(Unit) {
@@ -126,7 +116,6 @@ fun HeadTrackingScreen(
val coroutineScope = rememberCoroutineScope()
var lastClickTime by remember { mutableLongStateOf(0L) }
var shouldExplode by remember { mutableStateOf(false) }
val scrollState = rememberScrollState()
@@ -137,26 +126,28 @@ fun HeadTrackingScreen(
{ scaffoldBackdrop ->
if (LocalDesignSystem.current == DesignSystem.Material) {
FilledTonalIconToggleButton(
checked = uiState.state.headTrackingActive,
checked = state.headTrackingState == BuddyState.ACTIVE,
onCheckedChange = { if (it) viewModel.startHeadTracking() else viewModel.stopHeadTracking() },
modifier = Modifier
.minimumInteractiveComponentSize()
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)),
shape = IconButtonDefaults.mediumRoundShape
shape = IconButtonDefaults.mediumRoundShape,
enabled = state.headTrackingState != BuddyState.WAITING
) {
Icon(
imageVector = if (uiState.state.headTrackingActive) MaterialIcons.Pause else Icons.Default.PlayArrow,
imageVector = if (state.headTrackingState == BuddyState.ACTIVE) MaterialIcons.Pause else Icons.Default.PlayArrow,
contentDescription = "Play/Pause",
modifier = Modifier.size(IconButtonDefaults.mediumIconSize),
)
}
} else {
StyledIconButton(
onClick = if (!uiState.state.headTrackingActive) viewModel::startHeadTracking else viewModel::stopHeadTracking,
backdrop = scaffoldBackdrop
onClick = if (state.headTrackingState != BuddyState.ACTIVE) viewModel::startHeadTracking else viewModel::stopHeadTracking,
backdrop = scaffoldBackdrop,
enabled = state.headTrackingState != BuddyState.WAITING
) {
Icon(
imageVector = if (uiState.state.headTrackingActive) LocalIcons.current.Pause else LocalIcons.current.Play,
imageVector = if (state.headTrackingState == BuddyState.ACTIVE) LocalIcons.current.Pause else LocalIcons.current.Play,
contentDescription = "Play/Pause",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onBackground
@@ -224,7 +215,7 @@ fun HeadTrackingScreen(
lastClickTime = System.currentTimeMillis()
delay(3.seconds)
if (System.currentTimeMillis() - lastClickTime >= 3000) {
shouldExplode = true
gestureText = ""
}
}
}
@@ -264,28 +255,13 @@ fun HeadTrackingScreen(
)).togetherWith(fadeOut(animationSpec = tween(150)))
}
) { text ->
if (shouldExplode) {
LaunchedEffect(Unit) {
CoroutineScope(coroutineScope.coroutineContext).launch {
delay(750.milliseconds)
gestureText = ""
}
}
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center
)
} else {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}
@@ -353,7 +329,7 @@ private fun Plot() {
shape = RoundedCornerShape(28.dp)
) {
val horizontalColor = MaterialTheme.colorScheme.primary
val verticalColor = MaterialTheme.colorScheme.onPrimary
val verticalColor = MaterialTheme.colorScheme.secondaryContainer // random
Box(
modifier = Modifier
@@ -1,20 +1,24 @@
package me.kavishdevar.librepods.presentation.screens.apple
import android.text.format.DateFormat
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
@@ -37,6 +41,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.HeartRateRecord
@@ -45,6 +51,7 @@ import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import kotlinx.coroutines.flow.debounce
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.apple.BuddyState
import me.kavishdevar.librepods.devices.AppleSettings
import me.kavishdevar.librepods.presentation.components.primitives.StyledIconButton
import me.kavishdevar.librepods.presentation.components.primitives.StyledListItem
@@ -103,26 +110,28 @@ fun HeartRateScreen(
{ scaffoldBackdrop ->
if (LocalDesignSystem.current == DesignSystem.Material) {
FilledTonalIconToggleButton(
checked = state.hrmActive,
checked = state.hrmState == BuddyState.ACTIVE,
onCheckedChange = { if (it) startHr() else stopHr() },
modifier = Modifier
.minimumInteractiveComponentSize()
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)),
shape = IconButtonDefaults.mediumRoundShape
shape = IconButtonDefaults.mediumRoundShape,
enabled = state.hrmState != BuddyState.WAITING
) {
Icon(
imageVector = if (state.hrmActive) MaterialIcons.Pause else Icons.Default.PlayArrow,
imageVector = if (state.hrmState == BuddyState.ACTIVE) MaterialIcons.Pause else Icons.Default.PlayArrow,
contentDescription = "Start/Stop",
modifier = Modifier.size(IconButtonDefaults.mediumIconSize),
)
}
} else {
StyledIconButton(
onClick = { if (!state.hrmActive) startHr() else stopHr() },
backdrop = scaffoldBackdrop
onClick = { if (state.hrmState == BuddyState.INACTIVE) startHr() else stopHr() },
backdrop = scaffoldBackdrop,
enabled = state.hrmState != BuddyState.WAITING
) {
Icon(
imageVector = if (state.hrmActive) LocalIcons.current.Pause else LocalIcons.current.Play,
imageVector = if (state.hrmState == BuddyState.ACTIVE) LocalIcons.current.Pause else LocalIcons.current.Play,
contentDescription = "Start/Stop",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onBackground
@@ -142,56 +151,114 @@ fun HeartRateScreen(
val healthPermissions = rememberPermissionState(HealthPermission.getWritePermission(HeartRateRecord::class))
AnimatedVisibility(state.currentHeartRate != null) {
if (state.currentHeartRate == null) return@AnimatedVisibility
StyledListItem(
onClick = null,
content = {
Text(
text = "${state.currentHeartRate.bpm} bpm",
style = MaterialTheme.typography.bodyLargeEmphasized,
modifier = Modifier.fillMaxHeight()
)
},
supportingContent = {
val locale = LocalLocale.current.platformLocale
val timePattern = DateFormat.getBestDateTimePattern(
locale,
"jms"
)
val formatter = DateTimeFormatter.ofPattern(timePattern)
val defaultSpatialSpecFloat = MaterialTheme.motionScheme.defaultSpatialSpec<Float>()
val timeString = formatter.format(
Instant.ofEpochMilli(state.currentHeartRate.timestamp.toEpochMilliseconds())
.atZone(ZoneId.systemDefault())
)
val fastSpatialSpecFloat = MaterialTheme.motionScheme.fastSpatialSpec<Float>()
val fastSpatialSpecIntOffset = MaterialTheme.motionScheme.fastSpatialSpec<IntOffset>()
AnimatedContent(
targetState = state.hrmState,
transitionSpec = { fadeIn(defaultSpatialSpecFloat) togetherWith fadeOut(defaultSpatialSpecFloat) },
label = "hrm_state"
) { buddyState ->
when (buddyState) {
BuddyState.INACTIVE -> {}
BuddyState.WAITING -> {
Text(
text = timeString,
style = MaterialTheme.typography.bodySmall
text = stringResource(R.string.waiting_ellipsis),
style = MaterialTheme.typography.labelSmallEmphasized,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
},
leadingContent = {
Box(
modifier = Modifier
.size(48.dp)
.background(
MaterialTheme.colorScheme.primaryContainer,
MaterialShapes.SoftBurst.normalized()
.toShape()
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = LocalIcons.current.VitalSigns,
contentDescription = "vital signs",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
},
orientation = StyledListItemOrientation.Vertical
)
}
BuddyState.ACTIVE -> {
StyledListItem(
onClick = null,
content = {
Row(
verticalAlignment = Alignment.CenterVertically
) {
val bpm = state.currentHeartRate?.bpm ?: 0
// make this reusable
bpm.toString().forEachIndexed { index, digit ->
AnimatedContent(
targetState = digit,
transitionSpec = {
(
slideInVertically(
animationSpec = fastSpatialSpecIntOffset,
initialOffsetY = { it }
) + fadeIn(fastSpatialSpecFloat)
) togetherWith
(
slideOutVertically(
animationSpec = fastSpatialSpecIntOffset,
targetOffsetY = { -it }
) + fadeOut(fastSpatialSpecFloat)
)
},
label = "bpm_digit_$index"
) { value ->
Text(
text = value.toString(),
style = MaterialTheme.typography.headlineMediumEmphasized
)
}
}
Text(
text = " bpm",
style = MaterialTheme.typography.headlineMediumEmphasized
)
}
},
supportingContent = {
val locale = LocalLocale.current.platformLocale
val timePattern = DateFormat.getBestDateTimePattern(
locale,
"jms"
)
val formatter = DateTimeFormatter.ofPattern(timePattern)
val timeString = formatter.format(
Instant.ofEpochMilli(
state.currentHeartRate?.timestamp?.toEpochMilliseconds()
?: 0
)
.atZone(ZoneId.systemDefault())
)
Text(
text = timeString,
style = MaterialTheme.typography.bodySmall
)
},
leadingContent = {
Box(
modifier = Modifier
.size(48.dp)
.background(
MaterialTheme.colorScheme.primaryContainer,
MaterialShapes.SoftBurst.normalized()
.toShape()
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = LocalIcons.current.VitalSigns,
contentDescription = "vital signs",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
},
orientation = StyledListItemOrientation.Vertical
)
}
}
}
AnimatedVisibility(visible = !healthPermissions.status.isGranted) {
@@ -225,14 +292,25 @@ fun HeartRateScreen(
StyledToggle(
label = stringResource(R.string.heart_rate_alert),
description = stringResource(R.string.hrm_alert_description),
checked = settings.hrAlertEnabled,
checked = settings.hrmAlertEnabled,
onCheckedChange = { enabled ->
updateSettings {
it.copy(hrAlertEnabled = enabled)
it.copy(hrmAlertEnabled = enabled)
}
}
},
)
AnimatedVisibility(
visible = settings.hrmAlertEnabled && state.hrmState == BuddyState.INACTIVE
) {
Text(
text = stringResource(R.string.heart_rate_alerts_disabled_warning),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 16.dp)
)
}
val sliderValue = remember { mutableFloatStateOf(settings.hrmAlertThreshold.toFloat()) }
LaunchedEffect(sliderValue) {
@@ -249,22 +327,28 @@ fun HeartRateScreen(
label = stringResource(R.string.heart_rate_alert_threshold),
value = sliderValue.floatValue,
onValueChange = { sliderValue.floatValue = it },
valueRange = 120f..180f,
valueRange = 120f..200f,
description = "${sliderValue.floatValue.roundToInt()} bpm",
independent = true
)
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 350.dp)
.background(
MaterialTheme.colorScheme.surfaceContainerHigh,
RoundedCornerShape(28.dp)
)
) {
// TODO: graph or something
}
Text(
text = stringResource(R.string.hrm_alert_warning),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 16.dp)
)
// TODO: graph or something
// Column(
// modifier = Modifier
// .fillMaxWidth()
// .heightIn(min = 350.dp)
// .background(
// MaterialTheme.colorScheme.surfaceContainerHigh,
// RoundedCornerShape(28.dp)
// )
// ) { }
Spacer(modifier = Modifier.height(bottomPadding))
}
@@ -1,30 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
@@ -1,8 +1,5 @@
package me.kavishdevar.librepods.presentation.theme
import kotlinx.serialization.Serializable
@Serializable
enum class DesignSystem {
Apple,
Material
@@ -77,6 +77,7 @@ private val AppleLightColorScheme = lightColorScheme(
fun LibrePodsTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
designSystem: DesignSystem = DesignSystem.Material,
overrideMaterialColor: Color? = null,
content: @Composable () -> Unit
) {
val colorScheme = when(designSystem) {
@@ -99,6 +100,12 @@ fun LibrePodsTheme(
DesignSystem.Apple -> AppleIcons
}
) {
// var colorScheme = colorScheme
//
// if (designSystem == DesignSystem.Material) {
//
// }
MaterialExpressiveTheme(
colorScheme = colorScheme,
motionScheme = MotionScheme.expressive(),
@@ -106,6 +106,7 @@ val AppleTypography = Typography().run {
labelSmallEmphasized = labelSmallEmphasized.copy(
fontFamily = interFamily,
fontSize = 14.sp,
lineHeight = 18.sp,
fontWeight = FontWeight.Bold
)
)
@@ -45,7 +45,6 @@ class AppleRepository(
capabilities = state.capabilities,
magicKeys = state.magicKeys,
controlStates = state.controlStates,
customEq = state.customEq
)
} catch (e: Exception) {
Log.e(TAG, "Failed to create AppleCache from AppleState for ${macAddress.toRedactedString()}", e)
@@ -1,6 +1,6 @@
package me.kavishdevar.librepods.repository
import me.kavishdevar.librepods.data.heartrate.HeartRateDao
import me.kavishdevar.librepods.database.heartrate.HeartRateDao
import me.kavishdevar.librepods.data.heartrate.HeartRateSample
import me.kavishdevar.librepods.database.heartrate.HeartRateSampleEntity
import kotlin.time.Instant
@@ -77,7 +77,7 @@ import kotlin.time.toJavaInstant
private const val TAG = "LibrePodsService"
@SuppressLint("MissingPermission")
class LibrePodsService : Service() {
class LibrePodsService: Service() {
inner class LocalBinder : Binder() {
fun getService(): LibrePodsService = this@LibrePodsService
}
@@ -120,9 +120,8 @@ class LibrePodsService : Service() {
private val telephonyCallback = object: TelephonyCallback(), TelephonyCallback.CallStateListener {
override fun onCallStateChanged(state: Int) {
isCallRinging = state == TelephonyManager.CALL_STATE_RINGING
if (state == TelephonyManager.CALL_STATE_RINGING) {
isCallRinging = true
_devices.value.values.firstOrNull { device ->
device is AppleDevice &&
device.connectionState.value == ConnectionState.CONNECTED &&
@@ -161,6 +160,16 @@ class LibrePodsService : Service() {
}
}
}
} else {
if (isCallRinging) {
_devices.value.values.firstOrNull { device ->
device is AppleDevice &&
device.connectionState.value == ConnectionState.CONNECTED &&
device.state.value.componentState.any { it.status == ComponentStatus.IN_EAR }
}.let { device ->
(device as AppleDevice).stopHeadGestureDetection()
}
}
}
}
}
@@ -272,17 +281,19 @@ class LibrePodsService : Service() {
"Loaded metadata for device ${device.macAddress.toRedactedString()}: $metadata"
)
device.loadInitialState(
state = AppleState().copy(
capabilities = cache.capabilities,
magicKeys = cache.magicKeys,
controlStates = cache.controlStates,
customEq = cache.customEq,
),
settings = settings,
metadata = metadata
)
if (device.settings.value.hrmAlertEnabled) {
device.startHr()
}
}
deviceJobs[MacAddress(bluetoothDevice.address)] = mutableListOf()
@@ -577,7 +588,6 @@ class LibrePodsService : Service() {
capabilities = cache.capabilities,
magicKeys = cache.magicKeys,
controlStates = cache.controlStates,
customEq = cache.customEq,
),
settings = settings,
metadata = metadata
@@ -794,7 +804,8 @@ class LibrePodsService : Service() {
processHeartRateSample(
heartRateSample = heartRateSample,
interval = state.heartRateInterval
interval = state.heartRateInterval,
alertThreshold = deviceSettings.hrmAlertThreshold
)
}
}
@@ -996,19 +1007,25 @@ class LibrePodsService : Service() {
fun startForegroundNotification() {
val disconnectedNotificationChannel = NotificationChannel(
"background_service_status",
"Background Service Status",
"foreground_service_status",
getString(R.string.foreground_service_status),
NotificationManager.IMPORTANCE_NONE
)
val hrmAlertChannel = NotificationChannel(
"hrm_alert",
getString(R.string.heart_rate_alert),
NotificationManager.IMPORTANCE_HIGH
)
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(disconnectedNotificationChannel)
notificationManager.createNotificationChannel(hrmAlertChannel)
val notificationSettingsIntent =
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
putExtra(Settings.EXTRA_CHANNEL_ID, "background_service_status")
}
val notificationSettingsIntent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
putExtra(Settings.EXTRA_CHANNEL_ID, "foreground_service_status")
}
val pendingIntentNotifDisable = PendingIntent.getActivity(
this,
@@ -1017,9 +1034,9 @@ class LibrePodsService : Service() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(this, "background_service_status")
.setSmallIcon(R.drawable.ic_airpods).setContentTitle("Background Service Running")
.setContentText("Useless notification, disable it by clicking on it.")
val notification = NotificationCompat.Builder(this, "foreground_service_status")
.setSmallIcon(R.drawable.ic_airpods).setContentTitle(getString(R.string.service_running))
.setContentText(getString(R.string.foreground_notification_description))
.setContentIntent(pendingIntentNotifDisable).setCategory(Notification.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_LOW).setOngoing(true).build()
@@ -1180,13 +1197,6 @@ class LibrePodsService : Service() {
device.disableAudio(this)
device.disconnectAudio(this)
}
// not the right place because the stream doesn't switch to the remaining bud when the active one is removed
if (device is AppleDevice && device.state.value.hrmActive) {
device.updateState {
it.copy(hrmActive = false)
}
}
}
}
@@ -1210,41 +1220,73 @@ class LibrePodsService : Service() {
}
}
private fun processHeartRateSample(heartRateSample: HeartRateSample, interval: Duration) {
private fun processHeartRateSample(heartRateSample: HeartRateSample, interval: Duration, alertThreshold: Int) {
CoroutineScope(Dispatchers.IO).launch {
Log.d(TAG, "inserting to local db")
heartRateRepository.insert(heartRateSample)
}
if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 7) {
if (checkSelfPermission(HealthPermission.getWritePermission(HeartRateRecord::class)) != PackageManager.PERMISSION_GRANTED) return
val healthConnectHeartRateSample = HeartRateRecord.Sample(
time = heartRateSample.timestamp.toJavaInstant(),
beatsPerMinute = heartRateSample.bpm.toLong()
)
CoroutineScope(Dispatchers.Default).launch {
val notificationManager = getSystemService(NotificationManager::class.java)
val zoneOffset = ZoneOffset.systemDefault().rules.getOffset(heartRateSample.timestamp.toJavaInstant())
if (heartRateSample.bpm > alertThreshold) {
val notification = NotificationCompat.Builder(this@LibrePodsService, "hrm_alert")
.setSmallIcon(R.drawable.ic_pulse_alert)
.setContentTitle(getString(R.string.high_heart_rate))
.setContentText(
getString(
R.string.high_heart_rate_notification_text,
heartRateSample.bpm
)
)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setOngoing(true)
.build()
val heartRateRecord = HeartRateRecord(
startTime = (heartRateSample.timestamp - interval).toJavaInstant(),
endTime = heartRateSample.timestamp.toJavaInstant(),
startZoneOffset = zoneOffset,
endZoneOffset = zoneOffset,
samples = listOf(healthConnectHeartRateSample),
metadata = androidx.health.connect.client.records.metadata.Metadata.autoRecorded(
device = androidx.health.connect.client.records.metadata.Device(type = androidx.health.connect.client.records.metadata.Device.TYPE_HEARABLE)
notificationManager.notify(999, notification)
} else {
if (notificationManager.activeNotifications.any { it.id == 999 }) {
notificationManager.cancel(999)
}
}
}
CoroutineScope(Dispatchers.IO).launch {
if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 7) {
if (checkSelfPermission(HealthPermission.getWritePermission(HeartRateRecord::class)) != PackageManager.PERMISSION_GRANTED) return@launch
val healthConnectHeartRateSample = HeartRateRecord.Sample(
time = heartRateSample.timestamp.toJavaInstant(),
beatsPerMinute = heartRateSample.bpm.toLong()
)
)
if (healthConnectClient != null) {
CoroutineScope(Dispatchers.IO).launch {
healthConnectClient!!.insertRecords(listOf(heartRateRecord))
val zoneOffset =
ZoneOffset.systemDefault().rules.getOffset(heartRateSample.timestamp.toJavaInstant())
val heartRateRecord = HeartRateRecord(
startTime = (heartRateSample.timestamp - interval).toJavaInstant(),
endTime = heartRateSample.timestamp.toJavaInstant(),
startZoneOffset = zoneOffset,
endZoneOffset = zoneOffset,
samples = listOf(healthConnectHeartRateSample),
metadata = androidx.health.connect.client.records.metadata.Metadata.autoRecorded(
device = androidx.health.connect.client.records.metadata.Device(type = androidx.health.connect.client.records.metadata.Device.TYPE_HEARABLE)
)
)
if (healthConnectClient != null) {
CoroutineScope(Dispatchers.IO).launch {
try {
healthConnectClient!!.insertRecords(listOf(heartRateRecord))
} catch (e: Exception) {
Log.e(TAG, "Error inserting heart rate record", e)
}
}
} else {
Log.w(TAG, "Health Connect client not available")
}
} else {
Log.w(TAG, "Health Connect client not available")
Log.d(TAG, "U SDK Extension <7")
}
} else {
Log.d(TAG, "U SDK Extension <7")
}
}
@@ -90,8 +90,8 @@ class GestureDetector {
while (verticalAvgBuffer.size < 3) verticalAvgBuffer.add(0.0)
}
fun startDetection(acceleration: StateFlow<Acceleration>, callback: (Boolean) -> Unit) {
if (isRunning) return
fun startDetection(acceleration: StateFlow<Acceleration>, callback: (Boolean) -> Unit): Boolean {
if (isRunning) return false
Log.d(TAG, "Starting gesture detection...")
isRunning = true
@@ -123,6 +123,8 @@ class GestureDetector {
}
}
}
return true
}
fun stopDetection() {
if (!isRunning) return
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M440,466Q440,466 440,466Q440,466 440,466Q440,466 440,466Q440,466 440,466Q440,466 440,466Q440,466 440,466Q440,466 440,466Q440,466 440,466Q440,466 440,466Q440,466 440,466Q440,466 440,466Q440,466 440,466L440,466Q440,466 440,466Q440,466 440,466Q440,466 440,466Q440,466 440,466Q440,466 440,466Q440,466 440,466L440,466Q440,466 440,466Q440,466 440,466Q440,466 440,466Q440,466 440,466L440,466Q440,466 440,466Q440,466 440,466ZM87,400Q83,385 81.5,370Q80,355 80,339Q80,245 143,182.5Q206,120 300,120Q351,120 398.5,142Q446,164 480,204Q514,164 561,142Q608,120 660,120Q754,120 817,182.5Q880,245 880,339Q880,340 880,340Q880,340 880,341Q861,324 839,311.5Q817,299 792,291Q778,250 742,225Q706,200 660,200Q617,200 577,225.5Q537,251 503,300L457,300Q424,252 383,226Q342,200 300,200Q241,200 200.5,240.5Q160,281 160,339Q160,355 163,369.5Q166,384 172,400L87,400ZM480,840L353,726Q326,701 303,680Q280,659 260,640L377,640Q400,660 425.5,683Q451,706 480,732Q497,718 511.5,705Q526,692 540,679Q554,694 569.5,706.5Q585,719 603,729Q603,729 603,729Q603,729 603,729L480,840ZM691.5,668.5Q680,657 680,640Q680,623 691.5,611.5Q703,600 720,600Q737,600 748.5,611.5Q760,623 760,640Q760,657 748.5,668.5Q737,680 720,680Q703,680 691.5,668.5ZM680,560L680,360L760,360L760,560L680,560ZM40,560Q40,540 40,520Q40,500 40,480L218,480L288,377Q294,368 302.5,364Q311,360 321,360Q331,360 339.5,364.5Q348,369 354,378L422,480L484,480Q482,490 481,499.5Q480,509 480,520Q480,531 481,540.5Q482,550 484,560L400,560Q390,560 381,555Q372,550 367,542L320,472L273,542Q268,550 259,555Q250,560 240,560L40,560Z"/>
</vector>
+10 -2
View File
@@ -332,7 +332,7 @@
<string name="permission_description_bluetooth">Required to communicate with AirPods.</string>
<string name="optional_permissions">Optional Permissions</string>
<string name="notifications">Notifications</string>
<string name="permission_description_notification">Show battery status when connected or available nearby.</string>
<string name="permission_description_notification">Show battery status, alerts for high heart rate (on supported models), and more.</string>
<string name="permission_description_phone">Respond to phone calls with head gestures.</string>
<string name="permission_overlay">Display over other apps</string>
<string name="permission_description_overlay">Show popups when AirPods are nearby or audio switches to them.</string>
@@ -343,7 +343,15 @@
<string name="heart_rate_monitoring">Heart Rate Monitoring</string>
<string name="hrm_alert_description">Sends a notification when your heart rate is above the threshold. Consumes more battery.</string>
<string name="heart_rate_alert">Heart rate alert</string>
<string name="heart_rate_alert_threshold">Heart rate threshold</string>
<string name="heart_rate_alert_threshold">Alert threshold</string>
<string name="call_rejected">Call rejected</string>
<string name="call_accepted">Call accepted</string>
<string name="waiting_ellipsis">Waiting…</string>
<string name="service_running">Service Running</string>
<string name="foreground_notification_description">Useless notification, disable it by clicking on it.</string>
<string name="foreground_service_status">Foreground Service Status</string>
<string name="high_heart_rate_notification_text">Your heart rate is above the set threshold. (currently: %d bpm)</string>
<string name="high_heart_rate">High heart rate</string>
<string name="hrm_alert_warning">This alert does not currently account for exercise or physical activity. It is for informational purposes only and is not intended as medical advice.</string>
<string name="heart_rate_alerts_disabled_warning">Alerts have been disabled until reconnection or re-enabling heart rate monitoring.</string>
</resources>