From a68637ed7c7d4c774112c75899f4d858ce95c678 Mon Sep 17 00:00:00 2001 From: Kavish Devar Date: Tue, 25 Aug 2026 15:57:36 +0530 Subject: [PATCH] android: refactor... stuff (4, see description) hrm alerts --- android/app/build.gradle.kts | 2 +- .../2.json | 76 +++++- .../3.json | 226 ++++++++++++++++++ .../librepods/bluetooth/aacp/AACPManager.kt | 92 ++++--- .../bluetooth/aacp/packet/CustomEqPacket.kt | 9 +- .../librepods/data/apple/AppleCache.kt | 2 - .../librepods/data/apple/BuddyState.kt | 7 + .../data/heartrate/HeartRateSample.kt | 1 - .../librepods/database/Converters.kt | 16 +- .../librepods/database/LibrePodsDatabase.kt | 9 +- .../database/app/AppSettingsEntity.kt | 3 + .../librepods/database/apple/AppleDao.kt | 8 +- .../heartrate/HeartRateDao.kt | 3 +- .../librepods/devices/AppleDevice.kt | 51 ++-- .../librepods/devices/AppleSettings.kt | 5 +- .../librepods/devices/AppleState.kt | 6 +- .../presentation/activities/MainActivity.kt | 1 + .../activities/PrivacyPolicyActivity.kt | 2 +- .../components/primitives/StyledScaffold.kt | 15 +- .../presentation/navigation/AppNavGraph.kt | 37 ++- .../presentation/navigation/SwipeBack.kt | 1 + .../presentation/navigation/Transition.kt | 120 +++++++++- .../screens/apple/AppleSettingsScreen.kt | 5 +- .../screens/apple/HeadTrackingScreen.kt | 62 ++--- .../screens/apple/HeartRateScreen.kt | 224 +++++++++++------ .../librepods/presentation/theme/Color.kt | 30 --- .../presentation/theme/DesignSystem.kt | 3 - .../librepods/presentation/theme/Theme.kt | 7 + .../librepods/presentation/theme/Type.kt | 1 + .../librepods/repository/AppleRepository.kt | 1 - .../repository/HeartRateRepository.kt | 2 +- .../librepods/services/LibrePodsService.kt | 136 +++++++---- .../librepods/utils/GestureDetector.kt | 6 +- .../src/main/res/drawable/ic_pulse_alert.xml | 10 + android/app/src/main/res/values/strings.xml | 12 +- 35 files changed, 863 insertions(+), 328 deletions(-) create mode 100644 android/app/schemas/me.kavishdevar.librepods.database.LibrePodsDatabase/3.json create mode 100644 android/app/src/main/kotlin/me/kavishdevar/librepods/data/apple/BuddyState.kt rename android/app/src/main/kotlin/me/kavishdevar/librepods/{data => database}/heartrate/HeartRateDao.kt (79%) delete mode 100644 android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/Color.kt create mode 100644 android/app/src/main/res/drawable/ic_pulse_alert.xml diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 78e82816..0e077a75 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -77,7 +77,7 @@ android { defaultConfig { applicationId = "me.kavishdevar.librepods" targetSdk = 37 - versionCode = 80 + versionCode = 85 versionName = appVersionName } buildTypes { diff --git a/android/app/schemas/me.kavishdevar.librepods.database.LibrePodsDatabase/2.json b/android/app/schemas/me.kavishdevar.librepods.database.LibrePodsDatabase/2.json index bf635241..07623bd8 100644 --- a/android/app/schemas/me.kavishdevar.librepods.database.LibrePodsDatabase/2.json +++ b/android/app/schemas/me.kavishdevar.librepods.database.LibrePodsDatabase/2.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 2, - "identityHash": "b6034db24a2a1b8f4d424d89fcc4495f", + "identityHash": "721149fa5e2a13e82b73a83de312968a", "entities": [ { "tableName": "AppleEntity", @@ -42,7 +42,7 @@ }, { "tableName": "AppSettingsEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `nightMode` TEXT NOT NULL, `designSystem` TEXT NOT NULL, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `nightMode` TEXT NOT NULL, `designSystem` TEXT NOT NULL, `useHighestRefreshRate` INTEGER NOT NULL, `debugMode` INTEGER NOT NULL, `bleScanMode` INTEGER NOT NULL, `bleReportDelay` INTEGER NOT NULL, `swipeAnywhereForBack` INTEGER NOT NULL, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -61,6 +61,36 @@ "columnName": "designSystem", "affinity": "TEXT", "notNull": true + }, + { + "fieldPath": "useHighestRefreshRate", + "columnName": "useHighestRefreshRate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "debugMode", + "columnName": "debugMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bleScanMode", + "columnName": "bleScanMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bleReportDelay", + "columnName": "bleReportDelay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "swipeAnywhereForBack", + "columnName": "swipeAnywhereForBack", + "affinity": "INTEGER", + "notNull": true } ], "primaryKey": { @@ -72,7 +102,7 @@ }, { "tableName": "AppStateEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `hasCompletedOnboarding` INTEGER NOT NULL, `lastVersionShown` TEXT, `hasConnectedToAACP` INTEGER NOT NULL, `firstSuccessfulConnectionTime` INTEGER, `reviewPrompted` INTEGER NOT NULL, `timeUntilFOSSPremiumExpiry` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `isPremium` INTEGER NOT NULL, `hasCompletedOnboarding` INTEGER NOT NULL, `lastVersionShown` TEXT, `hasConnectedToAACP` INTEGER NOT NULL, `firstSuccessfulConnectionTime` INTEGER, `reviewPrompted` INTEGER NOT NULL, `timeUntilFOSSPremiumExpiry` INTEGER NOT NULL, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -80,6 +110,12 @@ "affinity": "INTEGER", "notNull": true }, + { + "fieldPath": "isPremium", + "columnName": "isPremium", + "affinity": "INTEGER", + "notNull": true + }, { "fieldPath": "hasCompletedOnboarding", "columnName": "hasCompletedOnboarding", @@ -145,11 +181,41 @@ "appWidgetId" ] } + }, + { + "tableName": "HeartRateSampleEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `bpm` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bpm", + "columnName": "bpm", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b6034db24a2a1b8f4d424d89fcc4495f')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '721149fa5e2a13e82b73a83de312968a')" ] } -} \ No newline at end of file +} diff --git a/android/app/schemas/me.kavishdevar.librepods.database.LibrePodsDatabase/3.json b/android/app/schemas/me.kavishdevar.librepods.database.LibrePodsDatabase/3.json new file mode 100644 index 00000000..4b8ddbd3 --- /dev/null +++ b/android/app/schemas/me.kavishdevar.librepods.database.LibrePodsDatabase/3.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/AACPManager.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/AACPManager.kt index 1600007e..75f7bf24 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/AACPManager.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/AACPManager.kt @@ -53,6 +53,7 @@ import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode import me.kavishdevar.librepods.bluetooth.aacp.types.RTBuddyDescriptor import me.kavishdevar.librepods.bluetooth.aacp.types.SensorDataWxBuddyPayload +import me.kavishdevar.librepods.data.apple.BuddyState import me.kavishdevar.librepods.data.audio.MicrophoneFrame import me.kavishdevar.librepods.data.heartrate.HeartRateSample import me.kavishdevar.librepods.devices.AppleDevice @@ -64,6 +65,7 @@ import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.time.Clock import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds class AACPManager(private val device: AppleDevice) { private val macParts = device.macAddress.value.split(":") @@ -73,6 +75,28 @@ class AACPManager(private val device: AppleDevice) { private val rtBuddyManager = RTBuddyManager(::sendPacket) + private suspend fun sendDelayedInitPackets() { + socket?.let { + while (!it.isConnected) { + Log.i(TAG, "waiting for connection...") + delay(500.milliseconds) + } + Log.i(TAG, "initializing connection") + + connectService4() + delay(200.milliseconds) + sendSourceFeatureCapabilities() + delay(200.milliseconds) + sendNotificationRequest() + delay(200.milliseconds) + sendCountryCode() + delay(200.milliseconds) + sendRequestMagicKeys((MagicKeyType.IRK.value + MagicKeyType.ENC_KEY.value).toByte()) + } ?: run { + Log.e(TAG, "socket is null after creation") + } + } + fun connect(): Boolean { if (socket != null && socket!!.isConnected) { Log.i(TAG, "Already connected") @@ -99,19 +123,9 @@ class AACPManager(private val device: AppleDevice) { } CoroutineScope(Dispatchers.IO).launch { - while(!socket.isConnected) { - Log.i(TAG, "waiting for connection...") - delay(500.milliseconds) - } - Log.i(TAG, "initializing connection") - - connectService4() - delay(200.milliseconds) - sendSourceFeatureCapabilities() - delay(200.milliseconds) - sendNotificationRequest() - delay(200.milliseconds) - sendRequestMagicKeys((MagicKeyType.IRK.value + MagicKeyType.ENC_KEY.value).toByte()) + sendDelayedInitPackets() + delay(3.seconds) // just android stuff ig + sendDelayedInitPackets() } CoroutineScope(Dispatchers.IO).launch { @@ -1031,6 +1045,8 @@ class AACPManager(private val device: AppleDevice) { microphoneFrames = emptyList(), controlStates = emptyMap(), aacpPackets = emptyList(), + headTrackingState = BuddyState.INACTIVE, + hrmState = BuddyState.INACTIVE, currentHeartRate = null ) } @@ -1053,36 +1069,6 @@ class AACPManager(private val device: AppleDevice) { return sendPacket(packet) } - fun parseCustomEqPacket(packet: ByteArray): CustomEq { - val data = packet.sliceArray(6 until packet.size) - - if (data.size < 7) { - Log.e(TAG, "custom EQ packet length less than 7, returning default") - return CustomEq(1, 50, 50, 50) - } - - val lengthLow = data[0].toInt() and 0xFF - val lengthHigh = data[1].toInt() and 0xFF - - val length = (lengthHigh shl 8) or lengthLow - - if (length != 5) { - Log.w(TAG, "parseCustomEqPacket: unexpected length ($length). parsing normally") - } - - val state = data[3].toInt() - val low = data[4].toInt() - val mid = data[5].toInt() - val high = data[6].toInt() - - return CustomEq( - state, - low, - mid, - high - ) - } - fun requestMicrophoneStream(): Boolean { val payload = byteArrayOf( 0x00, 0x00, @@ -1183,16 +1169,18 @@ class AACPManager(private val device: AppleDevice) { if (value and 0x8000 != 0) value - 0x10000 else value } + if (device.state.value.headTrackingState != BuddyState.ACTIVE) { + device.updateState { + it.copy( + headTrackingState = BuddyState.ACTIVE + ) + } + } + HeadTracking.addAccel(i16(device.settings.value.headGesturesVerticalOffset).toFloat(), i16(device.settings.value.headGesturesHorizontalOffset).toFloat()) } - SensorServiceType.HEARTRATE -> { - Log.d( - TAG, - "Received sensor data for service: ${data.command.service}, payload: ${data.command.payload.toByteArray().toHexString()}" - ) - } - + SensorServiceType.HEARTRATE, SensorServiceType.HEARTRATEv2 -> { val payload = data.command.payload.toByteArray() val timestamp = Clock.System.now() @@ -1208,10 +1196,10 @@ class AACPManager(private val device: AppleDevice) { return } - if (!device.state.value.hrmActive) { + if (device.state.value.hrmState != BuddyState.ACTIVE) { device.updateState { it.copy( - hrmActive = true + hrmState = BuddyState.ACTIVE ) } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/CustomEqPacket.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/CustomEqPacket.kt index 471a4145..859bb361 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/CustomEqPacket.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/CustomEqPacket.kt @@ -4,7 +4,6 @@ import me.kavishdevar.librepods.bluetooth.aacp.types.CustomEq import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode import me.kavishdevar.librepods.devices.PacketDestination - data class CustomEqPacket( val customEq: CustomEq, override val destination: PacketDestination @@ -26,10 +25,10 @@ data class CustomEqPacket( val length = payload[0].toInt() require(length == 5) { "Invalid length for CustomEqPacket: $length" } - val state = payload[2].toInt() - val low = payload[3].toInt() - val mid = payload[4].toInt() - val high = payload[5].toInt() + val state = payload[3].toInt() + val low = payload[4].toInt() + val mid = payload[5].toInt() + val high = payload[6].toInt() val customEq = CustomEq(state, low, mid, high) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/data/apple/AppleCache.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/apple/AppleCache.kt index 50925833..3a89afbd 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/data/apple/AppleCache.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/apple/AppleCache.kt @@ -3,7 +3,6 @@ package me.kavishdevar.librepods.data.apple import kotlinx.serialization.Serializable import me.kavishdevar.librepods.bluetooth.aacp.types.CapabilityEntry import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier -import me.kavishdevar.librepods.bluetooth.aacp.types.CustomEq import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType @Serializable @@ -11,5 +10,4 @@ data class AppleCache( val capabilities: Set = emptySet(), val magicKeys: Map = emptyMap(), val controlStates: Map = emptyMap(), - val customEq: CustomEq = CustomEq(1, 50, 50, 50), ) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/data/apple/BuddyState.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/apple/BuddyState.kt new file mode 100644 index 00000000..f34f12cf --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/apple/BuddyState.kt @@ -0,0 +1,7 @@ +package me.kavishdevar.librepods.data.apple + +enum class BuddyState { + INACTIVE, + WAITING, + ACTIVE +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/data/heartrate/HeartRateSample.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/heartrate/HeartRateSample.kt index eb96706d..2c82732d 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/data/heartrate/HeartRateSample.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/heartrate/HeartRateSample.kt @@ -6,4 +6,3 @@ data class HeartRateSample ( val bpm: Int, val timestamp: Instant ) -// TODO: implement records (don't have a better place to put this) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/database/Converters.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/Converters.kt index 655e8609..d1608f40 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/database/Converters.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/Converters.kt @@ -11,6 +11,10 @@ import me.kavishdevar.librepods.devices.AppleSettings import kotlin.time.Instant object Converters { + val cbor = Cbor { + ignoreUnknownKeys = true + } + @ColumnTypeConverter fun macAddressToString(mac: MacAddress): String = mac.value @@ -19,27 +23,27 @@ object Converters { @ColumnTypeConverter fun appleSettingsToBytes(settings: AppleSettings): ByteArray = - Cbor.encodeToByteArray(settings) + cbor.encodeToByteArray(settings) @ColumnTypeConverter fun bytesToAppleSettings(bytes: ByteArray): AppleSettings = - Cbor.decodeFromByteArray(bytes) + cbor.decodeFromByteArray(bytes) @ColumnTypeConverter fun appleMetadataToBytes(metadata: AppleMetadata): ByteArray = - Cbor.encodeToByteArray(metadata) + cbor.encodeToByteArray(metadata) @ColumnTypeConverter fun bytesToAppleMetadata(bytes: ByteArray): AppleMetadata = - Cbor.decodeFromByteArray(bytes) + cbor.decodeFromByteArray(bytes) @ColumnTypeConverter fun appleCacheToBytes(cache: AppleCache): ByteArray = - Cbor.encodeToByteArray(cache) + cbor.encodeToByteArray(cache) @ColumnTypeConverter fun bytesToAppleCache(bytes: ByteArray): AppleCache = - Cbor.decodeFromByteArray(bytes) + cbor.decodeFromByteArray(bytes) @ColumnTypeConverter fun kotlinInstantToLong(instant: Instant): Long = diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/database/LibrePodsDatabase.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/LibrePodsDatabase.kt index 4b26d87f..05a4b96c 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/database/LibrePodsDatabase.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/LibrePodsDatabase.kt @@ -1,15 +1,16 @@ package me.kavishdevar.librepods.database +import androidx.room3.AutoMigration import androidx.room3.ColumnTypeConverters import androidx.room3.Database import androidx.room3.RoomDatabase -import me.kavishdevar.librepods.data.heartrate.HeartRateDao import me.kavishdevar.librepods.database.app.AppSettingsDao import me.kavishdevar.librepods.database.app.AppSettingsEntity import me.kavishdevar.librepods.database.app.AppStateDao import me.kavishdevar.librepods.database.app.AppStateEntity import me.kavishdevar.librepods.database.apple.AppleDao import me.kavishdevar.librepods.database.apple.AppleEntity +import me.kavishdevar.librepods.database.heartrate.HeartRateDao import me.kavishdevar.librepods.database.heartrate.HeartRateSampleEntity import me.kavishdevar.librepods.database.widget.WidgetConfigDao import me.kavishdevar.librepods.database.widget.WidgetConfigEntity @@ -23,7 +24,11 @@ import me.kavishdevar.librepods.database.widget.WidgetConfigEntity WidgetConfigEntity::class, HeartRateSampleEntity::class ], - version = 1, + version = 3, + autoMigrations = [ + AutoMigration(from = 1, to = 2), + AutoMigration(from = 2, to = 3), + ] ) abstract class LibrePodsDatabase: RoomDatabase() { abstract fun appleDao(): AppleDao diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/database/app/AppSettingsEntity.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/app/AppSettingsEntity.kt index 4ccfcd86..408dba68 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/database/app/AppSettingsEntity.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/app/AppSettingsEntity.kt @@ -1,6 +1,7 @@ package me.kavishdevar.librepods.database.app import android.bluetooth.le.ScanSettings +import androidx.compose.ui.graphics.Color import androidx.room3.Entity import androidx.room3.PrimaryKey import me.kavishdevar.librepods.presentation.theme.DesignSystem @@ -13,6 +14,8 @@ data class AppSettingsEntity( val nightMode: NightTheme = NightTheme.System, val designSystem: DesignSystem = DesignSystem.Material, + val overrideMaterialColor: Color? = null, + val useHighestRefreshRate: Boolean = false, val debugMode: Boolean = false, diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/database/apple/AppleDao.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/apple/AppleDao.kt index 62884dd4..5af4510c 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/database/apple/AppleDao.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/apple/AppleDao.kt @@ -4,10 +4,10 @@ import android.util.Log import androidx.room3.Dao import androidx.room3.Query import androidx.room3.Upsert +import me.kavishdevar.librepods.bluetooth.MacAddress import me.kavishdevar.librepods.data.apple.AppleCache import me.kavishdevar.librepods.devices.AppleMetadata import me.kavishdevar.librepods.devices.AppleSettings -import me.kavishdevar.librepods.bluetooth.MacAddress private const val TAG = "AppleDao" @@ -26,18 +26,18 @@ interface AppleDao { suspend fun updateSettings(macAddress: MacAddress, settings: AppleSettings) { Log.d(TAG, "Updating settings for $macAddress: $settings") val device = get(macAddress)?: AppleEntity(macAddress = macAddress, settings = settings, metadata = AppleMetadata(), cache = AppleCache()) - upsert(device.copy(settings = settings)) + upsert(device.copy(settings = settings)) } suspend fun updateMetadata(macAddress: MacAddress, metadata: AppleMetadata) { Log.d(TAG, "Updating metadata for $macAddress: $metadata") val device = get(macAddress)?: AppleEntity(macAddress = macAddress, settings = AppleSettings(), metadata = metadata, cache = AppleCache()) - upsert(device.copy(metadata = metadata)) + upsert(device.copy(metadata = metadata)) } suspend fun saveCache(macAddress: MacAddress, cache: AppleCache) { Log.d(TAG, "Saving cache for $macAddress: $cache") val device = get(macAddress)?: AppleEntity(macAddress = macAddress, settings = AppleSettings(), metadata = AppleMetadata(), cache = cache) - upsert(device.copy(cache = cache)) + upsert(device.copy(cache = cache)) } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/data/heartrate/HeartRateDao.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/heartrate/HeartRateDao.kt similarity index 79% rename from android/app/src/main/kotlin/me/kavishdevar/librepods/data/heartrate/HeartRateDao.kt rename to android/app/src/main/kotlin/me/kavishdevar/librepods/database/heartrate/HeartRateDao.kt index bc7ebff0..447dd559 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/data/heartrate/HeartRateDao.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/heartrate/HeartRateDao.kt @@ -1,9 +1,8 @@ -package me.kavishdevar.librepods.data.heartrate +package me.kavishdevar.librepods.database.heartrate import androidx.room3.Dao import androidx.room3.Insert import androidx.room3.Query -import me.kavishdevar.librepods.database.heartrate.HeartRateSampleEntity import kotlin.time.Instant @Dao diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleDevice.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleDevice.kt index af96deac..6fb234cc 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleDevice.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleDevice.kt @@ -24,6 +24,7 @@ import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier import me.kavishdevar.librepods.bluetooth.att.ATTHandle import me.kavishdevar.librepods.bluetooth.att.ATTManager import me.kavishdevar.librepods.data.StemAction +import me.kavishdevar.librepods.data.apple.BuddyState import me.kavishdevar.librepods.utils.GestureDetector import me.kavishdevar.librepods.utils.HeadTracking import kotlin.time.Duration @@ -224,7 +225,9 @@ class AppleDevice( interval = _settings.value.headTrackingInterval ) _state.update { - it.copy(headTrackingActive = true) + it.copy( + headTrackingState = BuddyState.WAITING + ) } } @@ -234,20 +237,25 @@ class AppleDevice( interval = Duration.ZERO ) _state.update { - it.copy(headTrackingActive = false) + it.copy( + headTrackingState = BuddyState.INACTIVE, + ) } gestureDetector.stopDetection() } + // TODO: multiple callbacks fun detectHeadGestures(callback: (Boolean) -> Unit) { - if (!state.value.headTrackingActive) startHeadTracking() - if (!state.value.headTrackingActive) { - aacp.setSensorServiceReportInterval( - sensorServiceType = if (metadata.value.version3.first().digitToInt() >= 8) SensorServiceType.DEVMOTION6 else SensorServiceType.ACTIVITY, - interval = 40.milliseconds - ) + if (state.value.headTrackingState != BuddyState.ACTIVE) startHeadTracking() + val started = gestureDetector.startDetection(HeadTracking.acceleration, callback) + if (!started) { + Log.w(TAG, "Failed to start gesture detection, probably because already running.") } - gestureDetector.startDetection(HeadTracking.acceleration, callback) + } + + fun stopHeadGestureDetection() { + gestureDetector.stopDetection() + stopHeadTracking() } fun setHeadGesturesEnabled(enabled: Boolean) { @@ -287,19 +295,28 @@ class AppleDevice( } // AACPManager sets hrmActive true when a valid reading is received - fun startHr(): Boolean = aacp.setSensorServiceReportInterval( - sensorServiceType = if (metadata.value.version3.first().digitToInt() >= 8) SensorServiceType.HEARTRATE_COMMAND else SensorServiceType.HEARTRATE, - interval = 1.seconds - ) - + fun startHr(): Boolean { + val success = aacp.setSensorServiceReportInterval( + sensorServiceType = if (metadata.value.version3.first().digitToInt() >= 9) SensorServiceType.HEARTRATE_COMMAND else SensorServiceType.HEARTRATE, + interval = 1.seconds + ) + if (success) { + _state.update { + it.copy( + hrmState = BuddyState.WAITING + ) + } + } + return success + } fun stopHr(): Boolean { val success = aacp.setSensorServiceReportInterval( - sensorServiceType = if (metadata.value.version3.first().digitToInt() >= 8) SensorServiceType.HEARTRATE_COMMAND else SensorServiceType.HEARTRATE, + sensorServiceType = if (metadata.value.version3.first().digitToInt() >= 9) SensorServiceType.HEARTRATE_COMMAND else SensorServiceType.HEARTRATE, interval = Duration.ZERO ) - if (state.value.hrmActive && success) _state.update { + if (state.value.hrmState != BuddyState.INACTIVE && success) _state.update { it.copy( - hrmActive = false, + hrmState = BuddyState.INACTIVE, currentHeartRate = null ) } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleSettings.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleSettings.kt index cf2a16fc..16b76678 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleSettings.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleSettings.kt @@ -1,5 +1,6 @@ package me.kavishdevar.librepods.devices +import androidx.annotation.IntRange import kotlinx.serialization.Serializable import me.kavishdevar.librepods.data.StemAction import kotlin.time.Duration @@ -38,6 +39,6 @@ data class AppleSettings( val conversationalAwarenessVolume: Float = 43f, val conversationalAwarenessReducedVolume: Float = 20f, - val hrAlertEnabled: Boolean = true, - val hrmAlertThreshold: Int = 120, + val hrmAlertEnabled: Boolean = false, + @IntRange(from = 120, to = 200) val hrmAlertThreshold: Int = 150, ): DeviceSettings diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleState.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleState.kt index 4fa53cd2..d88893a5 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleState.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleState.kt @@ -9,6 +9,7 @@ import me.kavishdevar.librepods.bluetooth.aacp.types.ConnectedDevice import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier import me.kavishdevar.librepods.bluetooth.aacp.types.CustomEq import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType +import me.kavishdevar.librepods.data.apple.BuddyState import me.kavishdevar.librepods.data.audio.MicrophoneFrame import me.kavishdevar.librepods.data.audio.MicrophoneState import me.kavishdevar.librepods.data.heartrate.HeartRateSample @@ -32,8 +33,7 @@ data class AppleState( val magicKeys: Map = emptyMap(), - val headTrackingActive: Boolean = false, - val detectHeadGestures: Boolean = false, + val headTrackingState: BuddyState = BuddyState.INACTIVE, val loudSoundReductionEnabled: Boolean = false, val transparencyData: ByteArray = byteArrayOf(), @@ -55,7 +55,7 @@ data class AppleState( val headphoneAccomodationEnabledForPhone: Boolean = false, val currentHeartRate: HeartRateSample? = null, - val hrmActive: Boolean = false, + val hrmState: BuddyState = BuddyState.INACTIVE, val heartRateInterval: Duration = 1.seconds, val aacpPackets: List = emptyList(), diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/activities/MainActivity.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/activities/MainActivity.kt index 942556b2..97fa319a 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/activities/MainActivity.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/activities/MainActivity.kt @@ -115,6 +115,7 @@ class MainActivity : ComponentActivity() { LibrePodsTheme( designSystem = settings.designSystem, + overrideMaterialColor = settings.overrideMaterialColor, darkTheme = darkTheme ) { // For demo screenshots diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/activities/PrivacyPolicyActivity.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/activities/PrivacyPolicyActivity.kt index b55bb364..b49e0002 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/activities/PrivacyPolicyActivity.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/activities/PrivacyPolicyActivity.kt @@ -65,7 +65,7 @@ class PrivacyPolicyActivity : ComponentActivity() { StyledScaffold( title = stringResource(R.string.privacy_policy), - navigateBack = null + navigateBack = { finish() } ) { topPadding, bottomPadding -> Column { Spacer(modifier = Modifier.height(topPadding)) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledScaffold.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledScaffold.kt index 10d4608c..f1e460b5 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledScaffold.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/primitives/StyledScaffold.kt @@ -67,11 +67,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asComposeRenderEffect import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.input.pointer.consumePositionChange import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -207,10 +204,10 @@ fun StyledScaffold( val backdrop = rememberLayerBackdrop() val bgColor = MaterialTheme.colorScheme.surfaceContainer - val density = LocalDensity.current - val screenWidthPx = with(density) { - LocalWindowInfo.current.containerDpSize.width.toPx() - } +// val density = LocalDensity.current +// val screenWidthPx = with(density) { +// LocalWindowInfo.current.containerDpSize.width.toPx() +// } val isCurrentEntry = LocalIsCurrentEntry.current val transitionProgress = LocalTransitionProgress.current val sharedTransitionScope = LocalSharedTransitionScope.current @@ -233,7 +230,7 @@ fun StyledScaffold( val event = awaitPointerEvent() event.changes.forEach { change -> - change.consumePositionChange() + change.consume() } } while (event.changes.any { it.pressed }) } @@ -350,7 +347,7 @@ fun StyledScaffold( val event = awaitPointerEvent() event.changes.forEach { change -> - change.consumePositionChange() + change.consume() } } while (event.changes.any { it.pressed }) } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt index 2e2f12d0..f92a071e 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt @@ -1,6 +1,5 @@ package me.kavishdevar.librepods.presentation.navigation -import android.annotation.SuppressLint import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.SharedTransitionLayout @@ -11,7 +10,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.navigation3.runtime.NavBackStack @@ -44,18 +42,31 @@ fun AppNavGraph( val currentDevice: Device<*, *, *>? = devices[backStack.lastOrNull()?.let { (it as? DeviceScreen)?.macAddress }] - @SuppressLint("UnrememberedMutableState") - val currentConnectionState by (currentDevice as? AppleDevice)?.connectionState?.collectAsState()?: mutableStateOf(ConnectionState.DISCONNECTED) + LaunchedEffect(currentDevice) { + val device = currentDevice as? AppleDevice ?: return@LaunchedEffect - LaunchedEffect(currentConnectionState) { - if (currentConnectionState == ConnectionState.DISCONNECTED) { - while ( - backStack.size > 1 && - backStack.lastOrNull() is DeviceScreen - ) { - val completed = CompletableDeferred() - backRequests.send(completed) - completed.await() + var wasConnected = false + + device.connectionState.collect { state -> + when (state) { + ConnectionState.CONNECTED -> { + wasConnected = true + } + + ConnectionState.DISCONNECTED -> { + if (!wasConnected) return@collect + + while ( + backStack.size > 1 && + backStack.lastOrNull() is DeviceScreen + ) { + val completed = CompletableDeferred() + backRequests.send(completed) + completed.await() + } + } + + else -> Unit } } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/SwipeBack.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/SwipeBack.kt index 2417b2bd..66e115f0 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/SwipeBack.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/SwipeBack.kt @@ -169,6 +169,7 @@ private fun SwipeBackSceneContent( PredictiveBackHandler { progressFlow -> try { progressFlow.collect { backEvent -> + if (previousEntry == null) return@collect val progress = backEvent.progress transitionProgress = -progress diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/Transition.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/Transition.kt index 96051e89..24b7f126 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/Transition.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/Transition.kt @@ -1,11 +1,125 @@ package me.kavishdevar.librepods.presentation.navigation +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.BoundsTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import androidx.compose.animation.SharedTransitionScope import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.layout.AlignmentLine +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection -val LocalSharedTransitionScope = compositionLocalOf { - error("LocalSharedTransitionScope not provided") -} +val LocalSharedTransitionScope = compositionLocalOf { error("No SharedTransitionScope provided") } val LocalTransitionProgress = compositionLocalOf { 0f } val LocalIsCurrentEntry = compositionLocalOf { false } + + +/* + + */ +object DummySharedTransitionScope: SharedTransitionScope { + override val isTransitionActive: Boolean + get() = false + + override fun Modifier.skipToLookaheadSize(enabled: () -> Boolean): Modifier = this + + override fun Modifier.renderInSharedTransitionScopeOverlay( + zIndexInOverlay: Float, + renderInOverlay: () -> Boolean + ): Modifier = this + + override fun Modifier.sharedElement( + sharedContentState: SharedTransitionScope.SharedContentState, + animatedVisibilityScope: AnimatedVisibilityScope, + boundsTransform: BoundsTransform, + placeholderSize: SharedTransitionScope.PlaceholderSize, + renderInOverlayDuringTransition: Boolean, + zIndexInOverlay: Float, + clipInOverlayDuringTransition: SharedTransitionScope.OverlayClip + ): Modifier = this + + override fun Modifier.sharedBounds( + sharedContentState: SharedTransitionScope.SharedContentState, + animatedVisibilityScope: AnimatedVisibilityScope, + enter: EnterTransition, + exit: ExitTransition, + boundsTransform: BoundsTransform, + resizeMode: SharedTransitionScope.ResizeMode, + placeholderSize: SharedTransitionScope.PlaceholderSize, + renderInOverlayDuringTransition: Boolean, + zIndexInOverlay: Float, + clipInOverlayDuringTransition: SharedTransitionScope.OverlayClip + ): Modifier = this + + override fun Modifier.sharedElementWithCallerManagedVisibility( + sharedContentState: SharedTransitionScope.SharedContentState, + visible: Boolean, + boundsTransform: BoundsTransform, + placeholderSize: SharedTransitionScope.PlaceholderSize, + renderInOverlayDuringTransition: Boolean, + zIndexInOverlay: Float, + clipInOverlayDuringTransition: SharedTransitionScope.OverlayClip + ): Modifier = this + + override fun OverlayClip(clipShape: Shape): SharedTransitionScope.OverlayClip { + return object : SharedTransitionScope.OverlayClip { + override fun getClipPath( + sharedContentState: SharedTransitionScope.SharedContentState, + bounds: Rect, + layoutDirection: LayoutDirection, + density: Density + ): Path? { + return null + } + + } + } + + override val Placeable.PlacementScope.lookaheadScopeCoordinates: LayoutCoordinates + get() = DummyLookaheadCoordinates + + override fun LayoutCoordinates.toLookaheadCoordinates(): LayoutCoordinates { + return DummyLookaheadCoordinates + } +} + +object DummyLookaheadCoordinates: LayoutCoordinates { + override val size: IntSize + get() = IntSize(0, 0) + override val providedAlignmentLines: Set + 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 +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/AppleSettingsScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/AppleSettingsScreen.kt index 6e7a5580..1411e26e 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/AppleSettingsScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/AppleSettingsScreen.kt @@ -51,6 +51,7 @@ import kotlinx.coroutines.flow.debounce import me.kavishdevar.librepods.R import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier import me.kavishdevar.librepods.bluetooth.att.ATTHandle +import me.kavishdevar.librepods.data.apple.BuddyState import me.kavishdevar.librepods.devices.AirPodsSpecs import me.kavishdevar.librepods.devices.AppleSettings import me.kavishdevar.librepods.devices.BaseCapability @@ -302,6 +303,7 @@ fun AppleSettingsScreen( } if (baseCapabilities.contains(BaseCapability.HRM)) { + val showAlertDisabledMessage = state.hrmState != BuddyState.ACTIVE && settings.hrmAlertEnabled item(key = "spacer_heart_rate") { Spacer(modifier = Modifier.height(16.dp)) } @@ -309,7 +311,8 @@ fun AppleSettingsScreen( StyledListItem( contentText = stringResource(R.string.heart_rate), onClick = navigateToHeartRateScreen, - supportingText = state.currentHeartRate?.let { "${it.bpm} bpm" } + supportingText = if (showAlertDisabledMessage) stringResource(R.string.heart_rate_alerts_disabled_warning) else state.currentHeartRate?.let { "${it.bpm} bpm" }, + orientation = if (showAlertDisabledMessage) StyledListItemOrientation.Vertical else StyledListItemOrientation.Horizontal ) } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HeadTrackingScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HeadTrackingScreen.kt index cbfc715f..a5626d76 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HeadTrackingScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HeadTrackingScreen.kt @@ -16,16 +16,10 @@ along with this program. If not, see . */ - -// this is absolutely unnecessary, why did I make this. a simple toggle would've sufficed - -@file:OptIn(ExperimentalEncodingApi::class) - package me.kavishdevar.librepods.presentation.screens.apple import android.graphics.Paint import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -47,7 +41,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalIconToggleButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonDefaults @@ -78,12 +71,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.data.apple.BuddyState import me.kavishdevar.librepods.presentation.components.primitives.MaterialButtonStyle import me.kavishdevar.librepods.presentation.components.primitives.StyledButton import me.kavishdevar.librepods.presentation.components.primitives.StyledIconButton @@ -96,13 +88,10 @@ import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel import me.kavishdevar.librepods.utils.HeadTracking -import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.abs import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds -@ExperimentalHazeMaterialsApi -@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class) @Composable fun HeadTrackingScreen( viewModel: AppleViewModel, @@ -111,6 +100,7 @@ fun HeadTrackingScreen( ) { val uiState by viewModel.uiState.collectAsState() + val state = uiState.state val settings = uiState.settings DisposableEffect(Unit) { @@ -126,7 +116,6 @@ fun HeadTrackingScreen( val coroutineScope = rememberCoroutineScope() var lastClickTime by remember { mutableLongStateOf(0L) } - var shouldExplode by remember { mutableStateOf(false) } val scrollState = rememberScrollState() @@ -137,26 +126,28 @@ fun HeadTrackingScreen( { scaffoldBackdrop -> if (LocalDesignSystem.current == DesignSystem.Material) { FilledTonalIconToggleButton( - checked = uiState.state.headTrackingActive, + checked = state.headTrackingState == BuddyState.ACTIVE, onCheckedChange = { if (it) viewModel.startHeadTracking() else viewModel.stopHeadTracking() }, modifier = Modifier .minimumInteractiveComponentSize() .size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)), - shape = IconButtonDefaults.mediumRoundShape + shape = IconButtonDefaults.mediumRoundShape, + enabled = state.headTrackingState != BuddyState.WAITING ) { Icon( - imageVector = if (uiState.state.headTrackingActive) MaterialIcons.Pause else Icons.Default.PlayArrow, + imageVector = if (state.headTrackingState == BuddyState.ACTIVE) MaterialIcons.Pause else Icons.Default.PlayArrow, contentDescription = "Play/Pause", modifier = Modifier.size(IconButtonDefaults.mediumIconSize), ) } } else { StyledIconButton( - onClick = if (!uiState.state.headTrackingActive) viewModel::startHeadTracking else viewModel::stopHeadTracking, - backdrop = scaffoldBackdrop + onClick = if (state.headTrackingState != BuddyState.ACTIVE) viewModel::startHeadTracking else viewModel::stopHeadTracking, + backdrop = scaffoldBackdrop, + enabled = state.headTrackingState != BuddyState.WAITING ) { Icon( - imageVector = if (uiState.state.headTrackingActive) LocalIcons.current.Pause else LocalIcons.current.Play, + imageVector = if (state.headTrackingState == BuddyState.ACTIVE) LocalIcons.current.Pause else LocalIcons.current.Play, contentDescription = "Play/Pause", modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onBackground @@ -224,7 +215,7 @@ fun HeadTrackingScreen( lastClickTime = System.currentTimeMillis() delay(3.seconds) if (System.currentTimeMillis() - lastClickTime >= 3000) { - shouldExplode = true + gestureText = "" } } } @@ -264,28 +255,13 @@ fun HeadTrackingScreen( )).togetherWith(fadeOut(animationSpec = tween(150))) } ) { text -> - if (shouldExplode) { - LaunchedEffect(Unit) { - CoroutineScope(coroutineScope.coroutineContext).launch { - delay(750.milliseconds) - gestureText = "" - } - } - Text( - text = text, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onBackground, - textAlign = TextAlign.Center - ) - } else { - Text( - text = text, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onBackground, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) - } + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) } } @@ -353,7 +329,7 @@ private fun Plot() { shape = RoundedCornerShape(28.dp) ) { val horizontalColor = MaterialTheme.colorScheme.primary - val verticalColor = MaterialTheme.colorScheme.onPrimary + val verticalColor = MaterialTheme.colorScheme.secondaryContainer // random Box( modifier = Modifier diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HeartRateScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HeartRateScreen.kt index 4dd56859..87a49cb5 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HeartRateScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HeartRateScreen.kt @@ -1,20 +1,24 @@ package me.kavishdevar.librepods.presentation.screens.apple import android.text.format.DateFormat +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayArrow @@ -37,6 +41,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalLocale import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.health.connect.client.permission.HealthPermission import androidx.health.connect.client.records.HeartRateRecord @@ -45,6 +51,7 @@ import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState import kotlinx.coroutines.flow.debounce import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.data.apple.BuddyState import me.kavishdevar.librepods.devices.AppleSettings import me.kavishdevar.librepods.presentation.components.primitives.StyledIconButton import me.kavishdevar.librepods.presentation.components.primitives.StyledListItem @@ -103,26 +110,28 @@ fun HeartRateScreen( { scaffoldBackdrop -> if (LocalDesignSystem.current == DesignSystem.Material) { FilledTonalIconToggleButton( - checked = state.hrmActive, + checked = state.hrmState == BuddyState.ACTIVE, onCheckedChange = { if (it) startHr() else stopHr() }, modifier = Modifier .minimumInteractiveComponentSize() .size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)), - shape = IconButtonDefaults.mediumRoundShape + shape = IconButtonDefaults.mediumRoundShape, + enabled = state.hrmState != BuddyState.WAITING ) { Icon( - imageVector = if (state.hrmActive) MaterialIcons.Pause else Icons.Default.PlayArrow, + imageVector = if (state.hrmState == BuddyState.ACTIVE) MaterialIcons.Pause else Icons.Default.PlayArrow, contentDescription = "Start/Stop", modifier = Modifier.size(IconButtonDefaults.mediumIconSize), ) } } else { StyledIconButton( - onClick = { if (!state.hrmActive) startHr() else stopHr() }, - backdrop = scaffoldBackdrop + onClick = { if (state.hrmState == BuddyState.INACTIVE) startHr() else stopHr() }, + backdrop = scaffoldBackdrop, + enabled = state.hrmState != BuddyState.WAITING ) { Icon( - imageVector = if (state.hrmActive) LocalIcons.current.Pause else LocalIcons.current.Play, + imageVector = if (state.hrmState == BuddyState.ACTIVE) LocalIcons.current.Pause else LocalIcons.current.Play, contentDescription = "Start/Stop", modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onBackground @@ -142,56 +151,114 @@ fun HeartRateScreen( val healthPermissions = rememberPermissionState(HealthPermission.getWritePermission(HeartRateRecord::class)) - AnimatedVisibility(state.currentHeartRate != null) { - if (state.currentHeartRate == null) return@AnimatedVisibility - StyledListItem( - onClick = null, - content = { - Text( - text = "${state.currentHeartRate.bpm} bpm", - style = MaterialTheme.typography.bodyLargeEmphasized, - modifier = Modifier.fillMaxHeight() - ) - }, - supportingContent = { - val locale = LocalLocale.current.platformLocale - val timePattern = DateFormat.getBestDateTimePattern( - locale, - "jms" - ) - val formatter = DateTimeFormatter.ofPattern(timePattern) + val defaultSpatialSpecFloat = MaterialTheme.motionScheme.defaultSpatialSpec() - val timeString = formatter.format( - Instant.ofEpochMilli(state.currentHeartRate.timestamp.toEpochMilliseconds()) - .atZone(ZoneId.systemDefault()) - ) + val fastSpatialSpecFloat = MaterialTheme.motionScheme.fastSpatialSpec() + val fastSpatialSpecIntOffset = MaterialTheme.motionScheme.fastSpatialSpec() + AnimatedContent( + targetState = state.hrmState, + transitionSpec = { fadeIn(defaultSpatialSpecFloat) togetherWith fadeOut(defaultSpatialSpecFloat) }, + label = "hrm_state" + ) { buddyState -> + when (buddyState) { + BuddyState.INACTIVE -> {} + BuddyState.WAITING -> { Text( - text = timeString, - style = MaterialTheme.typography.bodySmall + text = stringResource(R.string.waiting_ellipsis), + style = MaterialTheme.typography.labelSmallEmphasized, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center ) - }, - leadingContent = { - Box( - modifier = Modifier - .size(48.dp) - .background( - MaterialTheme.colorScheme.primaryContainer, - MaterialShapes.SoftBurst.normalized() - .toShape() - ), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = LocalIcons.current.VitalSigns, - contentDescription = "vital signs", - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.onPrimaryContainer - ) - } - }, - orientation = StyledListItemOrientation.Vertical - ) + } + + BuddyState.ACTIVE -> { + StyledListItem( + onClick = null, + content = { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + val bpm = state.currentHeartRate?.bpm ?: 0 + + // make this reusable + bpm.toString().forEachIndexed { index, digit -> + AnimatedContent( + targetState = digit, + transitionSpec = { + ( + slideInVertically( + animationSpec = fastSpatialSpecIntOffset, + initialOffsetY = { it } + ) + fadeIn(fastSpatialSpecFloat) + ) togetherWith + ( + slideOutVertically( + animationSpec = fastSpatialSpecIntOffset, + targetOffsetY = { -it } + ) + fadeOut(fastSpatialSpecFloat) + ) + }, + label = "bpm_digit_$index" + ) { value -> + Text( + text = value.toString(), + style = MaterialTheme.typography.headlineMediumEmphasized + ) + } + } + + Text( + text = " bpm", + style = MaterialTheme.typography.headlineMediumEmphasized + ) + } + }, + supportingContent = { + val locale = LocalLocale.current.platformLocale + val timePattern = DateFormat.getBestDateTimePattern( + locale, + "jms" + ) + val formatter = DateTimeFormatter.ofPattern(timePattern) + + val timeString = formatter.format( + Instant.ofEpochMilli( + state.currentHeartRate?.timestamp?.toEpochMilliseconds() + ?: 0 + ) + .atZone(ZoneId.systemDefault()) + ) + + Text( + text = timeString, + style = MaterialTheme.typography.bodySmall + ) + }, + leadingContent = { + Box( + modifier = Modifier + .size(48.dp) + .background( + MaterialTheme.colorScheme.primaryContainer, + MaterialShapes.SoftBurst.normalized() + .toShape() + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = LocalIcons.current.VitalSigns, + contentDescription = "vital signs", + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + }, + orientation = StyledListItemOrientation.Vertical + ) + } + } } AnimatedVisibility(visible = !healthPermissions.status.isGranted) { @@ -225,14 +292,25 @@ fun HeartRateScreen( StyledToggle( label = stringResource(R.string.heart_rate_alert), description = stringResource(R.string.hrm_alert_description), - checked = settings.hrAlertEnabled, + checked = settings.hrmAlertEnabled, onCheckedChange = { enabled -> updateSettings { - it.copy(hrAlertEnabled = enabled) + it.copy(hrmAlertEnabled = enabled) } - } + }, ) + AnimatedVisibility( + visible = settings.hrmAlertEnabled && state.hrmState == BuddyState.INACTIVE + ) { + Text( + text = stringResource(R.string.heart_rate_alerts_disabled_warning), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 16.dp) + ) + } + val sliderValue = remember { mutableFloatStateOf(settings.hrmAlertThreshold.toFloat()) } LaunchedEffect(sliderValue) { @@ -249,22 +327,28 @@ fun HeartRateScreen( label = stringResource(R.string.heart_rate_alert_threshold), value = sliderValue.floatValue, onValueChange = { sliderValue.floatValue = it }, - valueRange = 120f..180f, + valueRange = 120f..200f, description = "${sliderValue.floatValue.roundToInt()} bpm", independent = true ) - Column( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 350.dp) - .background( - MaterialTheme.colorScheme.surfaceContainerHigh, - RoundedCornerShape(28.dp) - ) - ) { - // TODO: graph or something - } + Text( + text = stringResource(R.string.hrm_alert_warning), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 16.dp) + ) + + // TODO: graph or something +// Column( +// modifier = Modifier +// .fillMaxWidth() +// .heightIn(min = 350.dp) +// .background( +// MaterialTheme.colorScheme.surfaceContainerHigh, +// RoundedCornerShape(28.dp) +// ) +// ) { } Spacer(modifier = Modifier.height(bottomPadding)) } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/Color.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/Color.kt deleted file mode 100644 index f2e44c1d..00000000 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/Color.kt +++ /dev/null @@ -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 . -*/ - - -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) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/DesignSystem.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/DesignSystem.kt index 74a48778..72dbd896 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/DesignSystem.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/DesignSystem.kt @@ -1,8 +1,5 @@ package me.kavishdevar.librepods.presentation.theme -import kotlinx.serialization.Serializable - -@Serializable enum class DesignSystem { Apple, Material diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/Theme.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/Theme.kt index 62933279..f17edd56 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/Theme.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/Theme.kt @@ -77,6 +77,7 @@ private val AppleLightColorScheme = lightColorScheme( fun LibrePodsTheme( darkTheme: Boolean = isSystemInDarkTheme(), designSystem: DesignSystem = DesignSystem.Material, + overrideMaterialColor: Color? = null, content: @Composable () -> Unit ) { val colorScheme = when(designSystem) { @@ -99,6 +100,12 @@ fun LibrePodsTheme( DesignSystem.Apple -> AppleIcons } ) { +// var colorScheme = colorScheme +// +// if (designSystem == DesignSystem.Material) { +// +// } + MaterialExpressiveTheme( colorScheme = colorScheme, motionScheme = MotionScheme.expressive(), diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/Type.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/Type.kt index 88360310..cf808536 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/Type.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/theme/Type.kt @@ -106,6 +106,7 @@ val AppleTypography = Typography().run { labelSmallEmphasized = labelSmallEmphasized.copy( fontFamily = interFamily, fontSize = 14.sp, + lineHeight = 18.sp, fontWeight = FontWeight.Bold ) ) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/repository/AppleRepository.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/repository/AppleRepository.kt index e6de828f..68ab7357 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/repository/AppleRepository.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/repository/AppleRepository.kt @@ -45,7 +45,6 @@ class AppleRepository( capabilities = state.capabilities, magicKeys = state.magicKeys, controlStates = state.controlStates, - customEq = state.customEq ) } catch (e: Exception) { Log.e(TAG, "Failed to create AppleCache from AppleState for ${macAddress.toRedactedString()}", e) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/repository/HeartRateRepository.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/repository/HeartRateRepository.kt index a8142434..974ab4b4 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/repository/HeartRateRepository.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/repository/HeartRateRepository.kt @@ -1,6 +1,6 @@ package me.kavishdevar.librepods.repository -import me.kavishdevar.librepods.data.heartrate.HeartRateDao +import me.kavishdevar.librepods.database.heartrate.HeartRateDao import me.kavishdevar.librepods.data.heartrate.HeartRateSample import me.kavishdevar.librepods.database.heartrate.HeartRateSampleEntity import kotlin.time.Instant diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt index d03b0440..cadf113a 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt @@ -77,7 +77,7 @@ import kotlin.time.toJavaInstant private const val TAG = "LibrePodsService" @SuppressLint("MissingPermission") -class LibrePodsService : Service() { +class LibrePodsService: Service() { inner class LocalBinder : Binder() { fun getService(): LibrePodsService = this@LibrePodsService } @@ -120,9 +120,8 @@ class LibrePodsService : Service() { private val telephonyCallback = object: TelephonyCallback(), TelephonyCallback.CallStateListener { override fun onCallStateChanged(state: Int) { - isCallRinging = state == TelephonyManager.CALL_STATE_RINGING - if (state == TelephonyManager.CALL_STATE_RINGING) { + isCallRinging = true _devices.value.values.firstOrNull { device -> device is AppleDevice && device.connectionState.value == ConnectionState.CONNECTED && @@ -161,6 +160,16 @@ class LibrePodsService : Service() { } } } + } else { + if (isCallRinging) { + _devices.value.values.firstOrNull { device -> + device is AppleDevice && + device.connectionState.value == ConnectionState.CONNECTED && + device.state.value.componentState.any { it.status == ComponentStatus.IN_EAR } + }.let { device -> + (device as AppleDevice).stopHeadGestureDetection() + } + } } } } @@ -272,17 +281,19 @@ class LibrePodsService : Service() { "Loaded metadata for device ${device.macAddress.toRedactedString()}: $metadata" ) - device.loadInitialState( state = AppleState().copy( capabilities = cache.capabilities, magicKeys = cache.magicKeys, controlStates = cache.controlStates, - customEq = cache.customEq, ), settings = settings, metadata = metadata ) + + if (device.settings.value.hrmAlertEnabled) { + device.startHr() + } } deviceJobs[MacAddress(bluetoothDevice.address)] = mutableListOf() @@ -577,7 +588,6 @@ class LibrePodsService : Service() { capabilities = cache.capabilities, magicKeys = cache.magicKeys, controlStates = cache.controlStates, - customEq = cache.customEq, ), settings = settings, metadata = metadata @@ -794,7 +804,8 @@ class LibrePodsService : Service() { processHeartRateSample( heartRateSample = heartRateSample, - interval = state.heartRateInterval + interval = state.heartRateInterval, + alertThreshold = deviceSettings.hrmAlertThreshold ) } } @@ -996,19 +1007,25 @@ class LibrePodsService : Service() { fun startForegroundNotification() { val disconnectedNotificationChannel = NotificationChannel( - "background_service_status", - "Background Service Status", + "foreground_service_status", + getString(R.string.foreground_service_status), NotificationManager.IMPORTANCE_NONE ) + val hrmAlertChannel = NotificationChannel( + "hrm_alert", + getString(R.string.heart_rate_alert), + NotificationManager.IMPORTANCE_HIGH + ) + val notificationManager = getSystemService(NotificationManager::class.java) notificationManager.createNotificationChannel(disconnectedNotificationChannel) + notificationManager.createNotificationChannel(hrmAlertChannel) - val notificationSettingsIntent = - Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply { - putExtra(Settings.EXTRA_APP_PACKAGE, packageName) - putExtra(Settings.EXTRA_CHANNEL_ID, "background_service_status") - } + val notificationSettingsIntent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + putExtra(Settings.EXTRA_CHANNEL_ID, "foreground_service_status") + } val pendingIntentNotifDisable = PendingIntent.getActivity( this, @@ -1017,9 +1034,9 @@ class LibrePodsService : Service() { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) - val notification = NotificationCompat.Builder(this, "background_service_status") - .setSmallIcon(R.drawable.ic_airpods).setContentTitle("Background Service Running") - .setContentText("Useless notification, disable it by clicking on it.") + val notification = NotificationCompat.Builder(this, "foreground_service_status") + .setSmallIcon(R.drawable.ic_airpods).setContentTitle(getString(R.string.service_running)) + .setContentText(getString(R.string.foreground_notification_description)) .setContentIntent(pendingIntentNotifDisable).setCategory(Notification.CATEGORY_SERVICE) .setPriority(NotificationCompat.PRIORITY_LOW).setOngoing(true).build() @@ -1180,13 +1197,6 @@ class LibrePodsService : Service() { device.disableAudio(this) device.disconnectAudio(this) } - - // not the right place because the stream doesn't switch to the remaining bud when the active one is removed - if (device is AppleDevice && device.state.value.hrmActive) { - device.updateState { - it.copy(hrmActive = false) - } - } } } @@ -1210,41 +1220,73 @@ class LibrePodsService : Service() { } } - private fun processHeartRateSample(heartRateSample: HeartRateSample, interval: Duration) { + private fun processHeartRateSample(heartRateSample: HeartRateSample, interval: Duration, alertThreshold: Int) { CoroutineScope(Dispatchers.IO).launch { Log.d(TAG, "inserting to local db") heartRateRepository.insert(heartRateSample) } - if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 7) { - if (checkSelfPermission(HealthPermission.getWritePermission(HeartRateRecord::class)) != PackageManager.PERMISSION_GRANTED) return - val healthConnectHeartRateSample = HeartRateRecord.Sample( - time = heartRateSample.timestamp.toJavaInstant(), - beatsPerMinute = heartRateSample.bpm.toLong() - ) + CoroutineScope(Dispatchers.Default).launch { + val notificationManager = getSystemService(NotificationManager::class.java) - val zoneOffset = ZoneOffset.systemDefault().rules.getOffset(heartRateSample.timestamp.toJavaInstant()) + if (heartRateSample.bpm > alertThreshold) { + val notification = NotificationCompat.Builder(this@LibrePodsService, "hrm_alert") + .setSmallIcon(R.drawable.ic_pulse_alert) + .setContentTitle(getString(R.string.high_heart_rate)) + .setContentText( + getString( + R.string.high_heart_rate_notification_text, + heartRateSample.bpm + ) + ) + .setPriority(NotificationCompat.PRIORITY_MAX) + .setOngoing(true) + .build() - val heartRateRecord = HeartRateRecord( - startTime = (heartRateSample.timestamp - interval).toJavaInstant(), - endTime = heartRateSample.timestamp.toJavaInstant(), - startZoneOffset = zoneOffset, - endZoneOffset = zoneOffset, - samples = listOf(healthConnectHeartRateSample), - metadata = androidx.health.connect.client.records.metadata.Metadata.autoRecorded( - device = androidx.health.connect.client.records.metadata.Device(type = androidx.health.connect.client.records.metadata.Device.TYPE_HEARABLE) + notificationManager.notify(999, notification) + } else { + if (notificationManager.activeNotifications.any { it.id == 999 }) { + notificationManager.cancel(999) + } + } + } + + CoroutineScope(Dispatchers.IO).launch { + if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 7) { + if (checkSelfPermission(HealthPermission.getWritePermission(HeartRateRecord::class)) != PackageManager.PERMISSION_GRANTED) return@launch + val healthConnectHeartRateSample = HeartRateRecord.Sample( + time = heartRateSample.timestamp.toJavaInstant(), + beatsPerMinute = heartRateSample.bpm.toLong() ) - ) - if (healthConnectClient != null) { - CoroutineScope(Dispatchers.IO).launch { - healthConnectClient!!.insertRecords(listOf(heartRateRecord)) + val zoneOffset = + ZoneOffset.systemDefault().rules.getOffset(heartRateSample.timestamp.toJavaInstant()) + + val heartRateRecord = HeartRateRecord( + startTime = (heartRateSample.timestamp - interval).toJavaInstant(), + endTime = heartRateSample.timestamp.toJavaInstant(), + startZoneOffset = zoneOffset, + endZoneOffset = zoneOffset, + samples = listOf(healthConnectHeartRateSample), + metadata = androidx.health.connect.client.records.metadata.Metadata.autoRecorded( + device = androidx.health.connect.client.records.metadata.Device(type = androidx.health.connect.client.records.metadata.Device.TYPE_HEARABLE) + ) + ) + + if (healthConnectClient != null) { + CoroutineScope(Dispatchers.IO).launch { + try { + healthConnectClient!!.insertRecords(listOf(heartRateRecord)) + } catch (e: Exception) { + Log.e(TAG, "Error inserting heart rate record", e) + } + } + } else { + Log.w(TAG, "Health Connect client not available") } } else { - Log.w(TAG, "Health Connect client not available") + Log.d(TAG, "U SDK Extension <7") } - } else { - Log.d(TAG, "U SDK Extension <7") } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/GestureDetector.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/GestureDetector.kt index 4d81446e..5a279b24 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/GestureDetector.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/GestureDetector.kt @@ -90,8 +90,8 @@ class GestureDetector { while (verticalAvgBuffer.size < 3) verticalAvgBuffer.add(0.0) } - fun startDetection(acceleration: StateFlow, callback: (Boolean) -> Unit) { - if (isRunning) return + fun startDetection(acceleration: StateFlow, callback: (Boolean) -> Unit): Boolean { + if (isRunning) return false Log.d(TAG, "Starting gesture detection...") isRunning = true @@ -123,6 +123,8 @@ class GestureDetector { } } } + + return true } fun stopDetection() { if (!isRunning) return diff --git a/android/app/src/main/res/drawable/ic_pulse_alert.xml b/android/app/src/main/res/drawable/ic_pulse_alert.xml new file mode 100644 index 00000000..3ef3adc7 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_pulse_alert.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 103699e2..387a9963 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -332,7 +332,7 @@ Required to communicate with AirPods. Optional Permissions Notifications - Show battery status when connected or available nearby. + Show battery status, alerts for high heart rate (on supported models), and more. Respond to phone calls with head gestures. Display over other apps Show popups when AirPods are nearby or audio switches to them. @@ -343,7 +343,15 @@ Heart Rate Monitoring Sends a notification when your heart rate is above the threshold. Consumes more battery. Heart rate alert - Heart rate threshold + Alert threshold Call rejected Call accepted + Waiting… + Service Running + Useless notification, disable it by clicking on it. + Foreground Service Status + Your heart rate is above the set threshold. (currently: %d bpm) + High heart rate + This alert does not currently account for exercise or physical activity. It is for informational purposes only and is not intended as medical advice. + Alerts have been disabled until reconnection or re-enabling heart rate monitoring.