diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 2fd88956..14814371 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -9,6 +9,7 @@ plugins {
alias(libs.plugins.ksp)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.room3)
+ alias(libs.plugins.protobuf)
// alias(libs.plugins.hilt)
id("kotlin-parcelize")
}
@@ -31,13 +32,30 @@ room3 {
schemaDirectory("$projectDir/schemas")
}
+protobuf {
+ protoc {
+ artifact = libs.protobuf.protoc.get().toString()
+ }
+
+ generateProtoTasks {
+ all().forEach { task ->
+ task.builtins {
+ create("java")
+ create("kotlin")
+ }
+ }
+ }
+}
+
kotlin {
compilerOptions {
optIn.addAll(
"androidx.compose.material3.ExperimentalMaterial3ExpressiveApi",
"kotlin.uuid.ExperimentalUuidApi",
"kotlinx.coroutines.FlowPreview",
- "kotlinx.serialization.ExperimentalSerializationApi"
+ "kotlinx.serialization.ExperimentalSerializationApi",
+ "kotlinx.coroutines.ExperimentalCoroutinesApi",
+ "dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi"
)
}
}
@@ -136,6 +154,7 @@ dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.accompanist.permissions)
implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.foundation.layout)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
@@ -169,8 +188,9 @@ dependencies {
implementation(libs.androidx.room3.runtime)
ksp(libs.androidx.room3.compiler)
implementation(libs.kotlinx.serialization.cbor)
-
+ implementation(libs.protobuf.kotlin)
// compileOnly(files("../../../framework-classes.jar"))
+ implementation(libs.androidx.healthconnect.client)
}
aboutLibraries {
diff --git a/android/app/schemas/me.kavishdevar.librepods.database.LibrePodsDatabase/1.json b/android/app/schemas/me.kavishdevar.librepods.database.LibrePodsDatabase/1.json
index 674a5c26..e22a1ab7 100644
--- a/android/app/schemas/me.kavishdevar.librepods.database.LibrePodsDatabase/1.json
+++ b/android/app/schemas/me.kavishdevar.librepods.database.LibrePodsDatabase/1.json
@@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 1,
- "identityHash": "ae2e348e0198b8d871e9e3ed6959ab30",
+ "identityHash": "7a502eac34ab57e1c12ba8f849e172cf",
"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, `debugMode` INTEGER NOT NULL, `bleScanMode` INTEGER NOT NULL, `bleReportDelay` INTEGER 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",
@@ -62,6 +62,12 @@
"affinity": "TEXT",
"notNull": true
},
+ {
+ "fieldPath": "useHighestRefreshRate",
+ "columnName": "useHighestRefreshRate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
{
"fieldPath": "debugMode",
"columnName": "debugMode",
@@ -79,6 +85,12 @@
"columnName": "bleReportDelay",
"affinity": "INTEGER",
"notNull": true
+ },
+ {
+ "fieldPath": "swipeAnywhereForBack",
+ "columnName": "swipeAnywhereForBack",
+ "affinity": "INTEGER",
+ "notNull": true
}
],
"primaryKey": {
@@ -163,11 +175,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, 'ae2e348e0198b8d871e9e3ed6959ab30')"
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7a502eac34ab57e1c12ba8f849e172cf')"
]
}
}
\ No newline at end of file
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 526a6ed6..cd55e20a 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -27,7 +27,8 @@
android:name="android.permission.INTERACT_ACROSS_USERS"
tools:ignore="ProtectedPermissions" />
+ android:name="android.permission.MODIFY_AUDIO_ROUTING"
+ tools:ignore="ProtectedPermissions" />
@@ -48,6 +49,8 @@
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/LibrePodsApplication.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/LibrePodsApplication.kt
index 3317d188..b5000707 100644
--- a/android/app/src/main/kotlin/me/kavishdevar/librepods/LibrePodsApplication.kt
+++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/LibrePodsApplication.kt
@@ -1,6 +1,7 @@
package me.kavishdevar.librepods
import android.app.Application
+import androidx.health.connect.client.HealthConnectClient
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
@@ -12,7 +13,10 @@ import me.kavishdevar.librepods.billing.BillingProviderFactory
import me.kavishdevar.librepods.database.LibrePodsDatabase
import me.kavishdevar.librepods.repository.AppDataRepository
import me.kavishdevar.librepods.repository.AppleRepository
+import me.kavishdevar.librepods.repository.HeartRateRepository
+import me.kavishdevar.librepods.repository.RecordingRepository
import me.kavishdevar.librepods.repository.WidgetConfigRepository
+import me.kavishdevar.librepods.utils.GestureFeedback
import me.kavishdevar.librepods.utils.XposedServiceHolder
import me.kavishdevar.librepods.utils.XposedState
@@ -24,6 +28,18 @@ class LibrePodsApplication: Application(), XposedServiceHelper.OnServiceListener
val appDataRepository by lazy { AppDataRepository(database.appSettingsDao(), database.appStateDao()) }
val widgetConfigRepository by lazy { WidgetConfigRepository(database.widgetConfigDao()) }
+ val recordingRepository by lazy { RecordingRepository(applicationContext) }
+ val heartRateRepository by lazy { HeartRateRepository(database.heartRateDao()) }
+
+ val healthConnectClient: HealthConnectClient? by lazy {
+ val status = HealthConnectClient.getSdkStatus(this)
+ if (status == HealthConnectClient.SDK_AVAILABLE) {
+ HealthConnectClient.getOrCreate(this)
+ } else {
+ null
+ }
+ }
+
override fun onCreate() {
System.loadLibrary("hiddenapi")
@@ -37,6 +53,8 @@ class LibrePodsApplication: Application(), XposedServiceHelper.OnServiceListener
BillingManager.provider = BillingProviderFactory.create(this)
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
+ GestureFeedback.init(this)
+
super.onCreate()
}
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 62e36606..2d3322eb 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
@@ -37,8 +37,11 @@ import me.kavishdevar.librepods.bluetooth.aacp.packet.CustomEqPacket
import me.kavishdevar.librepods.bluetooth.aacp.packet.EarDetectionResponsePacket
import me.kavishdevar.librepods.bluetooth.aacp.packet.InformationPacket
import me.kavishdevar.librepods.bluetooth.aacp.packet.MagicKeyResponsePacket
+import me.kavishdevar.librepods.bluetooth.aacp.packet.RTBuddyPacket
import me.kavishdevar.librepods.bluetooth.aacp.packet.RenamePacket
import me.kavishdevar.librepods.bluetooth.aacp.packet.StemPressPacket
+import me.kavishdevar.librepods.bluetooth.aacp.rtbuddy.proto.SensorDataWX
+import me.kavishdevar.librepods.bluetooth.aacp.rtbuddy.proto.SensorServiceType
import me.kavishdevar.librepods.bluetooth.aacp.types.AppleEvent
import me.kavishdevar.librepods.bluetooth.aacp.types.Capability
import me.kavishdevar.librepods.bluetooth.aacp.types.CapabilityEntry
@@ -48,12 +51,18 @@ import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
import me.kavishdevar.librepods.bluetooth.aacp.types.CustomEq
import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
+import me.kavishdevar.librepods.bluetooth.aacp.types.RTBuddyDescriptor
+import me.kavishdevar.librepods.bluetooth.aacp.types.SensorDataWxBuddyPayload
import me.kavishdevar.librepods.data.audio.MicrophoneFrame
+import me.kavishdevar.librepods.data.heartrate.HeartRateSample
import me.kavishdevar.librepods.devices.AppleDevice
import me.kavishdevar.librepods.devices.BatteryStatus
+import me.kavishdevar.librepods.utils.HeadTracking
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.io.encoding.ExperimentalEncodingApi
+import kotlin.time.Clock
+import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
class AACPManager(private val device: AppleDevice) {
@@ -62,6 +71,8 @@ class AACPManager(private val device: AppleDevice) {
private var socket: BluetoothSocket? = null
+ private val rtBuddyManager = RTBuddyManager(::sendPacket)
+
fun connect(): Boolean {
if (socket != null && socket!!.isConnected) {
Log.i(TAG, "Already connected")
@@ -192,7 +203,7 @@ class AACPManager(private val device: AppleDevice) {
)
return
}
- Log.d(TAG, "received packet: ${packet.toHexString()}")
+// Log.d(TAG, "received packet: ${packet.toHexString()}")
val opcode = packet[4]
when (MessageOpcode.fromByte(opcode)) {
MessageOpcode.BUD_ROLE -> {
@@ -314,7 +325,28 @@ class AACPManager(private val device: AppleDevice) {
}
MessageOpcode.BUDDY_COMMAND -> {
- Log.w(TAG, "BUDDY not implemented")
+ val packet = RTBuddyPacket.parse(packet)
+
+ val rtBuddyPayload = packet.rtBuddyPayload
+
+ rtBuddyManager.handlePacket(packet)
+
+ when (rtBuddyPayload.descriptor) {
+ RTBuddyDescriptor.SENSOR_DATA_WX -> {
+ val sensorDataWxBuddyPayload = rtBuddyPayload as SensorDataWxBuddyPayload
+ val data = sensorDataWxBuddyPayload.data
+
+ handleSensorData(data)
+ } else -> {
+ Log.d(TAG, "Unhandled descriptor: ${rtBuddyPayload.descriptor}")
+ }
+ }
+
+// device.updateState {
+// it.copy(
+// aacpPackets = it.aacpPackets + packet
+// )
+// }
}
MessageOpcode.MAGIC_KEYS_RESPONSE -> {
@@ -625,55 +657,7 @@ class AACPManager(private val device: AppleDevice) {
return sendPacket(packet)
}
- fun sendStartHeadTracking(): Boolean {
- val payload = byteArrayOf(
- 0x00, 0x00, 0x10, 0x00,
- 0x10, 0x00, 0x08, 0xA1.toByte(), 0x02, 0x42, 0x0B, 0x08, 0x0E, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x40, 0x9C.toByte(), 0x00, 0x00
- )
- val packet = AACPPacket.createUnknownPacket(
- MessageOpcode.BUDDY_COMMAND,
- payload
- )
- return sendPacket(packet)
- }
-
- fun sendStartAlternateHeadTracking(): Boolean {
- val payload = byteArrayOf(
- 0x00, 0x00, 0x10, 0x00,
- 0x0F, 0x00, 0x08, 0x73, 0x42, 0x0B, 0x08, 0x10, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x40, 0x9C.toByte(), 0x00, 0x00
- )
- val packet = AACPPacket.createUnknownPacket(
- MessageOpcode.BUDDY_COMMAND,
- payload
- )
- return sendPacket(packet)
- }
-
- fun sendStopHeadTracking(): Boolean {
- val payload = byteArrayOf(
- 0x00, 0x00, 0x10, 0x00,
- 0x11, 0x00, 0x08, 0x7E, 0x10, 0x02, 0x42, 0x0B, 0x08, 0x4E, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00
- )
- val packet = AACPPacket.createUnknownPacket(
- MessageOpcode.BUDDY_COMMAND,
- payload
- )
- return sendPacket(packet)
- }
-
- fun sendStopAlternateHeadTracking(): Boolean {
- val payload = byteArrayOf(
- 0x00, 0x00, 0x10, 0x00,
- 0x0F, 0x00, 0x08, 0x75, 0x42, 0x0B, 0x08, 0x10, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00
- )
-
- val packet = AACPPacket.createUnknownPacket(
- opcode = MessageOpcode.BUDDY_COMMAND,
- payload = payload,
- )
-
- return sendPacket(packet)
- }
+ fun setSensorServiceReportInterval(sensorServiceType: SensorServiceType, interval: Duration): Boolean = rtBuddyManager.setSensorServiceReportInterval(sensorServiceType, interval)
fun sendRename(name: String): Boolean {
val packet = RenamePacket.create(name)
@@ -1045,6 +1029,8 @@ class AACPManager(private val device: AppleDevice) {
connectedDevices = emptyList(),
microphoneFrames = emptyList(),
controlStates = emptyMap(),
+ aacpPackets = emptyList(),
+ currentHeartRate = null
)
}
}
@@ -1174,4 +1160,93 @@ class AACPManager(private val device: AppleDevice) {
return capabilities
}
+
+ private fun handleSensorData(data: SensorDataWX) {
+ if (data.hasCommand()) {
+ when (data.command.service) {
+ SensorServiceType.ACTIVITY, SensorServiceType.DEVMOTION6 -> {
+ val payload = data.command.payload.toByteArray()
+
+ if (payload.size != 58) {
+ Log.w(
+ TAG,
+ "Unexpected payload size for ACTIVITY/DEVMOTION6: ${payload.size}, payload: ${payload.toHexString()}"
+ )
+ return
+ }
+
+ fun i16(offset: Int): Int =
+ (payload[offset].toInt() and 0xFF) or
+ ((payload[offset + 1].toInt() and 0xFF) shl 8)
+ .let { value ->
+ if (value and 0x8000 != 0) value - 0x10000 else value
+ }
+
+ 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.HEARTRATEv2 -> {
+ val payload = data.command.payload.toByteArray()
+ val timestamp = Clock.System.now()
+ if (payload.size == 18) {
+ val heartRate = payload[1].toInt()
+
+ // same as healthconnect's datatype. 300 isn't possible anyway, but whatever
+ if (heartRate !in 1..300) {
+ Log.w(
+ TAG,
+ "Invalid heart rate value: $heartRate"
+ )
+ return
+ }
+
+ if (!device.state.value.hrmActive) {
+ device.updateState {
+ it.copy(
+ hrmActive = true
+ )
+ }
+ }
+
+ Log.i(
+ TAG,
+ "hr: $heartRate bpm"
+ )
+
+ val heartRateSample = HeartRateSample(
+ bpm = heartRate,
+ timestamp = timestamp
+ )
+
+ device.updateState {
+ it.copy(
+ currentHeartRate = heartRateSample
+ )
+ }
+
+ } else {
+ Log.w(
+ TAG,
+ "Unexpected payload size for HEARTRATEv2: ${payload.size}, payload: ${payload.toHexString()}"
+ )
+ }
+ }
+
+ else -> {
+ val payload = data.command.payload.toByteArray()
+ Log.d(
+ TAG,
+ "Unhandled sensor command service: ${data.command.service}, payload: ${payload.toHexString()}"
+ )
+ }
+ }
+ }
+ }
}
diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/RTBuddyManager.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/RTBuddyManager.kt
new file mode 100644
index 00000000..3d08dd28
--- /dev/null
+++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/RTBuddyManager.kt
@@ -0,0 +1,122 @@
+package me.kavishdevar.librepods.bluetooth.aacp
+
+import android.util.Log
+import com.google.protobuf.ByteString
+import me.kavishdevar.librepods.bluetooth.aacp.packet.AACPPacket
+import me.kavishdevar.librepods.bluetooth.aacp.packet.RTBuddyPacket
+import me.kavishdevar.librepods.bluetooth.aacp.rtbuddy.proto.SensorDataWX
+import me.kavishdevar.librepods.bluetooth.aacp.rtbuddy.proto.SensorServiceSetting
+import me.kavishdevar.librepods.bluetooth.aacp.rtbuddy.proto.SensorServiceType
+import me.kavishdevar.librepods.bluetooth.aacp.types.SensorDataWxBuddyPayload
+import me.kavishdevar.librepods.devices.PacketDestination
+import kotlin.time.Duration
+
+class RTBuddyManager(
+ private val sendPacket: (AACPPacket) -> Boolean,
+) {
+ companion object {
+ private const val TAG = "RTBuddyManager"
+ }
+
+ private var sequence = 0
+
+ private fun nextSequence(): Int {
+ val next = sequence
+ sequence = (sequence + 1) and 0x7FFFFFFF
+ return next
+ }
+
+ private fun observeSequence(data: SensorDataWX) {
+ Log.d(TAG, "Received SensorDataWX seq=${data.seq}")
+ }
+
+ fun handlePacket(packet: RTBuddyPacket) {
+ when (val payload = packet.rtBuddyPayload) {
+ is SensorDataWxBuddyPayload -> {
+ val data = payload.data
+ observeSequence(data)
+
+// handleSensorData(data)
+ }
+
+ else -> {
+ Log.d(
+ TAG,
+ "Unhandled RTBuddy payload: ${payload.descriptor}"
+ )
+ }
+ }
+ }
+
+// private fun handleSensorData(data: SensorDataWX) {
+// if (data.hasServiceSettings()) {
+// when (data.serviceSettings.service) {
+// SensorServiceType.ACTIVITY,
+// SensorServiceType.DEVMOTION6 -> {
+// // Head tracking data/configuration
+// }
+//
+// SensorServiceType.HEARTRATE -> {
+// // Heart rate
+// }
+//
+// else -> {
+// Log.d(
+// TAG,
+// "Unhandled sensor service: ${data.serviceSettings.service}"
+// )
+// }
+// }
+// }
+// }
+
+ fun sendSensorData(
+ data: SensorDataWX,
+ ): Boolean {
+ val dataWithSequence = data.toBuilder()
+ .setSeq(nextSequence())
+ .build()
+
+ val packet = RTBuddyPacket.create(
+ rtBuddyPayload = SensorDataWxBuddyPayload(
+ data = dataWithSequence,
+ ),
+ destination = PacketDestination.DEVICE,
+ )
+
+ return sendPacket(packet)
+ }
+
+ private fun sendSensorServiceSetting(
+ service: SensorServiceType,
+ configuration: ByteArray,
+ ): Boolean {
+ val data = SensorDataWX.newBuilder()
+ .setServiceSettings(
+ SensorServiceSetting.newBuilder()
+ .setService(service)
+ .setSetting(2)
+ .setConfiguration(
+ ByteString.copyFrom(configuration)
+ )
+ )
+ .build()
+
+ return sendSensorData(data)
+ }
+
+ fun setSensorServiceReportInterval(sensorServiceType: SensorServiceType, interval: Duration): Boolean = sendSensorServiceSetting(
+ service = sensorServiceType,
+ configuration = byteArrayOf(0x01) + interval.toMicrosUINT32LE()
+ )
+}
+
+private fun Duration.toMicrosUINT32LE(): ByteArray {
+ val microseconds = this.inWholeMicroseconds
+ return byteArrayOf(
+ microseconds.toByte(),
+ (microseconds shr 8).toByte(),
+ (microseconds shr 16).toByte(),
+ (microseconds shr 24).toByte()
+ )
+}
diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/RTBuddyPacket.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/RTBuddyPacket.kt
new file mode 100644
index 00000000..3214d7c2
--- /dev/null
+++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/RTBuddyPacket.kt
@@ -0,0 +1,121 @@
+package me.kavishdevar.librepods.bluetooth.aacp.packet
+
+import me.kavishdevar.librepods.bluetooth.aacp.rtbuddy.proto.SensorDataWX
+import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
+import me.kavishdevar.librepods.bluetooth.aacp.types.Opcode
+import me.kavishdevar.librepods.bluetooth.aacp.types.RTBuddyDescriptor
+import me.kavishdevar.librepods.bluetooth.aacp.types.RTBuddyPayload
+import me.kavishdevar.librepods.bluetooth.aacp.types.SensorDataWxBuddyPayload
+import me.kavishdevar.librepods.bluetooth.aacp.types.UnknownBuddyPayload
+import me.kavishdevar.librepods.devices.PacketDestination
+
+data class RTBuddyPacket(
+ val rtBuddyPayload: RTBuddyPayload,
+
+ override val payload: ByteArray,
+ override val destination: PacketDestination,
+) : AACPPacket {
+
+ override val type: AACPPacketType = AACPPacketType.MESSAGE
+ override val service: Byte = 0x04
+ override val opcode: Opcode = MessageOpcode.BUDDY_COMMAND
+
+ companion object {
+ fun parse(
+ data: ByteArray,
+ destination: PacketDestination = PacketDestination.HOST,
+ ): RTBuddyPacket {
+ val payload = if (
+ data.size >= 6 &&
+ data[0] == AACPPacketType.MESSAGE.value &&
+ data[4] == MessageOpcode.BUDDY_COMMAND.value
+ ) {
+ data.copyOfRange(6, data.size)
+ } else {
+ data
+ }
+
+ require(payload.size >= 6) {
+ "RTBuddy packet is too short: ${payload.size}"
+ }
+
+ val descriptorValue =
+ (payload[0].toUInt() and 0xFFu) or
+ ((payload[1].toUInt() and 0xFFu) shl 8) or
+ ((payload[2].toUInt() and 0xFFu) shl 16) or
+ ((payload[3].toUInt() and 0xFFu) shl 24)
+
+ val length =
+ (payload[4].toInt() and 0xFF) or
+ ((payload[5].toInt() and 0xFF) shl 8)
+
+ require(payload.size >= 6 + length) {
+ "RTBuddy payload truncated: expected $length bytes, got ${payload.size - 6}"
+ }
+
+ val descriptor = RTBuddyDescriptor.fromValue(descriptorValue)
+ val data = payload.copyOfRange(6, 6 + length)
+
+ val rtBuddyPayload: RTBuddyPayload = when (descriptor) {
+ RTBuddyDescriptor.SENSOR_DATA_WX -> {
+ SensorDataWxBuddyPayload(
+ data = SensorDataWX.parseFrom(data),
+ )
+ }
+
+ else -> {
+ UnknownBuddyPayload(
+ descriptor = descriptor,
+ descriptorValue = descriptorValue,
+ data = data,
+ )
+ }
+ }
+
+ return RTBuddyPacket(
+ rtBuddyPayload = rtBuddyPayload,
+ payload = payload,
+ destination = destination,
+ )
+ }
+ fun create(
+ rtBuddyPayload: RTBuddyPayload,
+ destination: PacketDestination = PacketDestination.DEVICE,
+ ): RTBuddyPacket {
+ val data = when (rtBuddyPayload) {
+ is SensorDataWxBuddyPayload ->
+ rtBuddyPayload.data.toByteArray()
+
+ is UnknownBuddyPayload ->
+ rtBuddyPayload.data
+ }
+
+ val descriptorValue = when (rtBuddyPayload) {
+ is SensorDataWxBuddyPayload ->
+ rtBuddyPayload.descriptor.value
+
+ is UnknownBuddyPayload ->
+ rtBuddyPayload.descriptorValue
+ }
+
+ require(data.size <= 0xFFFF) {
+ "RTBuddy payload too large: ${data.size}"
+ }
+
+ val payload = byteArrayOf(
+ (descriptorValue and 0xFFu).toByte(),
+ ((descriptorValue shr 8) and 0xFFu).toByte(),
+ ((descriptorValue shr 16) and 0xFFu).toByte(),
+ ((descriptorValue shr 24) and 0xFFu).toByte(),
+ (data.size and 0xFF).toByte(),
+ ((data.size shr 8) and 0xFF).toByte(),
+ ) + data
+
+ return RTBuddyPacket(
+ rtBuddyPayload = rtBuddyPayload,
+ payload = payload,
+ destination = destination,
+ )
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/RTBuddyDescriptor.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/RTBuddyDescriptor.kt
new file mode 100644
index 00000000..9da77497
--- /dev/null
+++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/RTBuddyDescriptor.kt
@@ -0,0 +1,35 @@
+package me.kavishdevar.librepods.bluetooth.aacp.types
+
+enum class RTBuddyDescriptor(val value: UInt) {
+ ACOUSTIC(0x00000001u),
+ SCP(0x00000002u),
+ BUDDY(0x00000004u),
+ VIRTUAL_CLI_PRIMARY(0x00000008u),
+ VIRTUAL_CLI_SECONDARY(0x00000010u),
+ APP_DIAGNOSTICS(0x00000020u),
+ LOGGING_TRIGGER(0x00000040u),
+ DEBUG_DATA(0x00000080u),
+ TOUCH(0x00000100u),
+ LOG_CONFIG(0x00000200u),
+ LOG_MSG(0x00000400u),
+ SENSOR(0x00000800u),
+ SWITCH_CONTROL(0x00001000u),
+ MISMATCHED_BUDS(0x00002000u),
+ UNKNOWN_4000(0x00004000u),
+ B2P(0x00008000u),
+ CONTINUITY(0x00010000u),
+ BATTERY_HEALTH(0x00020000u),
+ SENSOR_V2(0x00040000u),
+ OBC_V2(0x00080000u),
+ SENSOR_DATA_WX(0x00100000u),
+ UNKNOWN_00200000(0x00200000u),
+ UNKNOWN_00400000(0x00400000u),
+ DIGITAL_ENGRAVING_INFO(0x00800000u),
+ ACTIVE_MODE_DATA(0x01000000u),
+
+ UNKNOWN(0xFFFFFFFFu); // better way?
+
+ companion object {
+ fun fromValue(value: UInt): RTBuddyDescriptor = entries.firstOrNull { it.value == value } ?: UNKNOWN
+ }
+}
diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/RTBuddyPayload.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/RTBuddyPayload.kt
new file mode 100644
index 00000000..b445af15
--- /dev/null
+++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/RTBuddyPayload.kt
@@ -0,0 +1,11 @@
+package me.kavishdevar.librepods.bluetooth.aacp.types
+
+sealed interface RTBuddyPayload {
+ val descriptor: RTBuddyDescriptor
+}
+
+data class UnknownBuddyPayload(
+ override val descriptor: RTBuddyDescriptor,
+ val descriptorValue: UInt,
+ val data: ByteArray,
+) : RTBuddyPayload
diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/SensorDataWxBuddyPayload.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/SensorDataWxBuddyPayload.kt
new file mode 100644
index 00000000..81b560ca
--- /dev/null
+++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/SensorDataWxBuddyPayload.kt
@@ -0,0 +1,9 @@
+package me.kavishdevar.librepods.bluetooth.aacp.types
+
+import me.kavishdevar.librepods.bluetooth.aacp.rtbuddy.proto.SensorDataWX
+
+data class SensorDataWxBuddyPayload(
+ val data: SensorDataWX,
+): RTBuddyPayload {
+ override val descriptor: RTBuddyDescriptor = RTBuddyDescriptor.SENSOR_DATA_WX
+}
diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/data/heartrate/HeartRateDao.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/heartrate/HeartRateDao.kt
new file mode 100644
index 00000000..bc7ebff0
--- /dev/null
+++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/heartrate/HeartRateDao.kt
@@ -0,0 +1,24 @@
+package me.kavishdevar.librepods.data.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
+interface HeartRateDao {
+ @Insert
+ suspend fun insert(sample: HeartRateSampleEntity)
+
+ @Query("""
+ SELECT * FROM HeartRateSampleEntity
+ WHERE timestamp >= :start
+ AND timestamp < :end
+ ORDER BY timestamp ASC
+ """)
+ suspend fun get(
+ start: Instant,
+ end: Instant,
+ ): List
+}
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
new file mode 100644
index 00000000..eb96706d
--- /dev/null
+++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/heartrate/HeartRateSample.kt
@@ -0,0 +1,9 @@
+package me.kavishdevar.librepods.data.heartrate
+
+import kotlin.time.Instant
+
+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/data/updates/Updates.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/updates/Updates.kt
index bcdab1fe..0d6a728c 100644
--- a/android/app/src/main/kotlin/me/kavishdevar/librepods/data/updates/Updates.kt
+++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/updates/Updates.kt
@@ -2,7 +2,7 @@ package me.kavishdevar.librepods.data.updates
import androidx.compose.runtime.Composable
import me.kavishdevar.librepods.R
-import me.kavishdevar.librepods.presentation.screens.apple.AirPodsSettingsScreenPreviewMaterial
+import me.kavishdevar.librepods.presentation.screens.apple.AppleSettingsScreenPreviewMaterial
import me.kavishdevar.librepods.presentation.screens.apple.EqualizerScreenPreviewApple
import me.kavishdevar.librepods.presentation.screens.apple.EqualizerScreenPreviewMaterial
import me.kavishdevar.librepods.presentation.theme.DesignSystem
@@ -13,7 +13,7 @@ val update1_0_0 = listOf(
titleRes = R.string.material3e,
descriptionRes = R.string.update_m3e_description,
demoComposeable = @Composable {
- AirPodsSettingsScreenPreviewMaterial()
+ AppleSettingsScreenPreviewMaterial()
}
),
UpdateItem(
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 af8e1905..655e8609 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
@@ -8,6 +8,7 @@ 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 kotlin.time.Instant
object Converters {
@ColumnTypeConverter
@@ -39,4 +40,12 @@ object Converters {
@ColumnTypeConverter
fun bytesToAppleCache(bytes: ByteArray): AppleCache =
Cbor.decodeFromByteArray(bytes)
+
+ @ColumnTypeConverter
+ fun kotlinInstantToLong(instant: Instant): Long =
+ instant.toEpochMilliseconds()
+
+ @ColumnTypeConverter
+ fun longToKotlinInstant(millis: Long): Instant =
+ Instant.fromEpochMilliseconds(millis)
}
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 ef16a0c3..4b26d87f 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
@@ -3,12 +3,14 @@ package me.kavishdevar.librepods.database
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.HeartRateSampleEntity
import me.kavishdevar.librepods.database.widget.WidgetConfigDao
import me.kavishdevar.librepods.database.widget.WidgetConfigEntity
@@ -19,6 +21,7 @@ import me.kavishdevar.librepods.database.widget.WidgetConfigEntity
AppSettingsEntity::class,
AppStateEntity::class,
WidgetConfigEntity::class,
+ HeartRateSampleEntity::class
],
version = 1,
)
@@ -29,4 +32,6 @@ abstract class LibrePodsDatabase: RoomDatabase() {
abstract fun appStateDao(): AppStateDao
abstract fun widgetConfigDao(): WidgetConfigDao
+
+ abstract fun heartRateDao(): HeartRateDao
}
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 5457ff20..4ccfcd86 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
@@ -13,12 +13,12 @@ data class AppSettingsEntity(
val nightMode: NightTheme = NightTheme.System,
val designSystem: DesignSystem = DesignSystem.Material,
+ val useHighestRefreshRate: Boolean = false,
- /**
- * Currently only shows the button for Debug screen.
- */
val debugMode: Boolean = false,
val bleScanMode: Int = ScanSettings.SCAN_MODE_BALANCED,
val bleReportDelay: Long = 0,
+
+ val swipeAnywhereForBack: Boolean = true,
)
diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/database/heartrate/HeartRateSampleEntity.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/heartrate/HeartRateSampleEntity.kt
new file mode 100644
index 00000000..0b036852
--- /dev/null
+++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/heartrate/HeartRateSampleEntity.kt
@@ -0,0 +1,14 @@
+package me.kavishdevar.librepods.database.heartrate
+
+import androidx.room3.Entity
+import androidx.room3.PrimaryKey
+import kotlin.time.Instant
+
+@Entity
+data class HeartRateSampleEntity(
+ @PrimaryKey(autoGenerate = true)
+ val id: Long = 0L,
+
+ val timestamp: Instant,
+ val bpm: Int,
+)
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 725c680c..5267dc63 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
@@ -18,12 +18,17 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.bluetooth.MacAddress
import me.kavishdevar.librepods.bluetooth.aacp.AACPManager
+import me.kavishdevar.librepods.bluetooth.aacp.rtbuddy.proto.SensorServiceType
import me.kavishdevar.librepods.bluetooth.aacp.types.AppleEvent
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.utils.GestureDetector
+import me.kavishdevar.librepods.utils.HeadTracking
+import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
private const val TAG = "AppleDevice"
@@ -77,6 +82,12 @@ class AppleDevice(
val aacp = AACPManager(this)
val att = ATTManager(this)
+
+ // ik, probably wrong place. TODO
+ private val gestureDetector by lazy {
+ GestureDetector()
+ }
+
init {
updateMetadata {
it.copy(
@@ -205,25 +216,35 @@ class AppleDevice(
}
fun startHeadTracking() {
- if (settings.value.alternateHeadTrackingPackets) {
- aacp.sendStartAlternateHeadTracking()
- } else {
- aacp.sendStartHeadTracking()
- }
+ aacp.setSensorServiceReportInterval(
+ sensorServiceType = if (metadata.value.version3.first().digitToInt() >= 8) SensorServiceType.DEVMOTION6 else SensorServiceType.ACTIVITY,
+ interval = _settings.value.headTrackingInterval
+ )
_state.update {
it.copy(headTrackingActive = true)
}
}
fun stopHeadTracking() {
- if (settings.value.alternateHeadTrackingPackets) {
- aacp.sendStopAlternateHeadTracking()
- } else {
- aacp.sendStopHeadTracking()
- }
+ aacp.setSensorServiceReportInterval(
+ sensorServiceType = if (metadata.value.version3.first().digitToInt() >= 8) SensorServiceType.DEVMOTION6 else SensorServiceType.ACTIVITY,
+ interval = Duration.ZERO
+ )
_state.update {
it.copy(headTrackingActive = false)
}
+ gestureDetector.stopDetection()
+ }
+
+ 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
+ )
+ }
+ gestureDetector.startDetection(HeadTracking.acceleration, callback)
}
fun setHeadGesturesEnabled(enabled: Boolean) {
@@ -232,6 +253,25 @@ class AppleDevice(
}
}
+ fun setHeadGesturesVerticalOffset(offset: Int) {
+ _settings.update {
+ it.copy(headGesturesVerticalOffset = offset)
+ }
+ }
+
+ fun setHeadGesturesHorizontalOffset(offset: Int) {
+ _settings.update {
+ it.copy(headGesturesHorizontalOffset = offset)
+ }
+ }
+
+ fun setHeadTrackingInterval(interval: Duration) {
+ startHeadTracking()
+ _settings.update {
+ it.copy(headTrackingInterval = interval)
+ }
+ }
+
fun setCustomEqEnabled(enabled: Boolean) {
aacp.setCustomEq(_state.value.customEq.copy(state = if(enabled) 2 else 1))
}
@@ -243,17 +283,24 @@ class AppleDevice(
aacp.setCustomEq(_state.value.customEq.copy(low = low, mid = mid, high = high))
}
- fun testHeadGestures() {
- if (settings.value.alternateHeadTrackingPackets) {
- aacp.sendStartAlternateHeadTracking()
- } else {
- aacp.sendStartHeadTracking()
- }
- _state.update {
+ // 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 stopHr(): Boolean {
+ val success = aacp.setSensorServiceReportInterval(
+ sensorServiceType = if (metadata.value.version3.first().digitToInt() >= 8) SensorServiceType.HEARTRATE_COMMAND else SensorServiceType.HEARTRATE,
+ interval = Duration.ZERO
+ )
+ if (state.value.hrmActive && success) _state.update {
it.copy(
- detectHeadGestures = true
+ hrmActive = false,
+ currentHeartRate = null
)
}
+ return success
}
fun sendRawPacket(data: ByteArray): Boolean = aacp.sendRawPacket(data)
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 6465f2e4..a1893466 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
@@ -2,6 +2,8 @@ package me.kavishdevar.librepods.devices
import kotlinx.serialization.Serializable
import me.kavishdevar.librepods.data.StemAction
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
@Serializable
data class AppleSettings(
@@ -10,6 +12,9 @@ data class AppleSettings(
val cacheDisconnectedComponentBattery: Boolean = true,
val headGesturesEnabled: Boolean = true, // head_gestures_enabled
+ val headGesturesVerticalOffset: Int = 30,
+ val headGesturesHorizontalOffset: Int = 28,
+ val headTrackingInterval: Duration = 40.milliseconds,
val leftLongPressAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES, // left_long_press_action
val rightLongPressAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES, // right_long_press_action
@@ -17,8 +22,6 @@ data class AppleSettings(
val showIslandPopup: Boolean = true, // show_island_popup
val showBottomSheetPopup: Boolean = true, // show_bottom_sheet_popup
- val alternateHeadTrackingPackets: Boolean = true, // use_alternate_head_tracking_packets
-
val takeoverWhenDisconnected: Boolean = true, // takeover_when_disconnected
val takeoverWhenIdle: Boolean = true, // takeover_when_idle
val takeoverWhenMusic: Boolean = true, // takeover_when_music
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 f06aafcf..4fa53cd2 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
@@ -11,7 +11,10 @@ import me.kavishdevar.librepods.bluetooth.aacp.types.CustomEq
import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType
import me.kavishdevar.librepods.data.audio.MicrophoneFrame
import me.kavishdevar.librepods.data.audio.MicrophoneState
+import me.kavishdevar.librepods.data.heartrate.HeartRateSample
import me.kavishdevar.librepods.data.recording.RecordingState
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
data class AppleState(
val isLocallyConnected: Boolean = false,
@@ -51,5 +54,9 @@ data class AppleState(
val headphoneAccomodationEnabledForMedia: Boolean = false,
val headphoneAccomodationEnabledForPhone: Boolean = false,
+ val currentHeartRate: HeartRateSample? = null,
+ val hrmActive: Boolean = false,
+ val heartRateInterval: Duration = 1.seconds,
+
val aacpPackets: List = emptyList(),
): DeviceState
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 b95dfcd6..9d5d8ac3 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
@@ -28,9 +28,11 @@ import android.content.Context
import android.content.Context.MODE_PRIVATE
import android.content.Intent
import android.content.ServiceConnection
+import android.hardware.display.DisplayManager
import android.os.Bundle
import android.os.IBinder
import android.util.Log
+import android.view.Display
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
@@ -58,8 +60,8 @@ import me.kavishdevar.librepods.services.LibrePodsService
import me.kavishdevar.librepods.utils.XposedState
import kotlin.io.encoding.ExperimentalEncodingApi
-lateinit var serviceConnection: ServiceConnection
-lateinit var connectionStatusReceiver: BroadcastReceiver
+private lateinit var serviceConnection: ServiceConnection
+private lateinit var connectionStatusReceiver: BroadcastReceiver
//lateinit var testReviewReceiver: BroadcastReceiver
class MainActivity : ComponentActivity() {
@@ -95,6 +97,22 @@ class MainActivity : ComponentActivity() {
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
+ LaunchedEffect(settings.swipeAnywhereForBack) {
+ if (settings.useHighestRefreshRate) {
+ val display = getSystemService(DisplayManager::class.java)
+ .getDisplay(Display.DEFAULT_DISPLAY)
+
+ val highest = display.supportedModes
+ .maxByOrNull { it.refreshRate }
+
+ highest?.let {
+ window.attributes = window.attributes.apply {
+ preferredRefreshRate = it.refreshRate
+ }
+ }
+ }
+ }
+
LibrePodsTheme(
designSystem = settings.designSystem,
darkTheme = darkTheme
diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/activities/NoiseControlWidgetConfigurationActivity.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/activities/NoiseControlWidgetConfigurationActivity.kt
index d9d7888b..d1af77a6 100644
--- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/activities/NoiseControlWidgetConfigurationActivity.kt
+++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/activities/NoiseControlWidgetConfigurationActivity.kt
@@ -7,7 +7,6 @@ import android.content.Intent
import android.content.ServiceConnection
import android.os.Bundle
import android.os.IBinder
-import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
@@ -15,6 +14,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -34,9 +34,9 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.LibrePodsApplication
import me.kavishdevar.librepods.R
+import me.kavishdevar.librepods.bluetooth.MacAddress
import me.kavishdevar.librepods.database.widget.WidgetConfigEntity
import me.kavishdevar.librepods.devices.Device
-import me.kavishdevar.librepods.bluetooth.MacAddress
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem
@@ -164,13 +164,15 @@ private fun WidgetDevicePickerContent(
) {
StyledScaffold(
title = stringResource(R.string.configure_widget),
- ) {
+ navigateBack = null
+ ) { topPadding, bottomPadding ->
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
+ Spacer(modifier = Modifier.size(topPadding))
if (devices.isEmpty()) {
Text(
text = "No devices found. Please ensure a compatible device is paired with your phone and try again.",
@@ -231,6 +233,8 @@ private fun WidgetDevicePickerContent(
)
}
}
+
+ Spacer(modifier = Modifier.size(bottomPadding))
}
}
}
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
new file mode 100644
index 00000000..7ec7b838
--- /dev/null
+++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/activities/PrivacyPolicyActivity.kt
@@ -0,0 +1,90 @@
+package me.kavishdevar.librepods.presentation.activities
+
+import android.app.Activity
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.core.view.WindowCompat
+import me.kavishdevar.librepods.LibrePodsApplication
+import me.kavishdevar.librepods.R
+import me.kavishdevar.librepods.presentation.components.StyledScaffold
+import me.kavishdevar.librepods.presentation.screens.onboarding.PrivacyPolicyPage
+import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
+import me.kavishdevar.librepods.presentation.theme.NightTheme
+
+class PrivacyPolicyActivity : ComponentActivity() {
+ val appDataRepository by lazy { (application as LibrePodsApplication).appDataRepository }
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ setContent {
+ val settings by appDataRepository.settings.collectAsState()
+
+ val systemDarkTheme = isSystemInDarkTheme()
+
+ val darkTheme = when (settings.nightMode) {
+ NightTheme.Dark -> true
+ NightTheme.Light -> false
+ NightTheme.System -> systemDarkTheme
+ }
+
+ val view = LocalView.current
+ val window = (view.context as Activity).window
+
+ LaunchedEffect(darkTheme) {
+ WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
+ }
+
+ LibrePodsTheme(
+ designSystem = settings.designSystem,
+ darkTheme = darkTheme
+ ) {
+// For demo screenshots
+// val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
+// windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+// windowInsetsController.hide(WindowInsetsCompat.Type.statusBars())
+
+ StyledScaffold(
+ title = stringResource(R.string.privacy_policy),
+ navigateBack = null
+ ) { topPadding, bottomPadding ->
+ Column {
+ Spacer(modifier = Modifier.height(topPadding))
+
+ Surface(
+ shape = RoundedCornerShape(52.dp),
+ modifier = Modifier
+ .fillMaxSize()
+ .clip(RoundedCornerShape(52.dp))
+ .padding(16.dp),
+ color = MaterialTheme.colorScheme.surfaceContainer
+ ) {
+ PrivacyPolicyPage { finish() }
+ }
+
+ Spacer(modifier = Modifier.height(bottomPadding))
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledList.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledList.kt
index 0e8498ee..d3433f49 100644
--- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledList.kt
+++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledList.kt
@@ -122,12 +122,13 @@ fun StyledListDemo() {
darkTheme = false
) {
StyledScaffold(
- title = "StyledListTest"
- ) {
+ title = "StyledListTest",
+ navigateBack = null
+ ) { topPadding, bottomPadding ->
Column (
modifier = Modifier.padding(horizontal = 12.dp)
) {
- Spacer(modifier = Modifier.height(56.dp))
+ Spacer(modifier = Modifier.height(topPadding))
StyledList(
title = "hello"
) {
@@ -145,6 +146,7 @@ fun StyledListDemo() {
onCheckedChange = { checked.value = it },
)
}
+ Spacer(modifier = Modifier.height(bottomPadding))
}
}
}
diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledListItem.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledListItem.kt
index b1c39517..39b4ad65 100644
--- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledListItem.kt
+++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledListItem.kt
@@ -668,7 +668,7 @@ fun StyledListScope.StyledListItem(
Text(
text = supportingText,
style = if (LocalDesignSystem.current == DesignSystem.Apple && orientation == StyledListItemOrientation.Horizontal) MaterialTheme.typography.bodyMedium else MaterialTheme.typography.bodySmall,
- color = if (selected == true) MaterialTheme.colorScheme.onPrimaryContainer.copy(0.7f) else MaterialTheme.colorScheme.onSurface.copy(0.7f), // TODO: move to color scheme
+ color = if (selected == true && LocalDesignSystem.current == DesignSystem.Material) MaterialTheme.colorScheme.onPrimaryContainer.copy(0.7f) else MaterialTheme.colorScheme.onSurface.copy(0.7f), // TODO: move to color scheme
)
}
} else null,
@@ -805,25 +805,27 @@ private fun StyledListItemContent(
}
)
.pointerInput(Unit) {
- detectTapGestures(
- onPress = {
- if (enabled) {
- backgroundColor = pressedColor
- tryAwaitRelease()
- backgroundColor = surfaceColor
- }
- },
- onTap = {
- if (enabled) {
- scope.launch {
- haptics.performHapticFeedback(
- HapticFeedbackType.ContextClick
- )
+ if (onClick != null) {
+ detectTapGestures(
+ onPress = {
+ if (enabled) {
+ backgroundColor = pressedColor
+ tryAwaitRelease()
+ backgroundColor = surfaceColor
+ }
+ },
+ onTap = {
+ if (enabled) {
+ scope.launch {
+ haptics.performHapticFeedback(
+ HapticFeedbackType.ContextClick
+ )
+ }
+ onClick.invoke()
}
- onClick?.invoke()
}
- }
- )
+ )
+ }
}
.heightIn(min = height)
.padding(horizontal = 16.dp)
diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledScaffold.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledScaffold.kt
index 3849b84c..6d205f2e 100644
--- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledScaffold.kt
+++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledScaffold.kt
@@ -18,6 +18,8 @@
package me.kavishdevar.librepods.presentation.components
+import android.graphics.RenderEffect
+import android.graphics.Shader
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
@@ -27,6 +29,7 @@ import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -61,10 +64,17 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
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
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import com.kyant.backdrop.backdrops.LayerBackdrop
@@ -75,6 +85,9 @@ import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.rememberHazeState
import me.kavishdevar.librepods.presentation.icons.LocalIcons
+import me.kavishdevar.librepods.presentation.navigation.LocalIsCurrentEntry
+import me.kavishdevar.librepods.presentation.navigation.LocalSharedTransitionScope
+import me.kavishdevar.librepods.presentation.navigation.LocalTransitionProgress
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
@@ -84,11 +97,10 @@ fun StyledScaffold(
modifier: Modifier = Modifier,
visible: Boolean = true,
title: String,
- showBackButton: Boolean = false,
- onNavigateBack: () -> Unit = {},
+ navigateBack: (() -> Unit)?,
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
- content: @Composable () -> Unit
+ content: @Composable (topPadding: Dp, bottomPadding: Dp) -> Unit
) {
val hazeState = rememberHazeState(blurEnabled = true)
@@ -105,14 +117,18 @@ fun StyledScaffold(
) {
TopAppBar(
navigationIcon = {
- if (showBackButton) {
+ if (navigateBack != null) {
Row {
Spacer(modifier = Modifier.width(12.dp))
FilledTonalIconButton(
- onClick = onNavigateBack,
+ onClick = navigateBack,
modifier = Modifier
.minimumInteractiveComponentSize()
- .size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Narrow)),
+ .size(
+ IconButtonDefaults.mediumContainerSize(
+ IconButtonDefaults.IconButtonWidthOption.Narrow
+ )
+ ),
shape = IconButtonDefaults.mediumRoundShape
) {
Icon(
@@ -130,7 +146,7 @@ fun StyledScaffold(
text = it,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
- modifier = Modifier.padding(start = if (showBackButton) 8.dp else 12.dp, end = 12.dp),
+ modifier = Modifier.padding(start = if (navigateBack != null) 8.dp else 12.dp, end = 12.dp),
style = MaterialTheme.typography.titleSmall
)
}
@@ -146,15 +162,20 @@ fun StyledScaffold(
}
},
) { paddingValues ->
- Column(
+ Box(
modifier = modifier
- .then(if (visible) Modifier.padding(start = paddingValues.calculateStartPadding(LocalLayoutDirection.current), end = paddingValues.calculateEndPadding(LocalLayoutDirection.current)) else Modifier)
+ .then(
+ if (visible) Modifier.padding(
+ start = paddingValues.calculateStartPadding(
+ LocalLayoutDirection.current
+ ),
+ end = paddingValues.calculateEndPadding(LocalLayoutDirection.current)
+ ) else Modifier
+ )
.fillMaxSize()
.hazeSource(hazeState)
) {
- Spacer(modifier = Modifier.height(paddingValues.calculateTopPadding()))
- content()
- Spacer(modifier = Modifier.height(paddingValues.calculateBottomPadding()))
+ content(paddingValues.calculateTopPadding(), paddingValues.calculateBottomPadding())
}
}
}
@@ -173,6 +194,7 @@ fun StyledScaffold(
)
) { paddingValues ->
val topPadding = paddingValues.calculateTopPadding()
+ val bottomPadding = paddingValues.calculateBottomPadding()
val startPadding = paddingValues.calculateLeftPadding(LocalLayoutDirection.current)
val endPadding = paddingValues.calculateRightPadding(LocalLayoutDirection.current)
@@ -184,31 +206,80 @@ fun StyledScaffold(
) {
val backdrop = rememberLayerBackdrop()
val bgColor = MaterialTheme.colorScheme.surfaceContainer
- AnimatedVisibility(
- visible = showBackButton,
- enter = fadeIn() + scaleIn(
- initialScale = 0f,
- animationSpec = tween()
- ),
- exit = fadeOut() + scaleOut(
- targetScale = 0.5f,
- animationSpec = tween(100)
- ),
- modifier = Modifier
- .zIndex(3f)
- .padding(top = topPadding, start = 8.dp)
- .align(Alignment.TopStart)
- ) {
- StyledIconButton(
- onClick = onNavigateBack,
- backdrop = backdrop
- ) {
- Icon(
- imageVector = LocalIcons.current.ArrowBack,
- contentDescription = "Back",
- modifier = Modifier.size(16.dp),
- tint = MaterialTheme.colorScheme.onBackground
- )
+
+ 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
+
+ val showBackButton = if (transitionProgress == 0f) navigateBack != null else !isCurrentEntry
+
+ if (showBackButton) {
+ with(sharedTransitionScope) {
+ Box(
+ modifier = Modifier
+ .zIndex(3f)
+ .padding(top = topPadding, start = 8.dp)
+ .align(Alignment.TopStart)
+ .pointerInput(Unit) {
+ awaitPointerEventScope {
+ while (true) {
+ awaitFirstDown(requireUnconsumed = false)
+
+ do {
+ val event = awaitPointerEvent()
+
+ event.changes.forEach { change ->
+ change.consumePositionChange()
+ }
+ } while (event.changes.any { it.pressed })
+ }
+ }
+ }
+ .renderInSharedTransitionScopeOverlay(
+ zIndexInOverlay = 3f,
+ renderInOverlay = {
+ !isCurrentEntry && transitionProgress != 0f
+ }
+ )
+ .graphicsLayer { // AI generated
+ if (!isCurrentEntry && navigateBack == null && transitionProgress < 0f) {
+ val progress = (-transitionProgress).coerceIn(0f, 1f)
+
+ val eased = progress * progress * (3f - 2f * progress)
+
+ val scale = 1f - 0.18f * eased
+
+ scaleX = scale
+ scaleY = scale
+
+ alpha = 1f - 0.28f * eased
+
+ val blur = 8f * progress
+
+ renderEffect = RenderEffect.createBlurEffect(
+ blur,
+ blur,
+ Shader.TileMode.DECAL
+ ).asComposeRenderEffect()
+ }
+ }
+ ) {
+ StyledIconButton(
+ onClick = { navigateBack?.invoke() },
+ backdrop = backdrop // i know, this doesn't capture what's actually beneath it. but it's going to matter just in the transition.
+ ) {
+ Icon(
+ imageVector = LocalIcons.current.ArrowBack,
+ contentDescription = "Back",
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.onBackground
+ )
+ }
+ }
}
}
@@ -270,6 +341,21 @@ fun StyledScaffold(
.zIndex(3f)
.padding(top = topPadding, end = 8.dp)
.align(Alignment.TopEnd)
+ .pointerInput(Unit) {
+ awaitPointerEventScope {
+ while (true) {
+ awaitFirstDown(requireUnconsumed = false)
+
+ do {
+ val event = awaitPointerEvent()
+
+ event.changes.forEach { change ->
+ change.consumePositionChange()
+ }
+ } while (event.changes.any { it.pressed })
+ }
+ }
+ }
) {
Row{
actionButtons.forEach { actionButton ->
@@ -283,7 +369,7 @@ fun StyledScaffold(
.hazeSource(hazeState)
.fillMaxSize()
) {
- content()
+ content(topPadding + 64.dp, bottomPadding)
}
}
}
diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledSlider.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledSlider.kt
index 37813b2b..6e7ddcbc 100644
--- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledSlider.kt
+++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledSlider.kt
@@ -759,12 +759,13 @@ fun StyledSliderPreview() {
) {
StyledScaffold(
title = "test",
- ) {
+ navigateBack = null
+ ) { topPadding, bottomPadding ->
Column(
modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
- Spacer(modifier = Modifier.height(72.dp))
+ Spacer(modifier = Modifier.height(topPadding))
StyledSlider(
value = a.floatValue,
onValueChange = {
@@ -803,6 +804,7 @@ fun StyledSliderPreview() {
independent = true,
description = stringResource(R.string.adaptive_audio_description),
)
+ Spacer(modifier = Modifier.height(bottomPadding))
}
}
}
diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/AppleIcons.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/AppleIcons.kt
index 6822e910..bd35aedd 100644
--- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/AppleIcons.kt
+++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/AppleIcons.kt
@@ -1248,4 +1248,54 @@ object AppleIcons: IconSet {
override val CircleDotted: ImageVector
get() = CommonIcons.CircleDotted
+
+ override val VitalSigns: ImageVector
+ get() = WaveFormPathEcg
+ val WaveFormPathEcg: ImageVector
+ get() {
+ val current = _waveformPathEcg
+ if (current != null) return current
+
+ return ImageVector.Builder(
+ name = "WaveformPathEcg",
+ defaultWidth = 62.78099822998047.dp,
+ defaultHeight = 67.31999969482422.dp,
+ viewportWidth = 62.781f,
+ viewportHeight = 67.32f,
+ ).apply {
+ path(
+ fill = SolidColor(Color(0xFFFFFFFF)),
+ fillAlpha = 0.85f,
+ ) {
+ moveTo(x = 2.25f, y = 39.7f)
+ horizontalLineToRelative(dx = 13.84f)
+ quadToRelative(dx1 = 2.1f, dy1 = 0.0f, dx2 = 2.57f, dy2 = -1.87f)
+ lineToRelative(dx = 4.84f, dy = -22.25f)
+ horizontalLineToRelative(dx = -0.28f)
+ lineToRelative(dx = 8.34f, dy = 50.06f)
+ curveToRelative(dx1 = 0.38f, dy1 = 2.25f, dx2 = 3.85f, dy2 = 2.22f, dx3 = 4.28f, dy3 = 0.0f)
+ lineToRelative(dx = 8.07f, dy = -37.75f)
+ lineToRelative(dx = -0.25f, dy = -0.03f)
+ lineToRelative(dx = 2.0f, dy = 9.72f)
+ curveToRelative(dx1 = 0.28f, dy1 = 1.44f, dx2 = 1.09f, dy2 = 2.13f, dx3 = 2.56f, dy3 = 2.13f)
+ horizontalLineToRelative(dx = 12.12f)
+ arcToRelative(a = 2.2f, b = 2.2f, theta = 0.0f, isMoreThanHalf = true, isPositiveArc = false, dx1 = 0.0f, dy1 = -4.4f)
+ horizontalLineTo(x = 48.66f)
+ lineToRelative(dx = 0.75f, dy = 0.46f)
+ lineToRelative(dx = -3.72f, dy = -16.38f)
+ curveToRelative(dx1 = -0.47f, dy1 = -2.18f, dx2 = -3.69f, dy2 = -2.15f, dx3 = -4.22f, dy3 = 0.1f)
+ lineTo(x = 33.4f, y = 55.43f)
+ horizontalLineToRelative(dx = 0.37f)
+ lineToRelative(dx = -8.37f, dy = -50.7f)
+ curveToRelative(dx1 = -0.35f, dy1 = -2.18f, dx2 = -3.53f, dy2 = -2.24f, dx3 = -4.03f, dy3 = 0.0f)
+ lineToRelative(dx = -6.72f, dy = 31.04f)
+ lineToRelative(dx = 0.4f, dy = -0.47f)
+ horizontalLineTo(x = 2.25f)
+ curveToRelative(dx1 = -1.28f, dy1 = 0.0f, dx2 = -2.25f, dy2 = 1.0f, dx3 = -2.25f, dy3 = 2.22f)
+ arcToRelative(a = 2.2f, b = 2.2f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.25f, dy1 = 2.19f)
+ }
+ }.build().also { _waveformPathEcg = it }
+ }
+
+ private var _waveformPathEcg: ImageVector? = null
}
diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/IconSet.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/IconSet.kt
index 0515c235..3d87c399 100644
--- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/IconSet.kt
+++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/IconSet.kt
@@ -55,6 +55,8 @@ interface IconSet {
val CircleDotted: ImageVector
+ val VitalSigns: ImageVector
+
/*
* AirPods Icons
*/
@@ -155,6 +157,7 @@ interface IconSet {
"BoltCircle" to BoltCircle,
"Circle" to Circle,
"CircleDotted" to CircleDotted,
+ "VitalSign" to VitalSigns,
"AirPods1" to AirPods1,
"AirPods1Case" to AirPods1Case,
diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/MaterialIcons.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/MaterialIcons.kt
index c8877b3d..dfc83155 100644
--- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/MaterialIcons.kt
+++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/MaterialIcons.kt
@@ -1658,4 +1658,69 @@ object MaterialIcons: IconSet {
override val CircleDotted: ImageVector
get() = CommonIcons.CircleDotted
+
+ override val VitalSigns: ImageVector
+ get() {
+ if (_vital_signs != null) {
+ return _vital_signs!!
+ }
+ _vital_signs =
+ ImageVector.Builder(
+ name = "vital_signs",
+ defaultWidth = 24.dp,
+ defaultHeight = 24.dp,
+ viewportWidth = 24f,
+ viewportHeight = 24f,
+ )
+ .apply {
+ path(
+ fill = SolidColor(Color.Black),
+ fillAlpha = 1f,
+ stroke = null,
+ strokeAlpha = 1f,
+ strokeLineWidth = 1f,
+ strokeLineCap = StrokeCap.Butt,
+ strokeLineJoin = StrokeJoin.Bevel,
+ strokeLineMiter = 1f,
+ pathFillType = PathFillType.Companion.NonZero,
+ ) {
+ moveTo(8.15f, 19.73f)
+ quadTo(7.78f, 19.45f, 7.6f, 19.02f)
+ lineTo(5.3f, 13f)
+ horizontalLineTo(2f)
+ quadTo(1.58f, 13f, 1.29f, 12.71f)
+ quadTo(1f, 12.43f, 1f, 12f)
+ reflectiveQuadTo(1.29f, 11.29f)
+ reflectiveQuadTo(2f, 11f)
+ horizontalLineTo(6f)
+ quadToRelative(0.33f, 0f, 0.56f, 0.17f)
+ reflectiveQuadToRelative(0.36f, 0.47f)
+ lineTo(9f, 17.1f)
+ lineTo(13.6f, 4.97f)
+ quadToRelative(0.17f, -0.43f, 0.55f, -0.7f)
+ reflectiveQuadTo(15f, 4f)
+ reflectiveQuadToRelative(0.85f, 0.27f)
+ reflectiveQuadToRelative(0.55f, 0.7f)
+ lineTo(18.7f, 11f)
+ horizontalLineTo(22f)
+ quadToRelative(0.43f, 0f, 0.71f, 0.29f)
+ reflectiveQuadTo(23f, 12f)
+ reflectiveQuadToRelative(-0.29f, 0.71f)
+ reflectiveQuadTo(22f, 13f)
+ horizontalLineTo(18f)
+ quadToRelative(-0.32f, 0f, -0.56f, -0.18f)
+ reflectiveQuadTo(17.08f, 12.35f)
+ lineTo(15f, 6.9f)
+ lineTo(10.4f, 19.02f)
+ quadToRelative(-0.17f, 0.43f, -0.55f, 0.7f)
+ reflectiveQuadTo(9f, 20f)
+ reflectiveQuadTo(8.15f, 19.73f)
+ close()
+ }
+ }
+ .build()
+ return _vital_signs!!
+ }
+
+ private var _vital_signs: ImageVector? = null
}
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 34338860..2e2f12d0 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,656 +1,112 @@
package me.kavishdevar.librepods.presentation.navigation
import android.annotation.SuppressLint
-import androidx.activity.BackEventCompat.Companion.EDGE_LEFT
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
import androidx.compose.animation.SharedTransitionLayout
-import androidx.compose.animation.core.tween
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.scaleOut
-import androidx.compose.animation.slideInHorizontally
-import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+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.snapshots.SnapshotStateList
+import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
-import androidx.lifecycle.viewmodel.compose.viewModel
-import androidx.lifecycle.viewmodel.initializer
-import androidx.lifecycle.viewmodel.viewModelFactory
+import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.ui.NavDisplay
-import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.channels.Channel
import me.kavishdevar.librepods.LibrePodsApplication
import me.kavishdevar.librepods.bluetooth.MacAddress
-import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
-import me.kavishdevar.librepods.data.updates.updates
import me.kavishdevar.librepods.devices.AppleDevice
import me.kavishdevar.librepods.devices.ConnectionState
import me.kavishdevar.librepods.devices.Device
-import me.kavishdevar.librepods.presentation.screens.AppSettingsScreen
-import me.kavishdevar.librepods.presentation.screens.BLESettingsScreenRoute
-import me.kavishdevar.librepods.presentation.screens.DeviceListRoute
-import me.kavishdevar.librepods.presentation.screens.OpenSourceLicensesScreen
-import me.kavishdevar.librepods.presentation.screens.PurchaseScreen
-import me.kavishdevar.librepods.presentation.screens.ReleaseNotesScreen
-import me.kavishdevar.librepods.presentation.screens.TroubleshootingScreen
-import me.kavishdevar.librepods.presentation.screens.apple.AccessibilitySettingsScreen
-import me.kavishdevar.librepods.presentation.screens.apple.AdaptiveStrengthScreen
-import me.kavishdevar.librepods.presentation.screens.apple.AirPodsSettingsRoute
-import me.kavishdevar.librepods.presentation.screens.apple.CallControlScreen
-import me.kavishdevar.librepods.presentation.screens.apple.DebugRoute
-import me.kavishdevar.librepods.presentation.screens.apple.EqualizerRoute
-import me.kavishdevar.librepods.presentation.screens.apple.HeadTrackingScreen
-import me.kavishdevar.librepods.presentation.screens.apple.HearingAidAdjustmentsScreen
-import me.kavishdevar.librepods.presentation.screens.apple.HearingAidScreen
-import me.kavishdevar.librepods.presentation.screens.apple.HearingProtectionScreen
-import me.kavishdevar.librepods.presentation.screens.apple.LongPress
-import me.kavishdevar.librepods.presentation.screens.apple.MicrophoneSettingsRoute
-import me.kavishdevar.librepods.presentation.screens.apple.RecordingScreenRoute
-import me.kavishdevar.librepods.presentation.screens.apple.RenameScreen
-import me.kavishdevar.librepods.presentation.screens.apple.TransparencySettingsScreen
-import me.kavishdevar.librepods.presentation.screens.apple.UpdateHearingTestRoute
-import me.kavishdevar.librepods.presentation.screens.apple.VersionScreen
-import me.kavishdevar.librepods.presentation.screens.onboarding.OnboardingScreen
-import me.kavishdevar.librepods.presentation.theme.DesignSystem
-import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
-import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
-import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
-import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
-import me.kavishdevar.librepods.repository.RecordingRepository
-@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
fun AppNavGraph(
+ backStack: NavBackStack,
+ devicesState: State