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