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

hrm alerts
This commit is contained in:
Kavish Devar
2026-08-25 15:57:36 +05:30
parent e950aa010d
commit a68637ed7c
35 changed files with 863 additions and 328 deletions
+1 -1
View File
@@ -77,7 +77,7 @@ android {
defaultConfig { 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')"
]
}
}
@@ -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,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,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(),
@@ -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
@@ -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))
@@ -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 })
} }
@@ -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
} }
} }
} }
@@ -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
@@ -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
}
@@ -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
) )
} }
} }
@@ -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
@@ -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 Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
@@ -1,8 +1,5 @@
package me.kavishdevar.librepods.presentation.theme 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,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>
+10 -2
View File
@@ -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>