mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-08-25 21:45:30 +02:00
android: refactor... stuff (4, see description)
hrm alerts
This commit is contained in:
@@ -77,7 +77,7 @@ android {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "me.kavishdevar.librepods"
|
applicationId = "me.kavishdevar.librepods"
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 80
|
versionCode = 85
|
||||||
versionName = appVersionName
|
versionName = appVersionName
|
||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"formatVersion": 1,
|
"formatVersion": 1,
|
||||||
"database": {
|
"database": {
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"identityHash": "b6034db24a2a1b8f4d424d89fcc4495f",
|
"identityHash": "721149fa5e2a13e82b73a83de312968a",
|
||||||
"entities": [
|
"entities": [
|
||||||
{
|
{
|
||||||
"tableName": "AppleEntity",
|
"tableName": "AppleEntity",
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"tableName": "AppSettingsEntity",
|
"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": [
|
"fields": [
|
||||||
{
|
{
|
||||||
"fieldPath": "id",
|
"fieldPath": "id",
|
||||||
@@ -61,6 +61,36 @@
|
|||||||
"columnName": "designSystem",
|
"columnName": "designSystem",
|
||||||
"affinity": "TEXT",
|
"affinity": "TEXT",
|
||||||
"notNull": true
|
"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": {
|
"primaryKey": {
|
||||||
@@ -72,7 +102,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"tableName": "AppStateEntity",
|
"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": [
|
"fields": [
|
||||||
{
|
{
|
||||||
"fieldPath": "id",
|
"fieldPath": "id",
|
||||||
@@ -80,6 +110,12 @@
|
|||||||
"affinity": "INTEGER",
|
"affinity": "INTEGER",
|
||||||
"notNull": true
|
"notNull": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isPremium",
|
||||||
|
"columnName": "isPremium",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"fieldPath": "hasCompletedOnboarding",
|
"fieldPath": "hasCompletedOnboarding",
|
||||||
"columnName": "hasCompletedOnboarding",
|
"columnName": "hasCompletedOnboarding",
|
||||||
@@ -145,11 +181,41 @@
|
|||||||
"appWidgetId"
|
"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": [
|
"setupQueries": [
|
||||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
"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')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
-52
@@ -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.MessageOpcode
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.types.RTBuddyDescriptor
|
import me.kavishdevar.librepods.bluetooth.aacp.types.RTBuddyDescriptor
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.types.SensorDataWxBuddyPayload
|
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.audio.MicrophoneFrame
|
||||||
import me.kavishdevar.librepods.data.heartrate.HeartRateSample
|
import me.kavishdevar.librepods.data.heartrate.HeartRateSample
|
||||||
import me.kavishdevar.librepods.devices.AppleDevice
|
import me.kavishdevar.librepods.devices.AppleDevice
|
||||||
@@ -64,6 +65,7 @@ import kotlin.io.encoding.ExperimentalEncodingApi
|
|||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
class AACPManager(private val device: AppleDevice) {
|
class AACPManager(private val device: AppleDevice) {
|
||||||
private val macParts = device.macAddress.value.split(":")
|
private val macParts = device.macAddress.value.split(":")
|
||||||
@@ -73,6 +75,28 @@ class AACPManager(private val device: AppleDevice) {
|
|||||||
|
|
||||||
private val rtBuddyManager = RTBuddyManager(::sendPacket)
|
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 {
|
fun connect(): Boolean {
|
||||||
if (socket != null && socket!!.isConnected) {
|
if (socket != null && socket!!.isConnected) {
|
||||||
Log.i(TAG, "Already connected")
|
Log.i(TAG, "Already connected")
|
||||||
@@ -99,19 +123,9 @@ class AACPManager(private val device: AppleDevice) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
while(!socket.isConnected) {
|
sendDelayedInitPackets()
|
||||||
Log.i(TAG, "waiting for connection...")
|
delay(3.seconds) // just android stuff ig
|
||||||
delay(500.milliseconds)
|
sendDelayedInitPackets()
|
||||||
}
|
|
||||||
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())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
@@ -1031,6 +1045,8 @@ class AACPManager(private val device: AppleDevice) {
|
|||||||
microphoneFrames = emptyList(),
|
microphoneFrames = emptyList(),
|
||||||
controlStates = emptyMap(),
|
controlStates = emptyMap(),
|
||||||
aacpPackets = emptyList(),
|
aacpPackets = emptyList(),
|
||||||
|
headTrackingState = BuddyState.INACTIVE,
|
||||||
|
hrmState = BuddyState.INACTIVE,
|
||||||
currentHeartRate = null
|
currentHeartRate = null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1053,36 +1069,6 @@ class AACPManager(private val device: AppleDevice) {
|
|||||||
return sendPacket(packet)
|
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 {
|
fun requestMicrophoneStream(): Boolean {
|
||||||
val payload = byteArrayOf(
|
val payload = byteArrayOf(
|
||||||
0x00, 0x00,
|
0x00, 0x00,
|
||||||
@@ -1183,16 +1169,18 @@ class AACPManager(private val device: AppleDevice) {
|
|||||||
if (value and 0x8000 != 0) value - 0x10000 else value
|
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())
|
HeadTracking.addAccel(i16(device.settings.value.headGesturesVerticalOffset).toFloat(), i16(device.settings.value.headGesturesHorizontalOffset).toFloat())
|
||||||
}
|
}
|
||||||
|
|
||||||
SensorServiceType.HEARTRATE -> {
|
SensorServiceType.HEARTRATE,
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"Received sensor data for service: ${data.command.service}, payload: ${data.command.payload.toByteArray().toHexString()}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
SensorServiceType.HEARTRATEv2 -> {
|
SensorServiceType.HEARTRATEv2 -> {
|
||||||
val payload = data.command.payload.toByteArray()
|
val payload = data.command.payload.toByteArray()
|
||||||
val timestamp = Clock.System.now()
|
val timestamp = Clock.System.now()
|
||||||
@@ -1208,10 +1196,10 @@ class AACPManager(private val device: AppleDevice) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!device.state.value.hrmActive) {
|
if (device.state.value.hrmState != BuddyState.ACTIVE) {
|
||||||
device.updateState {
|
device.updateState {
|
||||||
it.copy(
|
it.copy(
|
||||||
hrmActive = true
|
hrmState = BuddyState.ACTIVE
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-5
@@ -4,7 +4,6 @@ import me.kavishdevar.librepods.bluetooth.aacp.types.CustomEq
|
|||||||
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
|
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
|
||||||
import me.kavishdevar.librepods.devices.PacketDestination
|
import me.kavishdevar.librepods.devices.PacketDestination
|
||||||
|
|
||||||
|
|
||||||
data class CustomEqPacket(
|
data class CustomEqPacket(
|
||||||
val customEq: CustomEq,
|
val customEq: CustomEq,
|
||||||
override val destination: PacketDestination
|
override val destination: PacketDestination
|
||||||
@@ -26,10 +25,10 @@ data class CustomEqPacket(
|
|||||||
val length = payload[0].toInt()
|
val length = payload[0].toInt()
|
||||||
require(length == 5) { "Invalid length for CustomEqPacket: $length" }
|
require(length == 5) { "Invalid length for CustomEqPacket: $length" }
|
||||||
|
|
||||||
val state = payload[2].toInt()
|
val state = payload[3].toInt()
|
||||||
val low = payload[3].toInt()
|
val low = payload[4].toInt()
|
||||||
val mid = payload[4].toInt()
|
val mid = payload[5].toInt()
|
||||||
val high = payload[5].toInt()
|
val high = payload[6].toInt()
|
||||||
|
|
||||||
val customEq = CustomEq(state, low, mid, high)
|
val customEq = CustomEq(state, low, mid, high)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package me.kavishdevar.librepods.data.apple
|
|||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.types.CapabilityEntry
|
import me.kavishdevar.librepods.bluetooth.aacp.types.CapabilityEntry
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
|
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.bluetooth.aacp.types.MagicKeyType
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -11,5 +10,4 @@ data class AppleCache(
|
|||||||
val capabilities: Set<CapabilityEntry> = emptySet(),
|
val capabilities: Set<CapabilityEntry> = emptySet(),
|
||||||
val magicKeys: Map<MagicKeyType, ByteArray> = emptyMap(),
|
val magicKeys: Map<MagicKeyType, ByteArray> = emptyMap(),
|
||||||
val controlStates: Map<ControlCommandIdentifier, 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 bpm: Int,
|
||||||
val timestamp: Instant
|
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
|
import kotlin.time.Instant
|
||||||
|
|
||||||
object Converters {
|
object Converters {
|
||||||
|
val cbor = Cbor {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
}
|
||||||
|
|
||||||
@ColumnTypeConverter
|
@ColumnTypeConverter
|
||||||
fun macAddressToString(mac: MacAddress): String = mac.value
|
fun macAddressToString(mac: MacAddress): String = mac.value
|
||||||
|
|
||||||
@@ -19,27 +23,27 @@ object Converters {
|
|||||||
|
|
||||||
@ColumnTypeConverter
|
@ColumnTypeConverter
|
||||||
fun appleSettingsToBytes(settings: AppleSettings): ByteArray =
|
fun appleSettingsToBytes(settings: AppleSettings): ByteArray =
|
||||||
Cbor.encodeToByteArray(settings)
|
cbor.encodeToByteArray(settings)
|
||||||
|
|
||||||
@ColumnTypeConverter
|
@ColumnTypeConverter
|
||||||
fun bytesToAppleSettings(bytes: ByteArray): AppleSettings =
|
fun bytesToAppleSettings(bytes: ByteArray): AppleSettings =
|
||||||
Cbor.decodeFromByteArray(bytes)
|
cbor.decodeFromByteArray(bytes)
|
||||||
|
|
||||||
@ColumnTypeConverter
|
@ColumnTypeConverter
|
||||||
fun appleMetadataToBytes(metadata: AppleMetadata): ByteArray =
|
fun appleMetadataToBytes(metadata: AppleMetadata): ByteArray =
|
||||||
Cbor.encodeToByteArray(metadata)
|
cbor.encodeToByteArray(metadata)
|
||||||
|
|
||||||
@ColumnTypeConverter
|
@ColumnTypeConverter
|
||||||
fun bytesToAppleMetadata(bytes: ByteArray): AppleMetadata =
|
fun bytesToAppleMetadata(bytes: ByteArray): AppleMetadata =
|
||||||
Cbor.decodeFromByteArray(bytes)
|
cbor.decodeFromByteArray(bytes)
|
||||||
|
|
||||||
@ColumnTypeConverter
|
@ColumnTypeConverter
|
||||||
fun appleCacheToBytes(cache: AppleCache): ByteArray =
|
fun appleCacheToBytes(cache: AppleCache): ByteArray =
|
||||||
Cbor.encodeToByteArray(cache)
|
cbor.encodeToByteArray(cache)
|
||||||
|
|
||||||
@ColumnTypeConverter
|
@ColumnTypeConverter
|
||||||
fun bytesToAppleCache(bytes: ByteArray): AppleCache =
|
fun bytesToAppleCache(bytes: ByteArray): AppleCache =
|
||||||
Cbor.decodeFromByteArray(bytes)
|
cbor.decodeFromByteArray(bytes)
|
||||||
|
|
||||||
@ColumnTypeConverter
|
@ColumnTypeConverter
|
||||||
fun kotlinInstantToLong(instant: Instant): Long =
|
fun kotlinInstantToLong(instant: Instant): Long =
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
package me.kavishdevar.librepods.database
|
package me.kavishdevar.librepods.database
|
||||||
|
|
||||||
|
import androidx.room3.AutoMigration
|
||||||
import androidx.room3.ColumnTypeConverters
|
import androidx.room3.ColumnTypeConverters
|
||||||
import androidx.room3.Database
|
import androidx.room3.Database
|
||||||
import androidx.room3.RoomDatabase
|
import androidx.room3.RoomDatabase
|
||||||
import me.kavishdevar.librepods.data.heartrate.HeartRateDao
|
|
||||||
import me.kavishdevar.librepods.database.app.AppSettingsDao
|
import me.kavishdevar.librepods.database.app.AppSettingsDao
|
||||||
import me.kavishdevar.librepods.database.app.AppSettingsEntity
|
import me.kavishdevar.librepods.database.app.AppSettingsEntity
|
||||||
import me.kavishdevar.librepods.database.app.AppStateDao
|
import me.kavishdevar.librepods.database.app.AppStateDao
|
||||||
import me.kavishdevar.librepods.database.app.AppStateEntity
|
import me.kavishdevar.librepods.database.app.AppStateEntity
|
||||||
import me.kavishdevar.librepods.database.apple.AppleDao
|
import me.kavishdevar.librepods.database.apple.AppleDao
|
||||||
import me.kavishdevar.librepods.database.apple.AppleEntity
|
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.heartrate.HeartRateSampleEntity
|
||||||
import me.kavishdevar.librepods.database.widget.WidgetConfigDao
|
import me.kavishdevar.librepods.database.widget.WidgetConfigDao
|
||||||
import me.kavishdevar.librepods.database.widget.WidgetConfigEntity
|
import me.kavishdevar.librepods.database.widget.WidgetConfigEntity
|
||||||
@@ -23,7 +24,11 @@ import me.kavishdevar.librepods.database.widget.WidgetConfigEntity
|
|||||||
WidgetConfigEntity::class,
|
WidgetConfigEntity::class,
|
||||||
HeartRateSampleEntity::class
|
HeartRateSampleEntity::class
|
||||||
],
|
],
|
||||||
version = 1,
|
version = 3,
|
||||||
|
autoMigrations = [
|
||||||
|
AutoMigration(from = 1, to = 2),
|
||||||
|
AutoMigration(from = 2, to = 3),
|
||||||
|
]
|
||||||
)
|
)
|
||||||
abstract class LibrePodsDatabase: RoomDatabase() {
|
abstract class LibrePodsDatabase: RoomDatabase() {
|
||||||
abstract fun appleDao(): AppleDao
|
abstract fun appleDao(): AppleDao
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package me.kavishdevar.librepods.database.app
|
package me.kavishdevar.librepods.database.app
|
||||||
|
|
||||||
import android.bluetooth.le.ScanSettings
|
import android.bluetooth.le.ScanSettings
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.room3.Entity
|
import androidx.room3.Entity
|
||||||
import androidx.room3.PrimaryKey
|
import androidx.room3.PrimaryKey
|
||||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||||
@@ -13,6 +14,8 @@ data class AppSettingsEntity(
|
|||||||
|
|
||||||
val nightMode: NightTheme = NightTheme.System,
|
val nightMode: NightTheme = NightTheme.System,
|
||||||
val designSystem: DesignSystem = DesignSystem.Material,
|
val designSystem: DesignSystem = DesignSystem.Material,
|
||||||
|
val overrideMaterialColor: Color? = null,
|
||||||
|
|
||||||
val useHighestRefreshRate: Boolean = false,
|
val useHighestRefreshRate: Boolean = false,
|
||||||
|
|
||||||
val debugMode: Boolean = false,
|
val debugMode: Boolean = false,
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import android.util.Log
|
|||||||
import androidx.room3.Dao
|
import androidx.room3.Dao
|
||||||
import androidx.room3.Query
|
import androidx.room3.Query
|
||||||
import androidx.room3.Upsert
|
import androidx.room3.Upsert
|
||||||
|
import me.kavishdevar.librepods.bluetooth.MacAddress
|
||||||
import me.kavishdevar.librepods.data.apple.AppleCache
|
import me.kavishdevar.librepods.data.apple.AppleCache
|
||||||
import me.kavishdevar.librepods.devices.AppleMetadata
|
import me.kavishdevar.librepods.devices.AppleMetadata
|
||||||
import me.kavishdevar.librepods.devices.AppleSettings
|
import me.kavishdevar.librepods.devices.AppleSettings
|
||||||
import me.kavishdevar.librepods.bluetooth.MacAddress
|
|
||||||
|
|
||||||
private const val TAG = "AppleDao"
|
private const val TAG = "AppleDao"
|
||||||
|
|
||||||
@@ -26,18 +26,18 @@ interface AppleDao {
|
|||||||
suspend fun updateSettings(macAddress: MacAddress, settings: AppleSettings) {
|
suspend fun updateSettings(macAddress: MacAddress, settings: AppleSettings) {
|
||||||
Log.d(TAG, "Updating settings for $macAddress: $settings")
|
Log.d(TAG, "Updating settings for $macAddress: $settings")
|
||||||
val device = get(macAddress)?: AppleEntity(macAddress = macAddress, settings = settings, metadata = AppleMetadata(), cache = AppleCache())
|
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) {
|
suspend fun updateMetadata(macAddress: MacAddress, metadata: AppleMetadata) {
|
||||||
Log.d(TAG, "Updating metadata for $macAddress: $metadata")
|
Log.d(TAG, "Updating metadata for $macAddress: $metadata")
|
||||||
val device = get(macAddress)?: AppleEntity(macAddress = macAddress, settings = AppleSettings(), metadata = metadata, cache = AppleCache())
|
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) {
|
suspend fun saveCache(macAddress: MacAddress, cache: AppleCache) {
|
||||||
Log.d(TAG, "Saving cache for $macAddress: $cache")
|
Log.d(TAG, "Saving cache for $macAddress: $cache")
|
||||||
val device = get(macAddress)?: AppleEntity(macAddress = macAddress, settings = AppleSettings(), metadata = AppleMetadata(), cache = 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
-2
@@ -1,9 +1,8 @@
|
|||||||
package me.kavishdevar.librepods.data.heartrate
|
package me.kavishdevar.librepods.database.heartrate
|
||||||
|
|
||||||
import androidx.room3.Dao
|
import androidx.room3.Dao
|
||||||
import androidx.room3.Insert
|
import androidx.room3.Insert
|
||||||
import androidx.room3.Query
|
import androidx.room3.Query
|
||||||
import me.kavishdevar.librepods.database.heartrate.HeartRateSampleEntity
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@Dao
|
@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.ATTHandle
|
||||||
import me.kavishdevar.librepods.bluetooth.att.ATTManager
|
import me.kavishdevar.librepods.bluetooth.att.ATTManager
|
||||||
import me.kavishdevar.librepods.data.StemAction
|
import me.kavishdevar.librepods.data.StemAction
|
||||||
|
import me.kavishdevar.librepods.data.apple.BuddyState
|
||||||
import me.kavishdevar.librepods.utils.GestureDetector
|
import me.kavishdevar.librepods.utils.GestureDetector
|
||||||
import me.kavishdevar.librepods.utils.HeadTracking
|
import me.kavishdevar.librepods.utils.HeadTracking
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
@@ -224,7 +225,9 @@ class AppleDevice(
|
|||||||
interval = _settings.value.headTrackingInterval
|
interval = _settings.value.headTrackingInterval
|
||||||
)
|
)
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(headTrackingActive = true)
|
it.copy(
|
||||||
|
headTrackingState = BuddyState.WAITING
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,20 +237,25 @@ class AppleDevice(
|
|||||||
interval = Duration.ZERO
|
interval = Duration.ZERO
|
||||||
)
|
)
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(headTrackingActive = false)
|
it.copy(
|
||||||
|
headTrackingState = BuddyState.INACTIVE,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
gestureDetector.stopDetection()
|
gestureDetector.stopDetection()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: multiple callbacks
|
||||||
fun detectHeadGestures(callback: (Boolean) -> Unit) {
|
fun detectHeadGestures(callback: (Boolean) -> Unit) {
|
||||||
if (!state.value.headTrackingActive) startHeadTracking()
|
if (state.value.headTrackingState != BuddyState.ACTIVE) startHeadTracking()
|
||||||
if (!state.value.headTrackingActive) {
|
val started = gestureDetector.startDetection(HeadTracking.acceleration, callback)
|
||||||
aacp.setSensorServiceReportInterval(
|
if (!started) {
|
||||||
sensorServiceType = if (metadata.value.version3.first().digitToInt() >= 8) SensorServiceType.DEVMOTION6 else SensorServiceType.ACTIVITY,
|
Log.w(TAG, "Failed to start gesture detection, probably because already running.")
|
||||||
interval = 40.milliseconds
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
gestureDetector.startDetection(HeadTracking.acceleration, callback)
|
}
|
||||||
|
|
||||||
|
fun stopHeadGestureDetection() {
|
||||||
|
gestureDetector.stopDetection()
|
||||||
|
stopHeadTracking()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setHeadGesturesEnabled(enabled: Boolean) {
|
fun setHeadGesturesEnabled(enabled: Boolean) {
|
||||||
@@ -287,19 +295,28 @@ class AppleDevice(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AACPManager sets hrmActive true when a valid reading is received
|
// AACPManager sets hrmActive true when a valid reading is received
|
||||||
fun startHr(): Boolean = aacp.setSensorServiceReportInterval(
|
fun startHr(): Boolean {
|
||||||
sensorServiceType = if (metadata.value.version3.first().digitToInt() >= 8) SensorServiceType.HEARTRATE_COMMAND else SensorServiceType.HEARTRATE,
|
val success = aacp.setSensorServiceReportInterval(
|
||||||
interval = 1.seconds
|
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 {
|
fun stopHr(): Boolean {
|
||||||
val success = aacp.setSensorServiceReportInterval(
|
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
|
interval = Duration.ZERO
|
||||||
)
|
)
|
||||||
if (state.value.hrmActive && success) _state.update {
|
if (state.value.hrmState != BuddyState.INACTIVE && success) _state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
hrmActive = false,
|
hrmState = BuddyState.INACTIVE,
|
||||||
currentHeartRate = null
|
currentHeartRate = null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package me.kavishdevar.librepods.devices
|
package me.kavishdevar.librepods.devices
|
||||||
|
|
||||||
|
import androidx.annotation.IntRange
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import me.kavishdevar.librepods.data.StemAction
|
import me.kavishdevar.librepods.data.StemAction
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
@@ -38,6 +39,6 @@ data class AppleSettings(
|
|||||||
val conversationalAwarenessVolume: Float = 43f,
|
val conversationalAwarenessVolume: Float = 43f,
|
||||||
val conversationalAwarenessReducedVolume: Float = 20f,
|
val conversationalAwarenessReducedVolume: Float = 20f,
|
||||||
|
|
||||||
val hrAlertEnabled: Boolean = true,
|
val hrmAlertEnabled: Boolean = false,
|
||||||
val hrmAlertThreshold: Int = 120,
|
@IntRange(from = 120, to = 200) val hrmAlertThreshold: Int = 150,
|
||||||
): DeviceSettings
|
): 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.ControlCommandIdentifier
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.types.CustomEq
|
import me.kavishdevar.librepods.bluetooth.aacp.types.CustomEq
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType
|
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.MicrophoneFrame
|
||||||
import me.kavishdevar.librepods.data.audio.MicrophoneState
|
import me.kavishdevar.librepods.data.audio.MicrophoneState
|
||||||
import me.kavishdevar.librepods.data.heartrate.HeartRateSample
|
import me.kavishdevar.librepods.data.heartrate.HeartRateSample
|
||||||
@@ -32,8 +33,7 @@ data class AppleState(
|
|||||||
|
|
||||||
val magicKeys: Map<MagicKeyType, ByteArray> = emptyMap(),
|
val magicKeys: Map<MagicKeyType, ByteArray> = emptyMap(),
|
||||||
|
|
||||||
val headTrackingActive: Boolean = false,
|
val headTrackingState: BuddyState = BuddyState.INACTIVE,
|
||||||
val detectHeadGestures: Boolean = false,
|
|
||||||
|
|
||||||
val loudSoundReductionEnabled: Boolean = false,
|
val loudSoundReductionEnabled: Boolean = false,
|
||||||
val transparencyData: ByteArray = byteArrayOf(),
|
val transparencyData: ByteArray = byteArrayOf(),
|
||||||
@@ -55,7 +55,7 @@ data class AppleState(
|
|||||||
val headphoneAccomodationEnabledForPhone: Boolean = false,
|
val headphoneAccomodationEnabledForPhone: Boolean = false,
|
||||||
|
|
||||||
val currentHeartRate: HeartRateSample? = null,
|
val currentHeartRate: HeartRateSample? = null,
|
||||||
val hrmActive: Boolean = false,
|
val hrmState: BuddyState = BuddyState.INACTIVE,
|
||||||
val heartRateInterval: Duration = 1.seconds,
|
val heartRateInterval: Duration = 1.seconds,
|
||||||
|
|
||||||
val aacpPackets: List<AACPPacket> = emptyList(),
|
val aacpPackets: List<AACPPacket> = emptyList(),
|
||||||
|
|||||||
+1
@@ -115,6 +115,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
LibrePodsTheme(
|
LibrePodsTheme(
|
||||||
designSystem = settings.designSystem,
|
designSystem = settings.designSystem,
|
||||||
|
overrideMaterialColor = settings.overrideMaterialColor,
|
||||||
darkTheme = darkTheme
|
darkTheme = darkTheme
|
||||||
) {
|
) {
|
||||||
// For demo screenshots
|
// For demo screenshots
|
||||||
|
|||||||
+1
-1
@@ -65,7 +65,7 @@ class PrivacyPolicyActivity : ComponentActivity() {
|
|||||||
|
|
||||||
StyledScaffold(
|
StyledScaffold(
|
||||||
title = stringResource(R.string.privacy_policy),
|
title = stringResource(R.string.privacy_policy),
|
||||||
navigateBack = null
|
navigateBack = { finish() }
|
||||||
) { topPadding, bottomPadding ->
|
) { topPadding, bottomPadding ->
|
||||||
Column {
|
Column {
|
||||||
Spacer(modifier = Modifier.height(topPadding))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|||||||
+6
-9
@@ -67,11 +67,8 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.asComposeRenderEffect
|
import androidx.compose.ui.graphics.asComposeRenderEffect
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.graphics.luminance
|
import androidx.compose.ui.graphics.luminance
|
||||||
import androidx.compose.ui.input.pointer.consumePositionChange
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
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.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
@@ -207,10 +204,10 @@ fun StyledScaffold(
|
|||||||
val backdrop = rememberLayerBackdrop()
|
val backdrop = rememberLayerBackdrop()
|
||||||
val bgColor = MaterialTheme.colorScheme.surfaceContainer
|
val bgColor = MaterialTheme.colorScheme.surfaceContainer
|
||||||
|
|
||||||
val density = LocalDensity.current
|
// val density = LocalDensity.current
|
||||||
val screenWidthPx = with(density) {
|
// val screenWidthPx = with(density) {
|
||||||
LocalWindowInfo.current.containerDpSize.width.toPx()
|
// LocalWindowInfo.current.containerDpSize.width.toPx()
|
||||||
}
|
// }
|
||||||
val isCurrentEntry = LocalIsCurrentEntry.current
|
val isCurrentEntry = LocalIsCurrentEntry.current
|
||||||
val transitionProgress = LocalTransitionProgress.current
|
val transitionProgress = LocalTransitionProgress.current
|
||||||
val sharedTransitionScope = LocalSharedTransitionScope.current
|
val sharedTransitionScope = LocalSharedTransitionScope.current
|
||||||
@@ -233,7 +230,7 @@ fun StyledScaffold(
|
|||||||
val event = awaitPointerEvent()
|
val event = awaitPointerEvent()
|
||||||
|
|
||||||
event.changes.forEach { change ->
|
event.changes.forEach { change ->
|
||||||
change.consumePositionChange()
|
change.consume()
|
||||||
}
|
}
|
||||||
} while (event.changes.any { it.pressed })
|
} while (event.changes.any { it.pressed })
|
||||||
}
|
}
|
||||||
@@ -350,7 +347,7 @@ fun StyledScaffold(
|
|||||||
val event = awaitPointerEvent()
|
val event = awaitPointerEvent()
|
||||||
|
|
||||||
event.changes.forEach { change ->
|
event.changes.forEach { change ->
|
||||||
change.consumePositionChange()
|
change.consume()
|
||||||
}
|
}
|
||||||
} while (event.changes.any { it.pressed })
|
} while (event.changes.any { it.pressed })
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-13
@@ -1,6 +1,5 @@
|
|||||||
package me.kavishdevar.librepods.presentation.navigation
|
package me.kavishdevar.librepods.presentation.navigation
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import androidx.compose.animation.EnterTransition
|
import androidx.compose.animation.EnterTransition
|
||||||
import androidx.compose.animation.ExitTransition
|
import androidx.compose.animation.ExitTransition
|
||||||
import androidx.compose.animation.SharedTransitionLayout
|
import androidx.compose.animation.SharedTransitionLayout
|
||||||
@@ -11,7 +10,6 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.State
|
import androidx.compose.runtime.State
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.navigation3.runtime.NavBackStack
|
import androidx.navigation3.runtime.NavBackStack
|
||||||
@@ -44,18 +42,31 @@ fun AppNavGraph(
|
|||||||
|
|
||||||
val currentDevice: Device<*, *, *>? = devices[backStack.lastOrNull()?.let { (it as? DeviceScreen)?.macAddress }]
|
val currentDevice: Device<*, *, *>? = devices[backStack.lastOrNull()?.let { (it as? DeviceScreen)?.macAddress }]
|
||||||
|
|
||||||
@SuppressLint("UnrememberedMutableState")
|
LaunchedEffect(currentDevice) {
|
||||||
val currentConnectionState by (currentDevice as? AppleDevice)?.connectionState?.collectAsState()?: mutableStateOf(ConnectionState.DISCONNECTED)
|
val device = currentDevice as? AppleDevice ?: return@LaunchedEffect
|
||||||
|
|
||||||
LaunchedEffect(currentConnectionState) {
|
var wasConnected = false
|
||||||
if (currentConnectionState == ConnectionState.DISCONNECTED) {
|
|
||||||
while (
|
device.connectionState.collect { state ->
|
||||||
backStack.size > 1 &&
|
when (state) {
|
||||||
backStack.lastOrNull() is DeviceScreen
|
ConnectionState.CONNECTED -> {
|
||||||
) {
|
wasConnected = true
|
||||||
val completed = CompletableDeferred<Unit>()
|
}
|
||||||
backRequests.send(completed)
|
|
||||||
completed.await()
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -169,6 +169,7 @@ private fun <T : Any> SwipeBackSceneContent(
|
|||||||
PredictiveBackHandler { progressFlow ->
|
PredictiveBackHandler { progressFlow ->
|
||||||
try {
|
try {
|
||||||
progressFlow.collect { backEvent ->
|
progressFlow.collect { backEvent ->
|
||||||
|
if (previousEntry == null) return@collect
|
||||||
val progress = backEvent.progress
|
val progress = backEvent.progress
|
||||||
|
|
||||||
transitionProgress = -progress
|
transitionProgress = -progress
|
||||||
|
|||||||
+117
-3
@@ -1,11 +1,125 @@
|
|||||||
package me.kavishdevar.librepods.presentation.navigation
|
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.animation.SharedTransitionScope
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
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> {
|
val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope> { error("No SharedTransitionScope provided") }
|
||||||
error("LocalSharedTransitionScope not provided")
|
|
||||||
}
|
|
||||||
|
|
||||||
val LocalTransitionProgress = compositionLocalOf { 0f }
|
val LocalTransitionProgress = compositionLocalOf { 0f }
|
||||||
val LocalIsCurrentEntry = compositionLocalOf { false }
|
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
|
||||||
|
}
|
||||||
|
|||||||
+4
-1
@@ -51,6 +51,7 @@ import kotlinx.coroutines.flow.debounce
|
|||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
|
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
|
||||||
import me.kavishdevar.librepods.bluetooth.att.ATTHandle
|
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.AirPodsSpecs
|
||||||
import me.kavishdevar.librepods.devices.AppleSettings
|
import me.kavishdevar.librepods.devices.AppleSettings
|
||||||
import me.kavishdevar.librepods.devices.BaseCapability
|
import me.kavishdevar.librepods.devices.BaseCapability
|
||||||
@@ -302,6 +303,7 @@ fun AppleSettingsScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (baseCapabilities.contains(BaseCapability.HRM)) {
|
if (baseCapabilities.contains(BaseCapability.HRM)) {
|
||||||
|
val showAlertDisabledMessage = state.hrmState != BuddyState.ACTIVE && settings.hrmAlertEnabled
|
||||||
item(key = "spacer_heart_rate") {
|
item(key = "spacer_heart_rate") {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
@@ -309,7 +311,8 @@ fun AppleSettingsScreen(
|
|||||||
StyledListItem(
|
StyledListItem(
|
||||||
contentText = stringResource(R.string.heart_rate),
|
contentText = stringResource(R.string.heart_rate),
|
||||||
onClick = navigateToHeartRateScreen,
|
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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-43
@@ -16,16 +16,10 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
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
|
package me.kavishdevar.librepods.presentation.screens.apple
|
||||||
|
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.ExperimentalAnimationApi
|
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
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.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.FilledTonalIconToggleButton
|
import androidx.compose.material3.FilledTonalIconToggleButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
@@ -78,12 +71,11 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.debounce
|
import kotlinx.coroutines.flow.debounce
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.R
|
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.MaterialButtonStyle
|
||||||
import me.kavishdevar.librepods.presentation.components.primitives.StyledButton
|
import me.kavishdevar.librepods.presentation.components.primitives.StyledButton
|
||||||
import me.kavishdevar.librepods.presentation.components.primitives.StyledIconButton
|
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.theme.LocalDesignSystem
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
||||||
import me.kavishdevar.librepods.utils.HeadTracking
|
import me.kavishdevar.librepods.utils.HeadTracking
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun HeadTrackingScreen(
|
fun HeadTrackingScreen(
|
||||||
viewModel: AppleViewModel,
|
viewModel: AppleViewModel,
|
||||||
@@ -111,6 +100,7 @@ fun HeadTrackingScreen(
|
|||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
val state = uiState.state
|
||||||
val settings = uiState.settings
|
val settings = uiState.settings
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
@@ -126,7 +116,6 @@ fun HeadTrackingScreen(
|
|||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
var lastClickTime by remember { mutableLongStateOf(0L) }
|
var lastClickTime by remember { mutableLongStateOf(0L) }
|
||||||
var shouldExplode by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
@@ -137,26 +126,28 @@ fun HeadTrackingScreen(
|
|||||||
{ scaffoldBackdrop ->
|
{ scaffoldBackdrop ->
|
||||||
if (LocalDesignSystem.current == DesignSystem.Material) {
|
if (LocalDesignSystem.current == DesignSystem.Material) {
|
||||||
FilledTonalIconToggleButton(
|
FilledTonalIconToggleButton(
|
||||||
checked = uiState.state.headTrackingActive,
|
checked = state.headTrackingState == BuddyState.ACTIVE,
|
||||||
onCheckedChange = { if (it) viewModel.startHeadTracking() else viewModel.stopHeadTracking() },
|
onCheckedChange = { if (it) viewModel.startHeadTracking() else viewModel.stopHeadTracking() },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.minimumInteractiveComponentSize()
|
.minimumInteractiveComponentSize()
|
||||||
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)),
|
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)),
|
||||||
shape = IconButtonDefaults.mediumRoundShape
|
shape = IconButtonDefaults.mediumRoundShape,
|
||||||
|
enabled = state.headTrackingState != BuddyState.WAITING
|
||||||
) {
|
) {
|
||||||
Icon(
|
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",
|
contentDescription = "Play/Pause",
|
||||||
modifier = Modifier.size(IconButtonDefaults.mediumIconSize),
|
modifier = Modifier.size(IconButtonDefaults.mediumIconSize),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
StyledIconButton(
|
StyledIconButton(
|
||||||
onClick = if (!uiState.state.headTrackingActive) viewModel::startHeadTracking else viewModel::stopHeadTracking,
|
onClick = if (state.headTrackingState != BuddyState.ACTIVE) viewModel::startHeadTracking else viewModel::stopHeadTracking,
|
||||||
backdrop = scaffoldBackdrop
|
backdrop = scaffoldBackdrop,
|
||||||
|
enabled = state.headTrackingState != BuddyState.WAITING
|
||||||
) {
|
) {
|
||||||
Icon(
|
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",
|
contentDescription = "Play/Pause",
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
tint = MaterialTheme.colorScheme.onBackground
|
tint = MaterialTheme.colorScheme.onBackground
|
||||||
@@ -224,7 +215,7 @@ fun HeadTrackingScreen(
|
|||||||
lastClickTime = System.currentTimeMillis()
|
lastClickTime = System.currentTimeMillis()
|
||||||
delay(3.seconds)
|
delay(3.seconds)
|
||||||
if (System.currentTimeMillis() - lastClickTime >= 3000) {
|
if (System.currentTimeMillis() - lastClickTime >= 3000) {
|
||||||
shouldExplode = true
|
gestureText = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -264,28 +255,13 @@ fun HeadTrackingScreen(
|
|||||||
)).togetherWith(fadeOut(animationSpec = tween(150)))
|
)).togetherWith(fadeOut(animationSpec = tween(150)))
|
||||||
}
|
}
|
||||||
) { text ->
|
) { text ->
|
||||||
if (shouldExplode) {
|
Text(
|
||||||
LaunchedEffect(Unit) {
|
text = text,
|
||||||
CoroutineScope(coroutineScope.coroutineContext).launch {
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
delay(750.milliseconds)
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
gestureText = ""
|
textAlign = TextAlign.Center,
|
||||||
}
|
modifier = Modifier.fillMaxWidth()
|
||||||
}
|
)
|
||||||
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()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,7 +329,7 @@ private fun Plot() {
|
|||||||
shape = RoundedCornerShape(28.dp)
|
shape = RoundedCornerShape(28.dp)
|
||||||
) {
|
) {
|
||||||
val horizontalColor = MaterialTheme.colorScheme.primary
|
val horizontalColor = MaterialTheme.colorScheme.primary
|
||||||
val verticalColor = MaterialTheme.colorScheme.onPrimary
|
val verticalColor = MaterialTheme.colorScheme.secondaryContainer // random
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
+154
-70
@@ -1,20 +1,24 @@
|
|||||||
package me.kavishdevar.librepods.presentation.screens.apple
|
package me.kavishdevar.librepods.presentation.screens.apple
|
||||||
|
|
||||||
import android.text.format.DateFormat
|
import android.text.format.DateFormat
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
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.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
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.Modifier
|
||||||
import androidx.compose.ui.platform.LocalLocale
|
import androidx.compose.ui.platform.LocalLocale
|
||||||
import androidx.compose.ui.res.stringResource
|
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.compose.ui.unit.dp
|
||||||
import androidx.health.connect.client.permission.HealthPermission
|
import androidx.health.connect.client.permission.HealthPermission
|
||||||
import androidx.health.connect.client.records.HeartRateRecord
|
import androidx.health.connect.client.records.HeartRateRecord
|
||||||
@@ -45,6 +51,7 @@ import com.google.accompanist.permissions.isGranted
|
|||||||
import com.google.accompanist.permissions.rememberPermissionState
|
import com.google.accompanist.permissions.rememberPermissionState
|
||||||
import kotlinx.coroutines.flow.debounce
|
import kotlinx.coroutines.flow.debounce
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.data.apple.BuddyState
|
||||||
import me.kavishdevar.librepods.devices.AppleSettings
|
import me.kavishdevar.librepods.devices.AppleSettings
|
||||||
import me.kavishdevar.librepods.presentation.components.primitives.StyledIconButton
|
import me.kavishdevar.librepods.presentation.components.primitives.StyledIconButton
|
||||||
import me.kavishdevar.librepods.presentation.components.primitives.StyledListItem
|
import me.kavishdevar.librepods.presentation.components.primitives.StyledListItem
|
||||||
@@ -103,26 +110,28 @@ fun HeartRateScreen(
|
|||||||
{ scaffoldBackdrop ->
|
{ scaffoldBackdrop ->
|
||||||
if (LocalDesignSystem.current == DesignSystem.Material) {
|
if (LocalDesignSystem.current == DesignSystem.Material) {
|
||||||
FilledTonalIconToggleButton(
|
FilledTonalIconToggleButton(
|
||||||
checked = state.hrmActive,
|
checked = state.hrmState == BuddyState.ACTIVE,
|
||||||
onCheckedChange = { if (it) startHr() else stopHr() },
|
onCheckedChange = { if (it) startHr() else stopHr() },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.minimumInteractiveComponentSize()
|
.minimumInteractiveComponentSize()
|
||||||
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)),
|
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)),
|
||||||
shape = IconButtonDefaults.mediumRoundShape
|
shape = IconButtonDefaults.mediumRoundShape,
|
||||||
|
enabled = state.hrmState != BuddyState.WAITING
|
||||||
) {
|
) {
|
||||||
Icon(
|
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",
|
contentDescription = "Start/Stop",
|
||||||
modifier = Modifier.size(IconButtonDefaults.mediumIconSize),
|
modifier = Modifier.size(IconButtonDefaults.mediumIconSize),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
StyledIconButton(
|
StyledIconButton(
|
||||||
onClick = { if (!state.hrmActive) startHr() else stopHr() },
|
onClick = { if (state.hrmState == BuddyState.INACTIVE) startHr() else stopHr() },
|
||||||
backdrop = scaffoldBackdrop
|
backdrop = scaffoldBackdrop,
|
||||||
|
enabled = state.hrmState != BuddyState.WAITING
|
||||||
) {
|
) {
|
||||||
Icon(
|
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",
|
contentDescription = "Start/Stop",
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
tint = MaterialTheme.colorScheme.onBackground
|
tint = MaterialTheme.colorScheme.onBackground
|
||||||
@@ -142,56 +151,114 @@ fun HeartRateScreen(
|
|||||||
|
|
||||||
val healthPermissions = rememberPermissionState(HealthPermission.getWritePermission(HeartRateRecord::class))
|
val healthPermissions = rememberPermissionState(HealthPermission.getWritePermission(HeartRateRecord::class))
|
||||||
|
|
||||||
AnimatedVisibility(state.currentHeartRate != null) {
|
val defaultSpatialSpecFloat = MaterialTheme.motionScheme.defaultSpatialSpec<Float>()
|
||||||
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 timeString = formatter.format(
|
val fastSpatialSpecFloat = MaterialTheme.motionScheme.fastSpatialSpec<Float>()
|
||||||
Instant.ofEpochMilli(state.currentHeartRate.timestamp.toEpochMilliseconds())
|
val fastSpatialSpecIntOffset = MaterialTheme.motionScheme.fastSpatialSpec<IntOffset>()
|
||||||
.atZone(ZoneId.systemDefault())
|
|
||||||
)
|
|
||||||
|
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = state.hrmState,
|
||||||
|
transitionSpec = { fadeIn(defaultSpatialSpecFloat) togetherWith fadeOut(defaultSpatialSpecFloat) },
|
||||||
|
label = "hrm_state"
|
||||||
|
) { buddyState ->
|
||||||
|
when (buddyState) {
|
||||||
|
BuddyState.INACTIVE -> {}
|
||||||
|
BuddyState.WAITING -> {
|
||||||
Text(
|
Text(
|
||||||
text = timeString,
|
text = stringResource(R.string.waiting_ellipsis),
|
||||||
style = MaterialTheme.typography.bodySmall
|
style = MaterialTheme.typography.labelSmallEmphasized,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
leadingContent = {
|
|
||||||
Box(
|
BuddyState.ACTIVE -> {
|
||||||
modifier = Modifier
|
StyledListItem(
|
||||||
.size(48.dp)
|
onClick = null,
|
||||||
.background(
|
content = {
|
||||||
MaterialTheme.colorScheme.primaryContainer,
|
Row(
|
||||||
MaterialShapes.SoftBurst.normalized()
|
verticalAlignment = Alignment.CenterVertically
|
||||||
.toShape()
|
) {
|
||||||
),
|
val bpm = state.currentHeartRate?.bpm ?: 0
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
// make this reusable
|
||||||
Icon(
|
bpm.toString().forEachIndexed { index, digit ->
|
||||||
imageVector = LocalIcons.current.VitalSigns,
|
AnimatedContent(
|
||||||
contentDescription = "vital signs",
|
targetState = digit,
|
||||||
modifier = Modifier.size(24.dp),
|
transitionSpec = {
|
||||||
tint = MaterialTheme.colorScheme.onPrimaryContainer
|
(
|
||||||
)
|
slideInVertically(
|
||||||
}
|
animationSpec = fastSpatialSpecIntOffset,
|
||||||
},
|
initialOffsetY = { it }
|
||||||
orientation = StyledListItemOrientation.Vertical
|
) + 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) {
|
AnimatedVisibility(visible = !healthPermissions.status.isGranted) {
|
||||||
@@ -225,14 +292,25 @@ fun HeartRateScreen(
|
|||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.heart_rate_alert),
|
label = stringResource(R.string.heart_rate_alert),
|
||||||
description = stringResource(R.string.hrm_alert_description),
|
description = stringResource(R.string.hrm_alert_description),
|
||||||
checked = settings.hrAlertEnabled,
|
checked = settings.hrmAlertEnabled,
|
||||||
onCheckedChange = { enabled ->
|
onCheckedChange = { enabled ->
|
||||||
updateSettings {
|
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()) }
|
val sliderValue = remember { mutableFloatStateOf(settings.hrmAlertThreshold.toFloat()) }
|
||||||
|
|
||||||
LaunchedEffect(sliderValue) {
|
LaunchedEffect(sliderValue) {
|
||||||
@@ -249,22 +327,28 @@ fun HeartRateScreen(
|
|||||||
label = stringResource(R.string.heart_rate_alert_threshold),
|
label = stringResource(R.string.heart_rate_alert_threshold),
|
||||||
value = sliderValue.floatValue,
|
value = sliderValue.floatValue,
|
||||||
onValueChange = { sliderValue.floatValue = it },
|
onValueChange = { sliderValue.floatValue = it },
|
||||||
valueRange = 120f..180f,
|
valueRange = 120f..200f,
|
||||||
description = "${sliderValue.floatValue.roundToInt()} bpm",
|
description = "${sliderValue.floatValue.roundToInt()} bpm",
|
||||||
independent = true
|
independent = true
|
||||||
)
|
)
|
||||||
|
|
||||||
Column(
|
Text(
|
||||||
modifier = Modifier
|
text = stringResource(R.string.hrm_alert_warning),
|
||||||
.fillMaxWidth()
|
style = MaterialTheme.typography.labelMedium,
|
||||||
.heightIn(min = 350.dp)
|
color = MaterialTheme.colorScheme.error,
|
||||||
.background(
|
modifier = Modifier.padding(horizontal = 16.dp)
|
||||||
MaterialTheme.colorScheme.surfaceContainerHigh,
|
)
|
||||||
RoundedCornerShape(28.dp)
|
|
||||||
)
|
// TODO: graph or something
|
||||||
) {
|
// Column(
|
||||||
// TODO: graph or something
|
// modifier = Modifier
|
||||||
}
|
// .fillMaxWidth()
|
||||||
|
// .heightIn(min = 350.dp)
|
||||||
|
// .background(
|
||||||
|
// MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
// RoundedCornerShape(28.dp)
|
||||||
|
// )
|
||||||
|
// ) { }
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(bottomPadding))
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
/*
|
|
||||||
LibrePods - AirPods liberated from Apple’s 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)
|
|
||||||
-3
@@ -1,8 +1,5 @@
|
|||||||
package me.kavishdevar.librepods.presentation.theme
|
package me.kavishdevar.librepods.presentation.theme
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
enum class DesignSystem {
|
enum class DesignSystem {
|
||||||
Apple,
|
Apple,
|
||||||
Material
|
Material
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ private val AppleLightColorScheme = lightColorScheme(
|
|||||||
fun LibrePodsTheme(
|
fun LibrePodsTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
designSystem: DesignSystem = DesignSystem.Material,
|
designSystem: DesignSystem = DesignSystem.Material,
|
||||||
|
overrideMaterialColor: Color? = null,
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
val colorScheme = when(designSystem) {
|
val colorScheme = when(designSystem) {
|
||||||
@@ -99,6 +100,12 @@ fun LibrePodsTheme(
|
|||||||
DesignSystem.Apple -> AppleIcons
|
DesignSystem.Apple -> AppleIcons
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
// var colorScheme = colorScheme
|
||||||
|
//
|
||||||
|
// if (designSystem == DesignSystem.Material) {
|
||||||
|
//
|
||||||
|
// }
|
||||||
|
|
||||||
MaterialExpressiveTheme(
|
MaterialExpressiveTheme(
|
||||||
colorScheme = colorScheme,
|
colorScheme = colorScheme,
|
||||||
motionScheme = MotionScheme.expressive(),
|
motionScheme = MotionScheme.expressive(),
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ val AppleTypography = Typography().run {
|
|||||||
labelSmallEmphasized = labelSmallEmphasized.copy(
|
labelSmallEmphasized = labelSmallEmphasized.copy(
|
||||||
fontFamily = interFamily,
|
fontFamily = interFamily,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 18.sp,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ class AppleRepository(
|
|||||||
capabilities = state.capabilities,
|
capabilities = state.capabilities,
|
||||||
magicKeys = state.magicKeys,
|
magicKeys = state.magicKeys,
|
||||||
controlStates = state.controlStates,
|
controlStates = state.controlStates,
|
||||||
customEq = state.customEq
|
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to create AppleCache from AppleState for ${macAddress.toRedactedString()}", e)
|
Log.e(TAG, "Failed to create AppleCache from AppleState for ${macAddress.toRedactedString()}", e)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
package me.kavishdevar.librepods.repository
|
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.data.heartrate.HeartRateSample
|
||||||
import me.kavishdevar.librepods.database.heartrate.HeartRateSampleEntity
|
import me.kavishdevar.librepods.database.heartrate.HeartRateSampleEntity
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ import kotlin.time.toJavaInstant
|
|||||||
private const val TAG = "LibrePodsService"
|
private const val TAG = "LibrePodsService"
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
class LibrePodsService : Service() {
|
class LibrePodsService: Service() {
|
||||||
inner class LocalBinder : Binder() {
|
inner class LocalBinder : Binder() {
|
||||||
fun getService(): LibrePodsService = this@LibrePodsService
|
fun getService(): LibrePodsService = this@LibrePodsService
|
||||||
}
|
}
|
||||||
@@ -120,9 +120,8 @@ class LibrePodsService : Service() {
|
|||||||
|
|
||||||
private val telephonyCallback = object: TelephonyCallback(), TelephonyCallback.CallStateListener {
|
private val telephonyCallback = object: TelephonyCallback(), TelephonyCallback.CallStateListener {
|
||||||
override fun onCallStateChanged(state: Int) {
|
override fun onCallStateChanged(state: Int) {
|
||||||
isCallRinging = state == TelephonyManager.CALL_STATE_RINGING
|
|
||||||
|
|
||||||
if (state == TelephonyManager.CALL_STATE_RINGING) {
|
if (state == TelephonyManager.CALL_STATE_RINGING) {
|
||||||
|
isCallRinging = true
|
||||||
_devices.value.values.firstOrNull { device ->
|
_devices.value.values.firstOrNull { device ->
|
||||||
device is AppleDevice &&
|
device is AppleDevice &&
|
||||||
device.connectionState.value == ConnectionState.CONNECTED &&
|
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"
|
"Loaded metadata for device ${device.macAddress.toRedactedString()}: $metadata"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
device.loadInitialState(
|
device.loadInitialState(
|
||||||
state = AppleState().copy(
|
state = AppleState().copy(
|
||||||
capabilities = cache.capabilities,
|
capabilities = cache.capabilities,
|
||||||
magicKeys = cache.magicKeys,
|
magicKeys = cache.magicKeys,
|
||||||
controlStates = cache.controlStates,
|
controlStates = cache.controlStates,
|
||||||
customEq = cache.customEq,
|
|
||||||
),
|
),
|
||||||
settings = settings,
|
settings = settings,
|
||||||
metadata = metadata
|
metadata = metadata
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (device.settings.value.hrmAlertEnabled) {
|
||||||
|
device.startHr()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deviceJobs[MacAddress(bluetoothDevice.address)] = mutableListOf()
|
deviceJobs[MacAddress(bluetoothDevice.address)] = mutableListOf()
|
||||||
@@ -577,7 +588,6 @@ class LibrePodsService : Service() {
|
|||||||
capabilities = cache.capabilities,
|
capabilities = cache.capabilities,
|
||||||
magicKeys = cache.magicKeys,
|
magicKeys = cache.magicKeys,
|
||||||
controlStates = cache.controlStates,
|
controlStates = cache.controlStates,
|
||||||
customEq = cache.customEq,
|
|
||||||
),
|
),
|
||||||
settings = settings,
|
settings = settings,
|
||||||
metadata = metadata
|
metadata = metadata
|
||||||
@@ -794,7 +804,8 @@ class LibrePodsService : Service() {
|
|||||||
|
|
||||||
processHeartRateSample(
|
processHeartRateSample(
|
||||||
heartRateSample = heartRateSample,
|
heartRateSample = heartRateSample,
|
||||||
interval = state.heartRateInterval
|
interval = state.heartRateInterval,
|
||||||
|
alertThreshold = deviceSettings.hrmAlertThreshold
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -996,19 +1007,25 @@ class LibrePodsService : Service() {
|
|||||||
|
|
||||||
fun startForegroundNotification() {
|
fun startForegroundNotification() {
|
||||||
val disconnectedNotificationChannel = NotificationChannel(
|
val disconnectedNotificationChannel = NotificationChannel(
|
||||||
"background_service_status",
|
"foreground_service_status",
|
||||||
"Background Service Status",
|
getString(R.string.foreground_service_status),
|
||||||
NotificationManager.IMPORTANCE_NONE
|
NotificationManager.IMPORTANCE_NONE
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val hrmAlertChannel = NotificationChannel(
|
||||||
|
"hrm_alert",
|
||||||
|
getString(R.string.heart_rate_alert),
|
||||||
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
|
)
|
||||||
|
|
||||||
val notificationManager = getSystemService(NotificationManager::class.java)
|
val notificationManager = getSystemService(NotificationManager::class.java)
|
||||||
notificationManager.createNotificationChannel(disconnectedNotificationChannel)
|
notificationManager.createNotificationChannel(disconnectedNotificationChannel)
|
||||||
|
notificationManager.createNotificationChannel(hrmAlertChannel)
|
||||||
|
|
||||||
val notificationSettingsIntent =
|
val notificationSettingsIntent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
|
||||||
Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
|
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
|
||||||
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
|
putExtra(Settings.EXTRA_CHANNEL_ID, "foreground_service_status")
|
||||||
putExtra(Settings.EXTRA_CHANNEL_ID, "background_service_status")
|
}
|
||||||
}
|
|
||||||
|
|
||||||
val pendingIntentNotifDisable = PendingIntent.getActivity(
|
val pendingIntentNotifDisable = PendingIntent.getActivity(
|
||||||
this,
|
this,
|
||||||
@@ -1017,9 +1034,9 @@ class LibrePodsService : Service() {
|
|||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
)
|
)
|
||||||
|
|
||||||
val notification = NotificationCompat.Builder(this, "background_service_status")
|
val notification = NotificationCompat.Builder(this, "foreground_service_status")
|
||||||
.setSmallIcon(R.drawable.ic_airpods).setContentTitle("Background Service Running")
|
.setSmallIcon(R.drawable.ic_airpods).setContentTitle(getString(R.string.service_running))
|
||||||
.setContentText("Useless notification, disable it by clicking on it.")
|
.setContentText(getString(R.string.foreground_notification_description))
|
||||||
.setContentIntent(pendingIntentNotifDisable).setCategory(Notification.CATEGORY_SERVICE)
|
.setContentIntent(pendingIntentNotifDisable).setCategory(Notification.CATEGORY_SERVICE)
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW).setOngoing(true).build()
|
.setPriority(NotificationCompat.PRIORITY_LOW).setOngoing(true).build()
|
||||||
|
|
||||||
@@ -1180,13 +1197,6 @@ class LibrePodsService : Service() {
|
|||||||
device.disableAudio(this)
|
device.disableAudio(this)
|
||||||
device.disconnectAudio(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 {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
Log.d(TAG, "inserting to local db")
|
Log.d(TAG, "inserting to local db")
|
||||||
heartRateRepository.insert(heartRateSample)
|
heartRateRepository.insert(heartRateSample)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 7) {
|
CoroutineScope(Dispatchers.Default).launch {
|
||||||
if (checkSelfPermission(HealthPermission.getWritePermission(HeartRateRecord::class)) != PackageManager.PERMISSION_GRANTED) return
|
val notificationManager = getSystemService(NotificationManager::class.java)
|
||||||
val healthConnectHeartRateSample = HeartRateRecord.Sample(
|
|
||||||
time = heartRateSample.timestamp.toJavaInstant(),
|
|
||||||
beatsPerMinute = heartRateSample.bpm.toLong()
|
|
||||||
)
|
|
||||||
|
|
||||||
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(
|
notificationManager.notify(999, notification)
|
||||||
startTime = (heartRateSample.timestamp - interval).toJavaInstant(),
|
} else {
|
||||||
endTime = heartRateSample.timestamp.toJavaInstant(),
|
if (notificationManager.activeNotifications.any { it.id == 999 }) {
|
||||||
startZoneOffset = zoneOffset,
|
notificationManager.cancel(999)
|
||||||
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)
|
|
||||||
|
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) {
|
val zoneOffset =
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
ZoneOffset.systemDefault().rules.getOffset(heartRateSample.timestamp.toJavaInstant())
|
||||||
healthConnectClient!!.insertRecords(listOf(heartRateRecord))
|
|
||||||
|
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 {
|
} 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)
|
while (verticalAvgBuffer.size < 3) verticalAvgBuffer.add(0.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startDetection(acceleration: StateFlow<Acceleration>, callback: (Boolean) -> Unit) {
|
fun startDetection(acceleration: StateFlow<Acceleration>, callback: (Boolean) -> Unit): Boolean {
|
||||||
if (isRunning) return
|
if (isRunning) return false
|
||||||
|
|
||||||
Log.d(TAG, "Starting gesture detection...")
|
Log.d(TAG, "Starting gesture detection...")
|
||||||
isRunning = true
|
isRunning = true
|
||||||
@@ -123,6 +123,8 @@ class GestureDetector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
fun stopDetection() {
|
fun stopDetection() {
|
||||||
if (!isRunning) return
|
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>
|
||||||
@@ -332,7 +332,7 @@
|
|||||||
<string name="permission_description_bluetooth">Required to communicate with AirPods.</string>
|
<string name="permission_description_bluetooth">Required to communicate with AirPods.</string>
|
||||||
<string name="optional_permissions">Optional Permissions</string>
|
<string name="optional_permissions">Optional Permissions</string>
|
||||||
<string name="notifications">Notifications</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_description_phone">Respond to phone calls with head gestures.</string>
|
||||||
<string name="permission_overlay">Display over other apps</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>
|
<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="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="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">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_rejected">Call rejected</string>
|
||||||
<string name="call_accepted">Call accepted</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>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user