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

implement buddy (head tracking and heartrate, finally!), added swipe anywhere to go back, and cleaned up app settings a bit.
This commit is contained in:
Kavish Devar
2026-08-16 01:08:42 +05:30
parent 8457880210
commit ddd1a9c5ce
76 changed files with 5763 additions and 5110 deletions
+22 -2
View File
@@ -9,6 +9,7 @@ plugins {
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.room3) alias(libs.plugins.room3)
alias(libs.plugins.protobuf)
// alias(libs.plugins.hilt) // alias(libs.plugins.hilt)
id("kotlin-parcelize") id("kotlin-parcelize")
} }
@@ -31,13 +32,30 @@ room3 {
schemaDirectory("$projectDir/schemas") schemaDirectory("$projectDir/schemas")
} }
protobuf {
protoc {
artifact = libs.protobuf.protoc.get().toString()
}
generateProtoTasks {
all().forEach { task ->
task.builtins {
create("java")
create("kotlin")
}
}
}
}
kotlin { kotlin {
compilerOptions { compilerOptions {
optIn.addAll( optIn.addAll(
"androidx.compose.material3.ExperimentalMaterial3ExpressiveApi", "androidx.compose.material3.ExperimentalMaterial3ExpressiveApi",
"kotlin.uuid.ExperimentalUuidApi", "kotlin.uuid.ExperimentalUuidApi",
"kotlinx.coroutines.FlowPreview", "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(platform(libs.androidx.compose.bom))
implementation(libs.accompanist.permissions) implementation(libs.accompanist.permissions)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.foundation.layout)
implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
@@ -169,8 +188,9 @@ dependencies {
implementation(libs.androidx.room3.runtime) implementation(libs.androidx.room3.runtime)
ksp(libs.androidx.room3.compiler) ksp(libs.androidx.room3.compiler)
implementation(libs.kotlinx.serialization.cbor) implementation(libs.kotlinx.serialization.cbor)
implementation(libs.protobuf.kotlin)
// compileOnly(files("../../../framework-classes.jar")) // compileOnly(files("../../../framework-classes.jar"))
implementation(libs.androidx.healthconnect.client)
} }
aboutLibraries { aboutLibraries {
@@ -2,7 +2,7 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 1, "version": 1,
"identityHash": "ae2e348e0198b8d871e9e3ed6959ab30", "identityHash": "7a502eac34ab57e1c12ba8f849e172cf",
"entities": [ "entities": [
{ {
"tableName": "AppleEntity", "tableName": "AppleEntity",
@@ -42,7 +42,7 @@
}, },
{ {
"tableName": "AppSettingsEntity", "tableName": "AppSettingsEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `nightMode` TEXT NOT NULL, `designSystem` TEXT NOT NULL, `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": [ "fields": [
{ {
"fieldPath": "id", "fieldPath": "id",
@@ -62,6 +62,12 @@
"affinity": "TEXT", "affinity": "TEXT",
"notNull": true "notNull": true
}, },
{
"fieldPath": "useHighestRefreshRate",
"columnName": "useHighestRefreshRate",
"affinity": "INTEGER",
"notNull": true
},
{ {
"fieldPath": "debugMode", "fieldPath": "debugMode",
"columnName": "debugMode", "columnName": "debugMode",
@@ -79,6 +85,12 @@
"columnName": "bleReportDelay", "columnName": "bleReportDelay",
"affinity": "INTEGER", "affinity": "INTEGER",
"notNull": true "notNull": true
},
{
"fieldPath": "swipeAnywhereForBack",
"columnName": "swipeAnywhereForBack",
"affinity": "INTEGER",
"notNull": true
} }
], ],
"primaryKey": { "primaryKey": {
@@ -163,11 +175,41 @@
"appWidgetId" "appWidgetId"
] ]
} }
},
{
"tableName": "HeartRateSampleEntity",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `bpm` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bpm",
"columnName": "bpm",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
} }
], ],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ae2e348e0198b8d871e9e3ed6959ab30')" "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7a502eac34ab57e1c12ba8f849e172cf')"
] ]
} }
} }
+27 -2
View File
@@ -27,7 +27,8 @@
android:name="android.permission.INTERACT_ACROSS_USERS" android:name="android.permission.INTERACT_ACROSS_USERS"
tools:ignore="ProtectedPermissions" /> tools:ignore="ProtectedPermissions" />
<uses-permission <uses-permission
android:name="android.permission.MODIFY_AUDIO_ROUTING"/> android:name="android.permission.MODIFY_AUDIO_ROUTING"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.BLUETOOTH" />
@@ -48,6 +49,8 @@
<!-- android:maxSdkVersion="30" />--> <!-- android:maxSdkVersion="30" />-->
<uses-permission android:name="com.android.vending.BILLING" /> <uses-permission android:name="com.android.vending.BILLING" />
<uses-permission android:name="android.permission.health.WRITE_HEART_RATE"/>
<application <application
android:name=".LibrePodsApplication" android:name=".LibrePodsApplication"
android:allowBackup="true" android:allowBackup="true"
@@ -154,6 +157,28 @@
android:name="android.support.FILE_PROVIDER_PATHS" android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" /> android:resource="@xml/file_paths" />
</provider> </provider>
</application>
<activity
android:name=".presentation.activities.PrivacyPolicyActivity"
android:exported="true">
<intent-filter>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent-filter>
</activity>
<activity-alias
android:name="ViewPermissionUsageActivity"
android:exported="true"
android:targetActivity=".presentation.activities.PrivacyPolicyActivity"
android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
</intent-filter>
</activity-alias>
</application>
<queries>
<package android:name="com.google.android.apps.healthdata" />
</queries>
</manifest> </manifest>
@@ -1,6 +1,7 @@
package me.kavishdevar.librepods package me.kavishdevar.librepods
import android.app.Application import android.app.Application
import androidx.health.connect.client.HealthConnectClient
import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner
@@ -12,7 +13,10 @@ import me.kavishdevar.librepods.billing.BillingProviderFactory
import me.kavishdevar.librepods.database.LibrePodsDatabase import me.kavishdevar.librepods.database.LibrePodsDatabase
import me.kavishdevar.librepods.repository.AppDataRepository import me.kavishdevar.librepods.repository.AppDataRepository
import me.kavishdevar.librepods.repository.AppleRepository 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.repository.WidgetConfigRepository
import me.kavishdevar.librepods.utils.GestureFeedback
import me.kavishdevar.librepods.utils.XposedServiceHolder import me.kavishdevar.librepods.utils.XposedServiceHolder
import me.kavishdevar.librepods.utils.XposedState 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 appDataRepository by lazy { AppDataRepository(database.appSettingsDao(), database.appStateDao()) }
val widgetConfigRepository by lazy { WidgetConfigRepository(database.widgetConfigDao()) } 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() { override fun onCreate() {
System.loadLibrary("hiddenapi") System.loadLibrary("hiddenapi")
@@ -37,6 +53,8 @@ class LibrePodsApplication: Application(), XposedServiceHelper.OnServiceListener
BillingManager.provider = BillingProviderFactory.create(this) BillingManager.provider = BillingProviderFactory.create(this)
ProcessLifecycleOwner.get().lifecycle.addObserver(this) ProcessLifecycleOwner.get().lifecycle.addObserver(this)
GestureFeedback.init(this)
super<Application>.onCreate() super<Application>.onCreate()
} }
@@ -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.EarDetectionResponsePacket
import me.kavishdevar.librepods.bluetooth.aacp.packet.InformationPacket import me.kavishdevar.librepods.bluetooth.aacp.packet.InformationPacket
import me.kavishdevar.librepods.bluetooth.aacp.packet.MagicKeyResponsePacket 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.RenamePacket
import me.kavishdevar.librepods.bluetooth.aacp.packet.StemPressPacket 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.AppleEvent
import me.kavishdevar.librepods.bluetooth.aacp.types.Capability import me.kavishdevar.librepods.bluetooth.aacp.types.Capability
import me.kavishdevar.librepods.bluetooth.aacp.types.CapabilityEntry 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.CustomEq
import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
import me.kavishdevar.librepods.bluetooth.aacp.types.RTBuddyDescriptor
import me.kavishdevar.librepods.bluetooth.aacp.types.SensorDataWxBuddyPayload
import me.kavishdevar.librepods.data.audio.MicrophoneFrame import me.kavishdevar.librepods.data.audio.MicrophoneFrame
import me.kavishdevar.librepods.data.heartrate.HeartRateSample
import me.kavishdevar.librepods.devices.AppleDevice import me.kavishdevar.librepods.devices.AppleDevice
import me.kavishdevar.librepods.devices.BatteryStatus import me.kavishdevar.librepods.devices.BatteryStatus
import me.kavishdevar.librepods.utils.HeadTracking
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.time.Clock
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
class AACPManager(private val device: AppleDevice) { class AACPManager(private val device: AppleDevice) {
@@ -62,6 +71,8 @@ class AACPManager(private val device: AppleDevice) {
private var socket: BluetoothSocket? = null private var socket: BluetoothSocket? = null
private val rtBuddyManager = RTBuddyManager(::sendPacket)
fun connect(): Boolean { fun connect(): Boolean {
if (socket != null && socket!!.isConnected) { if (socket != null && socket!!.isConnected) {
Log.i(TAG, "Already connected") Log.i(TAG, "Already connected")
@@ -192,7 +203,7 @@ class AACPManager(private val device: AppleDevice) {
) )
return return
} }
Log.d(TAG, "received packet: ${packet.toHexString()}") // Log.d(TAG, "received packet: ${packet.toHexString()}")
val opcode = packet[4] val opcode = packet[4]
when (MessageOpcode.fromByte(opcode)) { when (MessageOpcode.fromByte(opcode)) {
MessageOpcode.BUD_ROLE -> { MessageOpcode.BUD_ROLE -> {
@@ -314,7 +325,28 @@ class AACPManager(private val device: AppleDevice) {
} }
MessageOpcode.BUDDY_COMMAND -> { 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 -> { MessageOpcode.MAGIC_KEYS_RESPONSE -> {
@@ -625,55 +657,7 @@ class AACPManager(private val device: AppleDevice) {
return sendPacket(packet) return sendPacket(packet)
} }
fun sendStartHeadTracking(): Boolean { fun setSensorServiceReportInterval(sensorServiceType: SensorServiceType, interval: Duration): Boolean = rtBuddyManager.setSensorServiceReportInterval(sensorServiceType, interval)
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 sendRename(name: String): Boolean { fun sendRename(name: String): Boolean {
val packet = RenamePacket.create(name) val packet = RenamePacket.create(name)
@@ -1045,6 +1029,8 @@ class AACPManager(private val device: AppleDevice) {
connectedDevices = emptyList(), connectedDevices = emptyList(),
microphoneFrames = emptyList(), microphoneFrames = emptyList(),
controlStates = emptyMap(), controlStates = emptyMap(),
aacpPackets = emptyList(),
currentHeartRate = null
) )
} }
} }
@@ -1174,4 +1160,93 @@ class AACPManager(private val device: AppleDevice) {
return capabilities 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()}"
)
}
}
}
}
} }
@@ -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()
)
}
@@ -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,
)
}
}
}
@@ -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
}
}
@@ -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
@@ -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
}
@@ -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<HeartRateSampleEntity>
}
@@ -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)
@@ -2,7 +2,7 @@ package me.kavishdevar.librepods.data.updates
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import me.kavishdevar.librepods.R 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.EqualizerScreenPreviewApple
import me.kavishdevar.librepods.presentation.screens.apple.EqualizerScreenPreviewMaterial import me.kavishdevar.librepods.presentation.screens.apple.EqualizerScreenPreviewMaterial
import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.DesignSystem
@@ -13,7 +13,7 @@ val update1_0_0 = listOf(
titleRes = R.string.material3e, titleRes = R.string.material3e,
descriptionRes = R.string.update_m3e_description, descriptionRes = R.string.update_m3e_description,
demoComposeable = @Composable { demoComposeable = @Composable {
AirPodsSettingsScreenPreviewMaterial() AppleSettingsScreenPreviewMaterial()
} }
), ),
UpdateItem( UpdateItem(
@@ -8,6 +8,7 @@ import me.kavishdevar.librepods.bluetooth.MacAddress
import me.kavishdevar.librepods.data.apple.AppleCache import me.kavishdevar.librepods.data.apple.AppleCache
import me.kavishdevar.librepods.devices.AppleMetadata import me.kavishdevar.librepods.devices.AppleMetadata
import me.kavishdevar.librepods.devices.AppleSettings import me.kavishdevar.librepods.devices.AppleSettings
import kotlin.time.Instant
object Converters { object Converters {
@ColumnTypeConverter @ColumnTypeConverter
@@ -39,4 +40,12 @@ object Converters {
@ColumnTypeConverter @ColumnTypeConverter
fun bytesToAppleCache(bytes: ByteArray): AppleCache = fun bytesToAppleCache(bytes: ByteArray): AppleCache =
Cbor.decodeFromByteArray(bytes) Cbor.decodeFromByteArray(bytes)
@ColumnTypeConverter
fun kotlinInstantToLong(instant: Instant): Long =
instant.toEpochMilliseconds()
@ColumnTypeConverter
fun longToKotlinInstant(millis: Long): Instant =
Instant.fromEpochMilliseconds(millis)
} }
@@ -3,12 +3,14 @@ package me.kavishdevar.librepods.database
import androidx.room3.ColumnTypeConverters import androidx.room3.ColumnTypeConverters
import androidx.room3.Database import androidx.room3.Database
import androidx.room3.RoomDatabase import androidx.room3.RoomDatabase
import me.kavishdevar.librepods.data.heartrate.HeartRateDao
import me.kavishdevar.librepods.database.app.AppSettingsDao import me.kavishdevar.librepods.database.app.AppSettingsDao
import me.kavishdevar.librepods.database.app.AppSettingsEntity import me.kavishdevar.librepods.database.app.AppSettingsEntity
import me.kavishdevar.librepods.database.app.AppStateDao import me.kavishdevar.librepods.database.app.AppStateDao
import me.kavishdevar.librepods.database.app.AppStateEntity import me.kavishdevar.librepods.database.app.AppStateEntity
import me.kavishdevar.librepods.database.apple.AppleDao import me.kavishdevar.librepods.database.apple.AppleDao
import me.kavishdevar.librepods.database.apple.AppleEntity import me.kavishdevar.librepods.database.apple.AppleEntity
import me.kavishdevar.librepods.database.heartrate.HeartRateSampleEntity
import me.kavishdevar.librepods.database.widget.WidgetConfigDao import me.kavishdevar.librepods.database.widget.WidgetConfigDao
import me.kavishdevar.librepods.database.widget.WidgetConfigEntity import me.kavishdevar.librepods.database.widget.WidgetConfigEntity
@@ -19,6 +21,7 @@ import me.kavishdevar.librepods.database.widget.WidgetConfigEntity
AppSettingsEntity::class, AppSettingsEntity::class,
AppStateEntity::class, AppStateEntity::class,
WidgetConfigEntity::class, WidgetConfigEntity::class,
HeartRateSampleEntity::class
], ],
version = 1, version = 1,
) )
@@ -29,4 +32,6 @@ abstract class LibrePodsDatabase: RoomDatabase() {
abstract fun appStateDao(): AppStateDao abstract fun appStateDao(): AppStateDao
abstract fun widgetConfigDao(): WidgetConfigDao abstract fun widgetConfigDao(): WidgetConfigDao
abstract fun heartRateDao(): HeartRateDao
} }
@@ -13,12 +13,12 @@ data class AppSettingsEntity(
val nightMode: NightTheme = NightTheme.System, val nightMode: NightTheme = NightTheme.System,
val designSystem: DesignSystem = DesignSystem.Material, val designSystem: DesignSystem = DesignSystem.Material,
val useHighestRefreshRate: Boolean = false,
/**
* Currently only shows the button for Debug screen.
*/
val debugMode: Boolean = false, val debugMode: Boolean = false,
val bleScanMode: Int = ScanSettings.SCAN_MODE_BALANCED, val bleScanMode: Int = ScanSettings.SCAN_MODE_BALANCED,
val bleReportDelay: Long = 0, val bleReportDelay: Long = 0,
val swipeAnywhereForBack: Boolean = true,
) )
@@ -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,
)
@@ -18,12 +18,17 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.bluetooth.MacAddress import me.kavishdevar.librepods.bluetooth.MacAddress
import me.kavishdevar.librepods.bluetooth.aacp.AACPManager 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.AppleEvent
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
import me.kavishdevar.librepods.bluetooth.att.ATTHandle import me.kavishdevar.librepods.bluetooth.att.ATTHandle
import me.kavishdevar.librepods.bluetooth.att.ATTManager import me.kavishdevar.librepods.bluetooth.att.ATTManager
import me.kavishdevar.librepods.data.StemAction import me.kavishdevar.librepods.data.StemAction
import me.kavishdevar.librepods.utils.GestureDetector
import me.kavishdevar.librepods.utils.HeadTracking
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
private const val TAG = "AppleDevice" private const val TAG = "AppleDevice"
@@ -77,6 +82,12 @@ class AppleDevice(
val aacp = AACPManager(this) val aacp = AACPManager(this)
val att = ATTManager(this) val att = ATTManager(this)
// ik, probably wrong place. TODO
private val gestureDetector by lazy {
GestureDetector()
}
init { init {
updateMetadata { updateMetadata {
it.copy( it.copy(
@@ -205,25 +216,35 @@ class AppleDevice(
} }
fun startHeadTracking() { fun startHeadTracking() {
if (settings.value.alternateHeadTrackingPackets) { aacp.setSensorServiceReportInterval(
aacp.sendStartAlternateHeadTracking() sensorServiceType = if (metadata.value.version3.first().digitToInt() >= 8) SensorServiceType.DEVMOTION6 else SensorServiceType.ACTIVITY,
} else { interval = _settings.value.headTrackingInterval
aacp.sendStartHeadTracking() )
}
_state.update { _state.update {
it.copy(headTrackingActive = true) it.copy(headTrackingActive = true)
} }
} }
fun stopHeadTracking() { fun stopHeadTracking() {
if (settings.value.alternateHeadTrackingPackets) { aacp.setSensorServiceReportInterval(
aacp.sendStopAlternateHeadTracking() sensorServiceType = if (metadata.value.version3.first().digitToInt() >= 8) SensorServiceType.DEVMOTION6 else SensorServiceType.ACTIVITY,
} else { interval = Duration.ZERO
aacp.sendStopHeadTracking() )
}
_state.update { _state.update {
it.copy(headTrackingActive = false) 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) { 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) { fun setCustomEqEnabled(enabled: Boolean) {
aacp.setCustomEq(_state.value.customEq.copy(state = if(enabled) 2 else 1)) 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)) aacp.setCustomEq(_state.value.customEq.copy(low = low, mid = mid, high = high))
} }
fun testHeadGestures() { // AACPManager sets hrmActive true when a valid reading is received
if (settings.value.alternateHeadTrackingPackets) { fun startHr(): Boolean = aacp.setSensorServiceReportInterval(
aacp.sendStartAlternateHeadTracking() sensorServiceType = if (metadata.value.version3.first().digitToInt() >= 8) SensorServiceType.HEARTRATE_COMMAND else SensorServiceType.HEARTRATE,
} else { interval = 1.seconds
aacp.sendStartHeadTracking() )
}
_state.update { 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( it.copy(
detectHeadGestures = true hrmActive = false,
currentHeartRate = null
) )
} }
return success
} }
fun sendRawPacket(data: ByteArray): Boolean = aacp.sendRawPacket(data) fun sendRawPacket(data: ByteArray): Boolean = aacp.sendRawPacket(data)
@@ -2,6 +2,8 @@ package me.kavishdevar.librepods.devices
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import me.kavishdevar.librepods.data.StemAction import me.kavishdevar.librepods.data.StemAction
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
@Serializable @Serializable
data class AppleSettings( data class AppleSettings(
@@ -10,6 +12,9 @@ data class AppleSettings(
val cacheDisconnectedComponentBattery: Boolean = true, val cacheDisconnectedComponentBattery: Boolean = true,
val headGesturesEnabled: Boolean = true, // head_gestures_enabled 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 leftLongPressAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES, // left_long_press_action
val rightLongPressAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES, // right_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 showIslandPopup: Boolean = true, // show_island_popup
val showBottomSheetPopup: Boolean = true, // show_bottom_sheet_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 takeoverWhenDisconnected: Boolean = true, // takeover_when_disconnected
val takeoverWhenIdle: Boolean = true, // takeover_when_idle val takeoverWhenIdle: Boolean = true, // takeover_when_idle
val takeoverWhenMusic: Boolean = true, // takeover_when_music val takeoverWhenMusic: Boolean = true, // takeover_when_music
@@ -11,7 +11,10 @@ import me.kavishdevar.librepods.bluetooth.aacp.types.CustomEq
import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType
import me.kavishdevar.librepods.data.audio.MicrophoneFrame import me.kavishdevar.librepods.data.audio.MicrophoneFrame
import me.kavishdevar.librepods.data.audio.MicrophoneState import me.kavishdevar.librepods.data.audio.MicrophoneState
import me.kavishdevar.librepods.data.heartrate.HeartRateSample
import me.kavishdevar.librepods.data.recording.RecordingState import me.kavishdevar.librepods.data.recording.RecordingState
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
data class AppleState( data class AppleState(
val isLocallyConnected: Boolean = false, val isLocallyConnected: Boolean = false,
@@ -51,5 +54,9 @@ data class AppleState(
val headphoneAccomodationEnabledForMedia: Boolean = false, val headphoneAccomodationEnabledForMedia: Boolean = false,
val headphoneAccomodationEnabledForPhone: Boolean = false, val headphoneAccomodationEnabledForPhone: Boolean = false,
val currentHeartRate: HeartRateSample? = null,
val hrmActive: Boolean = false,
val heartRateInterval: Duration = 1.seconds,
val aacpPackets: List<AACPPacket> = emptyList(), val aacpPackets: List<AACPPacket> = emptyList(),
): DeviceState ): DeviceState
@@ -28,9 +28,11 @@ import android.content.Context
import android.content.Context.MODE_PRIVATE import android.content.Context.MODE_PRIVATE
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection import android.content.ServiceConnection
import android.hardware.display.DisplayManager
import android.os.Bundle import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import android.util.Log import android.util.Log
import android.view.Display
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
@@ -58,8 +60,8 @@ import me.kavishdevar.librepods.services.LibrePodsService
import me.kavishdevar.librepods.utils.XposedState import me.kavishdevar.librepods.utils.XposedState
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
lateinit var serviceConnection: ServiceConnection private lateinit var serviceConnection: ServiceConnection
lateinit var connectionStatusReceiver: BroadcastReceiver private lateinit var connectionStatusReceiver: BroadcastReceiver
//lateinit var testReviewReceiver: BroadcastReceiver //lateinit var testReviewReceiver: BroadcastReceiver
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -95,6 +97,22 @@ class MainActivity : ComponentActivity() {
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme 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( LibrePodsTheme(
designSystem = settings.designSystem, designSystem = settings.designSystem,
darkTheme = darkTheme darkTheme = darkTheme
@@ -7,7 +7,6 @@ import android.content.Intent
import android.content.ServiceConnection import android.content.ServiceConnection
import android.os.Bundle import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.foundation.background 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@@ -34,9 +34,9 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.LibrePodsApplication import me.kavishdevar.librepods.LibrePodsApplication
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.MacAddress
import me.kavishdevar.librepods.database.widget.WidgetConfigEntity import me.kavishdevar.librepods.database.widget.WidgetConfigEntity
import me.kavishdevar.librepods.devices.Device 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.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledList import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem import me.kavishdevar.librepods.presentation.components.StyledListItem
@@ -164,13 +164,15 @@ private fun WidgetDevicePickerContent(
) { ) {
StyledScaffold( StyledScaffold(
title = stringResource(R.string.configure_widget), title = stringResource(R.string.configure_widget),
) { navigateBack = null
) { topPadding, bottomPadding ->
Column( Column(
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
) { ) {
Spacer(modifier = Modifier.size(topPadding))
if (devices.isEmpty()) { if (devices.isEmpty()) {
Text( Text(
text = "No devices found. Please ensure a compatible device is paired with your phone and try again.", 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))
} }
} }
} }
@@ -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))
}
}
}
}
}
}
@@ -122,12 +122,13 @@ fun StyledListDemo() {
darkTheme = false darkTheme = false
) { ) {
StyledScaffold( StyledScaffold(
title = "StyledListTest" title = "StyledListTest",
) { navigateBack = null
) { topPadding, bottomPadding ->
Column ( Column (
modifier = Modifier.padding(horizontal = 12.dp) modifier = Modifier.padding(horizontal = 12.dp)
) { ) {
Spacer(modifier = Modifier.height(56.dp)) Spacer(modifier = Modifier.height(topPadding))
StyledList( StyledList(
title = "hello" title = "hello"
) { ) {
@@ -145,6 +146,7 @@ fun StyledListDemo() {
onCheckedChange = { checked.value = it }, onCheckedChange = { checked.value = it },
) )
} }
Spacer(modifier = Modifier.height(bottomPadding))
} }
} }
} }
@@ -668,7 +668,7 @@ fun StyledListScope.StyledListItem(
Text( Text(
text = supportingText, text = supportingText,
style = if (LocalDesignSystem.current == DesignSystem.Apple && orientation == StyledListItemOrientation.Horizontal) MaterialTheme.typography.bodyMedium else MaterialTheme.typography.bodySmall, 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, } else null,
@@ -805,6 +805,7 @@ private fun StyledListItemContent(
} }
) )
.pointerInput(Unit) { .pointerInput(Unit) {
if (onClick != null) {
detectTapGestures( detectTapGestures(
onPress = { onPress = {
if (enabled) { if (enabled) {
@@ -820,11 +821,12 @@ private fun StyledListItemContent(
HapticFeedbackType.ContextClick HapticFeedbackType.ContextClick
) )
} }
onClick?.invoke() onClick.invoke()
} }
} }
) )
} }
}
.heightIn(min = height) .heightIn(min = height)
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
) { ) {
@@ -18,6 +18,8 @@
package me.kavishdevar.librepods.presentation.components package me.kavishdevar.librepods.presentation.components
import android.graphics.RenderEffect
import android.graphics.Shader
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
@@ -27,6 +29,7 @@ import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -61,10 +64,17 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color 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.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.LocalLayoutDirection
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import com.kyant.backdrop.backdrops.LayerBackdrop import com.kyant.backdrop.backdrops.LayerBackdrop
@@ -75,6 +85,9 @@ import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.rememberHazeState import dev.chrisbanes.haze.rememberHazeState
import me.kavishdevar.librepods.presentation.icons.LocalIcons 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.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
@@ -84,11 +97,10 @@ fun StyledScaffold(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
visible: Boolean = true, visible: Boolean = true,
title: String, title: String,
showBackButton: Boolean = false, navigateBack: (() -> Unit)?,
onNavigateBack: () -> Unit = {},
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(), actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
content: @Composable () -> Unit content: @Composable (topPadding: Dp, bottomPadding: Dp) -> Unit
) { ) {
val hazeState = rememberHazeState(blurEnabled = true) val hazeState = rememberHazeState(blurEnabled = true)
@@ -105,14 +117,18 @@ fun StyledScaffold(
) { ) {
TopAppBar( TopAppBar(
navigationIcon = { navigationIcon = {
if (showBackButton) { if (navigateBack != null) {
Row { Row {
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
FilledTonalIconButton( FilledTonalIconButton(
onClick = onNavigateBack, onClick = navigateBack,
modifier = Modifier modifier = Modifier
.minimumInteractiveComponentSize() .minimumInteractiveComponentSize()
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Narrow)), .size(
IconButtonDefaults.mediumContainerSize(
IconButtonDefaults.IconButtonWidthOption.Narrow
)
),
shape = IconButtonDefaults.mediumRoundShape shape = IconButtonDefaults.mediumRoundShape
) { ) {
Icon( Icon(
@@ -130,7 +146,7 @@ fun StyledScaffold(
text = it, text = it,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, 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 style = MaterialTheme.typography.titleSmall
) )
} }
@@ -146,15 +162,20 @@ fun StyledScaffold(
} }
}, },
) { paddingValues -> ) { paddingValues ->
Column( Box(
modifier = modifier 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() .fillMaxSize()
.hazeSource(hazeState) .hazeSource(hazeState)
) { ) {
Spacer(modifier = Modifier.height(paddingValues.calculateTopPadding())) content(paddingValues.calculateTopPadding(), paddingValues.calculateBottomPadding())
content()
Spacer(modifier = Modifier.height(paddingValues.calculateBottomPadding()))
} }
} }
} }
@@ -173,6 +194,7 @@ fun StyledScaffold(
) )
) { paddingValues -> ) { paddingValues ->
val topPadding = paddingValues.calculateTopPadding() val topPadding = paddingValues.calculateTopPadding()
val bottomPadding = paddingValues.calculateBottomPadding()
val startPadding = paddingValues.calculateLeftPadding(LocalLayoutDirection.current) val startPadding = paddingValues.calculateLeftPadding(LocalLayoutDirection.current)
val endPadding = paddingValues.calculateRightPadding(LocalLayoutDirection.current) val endPadding = paddingValues.calculateRightPadding(LocalLayoutDirection.current)
@@ -184,24 +206,71 @@ fun StyledScaffold(
) { ) {
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
val bgColor = MaterialTheme.colorScheme.surfaceContainer val bgColor = MaterialTheme.colorScheme.surfaceContainer
AnimatedVisibility(
visible = showBackButton, val density = LocalDensity.current
enter = fadeIn() + scaleIn( val screenWidthPx = with(density) {
initialScale = 0f, LocalWindowInfo.current.containerDpSize.width.toPx()
animationSpec = tween() }
), val isCurrentEntry = LocalIsCurrentEntry.current
exit = fadeOut() + scaleOut( val transitionProgress = LocalTransitionProgress.current
targetScale = 0.5f, val sharedTransitionScope = LocalSharedTransitionScope.current
animationSpec = tween(100)
), val showBackButton = if (transitionProgress == 0f) navigateBack != null else !isCurrentEntry
if (showBackButton) {
with(sharedTransitionScope) {
Box(
modifier = Modifier modifier = Modifier
.zIndex(3f) .zIndex(3f)
.padding(top = topPadding, start = 8.dp) .padding(top = topPadding, start = 8.dp)
.align(Alignment.TopStart) .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( StyledIconButton(
onClick = onNavigateBack, onClick = { navigateBack?.invoke() },
backdrop = backdrop backdrop = backdrop // i know, this doesn't capture what's actually beneath it. but it's going to matter just in the transition.
) { ) {
Icon( Icon(
imageVector = LocalIcons.current.ArrowBack, imageVector = LocalIcons.current.ArrowBack,
@@ -211,6 +280,8 @@ fun StyledScaffold(
) )
} }
} }
}
}
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
@@ -270,6 +341,21 @@ fun StyledScaffold(
.zIndex(3f) .zIndex(3f)
.padding(top = topPadding, end = 8.dp) .padding(top = topPadding, end = 8.dp)
.align(Alignment.TopEnd) .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{ Row{
actionButtons.forEach { actionButton -> actionButtons.forEach { actionButton ->
@@ -283,7 +369,7 @@ fun StyledScaffold(
.hazeSource(hazeState) .hazeSource(hazeState)
.fillMaxSize() .fillMaxSize()
) { ) {
content() content(topPadding + 64.dp, bottomPadding)
} }
} }
} }
@@ -759,12 +759,13 @@ fun StyledSliderPreview() {
) { ) {
StyledScaffold( StyledScaffold(
title = "test", title = "test",
) { navigateBack = null
) { topPadding, bottomPadding ->
Column( Column(
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Spacer(modifier = Modifier.height(72.dp)) Spacer(modifier = Modifier.height(topPadding))
StyledSlider( StyledSlider(
value = a.floatValue, value = a.floatValue,
onValueChange = { onValueChange = {
@@ -803,6 +804,7 @@ fun StyledSliderPreview() {
independent = true, independent = true,
description = stringResource(R.string.adaptive_audio_description), description = stringResource(R.string.adaptive_audio_description),
) )
Spacer(modifier = Modifier.height(bottomPadding))
} }
} }
} }
@@ -1248,4 +1248,54 @@ object AppleIcons: IconSet {
override val CircleDotted: ImageVector override val CircleDotted: ImageVector
get() = CommonIcons.CircleDotted 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
} }
@@ -55,6 +55,8 @@ interface IconSet {
val CircleDotted: ImageVector val CircleDotted: ImageVector
val VitalSigns: ImageVector
/* /*
* AirPods Icons * AirPods Icons
*/ */
@@ -155,6 +157,7 @@ interface IconSet {
"BoltCircle" to BoltCircle, "BoltCircle" to BoltCircle,
"Circle" to Circle, "Circle" to Circle,
"CircleDotted" to CircleDotted, "CircleDotted" to CircleDotted,
"VitalSign" to VitalSigns,
"AirPods1" to AirPods1, "AirPods1" to AirPods1,
"AirPods1Case" to AirPods1Case, "AirPods1Case" to AirPods1Case,
@@ -1658,4 +1658,69 @@ object MaterialIcons: IconSet {
override val CircleDotted: ImageVector override val CircleDotted: ImageVector
get() = CommonIcons.CircleDotted 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
} }
@@ -1,656 +1,112 @@
package me.kavishdevar.librepods.presentation.navigation package me.kavishdevar.librepods.presentation.navigation
import android.annotation.SuppressLint 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.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.animation.togetherWith
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation3.runtime.NavBackStack
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.ui.NavDisplay 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.LibrePodsApplication
import me.kavishdevar.librepods.bluetooth.MacAddress 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.AppleDevice
import me.kavishdevar.librepods.devices.ConnectionState import me.kavishdevar.librepods.devices.ConnectionState
import me.kavishdevar.librepods.devices.Device 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 @Composable
fun AppNavGraph( fun AppNavGraph(
backStack: NavBackStack<Screen>,
devicesState: State<Map<MacAddress, Device<*, *, *>>>,
showReleaseNotes: Boolean = false, showReleaseNotes: Boolean = false,
updatesShown: () -> Unit = {}, updatesShown: () -> Unit = {},
onboardingComplete: () -> Unit = {}, onboardingComplete: () -> Unit = {},
backStack: SnapshotStateList<Screen>,
devicesState: State<Map<MacAddress, Device<*, *, *>>>
) { ) {
val backRequests = remember { Channel<CompletableDeferred<Unit>>(Channel.BUFFERED) }
val devices by devicesState val devices by devicesState
val navigate: (Screen) -> Unit = { screen ->
backStack.add(screen)
}
fun navigateToPurchase() {
navigate(Screen.Purchase)
}
val context = LocalContext.current val context = LocalContext.current
val appDataRepository by lazy { (context.applicationContext as LibrePodsApplication).appDataRepository } val appDataRepository by lazy { (context.applicationContext as LibrePodsApplication).appDataRepository }
val recordingRepository = RecordingRepository(context) val recordingRepository by lazy { (context.applicationContext as LibrePodsApplication).recordingRepository }
val heartRateRepository by lazy { (context.applicationContext as LibrePodsApplication).heartRateRepository }
val currentDevice: Device<*, *, *>? = val currentDevice: Device<*, *, *>? = devices[backStack.lastOrNull()?.let { (it as? DeviceScreen)?.macAddress }]
devices[backStack.lastOrNull()?.let { (it as? DeviceScreen)?.macAddress }]
@SuppressLint("UnrememberedMutableState") @SuppressLint("UnrememberedMutableState")
val currentConnectionState by (currentDevice as? AppleDevice)?.connectionState?.collectAsState() val currentConnectionState by (currentDevice as? AppleDevice)?.connectionState?.collectAsState()?: mutableStateOf(ConnectionState.DISCONNECTED)
?: mutableStateOf(ConnectionState.DISCONNECTED)
LaunchedEffect(currentConnectionState) {
if (currentConnectionState == ConnectionState.DISCONNECTED) { if (currentConnectionState == ConnectionState.DISCONNECTED) {
// not sure how we will be able to navigate to another device from one device, but just in case we had two different devices in the backstack, we will remove DeviceScreens only of the disconnected device from the backstack while (
while (backStack.isNotEmpty() && backStack.last() is DeviceScreen && (backStack.last() as DeviceScreen).macAddress == currentDevice?.macAddress) { backStack.size > 1 &&
backStack.lastOrNull() is DeviceScreen
) {
val completed = CompletableDeferred<Unit>()
backRequests.send(completed)
completed.await()
}
}
}
val appSettings by appDataRepository.settings.collectAsState()
val sceneStrategy = remember(appSettings.swipeAnywhereForBack) {
SwipeBackSceneStrategy<Screen>(
enabled = appSettings.swipeAnywhereForBack,
backRequests = backRequests,
onDismiss = {
if (backStack.size > 1) {
backStack.removeAt(backStack.lastIndex) backStack.removeAt(backStack.lastIndex)
} }
} }
)
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material }
SharedTransitionLayout { SharedTransitionLayout {
CompositionLocalProvider(
LocalSharedTransitionScope provides this,
) {
NavDisplay( NavDisplay(
sharedTransitionScope = this, sharedTransitionScope = this,
backStack = backStack, backStack = backStack,
sceneStrategies = listOf(sceneStrategy),
onBack = { onBack = {
if (backStack.size > 1) { if (backStack.size > 1) {
backStack.removeAt(backStack.lastIndex) backStack.removeAt(backStack.lastIndex)
} }
}, },
entryProvider = { screen -> entryProvider = { screen ->
when (screen) {
Screen.Onboarding ->
NavEntry(screen) { NavEntry(screen) {
OnboardingScreen { RenderScreenContent(
onboardingComplete() screen = screen,
if (showReleaseNotes) navigate(Screen.ReleaseNotes) else navigate( backStack = backStack,
Screen.DeviceList backRequests = backRequests,
)
backStack.remove(screen)
}
}
Screen.DeviceList ->
NavEntry(screen) {
DeviceListRoute(
devices = devices, devices = devices,
navigateToDevice = { macAddress ->
when (devices[macAddress]) {
is AppleDevice -> navigate(Screen.AppleScreen(macAddress))
else -> {}
}
}
)
}
is Screen.AppleScreen ->
NavEntry(screen) {
val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry
val factory = viewModelFactory {
initializer {
AppleViewModel(
device = device,
recordingRepository = recordingRepository,
)
}
}
val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory)
AirPodsSettingsRoute(
viewModel = appleViewModel,
navigateToRename = { navigate(Screen.Rename(screen.macAddress)) },
navigateToHearingProtection = {
navigate(
Screen.HearingProtection(
screen.macAddress
)
)
},
navigateToHearingAid = { navigate(Screen.HearingAid(screen.macAddress)) },
navigateToLeftLongPress = {
navigate(
Screen.LongPress(screen.macAddress, "Left")
)
},
navigateToRightLongPress = {
navigate(
Screen.LongPress(screen.macAddress, "Right")
)
},
navigateToPurchase = { navigate(Screen.Purchase) },
navigateToAdaptiveStrength = {
navigate(
Screen.AdaptiveStrength(
screen.macAddress
)
)
},
navigateToEqualizer = { navigate(Screen.Equalizer(screen.macAddress)) },
navigateToHeadTracking = { navigate(Screen.HeadTracking(screen.macAddress)) },
navigateToAccessibility = { navigate(Screen.Accessibility(screen.macAddress)) },
navigateToVersion = { navigate(Screen.VersionInfo(screen.macAddress)) },
navigateToCallControlScreen = {
navigate(
Screen.CallControl(
screen.macAddress,
it
)
)
},
navigateToMicrophoneSettings = {
navigate(
Screen.MicrophoneSettings(
screen.macAddress
)
)
},
navigateToRecordingScreen = { navigate(Screen.Recording(screen.macAddress)) },
navigateToDebugScreen = { navigate(Screen.Debug(screen.macAddress)) }
)
}
is Screen.Rename ->
NavEntry(screen) {
val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry
val factory = viewModelFactory {
initializer {
AppleViewModel(
device = device,
recordingRepository = recordingRepository,
)
}
}
val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory)
RenameScreen(appleViewModel)
}
Screen.AppSettings ->
NavEntry(screen) {
val factory = viewModelFactory {
initializer {
AppSettingsViewModel(
appDataRepository = appDataRepository, appDataRepository = appDataRepository,
)
}
}
val appSettingsViewModel: AppSettingsViewModel = viewModel(factory = factory)
AppSettingsScreen(
viewModel = appSettingsViewModel,
navigateToPurchase = ::navigateToPurchase,
navigateToTroubleshooting = { navigate(Screen.Troubleshooting) },
navigateToOpenSourceLicenses = { navigate(Screen.OpenSourceLicenses) },
navigateToReleaseNotesScreen = { navigate(Screen.ReleaseNotes) },
navigateToBleSettingsScreen = { navigate(Screen.BLESettings) }
)
}
Screen.Troubleshooting ->
NavEntry(screen) {
TroubleshootingScreen()
}
is Screen.HeadTracking ->
NavEntry(screen) {
val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry
val factory = viewModelFactory {
initializer {
AppleViewModel(
device = device,
recordingRepository = recordingRepository, recordingRepository = recordingRepository,
heartRateRepository = heartRateRepository,
showReleaseNotes = showReleaseNotes,
updatesShown = updatesShown,
onboardingComplete = onboardingComplete
) )
} }
}
val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory)
HeadTrackingScreen(appleViewModel, ::navigateToPurchase)
}
is Screen.Accessibility ->
NavEntry(screen) {
val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry
val factory = viewModelFactory {
initializer {
AppleViewModel(
device = device,
recordingRepository = recordingRepository,
)
}
}
val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory)
AccessibilitySettingsScreen(
viewModel = appleViewModel,
navigateToPurchase = ::navigateToPurchase,
navigateToTransparencyCustomization = {
navigate(
Screen.TransparencyCustomization(
screen.macAddress
)
)
}
)
}
is Screen.TransparencyCustomization ->
NavEntry(screen) {
val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry
val factory = viewModelFactory {
initializer {
AppleViewModel(
device = device,
recordingRepository = recordingRepository,
)
}
}
val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory)
TransparencySettingsScreen(appleViewModel)
}
is Screen.HearingAid ->
NavEntry(screen) {
val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry
val factory = viewModelFactory {
initializer {
AppleViewModel(
device = device,
recordingRepository = recordingRepository,
)
}
}
val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory)
HearingAidScreen(
viewModel = appleViewModel,
onNavigateHearingAidAdjustments = {
navigate(
Screen.HearingAidAdjustments(
screen.macAddress
)
)
},
onNavigateHearingTest = { navigate(Screen.UpdateHearingTest(screen.macAddress)) },
)
}
is Screen.HearingAidAdjustments ->
NavEntry(screen) {
val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry
val factory = viewModelFactory {
initializer {
AppleViewModel(
device = device,
recordingRepository = recordingRepository,
)
}
}
val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory)
HearingAidAdjustmentsScreen(appleViewModel)
}
is Screen.AdaptiveStrength ->
NavEntry(screen) {
val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry
val factory = viewModelFactory {
initializer {
AppleViewModel(
device = device,
recordingRepository = recordingRepository,
)
}
}
val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory)
AdaptiveStrengthScreen(appleViewModel, ::navigateToPurchase)
}
// Screen.CameraControl ->
// NavEntry(screen) {
// CameraControlScreen(AppleViewModel)
// }
Screen.OpenSourceLicenses ->
NavEntry(screen) {
OpenSourceLicensesScreen()
}
is Screen.UpdateHearingTest ->
NavEntry(screen) {
val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry
val factory = viewModelFactory {
initializer {
AppleViewModel(
device = device,
recordingRepository = recordingRepository,
)
}
}
val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory)
UpdateHearingTestRoute(appleViewModel)
}
is Screen.VersionInfo ->
NavEntry(screen) {
val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry
val factory = viewModelFactory {
initializer {
AppleViewModel(
device = device,
recordingRepository = recordingRepository,
)
}
}
val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory)
VersionScreen(appleViewModel)
}
is Screen.HearingProtection ->
NavEntry(screen) {
val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry
val factory = viewModelFactory {
initializer {
AppleViewModel(
device = device,
recordingRepository = recordingRepository,
)
}
}
val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory)
HearingProtectionScreen(
viewModel = appleViewModel,
navigateToPurchase = ::navigateToPurchase
)
}
is Screen.Purchase ->
NavEntry(screen) {
val vm: PurchaseViewModel = viewModel()
PurchaseScreen(vm, backStack)
}
is Screen.Equalizer ->
NavEntry(screen) {
val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry
val factory = viewModelFactory {
initializer {
AppleViewModel(
device = device,
recordingRepository = recordingRepository,
)
}
}
val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory)
EqualizerRoute(appleViewModel)
}
is Screen.LongPress ->
NavEntry(screen) {
val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry
val factory = viewModelFactory {
initializer {
AppleViewModel(
device = device,
recordingRepository = recordingRepository,
)
}
}
val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory)
LongPress(
viewModel = appleViewModel,
name = screen.bud,
navigateToPurchase = ::navigateToPurchase
)
}
is Screen.CallControl ->
NavEntry(screen) {
val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry
val factory = viewModelFactory {
initializer {
AppleViewModel(
device = device,
recordingRepository = recordingRepository,
)
}
}
val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory)
CallControlScreen(
viewModel = appleViewModel,
action = screen.action,
onCallControlValueChanged = { flipped ->
device.setControlCommand(
ControlCommandIdentifier.CALL_MANAGEMENT_CONFIG,
if (flipped) byteArrayOf(0x00, 0x02) else byteArrayOf(
0x00,
0x03
)
)
}
)
}
is Screen.MicrophoneSettings ->
NavEntry(screen) {
val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry
val factory = viewModelFactory {
initializer {
AppleViewModel(
device = device,
recordingRepository = recordingRepository,
)
}
}
val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory)
MicrophoneSettingsRoute(viewModel = appleViewModel)
}
is Screen.ReleaseNotes ->
NavEntry(screen) {
ReleaseNotesScreen(
updates = updates,
releaseNotesShown = {
if (showReleaseNotes) {
navigate(Screen.DeviceList)
backStack.remove(screen)
updatesShown()
} else {
backStack.removeAt(backStack.lastIndex)
}
}
)
}
is Screen.Recording ->
NavEntry(screen) {
val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry
val factory = viewModelFactory {
initializer {
AppleViewModel(
device = device,
recordingRepository = recordingRepository,
)
}
}
val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory)
RecordingScreenRoute(appleViewModel)
}
is Screen.Debug ->
NavEntry(screen) {
val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry
val factory = viewModelFactory {
initializer {
AppleViewModel(
device = device,
recordingRepository = recordingRepository,
)
}
}
val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory)
DebugRoute(appleViewModel)
}
is Screen.BLESettings ->
NavEntry(screen) {
val factory = viewModelFactory {
initializer {
AppSettingsViewModel(
appDataRepository = appDataRepository,
)
}
}
val appSettingsViewModel: AppSettingsViewModel = viewModel(factory = factory)
BLESettingsScreenRoute(
viewModel = appSettingsViewModel
)
}
}
}, },
transitionSpec = { transitionSpec = { EnterTransition.None togetherWith ExitTransition.None },
slideInHorizontally { it } togetherWith slideOutHorizontally { -it / 4 } popTransitionSpec = { EnterTransition.None togetherWith ExitTransition.None },
}, predictivePopTransitionSpec = { EnterTransition.None togetherWith ExitTransition.None }
popTransitionSpec = {
slideInHorizontally { -it / 4 } togetherWith slideOutHorizontally { it }
},
predictivePopTransitionSpec = { swipeEdge ->
if (m3eEnabled) {
val enterOffset: (Int) -> Int =
if (swipeEdge == EDGE_LEFT) {
{ -it / 6 }
} else {
{ it / 6 }
}
val exitOffset: (Int) -> Int =
if (swipeEdge == EDGE_LEFT) {
{ it / 8 }
} else {
{ -it / 8 }
}
fadeIn(
animationSpec = tween(250)
) +
slideInHorizontally(
initialOffsetX = enterOffset,
animationSpec = tween(250)
) togetherWith
fadeOut(
targetAlpha = 0.75f,
animationSpec = tween(250)
) +
scaleOut(
targetScale = 0.85f,
animationSpec = tween(250)
) +
slideOutHorizontally(
targetOffsetX = exitOffset,
animationSpec = tween(250)
) )
} else {
slideInHorizontally { -it / 4 } togetherWith slideOutHorizontally { it }
} }
},
)
} }
} }
@@ -1,35 +1,26 @@
package me.kavishdevar.librepods.presentation.navigation package me.kavishdevar.librepods.presentation.navigation
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.FilledTonalIconToggleButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.runtime.saveable.rememberSerializable
import androidx.compose.ui.res.stringResource import androidx.navigation3.runtime.NavBackStack
import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.MutableSharedFlow
import com.kyant.backdrop.backdrops.LayerBackdrop import kotlinx.serialization.serializer
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.MacAddress import me.kavishdevar.librepods.bluetooth.MacAddress
import me.kavishdevar.librepods.devices.AppleDevice import me.kavishdevar.librepods.devices.AppleDevice
import me.kavishdevar.librepods.devices.ConnectionState
import me.kavishdevar.librepods.devices.Device import me.kavishdevar.librepods.devices.Device
import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold @Composable
import me.kavishdevar.librepods.presentation.icons.LocalIcons fun rememberScreenNavBackStack(vararg elements: Screen): NavBackStack<Screen> {
import me.kavishdevar.librepods.presentation.icons.MaterialIcons return rememberSerializable(serializer = serializer()) {
import me.kavishdevar.librepods.presentation.theme.DesignSystem NavBackStack(*elements)
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem }
}
@Composable @Composable
fun NavigationRoot( fun NavigationRoot(
@@ -41,133 +32,33 @@ fun NavigationRoot(
) { ) {
val devices by devicesState val devices by devicesState
val backStack = remember { val backStack = rememberScreenNavBackStack(
mutableStateListOf(
when { when {
showOnboarding -> Screen.Onboarding showOnboarding -> Screen.Onboarding
showReleaseNotes -> Screen.ReleaseNotes showReleaseNotes -> Screen.ReleaseNotes
else -> Screen.DeviceList else -> Screen.DeviceList
} }
) )
val connectedDevice = devices.values.firstOrNull { it.connectionState.collectAsState().value == ConnectionState.CONNECTED }
LaunchedEffect(connectedDevice) {
if (connectedDevice != null) {
val targetScreen = when (connectedDevice) {
is AppleDevice -> Screen.AppleScreen(connectedDevice.macAddress)
} }
val currentScreen = backStack.last() if (targetScreen !in backStack) {
backStack.add(targetScreen)
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material }
}
val title = when (currentScreen) {
Screen.Onboarding -> ""
Screen.DeviceList -> stringResource(R.string.app_name)
is Screen.AppleScreen -> devices[currentScreen.macAddress]?.metadata?.collectAsState()?.value?.name ?: currentScreen.macAddress.value
is Screen.Accessibility -> stringResource(R.string.accessibility)
is Screen.AdaptiveStrength -> stringResource(R.string.customize_adaptive_audio)
Screen.AppSettings -> stringResource(R.string.settings)
// Screen.CameraControl -> stringResource(R.string.camera_control)
is Screen.Equalizer -> stringResource(R.string.equalizer)
is Screen.HeadTracking -> stringResource(R.string.head_tracking)
is Screen.HearingAid -> stringResource(R.string.hearing_aid)
is Screen.HearingAidAdjustments -> stringResource(R.string.adjustments)
is Screen.HearingProtection -> stringResource(R.string.hearing_protection)
is Screen.LongPress -> currentScreen.bud
Screen.OpenSourceLicenses -> stringResource(R.string.open_source_licenses)
Screen.Purchase -> stringResource(R.string.unlock_advanced_features)
is Screen.Rename -> stringResource(R.string.name)
is Screen.TransparencyCustomization -> stringResource(R.string.customize_transparency_mode)
Screen.Troubleshooting -> stringResource(R.string.troubleshooting)
is Screen.UpdateHearingTest -> stringResource(R.string.update_hearing_test)
is Screen.VersionInfo -> stringResource(R.string.version)
is Screen.CallControl -> currentScreen.action
is Screen.MicrophoneSettings -> stringResource(R.string.microphone_mode)
Screen.ReleaseNotes -> ""
is Screen.Recording -> stringResource(R.string.recorder)
is Screen.Debug -> "debug"
is Screen.BLESettings -> stringResource(R.string.ble_settings)
} }
// is this a bad idea? probably. I can't think of a better way without having to pass around a shouldShowBackButton to each screen to pass to each scaffold
val actionButtons = when (currentScreen) {
is Screen.AppleScreen, is Screen.DeviceList -> listOf<@Composable (backdrop: LayerBackdrop) -> Unit>(
{ scaffoldBackdrop ->
if (m3eEnabled) {
FilledTonalIconButton(
onClick = { backStack.add(Screen.AppSettings) },
modifier = Modifier
.minimumInteractiveComponentSize()
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)),
) {
Icon(
imageVector = Icons.Outlined.Settings,
contentDescription = "settings",
modifier = Modifier.size(IconButtonDefaults.mediumIconSize),
)
}
} else {
StyledIconButton(
onClick = { backStack.add(Screen.AppSettings) },
backdrop = scaffoldBackdrop
) {
Icon(
imageVector = LocalIcons.current.Settings,
contentDescription = "Settings",
tint = MaterialTheme.colorScheme.onBackground
)
}
}
}
)
is Screen.HeadTracking -> listOf<@Composable (backdrop: LayerBackdrop) -> Unit>(
{ scaffoldBackdrop ->
val device = devices[currentScreen.macAddress] as? AppleDevice? ?: return@listOf
val state by device.state.collectAsState()
if (m3eEnabled) {
FilledTonalIconToggleButton(
checked = state.headTrackingActive,
onCheckedChange = { if (it) device.startHeadTracking() else device.stopHeadTracking() },
modifier = Modifier
.minimumInteractiveComponentSize()
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)),
shape = IconButtonDefaults.mediumRoundShape
) {
Icon(
imageVector = if (state.headTrackingActive) MaterialIcons.Pause else Icons.Default.PlayArrow,
contentDescription = "Play/Pause",
modifier = Modifier.size(IconButtonDefaults.mediumIconSize),
)
}
} else {
StyledIconButton(
onClick = if (!state.headTrackingActive) device::startHeadTracking else device::stopHeadTracking,
backdrop = scaffoldBackdrop
) {
Icon(
imageVector = if (state.headTrackingActive) LocalIcons.current.Pause else LocalIcons.current.Play,
contentDescription = "Play/Pause",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onBackground
)
}
}
}
)
else -> listOf()
}
StyledScaffold(
visible = currentScreen.showTopBar,
title = title,
showBackButton = backStack.size > 1,
onNavigateBack = { backStack.removeAt(backStack.lastIndex) },
actionButtons = actionButtons
) {
AppNavGraph( AppNavGraph(
backStack = backStack,
devicesState = devicesState,
showReleaseNotes = showReleaseNotes, showReleaseNotes = showReleaseNotes,
updatesShown = updatesShown, updatesShown = updatesShown,
onboardingComplete = onboardingComplete, onboardingComplete = onboardingComplete,
backStack = backStack,
devicesState = devicesState,
) )
}
} }
@@ -0,0 +1,438 @@
package me.kavishdevar.librepods.presentation.navigation
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import androidx.navigation3.runtime.NavBackStack
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.channels.Channel
import me.kavishdevar.librepods.R
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.Device
import me.kavishdevar.librepods.presentation.screens.AppSettingsScreen
import me.kavishdevar.librepods.presentation.screens.BLESettingsScreenRoute
import me.kavishdevar.librepods.presentation.screens.DeviceListScreen
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.apple.AccessibilitySettingsScreen
import me.kavishdevar.librepods.presentation.screens.apple.AdaptiveStrengthScreen
import me.kavishdevar.librepods.presentation.screens.apple.AppleSettingsRoute
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.HeartRateRoute
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.viewmodel.AppSettingsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
import me.kavishdevar.librepods.repository.AppDataRepository
import me.kavishdevar.librepods.repository.HeartRateRepository
import me.kavishdevar.librepods.repository.RecordingRepository
private fun createAppleViewModelFactory(
device: AppleDevice,
appDataRepository: AppDataRepository,
recordingRepository: RecordingRepository,
heartRateRepository: HeartRateRepository
): ViewModelProvider.Factory = viewModelFactory {
initializer {
AppleViewModel(
device = device,
appDataRepository = appDataRepository,
recordingRepository = recordingRepository,
heartRateRepository = heartRateRepository
)
}
}
@Composable
fun RenderScreenContent(
screen: Screen,
backStack: NavBackStack<Screen>,
backRequests: Channel<CompletableDeferred<Unit>>,
devices: Map<MacAddress, Device<*, *, *>>,
appDataRepository: AppDataRepository,
recordingRepository: RecordingRepository,
heartRateRepository: HeartRateRepository,
showReleaseNotes: Boolean,
updatesShown: () -> Unit,
onboardingComplete: () -> Unit
) {
val navigate: (Screen) -> Unit = { target -> backStack.add(target) }
fun navigateToPurchase() = navigate(Screen.Purchase)
val navigateBack: (() -> Unit)? = if (backStack.size > 1) {
{
val completed = CompletableDeferred<Unit>()
backRequests.trySend(completed)
}
} else null
when (screen) {
Screen.Onboarding -> {
OnboardingScreen {
onboardingComplete()
if (showReleaseNotes) navigate(Screen.ReleaseNotes) else navigate(Screen.DeviceList)
backStack.remove(screen)
}
}
Screen.DeviceList -> {
DeviceListScreen(
devices = devices,
navigateToAppSettings = { navigate(Screen.AppSettings) },
navigateToDevice = { macAddress ->
when (devices[macAddress]) {
is AppleDevice -> navigate(Screen.AppleScreen(macAddress))
else -> {}
}
}
)
}
is Screen.AppleScreen -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
val left = stringResource(R.string.left)
val right = stringResource(R.string.right)
AppleSettingsRoute(
viewModel = appleViewModel,
navigateBack = navigateBack,
navigateToRename = { navigate(Screen.Rename(screen.macAddress)) },
navigateToHearingProtection = { navigate(Screen.HearingProtection(screen.macAddress)) },
navigateToHearingAid = { navigate(Screen.HearingAid(screen.macAddress)) },
navigateToLeftLongPress = { navigate(Screen.LongPress(screen.macAddress, left)) },
navigateToRightLongPress = { navigate(Screen.LongPress(screen.macAddress, right)) },
navigateToPurchase = ::navigateToPurchase,
navigateToAdaptiveStrength = { navigate(Screen.AdaptiveStrength(screen.macAddress)) },
navigateToEqualizer = { navigate(Screen.Equalizer(screen.macAddress)) },
navigateToHeadTracking = { navigate(Screen.HeadTracking(screen.macAddress)) },
navigateToAccessibility = { navigate(Screen.Accessibility(screen.macAddress)) },
navigateToVersion = { navigate(Screen.VersionInfo(screen.macAddress)) },
navigateToCallControlScreen = { navigate(Screen.CallControl(screen.macAddress, it)) },
navigateToMicrophoneSettings = { navigate(Screen.MicrophoneSettings(screen.macAddress)) },
navigateToRecordingScreen = { navigate(Screen.Recording(screen.macAddress)) },
navigateToHeartRateScreen = { navigate(Screen.HeartRate(screen.macAddress)) },
navigateToDebugScreen = { navigate(Screen.Debug(screen.macAddress)) }
)
}
is Screen.Rename -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
RenameScreen(
viewModel = appleViewModel,
navigateBack = navigateBack,
)
}
Screen.AppSettings -> {
val factory = viewModelFactory {
initializer { AppSettingsViewModel(appDataRepository = appDataRepository) }
}
val appSettingsViewModel: AppSettingsViewModel = viewModel(factory = factory)
AppSettingsScreen(
viewModel = appSettingsViewModel,
navigateBack = navigateBack,
navigateToPurchase = ::navigateToPurchase,
navigateToOpenSourceLicenses = { navigate(Screen.OpenSourceLicenses) },
navigateToReleaseNotesScreen = { navigate(Screen.ReleaseNotes) },
navigateToBleSettingsScreen = { navigate(Screen.BLESettings) }
)
}
is Screen.HeadTracking -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
HeadTrackingScreen(
viewModel = appleViewModel,
navigateBack = navigateBack,
navigateToPurchase = ::navigateToPurchase
)
}
is Screen.Accessibility -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
AccessibilitySettingsScreen(
viewModel = appleViewModel,
navigateBack = navigateBack,
navigateToPurchase = ::navigateToPurchase,
navigateToTransparencyCustomization = {
navigate(Screen.TransparencyCustomization(screen.macAddress))
}
)
}
is Screen.TransparencyCustomization -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
TransparencySettingsScreen(
viewModel = appleViewModel,
navigateBack = navigateBack,
)
}
is Screen.HearingAid -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
HearingAidScreen(
viewModel = appleViewModel,
navigateBack = navigateBack,
navigateToHearingAidAdjustments = { navigate(Screen.HearingAidAdjustments(screen.macAddress)) },
navigateToHearingTest = { navigate(Screen.UpdateHearingTest(screen.macAddress)) }
)
}
is Screen.HearingAidAdjustments -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
HearingAidAdjustmentsScreen(
viewModel = appleViewModel,
navigateBack = navigateBack,
)
}
is Screen.AdaptiveStrength -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
AdaptiveStrengthScreen(
viewModel = appleViewModel,
navigateBack = navigateBack,
navigateToPurchase = ::navigateToPurchase
)
}
Screen.OpenSourceLicenses -> OpenSourceLicensesScreen(navigateBack)
is Screen.UpdateHearingTest -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
UpdateHearingTestRoute(
viewModel = appleViewModel,
navigateBack = navigateBack,
)
}
is Screen.VersionInfo -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
VersionScreen(
viewModel = appleViewModel,
navigateBack = navigateBack
)
}
is Screen.HearingProtection -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
HearingProtectionScreen(
viewModel = appleViewModel,
navigateBack = navigateBack,
navigateToPurchase = ::navigateToPurchase
)
}
is Screen.Purchase -> {
val viewModel: PurchaseViewModel = viewModel()
PurchaseScreen(
viewModel = viewModel,
navigateBack = navigateBack
)
}
is Screen.Equalizer -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
EqualizerRoute(
viewModel = appleViewModel,
navigateBack = navigateBack
)
}
is Screen.LongPress -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
LongPress(
viewModel = appleViewModel,
name = screen.bud,
navigateBack = navigateBack,
navigateToPurchase = ::navigateToPurchase
)
}
is Screen.CallControl -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
CallControlScreen(
viewModel = appleViewModel,
action = screen.action,
navigateBack = navigateBack,
onCallControlValueChanged = { flipped ->
device.setControlCommand(
ControlCommandIdentifier.CALL_MANAGEMENT_CONFIG,
if (flipped) byteArrayOf(0x00, 0x02) else byteArrayOf(0x00, 0x03)
)
}
)
}
is Screen.MicrophoneSettings -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
MicrophoneSettingsRoute(
viewModel = appleViewModel,
navigateBack = navigateBack
)
}
is Screen.ReleaseNotes -> {
ReleaseNotesScreen(
updates = updates,
releaseNotesShown = {
if (showReleaseNotes) {
navigate(Screen.DeviceList)
backStack.remove(screen)
updatesShown()
} else {
backStack.removeAt(backStack.lastIndex)
}
}
)
}
is Screen.Recording -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
RecordingScreenRoute(
viewModel = appleViewModel,
navigateBack = navigateBack
)
}
is Screen.Debug -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
DebugRoute(
viewModel = appleViewModel,
navigateBack = navigateBack
)
}
is Screen.BLESettings -> {
val factory = viewModelFactory {
initializer { AppSettingsViewModel(appDataRepository = appDataRepository) }
}
val appSettingsViewModel: AppSettingsViewModel = viewModel(factory = factory)
BLESettingsScreenRoute(
viewModel = appSettingsViewModel,
navigateBack = navigateBack
)
}
is Screen.HeartRate -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
HeartRateRoute(
viewModel = appleViewModel,
navigateBack = navigateBack
)
}
}
}
@@ -30,9 +30,6 @@ sealed interface Screen: NavKey {
@Serializable @Serializable
data object AppSettings: Screen data object AppSettings: Screen
@Serializable
data object Troubleshooting: Screen
@Serializable @Serializable
data class HeadTracking( data class HeadTracking(
override val macAddress: MacAddress override val macAddress: MacAddress
@@ -126,6 +123,11 @@ sealed interface Screen: NavKey {
@Serializable @Serializable
data object BLESettings: Screen data object BLESettings: Screen
@Serializable
data class HeartRate(
override val macAddress: MacAddress
): DeviceScreen
} }
@Serializable @Serializable
@@ -0,0 +1,297 @@
package me.kavishdevar.librepods.presentation.navigation
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.scene.Scene
import androidx.navigation3.scene.SceneStrategy
import androidx.navigation3.scene.SceneStrategyScope
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
// mostly AI generated
private enum class Direction {
Forward,
Back,
None
}
class SwipeBackSceneStrategy<T : Any>(
private val enabled: Boolean,
private val backRequests: Channel<CompletableDeferred<Unit>>,
private val onDismiss: () -> Unit
) : SceneStrategy<T> {
private var previousEntries: List<NavEntry<T>> = emptyList()
override fun SceneStrategyScope<T>.calculateScene(
entries: List<NavEntry<T>>
): Scene<T>? {
if (entries.isEmpty()) return null
val currentEntry = entries.last()
val previousEntry = entries.getOrNull(entries.lastIndex - 1)
val direction = when {
previousEntries.isEmpty() -> Direction.None
currentEntry in previousEntries -> Direction.Back
else -> Direction.Forward
}
previousEntries = entries
//
// if (previousEntry == null) {
// return object : Scene<T> {
// override val key: Any
// get() = "${currentEntry.contentKey}_${currentEntry.hashCode()}"
//
// override val entries: List<NavEntry<T>>
// get() = listOf(currentEntry)
//
// override val previousEntries: List<NavEntry<T>>
// get() = emptyList()
//
// override val content: @Composable () -> Unit
// get() = { currentEntry.Content() }
// }
// }
return object : Scene<T> {
override val key: Any
get() = "${currentEntry.contentKey}_${currentEntry.hashCode()}"
override val entries: List<NavEntry<T>>
get() = listOfNotNull(previousEntry, currentEntry)
override val previousEntries: List<NavEntry<T>>
get() = listOfNotNull(previousEntry)
override val content: @Composable () -> Unit
get() = {
SwipeBackSceneContent(
previousEntry = previousEntry,
currentEntry = currentEntry,
direction = direction,
swipeEnabled = enabled,
backRequests = backRequests,
onDismiss = onDismiss
)
}
}
}
}
@Composable
private fun <T : Any> SwipeBackSceneContent(
previousEntry: NavEntry<T>?,
currentEntry: NavEntry<T>,
direction: Direction,
swipeEnabled: Boolean,
backRequests: Channel<CompletableDeferred<Unit>>,
onDismiss: () -> Unit
) {
val density = LocalDensity.current
val screenWidthPx = with(density) {
LocalWindowInfo.current.containerDpSize.width.toPx()
}
val animatedOffset = remember(
"${currentEntry.contentKey}_${currentEntry.hashCode()}"
) {
Animatable(
if (direction == Direction.Forward) {
screenWidthPx
} else {
0f
}
)
}
var transitionProgress by remember {
mutableFloatStateOf(
if (direction == Direction.Forward) 1f else 0f
)
}
val scope = rememberCoroutineScope()
LaunchedEffect(
"${currentEntry.contentKey}_${currentEntry.hashCode()}",
direction
) {
if (direction == Direction.Forward) {
transitionProgress = 1f
animatedOffset.animateTo(
0f,
tween(220)
)
transitionProgress = 0f
}
}
LaunchedEffect(Unit) {
for (completed in backRequests) {
animatedOffset.animateTo(
screenWidthPx,
tween(150)
)
transitionProgress = -1f
onDismiss()
completed.complete(Unit)
}
}
PredictiveBackHandler { progressFlow ->
try {
progressFlow.collect { backEvent ->
val progress = backEvent.progress
transitionProgress = -progress
animatedOffset.snapTo(
progress * screenWidthPx
)
}
animatedOffset.animateTo(
screenWidthPx,
tween(150)
)
transitionProgress = -1f
onDismiss()
} catch (_: CancellationException) {
animatedOffset.animateTo(
0f,
tween(150)
)
transitionProgress = 0f
}
}
val draggableState = rememberDraggableState { delta ->
scope.launch {
val offset =
(animatedOffset.value + delta)
.coerceAtLeast(0f)
animatedOffset.snapTo(offset)
transitionProgress =
-(offset / screenWidthPx)
.coerceIn(0f, 1f)
}
}
CompositionLocalProvider(
LocalTransitionProgress provides transitionProgress
) {
Box(Modifier.fillMaxSize()) {
CompositionLocalProvider(
LocalIsCurrentEntry provides false
) {
Box(
Modifier
.fillMaxSize()
.graphicsLayer {
translationX =
if (direction == Direction.Forward) {
0f
} else {
(-screenWidthPx / 3f) +
(animatedOffset.value / 3f)
}
}
) {
previousEntry?.Content()
}
}
CompositionLocalProvider(
LocalIsCurrentEntry provides true
) {
Box(
Modifier
.fillMaxSize()
.graphicsLayer {
translationX = animatedOffset.value
}
.draggable(
enabled = swipeEnabled && previousEntry != null,
state = draggableState,
orientation = Orientation.Horizontal,
onDragStopped = { velocity ->
val currentOffset = animatedOffset.value
val currentProgress =
(currentOffset / screenWidthPx).coerceIn(0f, 1f)
val shouldDismiss =
currentOffset > screenWidthPx * 0.35f ||
velocity > 1000f
scope.launch {
if (shouldDismiss) {
animate(
initialValue = currentProgress,
targetValue = 1f,
animationSpec = tween(150)
) { value, _ ->
transitionProgress = -value
scope.launch {
animatedOffset.snapTo(value * screenWidthPx)
}
}
transitionProgress = -1f
onDismiss()
} else {
animate(
initialValue = currentProgress,
targetValue = 0f,
animationSpec = tween(150)
) { value, _ ->
transitionProgress = -value
scope.launch {
animatedOffset.snapTo(value * screenWidthPx)
}
}
transitionProgress = 0f
animatedOffset.snapTo(0f)
}
}
}
)
) {
currentEntry.Content()
}
}
}
}
}
@@ -0,0 +1,11 @@
package me.kavishdevar.librepods.presentation.navigation
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.runtime.compositionLocalOf
val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope> {
error("LocalSharedTransitionScope not provided")
}
val LocalTransitionProgress = compositionLocalOf { 0f }
val LocalIsCurrentEntry = compositionLocalOf { false }
@@ -29,14 +29,10 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.TextFieldState
@@ -76,10 +72,10 @@ import me.kavishdevar.librepods.presentation.components.StyledInputField
import me.kavishdevar.librepods.presentation.components.StyledList import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledToggle import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.icons.LocalIcons import me.kavishdevar.librepods.presentation.icons.LocalIcons
import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.theme.NightTheme import me.kavishdevar.librepods.presentation.theme.NightTheme
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
import me.kavishdevar.librepods.utils.XposedState import me.kavishdevar.librepods.utils.XposedState
@@ -89,8 +85,8 @@ import java.util.concurrent.TimeUnit
@Composable @Composable
fun AppSettingsScreen( fun AppSettingsScreen(
viewModel: AppSettingsViewModel = viewModel(), viewModel: AppSettingsViewModel = viewModel(),
navigateBack: (() -> Unit)?,
navigateToPurchase: () -> Unit, navigateToPurchase: () -> Unit,
navigateToTroubleshooting: () -> Unit,
navigateToOpenSourceLicenses: () -> Unit, navigateToOpenSourceLicenses: () -> Unit,
navigateToReleaseNotesScreen: () -> Unit, navigateToReleaseNotesScreen: () -> Unit,
navigateToBleSettingsScreen: () -> Unit navigateToBleSettingsScreen: () -> Unit
@@ -107,10 +103,10 @@ fun AppSettingsScreen(
val subjectFocusRequester = remember { FocusRequester() } val subjectFocusRequester = remember { FocusRequester() }
val descriptionFocusRequester = remember { FocusRequester() } val descriptionFocusRequester = remember { FocusRequester() }
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material StyledScaffold(
val topPadding = if (m3eEnabled) 16.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp title = stringResource(R.string.settings),
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp navigateBack = navigateBack
) { topPadding, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -137,6 +133,7 @@ fun AppSettingsScreen(
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
} }
if (state.state.timeUntilFOSSPremiumExpiry > 0L) { if (state.state.timeUntilFOSSPremiumExpiry > 0L) {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -208,10 +205,41 @@ fun AppSettingsScreen(
enabled = state.isPremium enabled = state.isPremium
) )
} }
Spacer(modifier = Modifier.height(16.dp))
StyledList(title = stringResource(R.string.interaction)) {
StyledToggle(
label = stringResource(R.string.swipe_anywhere_to_go_back),
checked = state.settings.swipeAnywhereForBack,
onCheckedChange = { checked ->
viewModel.updateSettings {
it.copy(swipeAnywhereForBack = checked)
}
},
)
StyledToggle(
label = stringResource(R.string.use_highest_refresh_rate),
checked = state.settings.useHighestRefreshRate,
onCheckedChange = { checked ->
viewModel.updateSettings {
it.copy(useHighestRefreshRate = checked)
}
}
)
}
} }
Spacer(modifier = Modifier.height(16.dp))
StyledList(
title = stringResource(R.string.advanced_options),
description = stringResource(R.string.do_not_change)
) {
if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) { if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) {
val restartBluetoothText = stringResource(R.string.found_offset_restart_bluetooth) val restartBluetoothText =
stringResource(R.string.found_offset_restart_bluetooth)
StyledToggle( StyledToggle(
label = stringResource(R.string.act_as_an_apple_device) + " (${ label = stringResource(R.string.act_as_an_apple_device) + " (${
stringResource( stringResource(
@@ -227,15 +255,9 @@ fun AppSettingsScreen(
) )
} }
StyledListItem(
contentText = stringResource(R.string.ble_settings),
supportingText = stringResource(R.string.do_not_change),
orientation = StyledListItemOrientation.Vertical,
onClick = navigateToBleSettingsScreen
)
StyledToggle( StyledToggle(
label = stringResource(R.string.enable_debug_mode), label = stringResource(R.string.enable_debug_mode),
description = stringResource(R.string.debug_mode_description),
checked = state.settings.debugMode, checked = state.settings.debugMode,
onCheckedChange = { checked -> onCheckedChange = { checked ->
viewModel.updateSettings { viewModel.updateSettings {
@@ -244,17 +266,14 @@ fun AppSettingsScreen(
} }
) )
if (!BuildConfig.PLAY_BUILD) {
Spacer(modifier = Modifier.height(16.dp))
StyledList {
StyledListItem( StyledListItem(
contentText = stringResource(R.string.troubleshooting), contentText = stringResource(R.string.ble_settings),
onClick = navigateToTroubleshooting, orientation = StyledListItemOrientation.Vertical,
onClick = navigateToBleSettingsScreen
) )
} }
}
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(16.dp))
StyledList(title = stringResource(R.string.contact)) { StyledList(title = stringResource(R.string.contact)) {
StyledListItem( StyledListItem(
@@ -325,9 +344,12 @@ fun AppSettingsScreen(
) )
} }
Spacer(modifier = Modifier.height(20.dp))
DeviceInfoCard()
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
DeviceInfoCard()
Spacer(modifier = Modifier.height(16.dp))
AppInfoCard(navigateToReleaseNotesScreen) AppInfoCard(navigateToReleaseNotesScreen)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@@ -404,6 +426,7 @@ fun AppSettingsScreen(
// }) // })
// } // }
} }
}
StyledBottomSheet( StyledBottomSheet(
visible = contactBottomSheet.value, visible = contactBottomSheet.value,
@@ -1,16 +1,9 @@
package me.kavishdevar.librepods.presentation.screens package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -24,38 +17,27 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledList import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSlider import me.kavishdevar.librepods.presentation.components.StyledSlider
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.AppSettingsViewModel
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@Composable @Composable
fun BLESettingsScreenRoute( fun BLESettingsScreenRoute(
viewModel: AppSettingsViewModel viewModel: AppSettingsViewModel,
navigateBack: (() -> Unit)?
) { ) {
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val settings = uiState.settings val settings = uiState.settings
Box (
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
BLESettingsScreen( BLESettingsScreen(
topPadding = topPadding, navigateBack = navigateBack,
bottomPadding = bottomPadding,
scanMode = settings.bleScanMode, scanMode = settings.bleScanMode,
onScanModeChanged = { scanMode -> onScanModeChanged = { scanMode ->
viewModel.updateSettings { viewModel.updateSettings {
@@ -69,13 +51,11 @@ fun BLESettingsScreenRoute(
} }
}, },
) )
}
} }
@Composable @Composable
fun BLESettingsScreen( fun BLESettingsScreen(
topPadding: Dp = 16.dp, navigateBack: (() -> Unit)? = null,
bottomPadding: Dp = 16.dp,
scanMode: Int, scanMode: Int,
onScanModeChanged: (Int) -> Unit, onScanModeChanged: (Int) -> Unit,
reportDelay: Long, reportDelay: Long,
@@ -83,6 +63,10 @@ fun BLESettingsScreen(
) { ) {
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
StyledScaffold(
title = stringResource(R.string.ble_settings),
navigateBack = navigateBack
) { topPadding, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
@@ -131,14 +115,16 @@ fun BLESettingsScreen(
} }
} }
StyledList(title = stringResource(R.string.ble_report_delay)) {
StyledSlider( StyledSlider(
label = stringResource(R.string.ble_report_delay),
value = sliderValue.floatValue, value = sliderValue.floatValue,
onValueChange = { sliderValue.floatValue = it }, onValueChange = { sliderValue.floatValue = it },
valueRange = 0f..1000f, valueRange = 0f..1000f,
description = sliderValue.floatValue.toString() + "ms", description = sliderValue.floatValue.toString() + "ms",
independent = true // i thought I got rid of all this lol
) )
}
Spacer(modifier = Modifier.padding(bottom = bottomPadding)) Spacer(modifier = Modifier.padding(bottom = bottomPadding))
} }
}
} }
@@ -9,26 +9,25 @@ import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.contentColorFor import androidx.compose.material3.contentColorFor
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.material3.toPath import androidx.compose.material3.toPath
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -48,7 +47,6 @@ import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.graphics.shapes.Morph import androidx.graphics.shapes.Morph
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -69,9 +67,11 @@ import me.kavishdevar.librepods.devices.BaseCapability
import me.kavishdevar.librepods.devices.ConnectionState import me.kavishdevar.librepods.devices.ConnectionState
import me.kavishdevar.librepods.devices.Device import me.kavishdevar.librepods.devices.Device
import me.kavishdevar.librepods.presentation.components.NoiseControlSettings import me.kavishdevar.librepods.presentation.components.NoiseControlSettings
import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.presentation.components.StyledList import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.icons.LocalIcons import me.kavishdevar.librepods.presentation.icons.LocalIcons
import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
@@ -79,38 +79,48 @@ import me.kavishdevar.librepods.presentation.utils.createAirPodsBatteryRichText
import kotlin.math.min import kotlin.math.min
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@Composable
fun DeviceListRoute(
devices: Map<MacAddress, Device<*, *, *>>,
navigateToDevice: (MacAddress) -> Unit,
) {
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Box (
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
DeviceListScreen(
devices = devices,
navigateToDevice = navigateToDevice,
topPadding = topPadding,
bottomPadding = bottomPadding
)
}
}
@Composable @Composable
fun DeviceListScreen( fun DeviceListScreen(
devices: Map<MacAddress, Device<*, *, *>>, devices: Map<MacAddress, Device<*, *, *>>,
navigateToAppSettings: () -> Unit,
navigateToDevice: (MacAddress) -> Unit, navigateToDevice: (MacAddress) -> Unit,
topPadding: Dp = 16.dp,
bottomPadding: Dp = 16.dp
) { ) {
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
StyledScaffold(
title = stringResource(R.string.app_name),
navigateBack = null,
actionButtons = listOf(
{ scaffoldBackdrop ->
if (LocalDesignSystem.current == DesignSystem.Material) {
FilledTonalIconButton(
onClick = navigateToAppSettings,
modifier = Modifier
.minimumInteractiveComponentSize()
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)),
) {
Icon(
imageVector = Icons.Outlined.Settings,
contentDescription = "settings",
modifier = Modifier.size(IconButtonDefaults.mediumIconSize),
)
}
} else {
StyledIconButton(
onClick = navigateToAppSettings,
backdrop = scaffoldBackdrop
) {
Icon(
imageVector = LocalIcons.current.Settings,
contentDescription = "Settings",
tint = MaterialTheme.colorScheme.onBackground
)
}
}
}
)
) { topPadding, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
@@ -463,4 +473,5 @@ fun DeviceListScreen(
Spacer(modifier = Modifier.padding(top = bottomPadding)) Spacer(modifier = Modifier.padding(top = bottomPadding))
} }
}
} }
@@ -1,35 +0,0 @@
package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
@Composable
fun LoadingScreen() {
Box(
modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceContainer),
contentAlignment = Alignment.Center
) {
CircularWavyProgressIndicator(
modifier = Modifier
.size(120.dp)
)
}
}
@Preview
@Composable
fun LoadingScreenPreview() {
LibrePodsTheme {
LoadingScreen()
}
}
@@ -43,6 +43,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.mikepenz.aboutlibraries.ui.compose.LibrariesContainer import com.mikepenz.aboutlibraries.ui.compose.LibrariesContainer
import com.mikepenz.aboutlibraries.ui.compose.LibraryDefaults import com.mikepenz.aboutlibraries.ui.compose.LibraryDefaults
@@ -57,15 +58,18 @@ import com.mikepenz.aboutlibraries.ui.compose.variant.LibraryDetailMode
import com.mikepenz.aboutlibraries.ui.compose.variant.LibraryInlineDetail import com.mikepenz.aboutlibraries.ui.compose.variant.LibraryInlineDetail
import com.mikepenz.aboutlibraries.ui.compose.variant.LibraryRow import com.mikepenz.aboutlibraries.ui.compose.variant.LibraryRow
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
@Composable @Composable
fun OpenSourceLicensesScreen() { fun OpenSourceLicensesScreen(
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material navigateBack: (() -> Unit)?
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp ) {
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp StyledScaffold(
title = stringResource(R.string.open_source_licenses),
navigateBack = navigateBack
) { topPadding, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -184,4 +188,5 @@ fun OpenSourceLicensesScreen() {
Spacer(Modifier.height(bottomPadding)) Spacer(Modifier.height(bottomPadding))
} }
}
} }
@@ -36,7 +36,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -45,12 +44,12 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
import me.kavishdevar.librepods.presentation.components.MaterialButtonStyle import me.kavishdevar.librepods.presentation.components.MaterialButtonStyle
import me.kavishdevar.librepods.presentation.components.StyledButton import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledList import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.navigation.Screen import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
@@ -59,7 +58,7 @@ import me.kavishdevar.librepods.utils.XposedState
@Composable @Composable
fun PurchaseScreen( fun PurchaseScreen(
viewModel: PurchaseViewModel = viewModel(), viewModel: PurchaseViewModel = viewModel(),
backStack: SnapshotStateList<Screen> navigateBack: (() -> Unit)?,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
@@ -68,10 +67,10 @@ fun PurchaseScreen(
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material StyledScaffold(
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp title = stringResource(R.string.unlock_advanced_features),
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp navigateBack = navigateBack
) { topPadding, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.layerBackdrop(backdrop) .layerBackdrop(backdrop)
@@ -83,9 +82,7 @@ fun PurchaseScreen(
LaunchedEffect(state.isPremium) { LaunchedEffect(state.isPremium) {
if (state.isPremium) { if (state.isPremium) {
if (backStack.size > 1) { navigateBack?.invoke()
backStack.removeAt(backStack.lastIndex)
}
} }
} }
if (!state.isPremium) { if (!state.isPremium) {
@@ -214,4 +211,5 @@ fun PurchaseScreen(
} }
Spacer(modifier = Modifier.height(bottomPadding)) Spacer(modifier = Modifier.height(bottomPadding))
} }
}
} }
@@ -1,922 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.screens
import android.content.Intent
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
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.Delete
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.icons.LocalIcons
import me.kavishdevar.librepods.utils.LogCollector
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun CustomIconButton(
onClick: () -> Unit,
content: @Composable () -> Unit
) {
Box(
modifier = Modifier
.clickable(onClick = onClick)
.padding(8.dp),
contentAlignment = Alignment.Center
) {
content()
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable
fun TroubleshootingScreen() {
val context = LocalContext.current
val scrollState = rememberScrollState()
val coroutineScope = rememberCoroutineScope()
val logCollector = remember { LogCollector(context) }
val savedLogs = remember { mutableStateListOf<File>() }
var isCollectingLogs by remember { mutableStateOf(false) }
var showTroubleshootingSteps by remember { mutableStateOf(false) }
var currentStep by remember { mutableIntStateOf(0) }
var logContent by remember { mutableStateOf("") }
var selectedLogFile by remember { mutableStateOf<File?>(null) }
var showDeleteDialog by remember { mutableStateOf(false) }
var showDeleteAllDialog by remember { mutableStateOf(false) }
var isLoadingLogContent by remember { mutableStateOf(false) }
var logContentLoaded by remember { mutableStateOf(false) }
LaunchedEffect(isCollectingLogs) {
while (isCollectingLogs) {
delay(250)
delay(250)
}
}
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
var showBottomSheet by remember { mutableStateOf(false) }
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black
val accentColor = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5)
val buttonBgColor = if (isSystemInDarkTheme()) Color(0xFF333333) else Color(0xFFDDDDDD)
var instructionText by remember { mutableStateOf("") }
val isDarkTheme = isSystemInDarkTheme()
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
val logsDir = File(context.filesDir, "logs")
if (logsDir.exists()) {
savedLogs.clear()
savedLogs.addAll(logsDir.listFiles()?.filter { it.name.endsWith(".txt") }
?.sortedByDescending { it.lastModified() } ?: emptyList())
}
}
}
val saveLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/plain")
) { uri ->
if (uri != null) {
coroutineScope.launch(Dispatchers.IO) {
try {
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write(logContent.toByteArray())
}
withContext(Dispatchers.Main) {
Toast.makeText(context, "Log saved successfully", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(
context,
"Failed to save log: ${e.localizedMessage}",
Toast.LENGTH_SHORT
).show()
}
}
}
}
}
LaunchedEffect(currentStep) {
instructionText = when (currentStep) {
0 -> "First, let's ensure Xposed module is properly configured. Tap the button below to check Xposed scope settings."
1 -> "Please put your AirPods in the case and close it, so they disconnect completely."
2 -> "Preparing to collect logs... Please wait."
3 -> "Now, open the AirPods case and connect your AirPods. Logs are being collected. Connection will be detected automatically, or you can manually stop logging when you're done."
4 -> "Log collection complete! You can now save or share the logs."
else -> ""
}
}
fun openLogBottomSheet(file: File) {
selectedLogFile = file
logContent = ""
isLoadingLogContent = false
logContentLoaded = false
showBottomSheet = true
}
val backdrop = rememberLayerBackdrop()
Box(
modifier = Modifier.fillMaxSize()
) {
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column(
modifier = Modifier
.fillMaxSize()
.layerBackdrop(backdrop)
.verticalScroll(scrollState)
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
Text(
text = stringResource(R.string.saved_logs),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.inter))
),
modifier = Modifier.padding(16.dp, bottom = 4.dp, top = 8.dp)
)
Spacer(modifier = Modifier.height(2.dp))
if (savedLogs.isEmpty()) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(
backgroundColor,
RoundedCornerShape(28.dp)
)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = stringResource(R.string.no_logs_found),
fontSize = 16.sp,
color = textColor
)
}
} else {
Column(
modifier = Modifier
.fillMaxWidth()
.background(
backgroundColor,
RoundedCornerShape(28.dp)
)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Total Logs: ${savedLogs.size}",
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = textColor
)
if (savedLogs.size > 1) {
TextButton(
onClick = { showDeleteAllDialog = true },
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text("Delete All")
}
}
}
savedLogs.forEach { logFile ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.clickable {
openLogBottomSheet(logFile)
},
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = logFile.name,
fontSize = 16.sp,
color = textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.US)
.format(Date(logFile.lastModified())),
fontSize = 14.sp,
color = textColor.copy(alpha = 0.6f)
)
}
CustomIconButton(
onClick = {
selectedLogFile = logFile
showDeleteDialog = true
}
) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
AnimatedVisibility(
visible = !showTroubleshootingSteps,
enter = fadeIn(animationSpec = tween(300)),
exit = fadeOut(animationSpec = tween(300))
) {
Button(
onClick = { showTroubleshootingSteps = true },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
),
enabled = !isCollectingLogs
) {
Text(stringResource(R.string.collect_logs))
}
}
AnimatedVisibility(
visible = showTroubleshootingSteps,
enter = fadeIn(animationSpec = tween(300)) +
slideInVertically(animationSpec = tween(300)) { it / 2 },
exit = fadeOut(animationSpec = tween(300)) +
slideOutVertically(animationSpec = tween(300)) { it / 2 }
) {
Column {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.troubleshooting_steps),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.inter))
),
modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 8.dp)
)
Spacer(modifier = Modifier.height(2.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.background(
backgroundColor,
RoundedCornerShape(28.dp)
)
.padding(16.dp)
) {
val textAlpha = animateFloatAsState(
targetValue = 1f,
animationSpec = tween(durationMillis = 300),
label = "textAlpha"
)
Text(
text = instructionText,
fontSize = 16.sp,
color = textColor.copy(alpha = textAlpha.value),
lineHeight = 22.sp
)
Spacer(modifier = Modifier.height(16.dp))
when (currentStep) {
0 -> {
Button(
onClick = {
coroutineScope.launch {
logCollector.openXposedSettings(context)
delay(2000)
currentStep = 1
}
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
)
) {
Text("Open Xposed Settings")
}
}
1 -> {
Button(
onClick = {
currentStep = 2
isCollectingLogs = true
coroutineScope.launch {
try {
logCollector.clearLogs()
logCollector.addLogMarker(LogCollector.LogMarkerType.START)
logCollector.killBluetoothService()
withContext(Dispatchers.Main) {
delay(500)
currentStep = 3
}
val timestamp = SimpleDateFormat(
"yyyyMMdd_HHmmss",
Locale.US
).format(Date())
logContent =
logCollector.startLogCollection(
listener = { /* Removed live log display */ },
connectionDetectedCallback = {
launch {
delay(5000)
withContext(Dispatchers.Main) {
if (isCollectingLogs) {
logCollector.stopLogCollection()
currentStep = 4
isCollectingLogs =
false
}
}
}
}
)
val logFile =
logCollector.saveLogToInternalStorage(
"airpods_log_$timestamp.txt",
logContent
)
logFile?.let {
withContext(Dispatchers.Main) {
savedLogs.add(0, it)
selectedLogFile = it
Toast.makeText(
context,
"Log saved: ${it.name}",
Toast.LENGTH_SHORT
).show()
}
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(
context,
"Error collecting logs: ${e.message}",
Toast.LENGTH_SHORT
).show()
isCollectingLogs = false
currentStep = 0
}
}
}
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
)
) {
Text("Continue")
}
}
2, 3 -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator(
color = accentColor
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = if (currentStep == 2) "Preparing..." else "Collecting logs...",
fontSize = 14.sp,
color = textColor
)
if (currentStep == 3) {
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
coroutineScope.launch {
logCollector.addLogMarker(
LogCollector.LogMarkerType.CUSTOM,
"Manual stop requested by user"
)
delay(1000)
logCollector.stopLogCollection()
delay(500)
withContext(Dispatchers.Main) {
currentStep = 4
isCollectingLogs = false
Toast.makeText(
context,
"Log collection stopped",
Toast.LENGTH_SHORT
).show()
}
}
},
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
),
modifier = Modifier
.fillMaxWidth()
) {
Text("Stop Collection")
}
}
}
}
4 -> {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Button(
onClick = {
selectedLogFile?.let { file ->
val fileUri = FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
file
)
val shareIntent =
Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(
Intent.EXTRA_STREAM,
fileUri
)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(
Intent.createChooser(
shareIntent,
"Share log file"
)
)
}
},
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
),
modifier = Modifier.width(150.dp)
) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = "Share"
)
Spacer(modifier = Modifier.width(8.dp))
Text("Share")
}
Spacer(modifier = Modifier.width(16.dp))
Button(
onClick = {
selectedLogFile?.let { file ->
saveLauncher.launch(
file.absolutePath
)
}
},
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
),
modifier = Modifier.width(150.dp)
) {
Icon(
imageVector = LocalIcons.current.Save,
contentDescription = "Save"
)
Spacer(modifier = Modifier.width(8.dp))
Text("Save")
}
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
currentStep = 0
showTroubleshootingSteps = false
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
)
) {
Text("Done")
}
}
}
}
}
}
if (showDeleteDialog && selectedLogFile != null) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
title = { Text("Delete Log File") },
text = {
Text("Are you sure you want to delete this log file? This action cannot be undone.")
},
confirmButton = {
TextButton(
onClick = {
selectedLogFile?.let { file ->
if (file.delete()) {
savedLogs.remove(file)
Toast.makeText(
context,
"Log file deleted",
Toast.LENGTH_SHORT
)
.show()
} else {
Toast.makeText(
context,
"Failed to delete log file",
Toast.LENGTH_SHORT
).show()
}
}
showDeleteDialog = false
}
) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) {
Text("Cancel")
}
}
)
}
if (showDeleteAllDialog) {
AlertDialog(
onDismissRequest = { showDeleteAllDialog = false },
title = { Text("Delete All Logs") },
text = {
Text("Are you sure you want to delete all log files? This action cannot be undone and will remove ${savedLogs.size} log files.")
},
confirmButton = {
TextButton(
onClick = {
coroutineScope.launch(Dispatchers.IO) {
var deletedCount = 0
savedLogs.forEach { file ->
if (file.delete()) {
deletedCount++
}
}
withContext(Dispatchers.Main) {
if (deletedCount > 0) {
savedLogs.clear()
Toast.makeText(
context,
"Deleted $deletedCount log files",
Toast.LENGTH_SHORT
).show()
} else {
Toast.makeText(
context,
"Failed to delete log files",
Toast.LENGTH_SHORT
).show()
}
}
}
showDeleteAllDialog = false
}
) {
Text("Delete All", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showDeleteAllDialog = false }) {
Text("Cancel")
}
}
)
}
Spacer(modifier = Modifier.height(bottomPadding))
}
if (showBottomSheet) {
ModalBottomSheet(
onDismissRequest = { showBottomSheet = false },
sheetState = sheetState,
containerColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7),
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
tonalElevation = 8.dp
) {
LaunchedEffect(selectedLogFile) {
if (!logContentLoaded) {
delay(300)
withContext(Dispatchers.IO) {
isLoadingLogContent = true
logContent = try {
selectedLogFile?.readText() ?: ""
} catch (e: Exception) {
"Error loading log content: ${e.message}"
}
isLoadingLogContent = false
logContentLoaded = true
}
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp)
.padding(bottom = 32.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
) {
Text(
text = selectedLogFile?.name ?: "Log Content",
style = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
fontFamily = FontFamily(Font(R.font.inter))
),
color = textColor
)
Text(
text = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.US)
.format(Date(selectedLogFile?.lastModified() ?: 0)),
fontSize = 14.sp,
color = textColor.copy(alpha = 0.7f),
fontFamily = FontFamily(Font(R.font.inter))
)
}
if (isLoadingLogContent) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(300.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = accentColor)
}
} else {
Box(
modifier = Modifier
.fillMaxWidth()
.height(300.dp)
.background(
color = Color.Black,
shape = RoundedCornerShape(8.dp)
)
) {
val horizontalScrollState = rememberScrollState()
val verticalScrollState = rememberScrollState()
Box(
modifier = Modifier
.fillMaxSize()
.padding(8.dp)
.horizontalScroll(horizontalScrollState)
.verticalScroll(verticalScrollState)
) {
Text(
text = logContent,
fontSize = 14.sp,
color = Color.LightGray,
lineHeight = 20.sp,
fontFamily = FontFamily.Monospace,
softWrap = false
)
}
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Button(
onClick = {
selectedLogFile?.let { file ->
val fileUri = FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
file
)
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_STREAM, fileUri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(
Intent.createChooser(
shareIntent,
"Share log file"
)
)
}
},
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
),
modifier = Modifier.weight(1f)
) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = "Share"
)
Spacer(modifier = Modifier.width(8.dp))
Text("Share")
}
Button(
onClick = {
selectedLogFile?.let { file ->
saveLauncher.launch(file.absolutePath)
}
},
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
),
modifier = Modifier.weight(1f)
) {
Icon(
imageVector = LocalIcons.current.Save,
contentDescription = "Save"
)
Spacer(modifier = Modifier.width(8.dp))
Text("Save")
}
}
}
}
}
}
DisposableEffect(Unit) {
onDispose {
logCollector.stopLogCollection()
}
}
}
@@ -18,7 +18,6 @@
package me.kavishdevar.librepods.presentation.screens.apple package me.kavishdevar.librepods.presentation.screens.apple
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -56,6 +55,7 @@ import me.kavishdevar.librepods.devices.BaseCapability
import me.kavishdevar.librepods.presentation.components.StyledButton import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledList import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSlider import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.icons.LocalIcons import me.kavishdevar.librepods.presentation.icons.LocalIcons
@@ -67,7 +67,12 @@ import kotlin.time.Duration.Companion.milliseconds
//private var phoneMediaDebounceJob: Job? = null //private var phoneMediaDebounceJob: Job? = null
@Composable @Composable
fun AccessibilitySettingsScreen(viewModel: AppleViewModel, navigateToPurchase: () -> Unit, navigateToTransparencyCustomization: () -> Unit) { fun AccessibilitySettingsScreen(
viewModel: AppleViewModel,
navigateBack: (() -> Unit)?,
navigateToPurchase: () -> Unit,
navigateToTransparencyCustomization: () -> Unit
) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val state = uiState.state val state = uiState.state
@@ -82,14 +87,13 @@ fun AccessibilitySettingsScreen(viewModel: AppleViewModel, navigateToPurchase: (
)?.toInt() == 1 )?.toInt() == 1
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material StyledScaffold(
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp title = stringResource(R.string.accessibility),
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp navigateBack = navigateBack
) { topPadding, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
@@ -572,4 +576,5 @@ fun AccessibilitySettingsScreen(viewModel: AppleViewModel, navigateToPurchase: (
// } // }
Spacer(modifier = Modifier.height(bottomPadding)) Spacer(modifier = Modifier.height(bottomPadding))
} }
}
} }
@@ -18,7 +18,6 @@
package me.kavishdevar.librepods.presentation.screens.apple package me.kavishdevar.librepods.presentation.screens.apple
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -48,6 +47,7 @@ import kotlinx.coroutines.flow.debounce
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
import me.kavishdevar.librepods.presentation.components.StyledButton import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSlider import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.icons.LocalIcons import me.kavishdevar.librepods.presentation.icons.LocalIcons
import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.DesignSystem
@@ -56,21 +56,24 @@ import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@Composable @Composable
fun AdaptiveStrengthScreen(viewModel: AppleViewModel, navigateToPurchase: () -> Unit) { fun AdaptiveStrengthScreen(
viewModel: AppleViewModel,
navigateBack: (() -> Unit)?,
navigateToPurchase: () -> Unit
) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val state = uiState.state val state = uiState.state
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material StyledScaffold(
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp title = stringResource(R.string.customize_adaptive_audio),
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp navigateBack = navigateBack
) { topPadding, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.layerBackdrop(backdrop) .layerBackdrop(backdrop)
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
@@ -107,7 +110,6 @@ fun AdaptiveStrengthScreen(viewModel: AppleViewModel, navigateToPurchase: () ->
} }
StyledSlider( StyledSlider(
label = stringResource(R.string.customize_adaptive_audio),
value = sliderValue.floatValue, value = sliderValue.floatValue,
onValueChange = { sliderValue.floatValue = it }, onValueChange = { sliderValue.floatValue = it },
valueRange = 0f..100f, valueRange = 0f..100f,
@@ -120,4 +122,5 @@ fun AdaptiveStrengthScreen(viewModel: AppleViewModel, navigateToPurchase: () ->
) )
Spacer(modifier = Modifier.height(bottomPadding)) Spacer(modifier = Modifier.height(bottomPadding))
} }
}
} }
@@ -16,25 +16,17 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.screens.apple package me.kavishdevar.librepods.presentation.screens.apple
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -44,10 +36,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
import me.kavishdevar.librepods.bluetooth.att.ATTHandle import me.kavishdevar.librepods.bluetooth.att.ATTHandle
@@ -59,22 +49,22 @@ import me.kavishdevar.librepods.presentation.components.BatteryView
import me.kavishdevar.librepods.presentation.components.CallControlSettings import me.kavishdevar.librepods.presentation.components.CallControlSettings
import me.kavishdevar.librepods.presentation.components.ConnectionSettings import me.kavishdevar.librepods.presentation.components.ConnectionSettings
import me.kavishdevar.librepods.presentation.components.HearingHealthSettings import me.kavishdevar.librepods.presentation.components.HearingHealthSettings
import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
import me.kavishdevar.librepods.presentation.components.NoiseControlSettings import me.kavishdevar.librepods.presentation.components.NoiseControlSettings
import me.kavishdevar.librepods.presentation.components.PressAndHoldSettings import me.kavishdevar.librepods.presentation.components.PressAndHoldSettings
import me.kavishdevar.librepods.presentation.components.StyledButton import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledListItem import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledToggle import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable @Composable
fun AirPodsSettingsRoute( fun AppleSettingsRoute(
viewModel: AppleViewModel, viewModel: AppleViewModel,
navigateBack: (() -> Unit)?,
navigateToRename: () -> Unit, navigateToRename: () -> Unit,
navigateToHearingProtection: () -> Unit, navigateToHearingProtection: () -> Unit,
navigateToHearingAid: () -> Unit, navigateToHearingAid: () -> Unit,
@@ -89,27 +79,14 @@ fun AirPodsSettingsRoute(
navigateToCallControlScreen: (action: String) -> Unit, navigateToCallControlScreen: (action: String) -> Unit,
navigateToMicrophoneSettings: () -> Unit, navigateToMicrophoneSettings: () -> Unit,
navigateToRecordingScreen: () -> Unit, navigateToRecordingScreen: () -> Unit,
navigateToDebugScreen: () -> Unit navigateToDebugScreen: () -> Unit,
navigateToHeartRateScreen: () -> Unit
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material AppleSettingsScreen(
val topPadding = WindowInsets.statusBars.asPaddingValues()
.calculateTopPadding() + if (m3eEnabled) 0.dp else 84.dp
val bottomPadding =
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
AirPodsSettingsScreen(
uiState = uiState, uiState = uiState,
topPadding = topPadding,
bottomPadding = bottomPadding,
setControlCommandInt = { id, value -> viewModel.setControlCommand(id, value) }, setControlCommandInt = { id, value -> viewModel.setControlCommand(id, value) },
setControlCommandBoolean = { id, value -> viewModel.setControlCommand(id, value) }, setControlCommandBoolean = { id, value -> viewModel.setControlCommand(id, value) },
// setControlCommandByte = { id, value -> viewModel.setControlCommand(id, value) }, // setControlCommandByte = { id, value -> viewModel.setControlCommand(id, value) },
@@ -121,6 +98,7 @@ fun AirPodsSettingsRoute(
// onAutomaticConnectionChanged = viewModel::setAutomaticConnectionEnabled, // onAutomaticConnectionChanged = viewModel::setAutomaticConnectionEnabled,
disconnect = viewModel::disconnect, disconnect = viewModel::disconnect,
navigateBack = navigateBack,
navigateToRename = navigateToRename, navigateToRename = navigateToRename,
navigateToHearingProtection = navigateToHearingProtection, navigateToHearingProtection = navigateToHearingProtection,
navigateToHearingAid = navigateToHearingAid, navigateToHearingAid = navigateToHearingAid,
@@ -135,20 +113,16 @@ fun AirPodsSettingsRoute(
navigateToCallControlScreen = navigateToCallControlScreen, navigateToCallControlScreen = navigateToCallControlScreen,
navigateToMicrophoneSettings = navigateToMicrophoneSettings, navigateToMicrophoneSettings = navigateToMicrophoneSettings,
navigateToRecordingScreen = navigateToRecordingScreen, navigateToRecordingScreen = navigateToRecordingScreen,
navigateToHeartRateScreen = navigateToHeartRateScreen,
navigateToDebugScreen = navigateToDebugScreen navigateToDebugScreen = navigateToDebugScreen
) )
}
} }
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag") @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
@Composable @Composable
fun AirPodsSettingsScreen( fun AppleSettingsScreen(
uiState: AppleUiState, uiState: AppleUiState,
topPadding: Dp = 16.dp,
bottomPadding: Dp = 16.dp,
setControlCommandInt: (ControlCommandIdentifier, Int) -> Unit, setControlCommandInt: (ControlCommandIdentifier, Int) -> Unit,
setControlCommandBoolean: (ControlCommandIdentifier, Boolean) -> Unit, setControlCommandBoolean: (ControlCommandIdentifier, Boolean) -> Unit,
// setControlCommandByte: (ControlCommandIdentifier, Byte) -> Unit, // setControlCommandByte: (ControlCommandIdentifier, Byte) -> Unit,
@@ -161,6 +135,7 @@ fun AirPodsSettingsScreen(
disconnect: () -> Unit, disconnect: () -> Unit,
navigateBack: (() -> Unit)?,
navigateToRename: () -> Unit, navigateToRename: () -> Unit,
navigateToHearingProtection: () -> Unit, navigateToHearingProtection: () -> Unit,
navigateToHearingAid: () -> Unit, navigateToHearingAid: () -> Unit,
@@ -175,6 +150,7 @@ fun AirPodsSettingsScreen(
navigateToCallControlScreen: (action: String) -> Unit, navigateToCallControlScreen: (action: String) -> Unit,
navigateToMicrophoneSettings: () -> Unit, navigateToMicrophoneSettings: () -> Unit,
navigateToRecordingScreen: () -> Unit, navigateToRecordingScreen: () -> Unit,
navigateToHeartRateScreen: () -> Unit,
navigateToDebugScreen: () -> Unit navigateToDebugScreen: () -> Unit
) { ) {
val state = uiState.state val state = uiState.state
@@ -185,10 +161,12 @@ fun AirPodsSettingsScreen(
val baseCapabilities = spec.baseCapabilities val baseCapabilities = spec.baseCapabilities
StyledScaffold(
title = uiState.metadata.name,
navigateBack = navigateBack
) { topPadding, bottomPadding ->
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier.padding(horizontal = 16.dp),
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp),
) { ) {
item(key = "top_padding") { Spacer(modifier = Modifier.height(topPadding)) } item(key = "top_padding") { Spacer(modifier = Modifier.height(topPadding)) }
@@ -262,6 +240,19 @@ fun AirPodsSettingsScreen(
} }
} }
if (baseCapabilities.contains(BaseCapability.HRM)) {
item(key = "spacer_heart_rate") {
Spacer(modifier = Modifier.height(16.dp))
}
item(key = "heart_rate") {
StyledListItem(
contentText = stringResource(R.string.heart_rate),
onClick = navigateToHeartRateScreen,
supportingText = state.currentHeartRate?.let { "${it.bpm} bpm" }
)
}
}
if (baseCapabilities.contains(BaseCapability.STEM_CONFIG)) { if (baseCapabilities.contains(BaseCapability.STEM_CONFIG)) {
item(key = "spacer_press_hold") { item(key = "spacer_press_hold") {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@@ -494,20 +485,24 @@ fun AirPodsSettingsScreen(
} }
item(key = "spacer_debug") { Spacer(modifier = Modifier.height(16.dp)) } item(key = "spacer_debug") { Spacer(modifier = Modifier.height(16.dp)) }
if (uiState.appSettings.debugMode) {
item(key = "debug") { item(key = "debug") {
StyledListItem( StyledListItem(
contentText = "debug", contentText = "debug",
onClick = navigateToDebugScreen onClick = navigateToDebugScreen
) )
} }
}
item(key = "bottom_padding") { Spacer(modifier = Modifier.height(bottomPadding)) } item(key = "bottom_padding") { Spacer(modifier = Modifier.height(bottomPadding)) }
} }
}
} }
@Preview(name = "Apple") @Preview(name = "Apple")
@Composable @Composable
fun AirPodsSettingsScreenPreviewApple() { fun AppleSettingsScreenPreviewApple() {
LibrePodsTheme( LibrePodsTheme(
designSystem = DesignSystem.Apple designSystem = DesignSystem.Apple
) { ) {
@@ -515,7 +510,7 @@ fun AirPodsSettingsScreenPreviewApple() {
modifier = Modifier modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceContainer) .background(MaterialTheme.colorScheme.surfaceContainer)
) { ) {
AirPodsSettingsScreen( AppleSettingsScreen(
uiState = AppleUiState(), uiState = AppleUiState(),
setControlCommandInt = { _, _ -> }, setControlCommandInt = { _, _ -> },
@@ -524,6 +519,7 @@ fun AirPodsSettingsScreenPreviewApple() {
disconnect = {}, disconnect = {},
navigateBack = null,
navigateToRename = {}, navigateToRename = {},
navigateToHearingProtection = {}, navigateToHearingProtection = {},
navigateToHearingAid = {}, navigateToHearingAid = {},
@@ -538,6 +534,7 @@ fun AirPodsSettingsScreenPreviewApple() {
navigateToCallControlScreen = {}, navigateToCallControlScreen = {},
navigateToMicrophoneSettings = {}, navigateToMicrophoneSettings = {},
navigateToRecordingScreen = {}, navigateToRecordingScreen = {},
navigateToHeartRateScreen = {},
navigateToDebugScreen = {} navigateToDebugScreen = {}
) )
} }
@@ -547,7 +544,7 @@ fun AirPodsSettingsScreenPreviewApple() {
@Preview(name = "Material") @Preview(name = "Material")
@Composable @Composable
fun AirPodsSettingsScreenPreviewMaterial() { fun AppleSettingsScreenPreviewMaterial() {
LibrePodsTheme( LibrePodsTheme(
designSystem = DesignSystem.Material designSystem = DesignSystem.Material
) { ) {
@@ -555,7 +552,7 @@ fun AirPodsSettingsScreenPreviewMaterial() {
modifier = Modifier modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceContainer) .background(MaterialTheme.colorScheme.surfaceContainer)
) { ) {
AirPodsSettingsScreen( AppleSettingsScreen(
uiState = AppleUiState(), uiState = AppleUiState(),
setControlCommandInt = { _, _ -> }, setControlCommandInt = { _, _ -> },
@@ -564,6 +561,7 @@ fun AirPodsSettingsScreenPreviewMaterial() {
disconnect = {}, disconnect = {},
navigateBack = null,
navigateToRename = {}, navigateToRename = {},
navigateToHearingProtection = {}, navigateToHearingProtection = {},
navigateToHearingAid = {}, navigateToHearingAid = {},
@@ -578,6 +576,7 @@ fun AirPodsSettingsScreenPreviewMaterial() {
navigateToCallControlScreen = {}, navigateToCallControlScreen = {},
navigateToMicrophoneSettings = {}, navigateToMicrophoneSettings = {},
navigateToRecordingScreen = {}, navigateToRecordingScreen = {},
navigateToHeartRateScreen = {},
navigateToDebugScreen = {} navigateToDebugScreen = {}
) )
} }
@@ -1,19 +1,12 @@
package me.kavishdevar.librepods.presentation.screens.apple package me.kavishdevar.librepods.presentation.screens.apple
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
@@ -28,27 +21,23 @@ import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
import me.kavishdevar.librepods.presentation.components.StyledList import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun CallControlScreen(viewModel: AppleViewModel, action: String, onCallControlValueChanged: (Boolean) -> Unit) { fun CallControlScreen(
viewModel: AppleViewModel,
action: String,
navigateBack: (() -> Unit)?,
onCallControlValueChanged: (Boolean) -> Unit
) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val state = uiState.state val state = uiState.state
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val bytes = val bytes = state.controlStates[ControlCommandIdentifier.CALL_MANAGEMENT_CONFIG]?.take(2)?.toByteArray() ?: byteArrayOf(0x00, 0x00)
state.controlStates[ControlCommandIdentifier.CALL_MANAGEMENT_CONFIG]?.take(
2
)?.toByteArray() ?: byteArrayOf(0x00, 0x00)
val flipped = try { val flipped = try {
bytes[1] == 0x02.toByte() bytes[1] == 0x02.toByte()
} catch (e: Exception) { } catch (e: Exception) {
@@ -64,10 +53,13 @@ fun CallControlScreen(viewModel: AppleViewModel, action: String, onCallControlVa
val pressOnceIsAction by remember { derivedStateOf { singlePressAction == pressOnceText } } val pressOnceIsAction by remember { derivedStateOf { singlePressAction == pressOnceText } }
val flippedValue = action != muteUnmuteText val flippedValue = action != muteUnmuteText
StyledScaffold(
title = action,
navigateBack = navigateBack
) { topPadding, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.verticalScroll(scrollState) .verticalScroll(scrollState)
.padding(top = 8.dp) .padding(top = 8.dp)
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
@@ -96,4 +88,5 @@ fun CallControlScreen(viewModel: AppleViewModel, action: String, onCallControlVa
Spacer(modifier = Modifier.height(bottomPadding)) Spacer(modifier = Modifier.height(bottomPadding))
} }
}
} }
@@ -1,18 +1,12 @@
package me.kavishdevar.librepods.presentation.screens.apple package me.kavishdevar.librepods.presentation.screens.apple
import android.util.Log import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.delete import androidx.compose.foundation.text.input.delete
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -26,61 +20,59 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.bluetooth.aacp.packet.AACPPacketType import me.kavishdevar.librepods.bluetooth.aacp.packet.AACPPacketType
import me.kavishdevar.librepods.bluetooth.aacp.packet.BatteryInfoPacket import me.kavishdevar.librepods.bluetooth.aacp.packet.BatteryInfoPacket
import me.kavishdevar.librepods.bluetooth.aacp.packet.ControlCommandPacket import me.kavishdevar.librepods.bluetooth.aacp.packet.ControlCommandPacket
import me.kavishdevar.librepods.bluetooth.aacp.packet.EarDetectionResponsePacket import me.kavishdevar.librepods.bluetooth.aacp.packet.EarDetectionResponsePacket
import me.kavishdevar.librepods.bluetooth.aacp.packet.MagicKeyResponsePacket 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.RenamePacket
import me.kavishdevar.librepods.bluetooth.aacp.rtbuddy.proto.SensorServiceType
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
import me.kavishdevar.librepods.bluetooth.aacp.types.RTBuddyDescriptor
import me.kavishdevar.librepods.bluetooth.aacp.types.SensorDataWxBuddyPayload
import me.kavishdevar.librepods.devices.DeviceComponent import me.kavishdevar.librepods.devices.DeviceComponent
import me.kavishdevar.librepods.devices.PacketDestination import me.kavishdevar.librepods.devices.PacketDestination
import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
import me.kavishdevar.librepods.presentation.components.StyledButton import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledInputField import me.kavishdevar.librepods.presentation.components.StyledInputField
import me.kavishdevar.librepods.presentation.components.StyledList import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.icons.richText import me.kavishdevar.librepods.presentation.icons.richText
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
import me.kavishdevar.librepods.utils.nonScaledSp import me.kavishdevar.librepods.utils.nonScaledSp
@Composable @Composable
fun DebugRoute(viewModel: AppleViewModel) { fun DebugRoute(
viewModel: AppleViewModel,
navigateBack: (() -> Unit)?
) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Box (
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
DebugScreen( DebugScreen(
uiState = uiState, uiState = uiState,
topPadding = topPadding, navigateBack = navigateBack,
bottomPadding = bottomPadding, sendPacket = viewModel::sendRawPacket,
sendPacket = viewModel::sendRawPacket
) )
}
} }
@Composable @Composable
fun DebugScreen( fun DebugScreen(
uiState: AppleUiState, uiState: AppleUiState,
topPadding: Dp = 16.dp, navigateBack: (() -> Unit)?,
bottomPadding: Dp = 16.dp, sendPacket: (ByteArray) -> Boolean,
sendPacket: (ByteArray) -> Boolean = { false }
) { ) {
val state = uiState.state val state = uiState.state
Log.d("DebugScreen", "Screen ${state.aacpPackets.size}") // Log.d("DebugScreen", "Screen ${state.aacpPackets.size}")
StyledScaffold(
title = "debug",
navigateBack = navigateBack
) { topPadding, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
@@ -203,6 +195,62 @@ fun DebugScreen(
"Magic Keys (${packet.magicKeys.size})" "Magic Keys (${packet.magicKeys.size})"
} }
is RTBuddyPacket -> {
val rtBuddyPayload = packet.rtBuddyPayload
when (rtBuddyPayload.descriptor) {
RTBuddyDescriptor.SENSOR_DATA_WX -> {
val sensorDataWxBuddyPayload =
rtBuddyPayload as SensorDataWxBuddyPayload
val data = sensorDataWxBuddyPayload.data
if (data.hasCommand()) {
when (data.command.service) {
SensorServiceType.ACTIVITY, SensorServiceType.DEVMOTION6 -> {
val payload = data.command.payload.toByteArray()
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
}
fun vec3(offset: Int): String =
"(${i16(offset)}, ${i16(offset + 2)}, ${i16(offset + 4)})"
"""
DEVMOTION
v0: ${vec3(20)}
v1: ${vec3(26)}
v2: ${vec3(32)}
v3: ${vec3(38)}
v4: ${vec3(44)}
""".trimIndent()
}
SensorServiceType.HEARTRATEv2 -> {
val payload = data.command.payload.toByteArray()
if (payload.size >= 11) {
val heartRate = payload[1].toInt() and 0xFF
"Possible HR: $heartRate, payload=${payload.toHexString()}"
} else "???"
}
else -> {
"Unhandled sensor command service: ${data.command.service}"
}
}
} else "?????"
}
else -> {
"Unhandled descriptor: ${rtBuddyPayload.descriptor}"
}
}
}
else -> packet.opcode.toString() else -> packet.opcode.toString()
} }
} else { } else {
@@ -269,4 +317,5 @@ fun DebugScreen(
Spacer(modifier = Modifier.padding(top = bottomPadding)) Spacer(modifier = Modifier.padding(top = bottomPadding))
} }
}
} }
@@ -30,20 +30,15 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.visible import androidx.compose.foundation.layout.visible
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -72,7 +67,6 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp import androidx.compose.ui.util.lerp
@@ -86,9 +80,9 @@ import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledButton import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledList import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
import kotlin.math.abs import kotlin.math.abs
@@ -96,32 +90,23 @@ import kotlin.math.roundToInt
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@Composable @Composable
fun EqualizerRoute(viewModel: AppleViewModel) { fun EqualizerRoute(
viewModel: AppleViewModel,
navigateBack: (() -> Unit)?
) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Box (
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
EqualizerScreen( EqualizerScreen(
uiState = uiState, uiState = uiState,
topPadding = topPadding, navigateBack = navigateBack,
bottomPadding = bottomPadding,
setCustomEqEnabled = viewModel::setCustomEqEnabled, setCustomEqEnabled = viewModel::setCustomEqEnabled,
setCustomEq = viewModel::setCustomEq setCustomEq = viewModel::setCustomEq
) )
}
} }
@Composable @Composable
fun EqualizerScreen( fun EqualizerScreen(
uiState: AppleUiState, uiState: AppleUiState,
topPadding: Dp = 16.dp, navigateBack: (() -> Unit)?,
bottomPadding: Dp = 16.dp,
setCustomEqEnabled: (Boolean) -> Unit, setCustomEqEnabled: (Boolean) -> Unit,
setCustomEq: (Int, Int, Int) -> Unit setCustomEq: (Int, Int, Int) -> Unit
) { ) {
@@ -131,6 +116,10 @@ fun EqualizerScreen(
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
StyledScaffold(
title = stringResource(R.string.equalizer),
navigateBack = navigateBack
) { topPadding, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
@@ -228,6 +217,7 @@ fun EqualizerScreen(
Spacer(modifier = Modifier.height(bottomPadding)) Spacer(modifier = Modifier.height(bottomPadding))
} }
}
} }
@@ -698,16 +688,13 @@ fun EqualizerScreenPreviewApple() {
) { ) {
val state = remember { mutableStateOf(AppleUiState()) } val state = remember { mutableStateOf(AppleUiState()) }
Box (
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)
) {
EqualizerScreen( EqualizerScreen(
uiState = state.value, uiState = state.value,
navigateBack = null,
setCustomEqEnabled = { state.value = state.value.copy(state = state.value.state.copy(customEq = state.value.state.customEq.copy(state = if (it) 2 else 1))) }, setCustomEqEnabled = { state.value = state.value.copy(state = state.value.state.copy(customEq = state.value.state.customEq.copy(state = if (it) 2 else 1))) },
setCustomEq = {low, mid, high -> state.value = state.value.copy(state = state.value.state.copy(customEq = state.value.state.customEq.copy(low = low, mid = mid, high = high)))} setCustomEq = {low, mid, high -> state.value = state.value.copy(state = state.value.state.copy(customEq = state.value.state.customEq.copy(low = low, mid = mid, high = high)))}
) )
} }
}
} }
@Preview(name = "Material") @Preview(name = "Material")
@@ -717,16 +704,12 @@ fun EqualizerScreenPreviewMaterial() {
designSystem = DesignSystem.Material designSystem = DesignSystem.Material
) { ) {
val state = remember { mutableStateOf(AppleUiState()) } val state = remember { mutableStateOf(AppleUiState()) }
Box (
modifier = Modifier
.wrapContentHeight()
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
EqualizerScreen( EqualizerScreen(
uiState = state.value, uiState = state.value,
navigateBack = null,
setCustomEqEnabled = { state.value = state.value.copy(state = state.value.state.copy(customEq = state.value.state.customEq.copy(state = if (it) 2 else 1))) }, setCustomEqEnabled = { state.value = state.value.copy(state = state.value.state.copy(customEq = state.value.state.customEq.copy(state = if (it) 2 else 1))) },
setCustomEq = {low, mid, high -> state.value = state.value.copy(state = state.value.state.copy(customEq = state.value.state.customEq.copy(low = low, mid = mid, high = high)))} setCustomEq = {low, mid, high -> state.value = state.value.copy(state = state.value.state.copy(customEq = state.value.state.customEq.copy(low = low, mid = mid, high = high)))}
) )
} }
}
} }
@@ -32,26 +32,28 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconToggleButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -64,6 +66,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
@@ -78,14 +81,17 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.aacp.types.AppleEvent
import me.kavishdevar.librepods.presentation.components.MaterialButtonStyle import me.kavishdevar.librepods.presentation.components.MaterialButtonStyle
import me.kavishdevar.librepods.presentation.components.StyledButton import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.icons.LocalIcons
import me.kavishdevar.librepods.presentation.icons.MaterialIcons
import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
@@ -98,7 +104,11 @@ import kotlin.time.Duration.Companion.seconds
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
@Composable @Composable
fun HeadTrackingScreen(viewModel: AppleViewModel, navigateToPurchase: () -> Unit) { fun HeadTrackingScreen(
viewModel: AppleViewModel,
navigateBack: (() -> Unit)?,
navigateToPurchase: () -> Unit
) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val settings = uiState.settings val settings = uiState.settings
@@ -112,10 +122,6 @@ fun HeadTrackingScreen(viewModel: AppleViewModel, navigateToPurchase: () -> Unit
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
var gestureText by remember { mutableStateOf("") } var gestureText by remember { mutableStateOf("") }
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@@ -124,10 +130,46 @@ fun HeadTrackingScreen(viewModel: AppleViewModel, navigateToPurchase: () -> Unit
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
StyledScaffold(
title = stringResource(R.string.head_gestures),
navigateBack = navigateBack,
actionButtons = listOf(
{ scaffoldBackdrop ->
if (LocalDesignSystem.current == DesignSystem.Material) {
FilledTonalIconToggleButton(
checked = uiState.state.headTrackingActive,
onCheckedChange = { if (it) viewModel.startHeadTracking() else viewModel.stopHeadTracking() },
modifier = Modifier
.minimumInteractiveComponentSize()
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)),
shape = IconButtonDefaults.mediumRoundShape
) {
Icon(
imageVector = if (uiState.state.headTrackingActive) 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
) {
Icon(
imageVector = if (uiState.state.headTrackingActive) LocalIcons.current.Pause else LocalIcons.current.Play,
contentDescription = "Play/Pause",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onBackground
)
}
}
}
)
) { topPadding, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer) .padding(horizontal = 16.dp)
.verticalScroll(scrollState), .verticalScroll(scrollState),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
@@ -138,7 +180,6 @@ fun HeadTrackingScreen(viewModel: AppleViewModel, navigateToPurchase: () -> Unit
.fillMaxWidth() .fillMaxWidth()
.layerBackdrop(backdrop) .layerBackdrop(backdrop)
.padding(top = 8.dp) .padding(top = 8.dp)
.padding(horizontal = 16.dp)
) { ) {
if (!uiState.isPremium) { if (!uiState.isPremium) {
@@ -167,14 +208,13 @@ fun HeadTrackingScreen(viewModel: AppleViewModel, navigateToPurchase: () -> Unit
header = true header = true
) )
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
"Velocity", "Velocity",
style = MaterialTheme.typography.labelSmallEmphasized, style = MaterialTheme.typography.labelSmallEmphasized,
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp) modifier = Modifier.padding(vertical = 8.dp)
) )
Plot() Plot()
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@@ -194,18 +234,12 @@ fun HeadTrackingScreen(viewModel: AppleViewModel, navigateToPurchase: () -> Unit
onClick = { onClick = {
gestureText = gestureTextValue gestureText = gestureTextValue
coroutineScope.launch { coroutineScope.launch {
viewModel.testHeadGestures() viewModel.detectHeadGestures { gestureText = if (it) "\"Yes\" gesture detected." else "\"No\" gesture detected." }
val accepted = viewModel.events
.filterIsInstance<AppleEvent.HeadGesturesResult>()
.first()
.yes
gestureText = if (accepted) "\"Yes\" gesture detected." else "\"No\" gesture detected."
} }
}, },
backdrop = backdrop, backdrop = backdrop,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth(),
.padding(horizontal = 16.dp),
maxScale = 0.05f, maxScale = 0.05f,
materialButtonStyle = MaterialButtonStyle.Outlined materialButtonStyle = MaterialButtonStyle.Outlined
) { ) {
@@ -254,8 +288,43 @@ fun HeadTrackingScreen(viewModel: AppleViewModel, navigateToPurchase: () -> Unit
} }
} }
} }
if (uiState.appSettings.debugMode) {
Spacer(modifier = Modifier.height(16.dp))
StyledToggle(
label = "[debug] alternate horizontal byte offset",
checked = settings.headGesturesHorizontalOffset == 26,
onCheckedChange = { viewModel.setHeadGesturesHorizontalOffset(if (it) 26 else 28) }
)
Spacer(modifier = Modifier.height(16.dp))
val sliderValue = remember {
mutableFloatStateOf(settings.headTrackingInterval.inWholeMilliseconds.toFloat())
}
LaunchedEffect(sliderValue) {
snapshotFlow { sliderValue.floatValue }
.debounce(100.milliseconds)
.collect { value ->
viewModel.setHeadTrackingInterval(value.toInt().milliseconds)
}
}
StyledSlider(
label = "[debug] head tracking interval",
value = sliderValue.floatValue,
onValueChange = { sliderValue.floatValue = it },
valueRange = 10f..200f,
snapPoints = listOf(40f),
independent = true,
description = "how often airpods report sensor information",
enabled = uiState.isPremium
)
}
Spacer(modifier = Modifier.height(bottomPadding)) Spacer(modifier = Modifier.height(bottomPadding))
} }
}
} }
@Composable @Composable
@@ -23,13 +23,9 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -52,17 +48,19 @@ import me.kavishdevar.librepods.bluetooth.att.ATTHandle
import me.kavishdevar.librepods.bluetooth.att.types.HearingAidSettings import me.kavishdevar.librepods.bluetooth.att.types.HearingAidSettings
import me.kavishdevar.librepods.bluetooth.att.types.parseHearingAidSettingsResponse import me.kavishdevar.librepods.bluetooth.att.types.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.bluetooth.att.types.sendHearingAidSettings import me.kavishdevar.librepods.bluetooth.att.types.sendHearingAidSettings
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSlider import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.icons.LocalIcons import me.kavishdevar.librepods.presentation.icons.LocalIcons
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.presentation.viewmodel.AppleViewModel
private const val TAG = "HearingAidAdjustments" private const val TAG = "HearingAidAdjustments"
@Composable @Composable
fun HearingAidAdjustmentsScreen(viewModel: AppleViewModel) { fun HearingAidAdjustmentsScreen(
viewModel: AppleViewModel,
navigateBack: (() -> Unit)?
) {
val verticalScrollState = rememberScrollState() val verticalScrollState = rememberScrollState()
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
@@ -149,10 +147,10 @@ fun HearingAidAdjustmentsScreen(viewModel: AppleViewModel) {
sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, viewModel::writeATTCharacteristic) sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, viewModel::writeATTCharacteristic)
} }
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material StyledScaffold(
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp title = stringResource(R.string.adjustments),
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp navigateBack = navigateBack
) { topPadding, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -228,4 +226,5 @@ fun HearingAidAdjustmentsScreen(viewModel: AppleViewModel) {
Spacer(modifier = Modifier.height(bottomPadding)) Spacer(modifier = Modifier.height(bottomPadding))
} }
}
} }
@@ -24,16 +24,11 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -48,7 +43,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -59,17 +53,20 @@ import me.kavishdevar.librepods.bluetooth.att.types.sendTransparencySettings
import me.kavishdevar.librepods.presentation.components.ConfirmationDialog import me.kavishdevar.librepods.presentation.components.ConfirmationDialog
import me.kavishdevar.librepods.presentation.components.StyledList import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledToggle import me.kavishdevar.librepods.presentation.components.StyledToggle
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.presentation.viewmodel.AppleViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
private const val TAG = "AccessibilitySettings" private const val TAG = "HearingAidScreen"
@SuppressLint("DefaultLocale") @SuppressLint("DefaultLocale")
@Composable @Composable
fun HearingAidScreen(viewModel: AppleViewModel, onNavigateHearingAidAdjustments: () -> Unit, onNavigateHearingTest: () -> Unit) { fun HearingAidScreen(
viewModel: AppleViewModel,
navigateBack: (() -> Unit)?,
navigateToHearingAidAdjustments: () -> Unit,
navigateToHearingTest: () -> Unit
) {
val verticalScrollState = rememberScrollState() val verticalScrollState = rememberScrollState()
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
@@ -86,10 +83,10 @@ fun HearingAidScreen(viewModel: AppleViewModel, onNavigateHearingAidAdjustments:
mutableStateOf((aidStatus?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.getOrNull(0) == 0x01.toByte())) mutableStateOf((aidStatus?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.getOrNull(0) == 0x01.toByte()))
} }
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material StyledScaffold(
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp title = stringResource(R.string.hearing_aid),
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp navigateBack = navigateBack
) { topPadding, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.layerBackdrop(backdrop) .layerBackdrop(backdrop)
@@ -132,7 +129,7 @@ fun HearingAidScreen(viewModel: AppleViewModel, onNavigateHearingAidAdjustments:
) )
StyledListItem( StyledListItem(
contentText = stringResource(R.string.adjustments), contentText = stringResource(R.string.adjustments),
onClick = onNavigateHearingAidAdjustments, onClick = navigateToHearingAidAdjustments,
) )
} }
@@ -146,7 +143,7 @@ fun HearingAidScreen(viewModel: AppleViewModel, onNavigateHearingAidAdjustments:
StyledListItem( StyledListItem(
contentText = stringResource(R.string.update_hearing_test), contentText = stringResource(R.string.update_hearing_test),
onClick = onNavigateHearingTest, onClick = navigateToHearingTest,
) )
// not implemented yet // not implemented yet
@@ -188,6 +185,7 @@ fun HearingAidScreen(viewModel: AppleViewModel, onNavigateHearingAidAdjustments:
// } // }
Spacer(modifier = Modifier.height(bottomPadding)) Spacer(modifier = Modifier.height(bottomPadding))
} }
}
ConfirmationDialog( ConfirmationDialog(
showDialog = showDialog, showDialog = showDialog,
@@ -213,13 +211,13 @@ fun HearingAidScreen(viewModel: AppleViewModel, onNavigateHearingAidAdjustments:
} }
val parsed = parseTransparencySettingsResponse(state.hearingAidData) val parsed = parseTransparencySettingsResponse(state.hearingAidData)
if (parsed == null) { if (parsed == null) {
Log.w(TAG, "transparency parse failed") Log.w(TAG, "hearingaid parse failed")
return@launch return@launch
} }
val disabledSettings = parsed.copy(enabled = false) val disabledSettings = parsed.copy(enabled = false)
sendTransparencySettings(viewModel::writeATTCharacteristic, disabledSettings) sendTransparencySettings(viewModel::writeATTCharacteristic, disabledSettings)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error disabling transparency: ${e.message}") Log.e(TAG, "Error disabling hearingaid: ${e.message}")
} }
} }
}, },
@@ -21,14 +21,10 @@ package me.kavishdevar.librepods.presentation.screens.apple
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -40,25 +36,28 @@ import androidx.compose.ui.unit.dp
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.att.ATTHandle
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
import me.kavishdevar.librepods.bluetooth.att.ATTHandle
import me.kavishdevar.librepods.presentation.components.StyledButton import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledToggle import me.kavishdevar.librepods.presentation.components.StyledToggle
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.presentation.viewmodel.AppleViewModel
@Composable @Composable
fun HearingProtectionScreen(viewModel: AppleViewModel, navigateToPurchase: () -> Unit) { fun HearingProtectionScreen(
viewModel: AppleViewModel,
navigateBack: (() -> Unit)?,
navigateToPurchase: () -> Unit
) {
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val state = uiState.state val state = uiState.state
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material StyledScaffold(
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp title = stringResource(R.string.hearing_protection),
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp navigateBack = navigateBack
) { topPadding, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -117,4 +116,5 @@ fun HearingProtectionScreen(viewModel: AppleViewModel, navigateToPurchase: () ->
) )
Spacer(modifier = Modifier.height(bottomPadding)) Spacer(modifier = Modifier.height(bottomPadding))
} }
}
} }
@@ -0,0 +1,253 @@
package me.kavishdevar.librepods.presentation.screens.apple
import android.text.format.DateFormat
import androidx.compose.animation.AnimatedVisibility
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.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
import androidx.compose.material3.FilledTonalIconToggleButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
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.unit.dp
import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.HeartRateRecord
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.icons.LocalIcons
import me.kavishdevar.librepods.presentation.icons.MaterialIcons
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Composable
fun HeartRateRoute(
viewModel: AppleViewModel,
navigateBack: (() -> Unit)?
) {
val uiState by viewModel.uiState.collectAsState()
HeartRateScreen(
uiState = uiState,
navigateBack = navigateBack,
startHr = viewModel::startHr,
stopHr = viewModel::stopHr,
setHrRange = viewModel::setHrRange
)
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun HeartRateScreen(
uiState: AppleUiState,
navigateBack: (() -> Unit)?,
startHr: () -> Unit,
stopHr: () -> Unit,
setHrRange: (ClosedRange<Long>) -> Unit
) {
val state = uiState.state
val scrollState = rememberScrollState()
StyledScaffold(
title = stringResource(R.string.heart_rate),
navigateBack = navigateBack,
actionButtons = listOf(
{ scaffoldBackdrop ->
if (LocalDesignSystem.current == DesignSystem.Material) {
FilledTonalIconToggleButton(
checked = state.hrmActive,
onCheckedChange = { if (it) startHr() else stopHr() },
modifier = Modifier
.minimumInteractiveComponentSize()
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)),
shape = IconButtonDefaults.mediumRoundShape
) {
Icon(
imageVector = if (state.hrmActive) 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
) {
Icon(
imageVector = if (state.hrmActive) LocalIcons.current.Pause else LocalIcons.current.Play,
contentDescription = "Start/Stop",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onBackground
)
}
}
}
)
) { topPadding, bottomPadding ->
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
val sliderValue = remember {
mutableFloatStateOf(state.heartRateInterval.inWholeMilliseconds.toFloat())
}
val healthPermissions = rememberPermissionState(
HealthPermission.getWritePermission(HeartRateRecord::class)
)
AnimatedVisibility(visible = !healthPermissions.status.isGranted) {
if (healthPermissions.status.isGranted) return@AnimatedVisibility
StyledListItem(
contentText = stringResource(R.string.permission_healthconnect),
onClick = { healthPermissions.launchPermissionRequest() },
supportingText = stringResource(R.string.permission_description_healthconnect),
leadingContent = {
Box(
modifier = Modifier
.size(48.dp)
.background(
MaterialTheme.colorScheme.surfaceContainerLow,
MaterialShapes.SoftBurst.normalized()
.toShape()
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = LocalIcons.current.VitalSigns,
contentDescription = "vital signs",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurface
)
}
},
orientation = StyledListItemOrientation.Vertical
)
}
// saw this while using one of the forks. 1 minute doesn't seem to work for me
// StyledList(
// title = stringResource(R.string.interval),
// description = stringResource(R.string.heart_rate_interval_description),
// ) {
// StyledListItem(
// onClick = { setHrInterval(1.seconds) },
// contentText = stringResource(R.string.one_second),
// selected = state.heartRateInterval == 1.seconds
// )
// StyledListItem(
// onClick = { setHrInterval(1.minutes) },
// contentText = stringResource(R.string.one_minute),
// selected = state.heartRateInterval == 1.minutes
// )
// }
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 timeString = formatter.format(
Instant.ofEpochMilli(state.currentHeartRate.timestamp.toEpochMilliseconds())
.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
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 350.dp)
.background(
MaterialTheme.colorScheme.surfaceContainerHigh,
RoundedCornerShape(28.dp)
)
) {
// TODO: graph or something
}
Spacer(modifier = Modifier.height(bottomPadding))
}
}
}
@@ -1,16 +1,11 @@
package me.kavishdevar.librepods.presentation.screens.apple package me.kavishdevar.librepods.presentation.screens.apple
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -19,55 +14,46 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
import me.kavishdevar.librepods.presentation.components.StyledList import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
@Composable @Composable
fun MicrophoneSettingsRoute( fun MicrophoneSettingsRoute(
viewModel: AppleViewModel viewModel: AppleViewModel,
navigateBack: (() -> Unit)?
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val state = uiState.state val state = uiState.state
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
val id = ControlCommandIdentifier.MIC_MODE val id = ControlCommandIdentifier.MIC_MODE
Box (
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
MicrophoneSettingsScreen( MicrophoneSettingsScreen(
navigateBack = navigateBack,
selectedMode = state.controlStates[id]?.getOrNull(0)?.toInt() ?: 0, selectedMode = state.controlStates[id]?.getOrNull(0)?.toInt() ?: 0,
topPadding = topPadding,
bottomPadding = bottomPadding,
onMicrophoneSettingsChanged = { onMicrophoneSettingsChanged = {
viewModel.setControlCommand(id, it) viewModel.setControlCommand(id, it)
} }
) )
}
} }
@Composable @Composable
fun MicrophoneSettingsScreen( fun MicrophoneSettingsScreen(
navigateBack: (() -> Unit)?,
selectedMode: Int, selectedMode: Int,
topPadding: Dp = 16.dp,
bottomPadding: Dp = 16.dp,
onMicrophoneSettingsChanged: (Int) -> Unit onMicrophoneSettingsChanged: (Int) -> Unit
) { ) {
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
StyledScaffold(
title = stringResource(R.string.microphone_mode),
navigateBack = navigateBack
) { topPadding, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -100,4 +86,5 @@ fun MicrophoneSettingsScreen(
Spacer(modifier = Modifier.height(bottomPadding)) Spacer(modifier = Modifier.height(bottomPadding))
} }
}
} }
@@ -24,18 +24,13 @@ import android.util.Log
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -47,24 +42,25 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
import me.kavishdevar.librepods.data.StemAction import me.kavishdevar.librepods.data.StemAction
import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
import me.kavishdevar.librepods.presentation.components.StyledButton import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledList import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
import kotlin.experimental.and import kotlin.experimental.and
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LongPress(viewModel: AppleViewModel, name: String, navigateToPurchase: () -> Unit) { fun LongPress(
viewModel: AppleViewModel,
name: String,
navigateBack: (() -> Unit)?,
navigateToPurchase: () -> Unit
) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val state = uiState.state val state = uiState.state
@@ -78,14 +74,14 @@ fun LongPress(viewModel: AppleViewModel, name: String, navigateToPurchase: () ->
Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x02) != 0.toByte()}") Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x02) != 0.toByte()}")
Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}") Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}")
val longPressAction = if (name.lowercase() == "left") settings.leftLongPressAction else settings.rightLongPressAction val longPressAction = if (name == stringResource(R.string.left)) settings.leftLongPressAction else settings.rightLongPressAction
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
StyledScaffold(
title = name,
navigateBack = navigateBack
) { topPadding, bottomPadding ->
Column ( Column (
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -233,4 +229,5 @@ fun LongPress(viewModel: AppleViewModel, name: String, navigateToPurchase: () ->
} }
Spacer(modifier = Modifier.height(bottomPadding)) Spacer(modifier = Modifier.height(bottomPadding))
} }
}
} }
@@ -8,14 +8,10 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@@ -45,12 +41,11 @@ import androidx.core.content.FileProvider
import me.kavishdevar.librepods.BuildConfig import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.recording.Recording import me.kavishdevar.librepods.data.recording.Recording
import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
import me.kavishdevar.librepods.presentation.components.StyledButton import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledList import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
import java.time.Instant import java.time.Instant
@@ -60,27 +55,17 @@ import java.time.format.DateTimeFormatter
@Composable @Composable
fun RecordingScreenRoute( fun RecordingScreenRoute(
viewModel: AppleViewModel, viewModel: AppleViewModel,
navigateBack: (() -> Unit)?
) { ) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Box (
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
RecordingScreen( RecordingScreen(
uiState = uiState, uiState = uiState,
navigateBack = navigateBack,
recordings = viewModel.recordings(), recordings = viewModel.recordings(),
startRecording = viewModel::startRecording, startRecording = viewModel::startRecording,
stopRecording = viewModel::stopRecording, stopRecording = viewModel::stopRecording,
topPadding = topPadding,
bottomPadding = bottomPadding
) )
}
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
@@ -92,11 +77,10 @@ fun RecordingScreenRoute(
@Composable @Composable
fun RecordingScreen( fun RecordingScreen(
uiState: AppleUiState, uiState: AppleUiState,
navigateBack: (() -> Unit)?,
recordings: List<Recording>, recordings: List<Recording>,
startRecording: () -> Unit, startRecording: () -> Unit,
stopRecording: () -> Unit, stopRecording: () -> Unit,
topPadding: Dp = 16.dp,
bottomPadding: Dp = 16.dp
) { ) {
val state = uiState.state val state = uiState.state
@@ -139,6 +123,10 @@ fun RecordingScreen(
} }
} }
StyledScaffold(
title = stringResource(R.string.recorder),
navigateBack = navigateBack
) { topPadding, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -285,4 +273,5 @@ fun RecordingScreen(
} }
Spacer(modifier = Modifier.padding(bottom = bottomPadding)) Spacer(modifier = Modifier.padding(bottom = bottomPadding))
} }
}
} }
@@ -20,20 +20,13 @@
package me.kavishdevar.librepods.presentation.screens.apple package me.kavishdevar.librepods.presentation.screens.apple
import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -41,21 +34,22 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledInputField import me.kavishdevar.librepods.presentation.components.StyledInputField
import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable @Composable
fun RenameScreen(viewModel: AppleViewModel) { fun RenameScreen(
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE) viewModel: AppleViewModel,
navigateBack: (() -> Unit)?
) {
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
@@ -64,17 +58,16 @@ fun RenameScreen(viewModel: AppleViewModel) {
keyboardController?.show() keyboardController?.show()
} }
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val metadata = uiState.metadata val metadata = uiState.metadata
StyledScaffold(
title = stringResource(R.string.name),
navigateBack = navigateBack
) { topPadding, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
) { ) {
Spacer(modifier = Modifier.height(topPadding)) Spacer(modifier = Modifier.height(topPadding))
@@ -93,4 +86,5 @@ fun RenameScreen(viewModel: AppleViewModel) {
Spacer(modifier = Modifier.height(bottomPadding)) Spacer(modifier = Modifier.height(bottomPadding))
} }
}
} }
@@ -26,20 +26,15 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults import androidx.compose.material3.SliderDefaults
@@ -61,11 +56,11 @@ import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.att.types.TransparencySettings import me.kavishdevar.librepods.bluetooth.att.types.TransparencySettings
import me.kavishdevar.librepods.bluetooth.att.types.parseTransparencySettingsResponse import me.kavishdevar.librepods.bluetooth.att.types.parseTransparencySettingsResponse
import me.kavishdevar.librepods.bluetooth.att.types.sendTransparencySettings import me.kavishdevar.librepods.bluetooth.att.types.sendTransparencySettings
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSlider import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.icons.LocalIcons import me.kavishdevar.librepods.presentation.icons.LocalIcons
@@ -73,15 +68,15 @@ import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.theme.sectionHeader import me.kavishdevar.librepods.presentation.theme.sectionHeader
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
private const val TAG = "TransparencySettings" private const val TAG = "TransparencySettings"
@SuppressLint("DefaultLocale") @SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun TransparencySettingsScreen(viewModel: AppleViewModel) { fun TransparencySettingsScreen(
viewModel: AppleViewModel,
navigateBack: (() -> Unit)?,
) {
val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val verticalScrollState = rememberScrollState() val verticalScrollState = rememberScrollState()
@@ -94,10 +89,10 @@ fun TransparencySettingsScreen(viewModel: AppleViewModel) {
val state = uiState.state val state = uiState.state
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material StyledScaffold(
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp title = stringResource(R.string.customize_transparency_mode),
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp navigateBack = navigateBack
) { topPadding, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -263,7 +258,7 @@ fun TransparencySettingsScreen(viewModel: AppleViewModel) {
Text( Text(
text = stringResource(R.string.equalizer), text = stringResource(R.string.equalizer),
style = MaterialTheme.typography.labelSmallEmphasized, style = MaterialTheme.typography.labelSmallEmphasized,
color = if (m3eEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.sectionHeader, color = if (LocalDesignSystem.current == DesignSystem.Material) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.sectionHeader,
modifier = Modifier.padding(16.dp, bottom = 4.dp) modifier = Modifier.padding(16.dp, bottom = 4.dp)
) )
@@ -358,4 +353,5 @@ fun TransparencySettingsScreen(viewModel: AppleViewModel) {
Spacer(modifier = Modifier.height(bottomPadding)) Spacer(modifier = Modifier.height(bottomPadding))
} }
}
} }
@@ -25,14 +25,9 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -56,7 +51,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
@@ -64,47 +58,42 @@ import me.kavishdevar.librepods.bluetooth.att.ATTHandle
import me.kavishdevar.librepods.bluetooth.att.types.HearingAidSettings import me.kavishdevar.librepods.bluetooth.att.types.HearingAidSettings
import me.kavishdevar.librepods.bluetooth.att.types.parseHearingAidSettingsResponse import me.kavishdevar.librepods.bluetooth.att.types.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.bluetooth.att.types.sendHearingAidSettings import me.kavishdevar.librepods.bluetooth.att.types.sendHearingAidSettings
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
private const val TAG = "UpdateHearingTestScreen" private const val TAG = "UpdateHearingTestScreen"
@Composable @Composable
fun UpdateHearingTestRoute(viewModel: AppleViewModel) { fun UpdateHearingTestRoute(
viewModel: AppleViewModel,
navigateBack: (() -> Unit)?
) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
UpdateHearingTestScreen( UpdateHearingTestScreen(
uiState = uiState, uiState = uiState,
topPadding = topPadding, navigateBack = navigateBack,
bottomPadding = bottomPadding,
setATTCharacteristicValue = viewModel::writeATTCharacteristic setATTCharacteristicValue = viewModel::writeATTCharacteristic
) )
}
} }
@Composable @Composable
fun UpdateHearingTestScreen( fun UpdateHearingTestScreen(
uiState: AppleUiState, uiState: AppleUiState,
topPadding: Dp = 16.dp, navigateBack: (() -> Unit)?,
bottomPadding: Dp = 16.dp,
setATTCharacteristicValue: (ATTHandle, ByteArray) -> Unit setATTCharacteristicValue: (ATTHandle, ByteArray) -> Unit
) { ) {
val state = uiState.state val state = uiState.state
val verticalScrollState = rememberScrollState() val verticalScrollState = rememberScrollState()
StyledScaffold(
title = stringResource(R.string.update_hearing_test),
navigateBack = navigateBack
) { topPadding, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.verticalScroll(verticalScrollState) .verticalScroll(verticalScrollState)
@@ -282,6 +271,7 @@ fun UpdateHearingTestScreen(
} }
Spacer(modifier = Modifier.height(bottomPadding)) Spacer(modifier = Modifier.height(bottomPadding))
} }
}
} }
@Preview(name = "Apple") @Preview(name = "Apple")
@@ -295,6 +285,7 @@ fun UpdateHearingTestScreenPreviewApple() {
) { ) {
UpdateHearingTestScreen( UpdateHearingTestScreen(
uiState = AppleUiState(), uiState = AppleUiState(),
navigateBack = null,
setATTCharacteristicValue = { _, _ -> } setATTCharacteristicValue = { _, _ -> }
) )
} }
@@ -314,6 +305,7 @@ fun UpdateHearingTestScreenPreviewMaterial() {
) { ) {
UpdateHearingTestScreen( UpdateHearingTestScreen(
uiState = AppleUiState(), uiState = AppleUiState(),
navigateBack = null,
setATTCharacteristicValue = { _, _ -> } setATTCharacteristicValue = { _, _ -> }
) )
} }
@@ -18,17 +18,11 @@
package me.kavishdevar.librepods.presentation.screens.apple package me.kavishdevar.librepods.presentation.screens.apple
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -38,24 +32,25 @@ import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledList import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
@Composable @Composable
fun VersionScreen(viewModel: AppleViewModel) { fun VersionScreen(
viewModel: AppleViewModel,
navigateBack: (() -> Unit)?
) {
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val metadata = uiState.metadata val metadata = uiState.metadata
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material StyledScaffold(
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp title = stringResource(R.string.version),
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp navigateBack = navigateBack
) { topPadding, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
) { ) {
Spacer(modifier = Modifier.height(topPadding)) Spacer(modifier = Modifier.height(topPadding))
@@ -80,4 +75,5 @@ fun VersionScreen(viewModel: AppleViewModel) {
} }
Spacer(modifier = Modifier.height(bottomPadding)) Spacer(modifier = Modifier.height(bottomPadding))
} }
}
} }
@@ -93,6 +93,7 @@ fun OnboardingScreen(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Spacer(modifier = Modifier.height(topPadding))
HorizontalUncontainedCarousel( HorizontalUncontainedCarousel(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -37,8 +37,11 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.HeartRateRecord
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -46,9 +49,10 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledList import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledListItem import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
import me.kavishdevar.librepods.presentation.icons.MaterialIcons import me.kavishdevar.librepods.presentation.icons.MaterialIcons
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class)
@@ -63,11 +67,8 @@ fun PermissionsPage(
val context = LocalContext.current val context = LocalContext.current
val canDrawOverlays = remember { mutableStateOf(Settings.canDrawOverlays(context)) } val canDrawOverlays = remember { mutableStateOf(Settings.canDrawOverlays(context)) }
val phonePermissionState = rememberMultiplePermissionsState( val healthPermissions = rememberPermissionState(
listOf( HealthPermission.getWritePermission(HeartRateRecord::class)
"android.permission.READ_PHONE_STATE",
"android.permission.ANSWER_PHONE_CALLS"
)
) { ) {
if (grantingAll) { if (grantingAll) {
if (!canDrawOverlays.value) { if (!canDrawOverlays.value) {
@@ -80,10 +81,23 @@ fun PermissionsPage(
} }
} }
val phonePermissionState = rememberMultiplePermissionsState(
listOf(
"android.permission.READ_PHONE_STATE",
"android.permission.ANSWER_PHONE_CALLS"
)
) {
if (grantingAll) {
if (!healthPermissions.status.isGranted) healthPermissions.launchPermissionRequest()
else if (!canDrawOverlays.value) canDrawOverlays.value = Settings.canDrawOverlays(context)
}
}
val notificationPermissionState = rememberPermissionState("android.permission.POST_NOTIFICATIONS") { val notificationPermissionState = rememberPermissionState("android.permission.POST_NOTIFICATIONS") {
if (grantingAll) { if (grantingAll) {
if (!phonePermissionState.allPermissionsGranted) phonePermissionState.launchMultiplePermissionRequest() if (!phonePermissionState.allPermissionsGranted) phonePermissionState.launchMultiplePermissionRequest()
else if (!healthPermissions.status.isGranted) healthPermissions.launchPermissionRequest()
else if (!canDrawOverlays.value) canDrawOverlays.value = Settings.canDrawOverlays(context) else if (!canDrawOverlays.value) canDrawOverlays.value = Settings.canDrawOverlays(context)
} }
} }
@@ -100,6 +114,7 @@ fun PermissionsPage(
if (grantingAll) { if (grantingAll) {
if (!notificationPermissionState.status.isGranted) notificationPermissionState.launchPermissionRequest() if (!notificationPermissionState.status.isGranted) notificationPermissionState.launchPermissionRequest()
else if (!phonePermissionState.allPermissionsGranted) phonePermissionState.launchMultiplePermissionRequest() else if (!phonePermissionState.allPermissionsGranted) phonePermissionState.launchMultiplePermissionRequest()
else if (!healthPermissions.status.isGranted) healthPermissions.launchPermissionRequest()
else if (!canDrawOverlays.value) canDrawOverlays.value = Settings.canDrawOverlays(context) else if (!canDrawOverlays.value) canDrawOverlays.value = Settings.canDrawOverlays(context)
} }
} }
@@ -135,21 +150,21 @@ fun PermissionsPage(
.verticalScroll(scrollState), .verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
StyledList(title = "Required Permissions") { StyledList(title = stringResource(R.string.required_permissions)) {
val animatedBluetoothIconColor by animateColorAsState(if (bluetoothPermissionsState.allPermissionsGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface) val animatedBluetoothIconColor by animateColorAsState(if (bluetoothPermissionsState.allPermissionsGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant)
val animatedBluetoothContainerColor by animateColorAsState( val animatedBluetoothContainerColor by animateColorAsState(
if (bluetoothPermissionsState.allPermissionsGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest if (bluetoothPermissionsState.allPermissionsGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
) )
StyledListItem( StyledListItem(
contentText = "Bluetooth", contentText = stringResource(R.string.bluetooth),
onClick = if (!bluetoothPermissionsState.allPermissionsGranted) { onClick = if (!bluetoothPermissionsState.allPermissionsGranted) {
{ {
grantingAll = false grantingAll = false
bluetoothPermissionsState.launchMultiplePermissionRequest() bluetoothPermissionsState.launchMultiplePermissionRequest()
} }
} else null, } else null,
supportingText = "Required to communicate with AirPods", supportingText = stringResource(R.string.permission_description_bluetooth),
orientation = StyledListItemOrientation.Vertical, orientation = StyledListItemOrientation.Vertical,
leadingContent = { leadingContent = {
Box( Box(
@@ -172,27 +187,27 @@ fun PermissionsPage(
}, },
) )
} }
StyledList(title = "Optional Permissions") { StyledList(title = stringResource(R.string.optional_permissions)) {
val animatedNotificationsIconColor by animateColorAsState( val animatedNotificationsIconColor by animateColorAsState(
if (notificationPermissionState.status.isGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface if (notificationPermissionState.status.isGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant
) )
val animatedNotificationsContainerColor by animateColorAsState( val animatedNotificationsContainerColor by animateColorAsState(
if (notificationPermissionState.status.isGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest if (notificationPermissionState.status.isGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
) )
val animatedPhoneIconColor by animateColorAsState(if (phonePermissionState.allPermissionsGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface) val animatedPhoneIconColor by animateColorAsState(if (phonePermissionState.allPermissionsGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant)
val animatedPhoneContainerColor by animateColorAsState( val animatedPhoneContainerColor by animateColorAsState(
if (phonePermissionState.allPermissionsGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest if (phonePermissionState.allPermissionsGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
) )
StyledListItem( StyledListItem(
contentText = "Notifications", contentText = stringResource(R.string.notifications),
onClick = if (!notificationPermissionState.status.isGranted) { onClick = if (!notificationPermissionState.status.isGranted) {
{ {
grantingAll = false grantingAll = false
notificationPermissionState.launchPermissionRequest() notificationPermissionState.launchPermissionRequest()
} }
} else null, } else null,
supportingText = "Show battery status", supportingText = stringResource(R.string.permission_description_notification),
orientation = StyledListItemOrientation.Vertical, orientation = StyledListItemOrientation.Vertical,
leadingContent = { leadingContent = {
Box( Box(
@@ -215,14 +230,14 @@ fun PermissionsPage(
}, },
) )
StyledListItem( StyledListItem(
contentText = "Phone", contentText = stringResource(R.string.phone),
onClick = if (!phonePermissionState.allPermissionsGranted) { onClick = if (!phonePermissionState.allPermissionsGranted) {
{ {
grantingAll = false grantingAll = false
phonePermissionState.launchMultiplePermissionRequest() phonePermissionState.launchMultiplePermissionRequest()
} }
} else null, } else null,
supportingText = "Respond to phone calls with head gestures", supportingText = stringResource(R.string.permission_description_phone),
orientation = StyledListItemOrientation.Vertical, orientation = StyledListItemOrientation.Vertical,
leadingContent = { leadingContent = {
Box( Box(
@@ -237,7 +252,7 @@ fun PermissionsPage(
) { ) {
Icon( Icon(
imageVector = MaterialIcons.Call, imageVector = MaterialIcons.Call,
contentDescription = "bluetooth", contentDescription = "call",
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
tint = animatedPhoneIconColor tint = animatedPhoneIconColor
) )
@@ -246,11 +261,44 @@ fun PermissionsPage(
) )
} }
val animatedOverlayIconColor by animateColorAsState(if (canDrawOverlays.value) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface) val animatedHealthConnectIconColor by animateColorAsState(if (healthPermissions.status.isGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant)
val animatedOverlayContainerColor by animateColorAsState(if (canDrawOverlays.value) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest) val animatedHealthConnectContainerColor by animateColorAsState(if (healthPermissions.status.isGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant)
StyledListItem( StyledListItem(
contentText = "Display over other apps", contentText = stringResource(R.string.permission_healthconnect),
onClick = if (!healthPermissions.status.isGranted) {
{
grantingAll = false
healthPermissions.launchPermissionRequest()
}
} else null,
supportingText = stringResource(R.string.permission_description_healthconnect),
leadingContent = {
Box(
modifier = Modifier
.size(48.dp)
.background(
animatedHealthConnectContainerColor,
MaterialShapes.SoftBurst.normalized()
.toShape()
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = MaterialIcons.VitalSigns,
contentDescription = "vital signs",
modifier = Modifier.size(24.dp),
tint = animatedHealthConnectIconColor
)
}
}
)
val animatedOverlayIconColor by animateColorAsState(if (canDrawOverlays.value) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant)
val animatedOverlayContainerColor by animateColorAsState(if (canDrawOverlays.value) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant)
StyledListItem(
contentText = stringResource(R.string.permission_overlay),
onClick = if (!canDrawOverlays.value) { onClick = if (!canDrawOverlays.value) {
{ {
grantingAll = false grantingAll = false
@@ -261,7 +309,7 @@ fun PermissionsPage(
context.startActivity(intent) context.startActivity(intent)
} }
} else null, } else null,
supportingText = "Show popups when AirPods are nearby or audio switches to them.", supportingText = stringResource(R.string.permission_description_overlay),
orientation = StyledListItemOrientation.Vertical, orientation = StyledListItemOrientation.Vertical,
leadingContent = { leadingContent = {
Box( Box(
@@ -276,7 +324,7 @@ fun PermissionsPage(
) { ) {
Icon( Icon(
imageVector = MaterialIcons.Overlay, imageVector = MaterialIcons.Overlay,
contentDescription = "bluetooth", contentDescription = "overlay",
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
tint = animatedOverlayIconColor tint = animatedOverlayIconColor
) )
@@ -48,42 +48,50 @@ fun PrivacyPolicyPage(
Text( Text(
text = "Overview", text = "Overview",
style = MaterialTheme.typography.titleLarge style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
) )
Text( Text(
text = "LibrePods does not collect, store, sell, or share personal information for advertising, analytics, tracking, or profiling purposes. The app does not include analytics, crash reporting, telemetry, advertising SDKs, or tracking services.", text = "LibrePods does not collect, store, sell, or share personal information for advertising, analytics, tracking, or profiling purposes. The app does not include analytics, crash reporting, telemetry, advertising SDKs, or tracking services.",
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
) )
Text( Text(
text = "All information remains on your device unless you explicitly choose to contact me, create a GitHub issue from the app, or make a purchase or sponsorship through a third-party platform.", text = "All information remains on your device unless you explicitly choose to contact me, create a GitHub issue from the app, or make a purchase or sponsorship through a third-party platform.",
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
) )
Text( Text(
text = "Third Party Services", text = "Third Party Services",
style = MaterialTheme.typography.titleLarge style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
) )
Text( Text(
text = "LibrePods provides several ways to contact me, including email, Discord, and GitHub Issues.", text = "LibrePods provides several ways to contact me, including email, Discord, and GitHub Issues.",
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
) )
Text( Text(
text = "Email", text = "Email",
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
) )
Text( Text(
text = "If you contact me by email, I receive your email address and any information you choose to include in your message. When using the contact form within LibrePods, your email client will open with a pre-filled email address, the subject line and body that you fill out. The body will also include LibrePods version information and device information to help with troubleshooting.", text = "If you contact me by email, I receive your email address and any information you choose to include in your message. When using the contact form within LibrePods, your email client will open with a pre-filled email address, the subject line and body that you fill out. The body will also include LibrePods version information and device information to help with troubleshooting.",
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
) )
Text( Text(
text = "You can edit or remove any of this information before sending the email.", text = "You can edit or remove any of this information before sending the email.",
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
) )
Text( Text(
@@ -93,22 +101,26 @@ fun PrivacyPolicyPage(
Text( Text(
text = "The app provides a link to the LibrePods Discord server. If you choose to join the Discord server, you will be subject to Discord's privacy policy.", text = "The app provides a link to the LibrePods Discord server. If you choose to join the Discord server, you will be subject to Discord's privacy policy.",
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
) )
Text( Text(
text = "I do not receive any information about you from Discord other than what is publicly visible in the Discord server, such as your username, joining date, common servers, and any messages or content you post in the server, unless you choose to share it with me in the Discord server.", text = "I do not receive any information about you from Discord other than what is publicly visible in the Discord server, such as your username, joining date, common servers, and any messages or content you post in the server, unless you choose to share it with me in the Discord server.",
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
) )
Text( Text(
text = "GitHub Issues", text = "GitHub Issues",
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
) )
Text( Text(
text = "When creating a GitHub issue through LibrePods, the app will pre-fill the issue form with:", text = "When creating a GitHub issue through LibrePods, the app will pre-fill the issue form with:",
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
) )
Column( Column(
@@ -117,68 +129,86 @@ fun PrivacyPolicyPage(
) { ) {
Text( Text(
"• LibrePods version name and version code", "• LibrePods version name and version code",
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
) )
Text( Text(
text = "• Device manufacturer and model", text = "• Device manufacturer and model",
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
) )
Text( Text(
text = "• Android build information", text = "• Android build information",
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
) )
Text( Text(
text = "• Installation source (Google Play or GitHub)", text = "• Installation source (Google Play or GitHub)",
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
) )
} }
Text( Text(
text = "This information helps diagnose bugs and provide support. No information is sent automatically. The information is only submitted if you choose to create the GitHub issue.", text = "This information helps diagnose bugs and provide support. No information is sent automatically. The information is only submitted if you choose to create the GitHub issue.",
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
) )
Text( Text(
text = "Payments", style = MaterialTheme.typography.titleLarge text = "Payments",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
) )
if (BuildConfig.PLAY_BUILD) { if (BuildConfig.PLAY_BUILD) {
Text( Text(
text = "Google Play", style = MaterialTheme.typography.titleMedium text = "Google Play",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
) )
Text( Text(
text = "When using the version available on Google Play, purchases are processed by Google Play.", text = "When using the version available on Google Play, purchases are processed by Google Play.",
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
) )
Text( Text(
text = "LibrePods verifies the purchase with Google Play on-device, not with a remote server that I control. I do not receive any information about you or your purchase from Google Play. Payment processing is handled entirely by Google Play, and I do not have access to any of your payment information.", text = "LibrePods verifies the purchase with Google Play on-device, not with a remote server that I control. I do not receive any information about you or your purchase from Google Play. Payment processing is handled entirely by Google Play, and I do not have access to any of your payment information.",
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
) )
} else { } else {
Text( Text(
text = "GitHub Sponsors", style = MaterialTheme.typography.titleMedium text = "GitHub Sponsors",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
) )
Text( Text(
text = "When using the FOSS version available on GitHub, the upgrade button links to GitHub Sponsors. If you choose to sponsor LibrePods, your sponsorship is processed by GitHub.", text = "When using the FOSS version available on GitHub, the upgrade button links to GitHub Sponsors. If you choose to sponsor LibrePods, your sponsorship is processed by GitHub.",
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
) )
Text( Text(
text = "Your username and country/region are shared with me when you sponsor LibrePods. Depending on your GitHub Sponsors privacy settings, I may also receive your email address.", text = "Your username and country/region are shared with me when you sponsor LibrePods. Depending on your GitHub Sponsors privacy settings, I may also receive your email address.",
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
) )
} }
Text( Text(
text = "Contact", style = MaterialTheme.typography.titleLarge text = "Contact",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
) )
Text( Text(
text = "If you have questions about this privacy policy, please contact me via email at privacy@kavish.xyz.", text = "If you have questions about this privacy policy, please contact me via email at privacy@kavish.xyz.",
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
) )
Button( Button(
@@ -187,7 +217,7 @@ fun PrivacyPolicyPage(
) { ) {
Text( Text(
text = stringResource(R.string.i_agree), text = stringResource(R.string.i_agree),
style = MaterialTheme.typography.labelMediumEmphasized style = MaterialTheme.typography.labelMediumEmphasized,
) )
} }
@@ -21,35 +21,54 @@ package me.kavishdevar.librepods.presentation.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.billing.BillingManager import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
import me.kavishdevar.librepods.bluetooth.att.ATTHandle import me.kavishdevar.librepods.bluetooth.att.ATTHandle
import me.kavishdevar.librepods.data.StemAction import me.kavishdevar.librepods.data.StemAction
import me.kavishdevar.librepods.data.heartrate.HeartRateSample
import me.kavishdevar.librepods.data.recording.Recording import me.kavishdevar.librepods.data.recording.Recording
import me.kavishdevar.librepods.data.xposed.XposedRemotePrefProvider import me.kavishdevar.librepods.data.xposed.XposedRemotePrefProvider
import me.kavishdevar.librepods.database.app.AppSettingsEntity
import me.kavishdevar.librepods.database.app.AppStateEntity
import me.kavishdevar.librepods.devices.AppleDevice import me.kavishdevar.librepods.devices.AppleDevice
import me.kavishdevar.librepods.devices.AppleMetadata import me.kavishdevar.librepods.devices.AppleMetadata
import me.kavishdevar.librepods.devices.AppleSettings import me.kavishdevar.librepods.devices.AppleSettings
import me.kavishdevar.librepods.devices.AppleState import me.kavishdevar.librepods.devices.AppleState
import me.kavishdevar.librepods.repository.AppDataRepository
import me.kavishdevar.librepods.repository.HeartRateRepository
import me.kavishdevar.librepods.repository.RecordingRepository import me.kavishdevar.librepods.repository.RecordingRepository
import kotlin.time.Duration
import kotlin.time.Instant
data class AppleUiState( data class AppleUiState(
val state: AppleState = AppleState(), val state: AppleState = AppleState(),
val settings: AppleSettings = AppleSettings(), val settings: AppleSettings = AppleSettings(),
val metadata: AppleMetadata = AppleMetadata(), val metadata: AppleMetadata = AppleMetadata(),
val appState: AppStateEntity = AppStateEntity(),
val appSettings: AppSettingsEntity = AppSettingsEntity(),
val isPremium: Boolean = false, val isPremium: Boolean = false,
val vendorIdHook: Boolean = false, val vendorIdHook: Boolean = false,
val recordings: List<Recording> = emptyList(), val recordings: List<Recording> = emptyList(),
val heartRateSamples: List<HeartRateSample> = emptyList(),
val heartRateRange: ClosedRange<Long>? = null,
) )
class AppleViewModel( class AppleViewModel(
private val device: AppleDevice, private val device: AppleDevice,
private val appDataRepository: AppDataRepository,
private val recordingRepository: RecordingRepository, private val recordingRepository: RecordingRepository,
private val heartRateRepository: HeartRateRepository
) : ViewModel(), DeviceViewModel { ) : ViewModel(), DeviceViewModel {
val billingManager = BillingManager val billingManager = BillingManager
@@ -57,12 +76,42 @@ class AppleViewModel(
private var attObserveJob: Job? = null private var attObserveJob: Job? = null
val uiState = combine( private val _heartRateRange = MutableStateFlow<ClosedRange<Long>?>(null)
private val heartRateSamples: Flow<List<HeartRateSample>> = _heartRateRange
.transformLatest { range ->
if (range == null) {
emit(emptyList())
} else {
val startInstant = Instant.fromEpochMilliseconds(range.start)
val endInstant = Instant.fromEpochMilliseconds(range.endInclusive)
val samples = heartRateRepository.get(startInstant, endInstant)
emit(samples)
}
}
private val deviceDetails = combine(
device.state, device.state,
device.settings, device.settings,
device.metadata, device.metadata
) { state, settings, metadata ->
Triple(state, settings, metadata)
}
private val appData = combine(
appDataRepository.state,
appDataRepository.settings
) { appState, appSettings ->
Pair(appState, appSettings)
}
val uiState = combine(
deviceDetails,
appData,
billingManager.provider.isPremium, billingManager.provider.isPremium,
) { state, settings, metadata, isPremium -> _heartRateRange,
heartRateSamples
) { (state, settings, metadata), (appState, appSettings), isPremium, heartRateRange, heartRateSamples ->
AppleUiState( AppleUiState(
state = state, state = state,
settings = settings, settings = settings,
@@ -71,7 +120,11 @@ class AppleViewModel(
vendorIdHook = XposedRemotePrefProvider.create().getBoolean( vendorIdHook = XposedRemotePrefProvider.create().getBoolean(
"vendor_id_hook", "vendor_id_hook",
false false
) // TODO: make this a Flow, even if it means polling every few seconds ), // TODO: make this a Flow (even if it means polling every few seconds?)
heartRateRange = heartRateRange,
heartRateSamples = heartRateSamples,
appState = appState,
appSettings = appSettings
) )
}.stateIn( }.stateIn(
viewModelScope, viewModelScope,
@@ -133,7 +186,17 @@ class AppleViewModel(
fun setCustomEqEnabled(enabled: Boolean) = device.setCustomEqEnabled(enabled) fun setCustomEqEnabled(enabled: Boolean) = device.setCustomEqEnabled(enabled)
fun setCustomEq(low: Int, mid: Int, high: Int) = device.setCustomEq(low, mid, high) fun setCustomEq(low: Int, mid: Int, high: Int) = device.setCustomEq(low, mid, high)
fun testHeadGestures() = device.testHeadGestures() fun detectHeadGestures(callback: (Boolean) -> Unit) = device.detectHeadGestures(callback)
fun setHeadGesturesVerticalOffset(offset: Int) = device.setHeadGesturesVerticalOffset(offset)
fun setHeadGesturesHorizontalOffset(offset: Int) = device.setHeadGesturesHorizontalOffset(offset)
fun setHeadTrackingInterval(interval: Duration) = device.setHeadTrackingInterval(interval)
fun startHr() = device.startHr()
fun stopHr() = device.stopHr()
fun setHrRange(range: ClosedRange<Long>) {
_heartRateRange.value = range
}
fun sendRawPacket(data: ByteArray): Boolean = device.sendRawPacket(data) fun sendRawPacket(data: ByteArray): Boolean = device.sendRawPacket(data)
} }
@@ -39,7 +39,7 @@ class AppleRepository(
} }
suspend fun saveCacheFromState(macAddress: MacAddress, state: AppleState) { suspend fun saveCacheFromState(macAddress: MacAddress, state: AppleState) {
Log.d(TAG, "Saving AppleCache from AppleState for ${macAddress.toRedactedString()}: $state") Log.d(TAG, "Saving AppleCache from AppleState for ${macAddress.toRedactedString()}")
val cache = try { val cache = try {
AppleCache( AppleCache(
capabilities = state.capabilities, capabilities = state.capabilities,
@@ -48,7 +48,7 @@ class AppleRepository(
customEq = state.customEq customEq = state.customEq
) )
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to create AppleCache from AppleState for ${macAddress.toRedactedString()}: $state", e) Log.e(TAG, "Failed to create AppleCache from AppleState for ${macAddress.toRedactedString()}", e)
return return
} }
@@ -0,0 +1,29 @@
package me.kavishdevar.librepods.repository
import me.kavishdevar.librepods.data.heartrate.HeartRateDao
import me.kavishdevar.librepods.data.heartrate.HeartRateSample
import me.kavishdevar.librepods.database.heartrate.HeartRateSampleEntity
import kotlin.time.Instant
class HeartRateRepository(
private val dao: HeartRateDao,
) {
suspend fun insert(sample: HeartRateSample) {
dao.insert(sample.toEntity())
}
suspend fun get(
start: Instant,
end: Instant,
): List<HeartRateSample> = dao.get(start, end).map { it.toSample() }
private fun HeartRateSample.toEntity() = HeartRateSampleEntity(
timestamp = timestamp,
bpm = bpm
)
private fun HeartRateSampleEntity.toSample() = HeartRateSample(
bpm = bpm,
timestamp = timestamp
)
}
@@ -18,16 +18,21 @@ import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.PackageManager
import android.media.AudioManager import android.media.AudioManager
import android.os.BatteryManager import android.os.BatteryManager
import android.os.Binder import android.os.Binder
import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.os.ParcelUuid import android.os.ParcelUuid
import android.os.ext.SdkExtensions
import android.provider.Settings import android.provider.Settings
import android.util.Log import android.util.Log
import android.view.View import android.view.View
import android.widget.RemoteViews import android.widget.RemoteViews
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.HeartRateRecord
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -42,6 +47,7 @@ import me.kavishdevar.librepods.bluetooth.MacAddress
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType
import me.kavishdevar.librepods.bluetooth.verifyRPA import me.kavishdevar.librepods.bluetooth.verifyRPA
import me.kavishdevar.librepods.data.heartrate.HeartRateSample
import me.kavishdevar.librepods.database.app.AppSettingsEntity import me.kavishdevar.librepods.database.app.AppSettingsEntity
import me.kavishdevar.librepods.devices.AppleDevice import me.kavishdevar.librepods.devices.AppleDevice
import me.kavishdevar.librepods.devices.AppleSettings import me.kavishdevar.librepods.devices.AppleSettings
@@ -58,7 +64,10 @@ import me.kavishdevar.librepods.presentation.overlays.IslandWindow
import me.kavishdevar.librepods.presentation.widgets.BatteryWidget import me.kavishdevar.librepods.presentation.widgets.BatteryWidget
import me.kavishdevar.librepods.utils.MediaController import me.kavishdevar.librepods.utils.MediaController
import me.kavishdevar.librepods.utils.redactMac import me.kavishdevar.librepods.utils.redactMac
import java.time.ZoneOffset
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaInstant
private const val TAG = "LibrePodsService" private const val TAG = "LibrePodsService"
@@ -94,6 +103,14 @@ class LibrePodsService : Service() {
(application as LibrePodsApplication).widgetConfigRepository (application as LibrePodsApplication).widgetConfigRepository
} }
private val heartRateRepository by lazy {
(application as LibrePodsApplication).heartRateRepository
}
private val healthConnectClient by lazy {
(application as LibrePodsApplication).healthConnectClient
}
private val hasConnectedToAACP by lazy { private val hasConnectedToAACP by lazy {
appDataRepository.state.value.hasConnectedToAACP appDataRepository.state.value.hasConnectedToAACP
} }
@@ -684,6 +701,24 @@ class LibrePodsService : Service() {
6, 8, 9 -> MediaController.stopSpeaking() 6, 8, 9 -> MediaController.stopSpeaking()
} }
} }
state.currentHeartRate?.timestamp != previousState.currentHeartRate?.timestamp -> {
state.currentHeartRate?.let { heartRateSample ->
Log.i(
TAG,
"current heart rate changed from device ${device.macAddress.toRedactedString()}"
)
Log.d(
TAG,
"current heart rate: ${heartRateSample.bpm} bpm, timestamp: ${heartRateSample.timestamp.toJavaInstant()}. processing."
)
processHeartRateSample(
heartRateSample = heartRateSample,
interval = state.heartRateInterval
)
}
}
} }
previousState = state previousState = state
} }
@@ -1066,6 +1101,13 @@ class LibrePodsService : Service() {
device.disableAudio(this) device.disableAudio(this)
device.disconnectAudio(this) device.disconnectAudio(this)
} }
// not the right place because the stream doesn't switch to the remaining bud when the active one is removed
if (device is AppleDevice && device.state.value.hrmActive) {
device.updateState {
it.copy(hrmActive = false)
}
}
} }
} }
@@ -1089,6 +1131,44 @@ class LibrePodsService : Service() {
} }
} }
private fun processHeartRateSample(heartRateSample: HeartRateSample, interval: Duration) {
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()
)
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 {
healthConnectClient!!.insertRecords(listOf(heartRateRecord))
}
} else {
Log.w(TAG, "Health Connect client not available")
}
} else {
Log.d(TAG, "U SDK Extension <7")
}
}
// TODO: Shizuku // TODO: Shizuku
private fun setAppleBluetoothMetadata( private fun setAppleBluetoothMetadata(
@Suppress("unused") device: AppleDevice @Suppress("unused") device: AppleDevice
@@ -16,20 +16,16 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.utils
import android.os.Build
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import me.kavishdevar.librepods.services.LibrePodsService
import java.util.Collections import java.util.Collections
import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CopyOnWriteArrayList
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -37,10 +33,10 @@ import kotlin.math.abs
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
import kotlin.math.pow import kotlin.math.pow
import kotlin.time.Duration.Companion.milliseconds
class GestureDetector( // TODO: rewrite
private val librepodsService: LibrePodsService class GestureDetector {
) {
companion object { companion object {
private const val TAG = "GestureDetector" private const val TAG = "GestureDetector"
@@ -54,8 +50,6 @@ class GestureDetector(
private const val MAX_VALID_ORIENTATION_VALUE = 6000 private const val MAX_VALID_ORIENTATION_VALUE = 6000
} }
// val audio = GestureFeedback(ServiceManager.getService()?.baseContext!!)
private val horizontalBuffer = Collections.synchronizedList(ArrayList<Double>()) private val horizontalBuffer = Collections.synchronizedList(ArrayList<Double>())
private val verticalBuffer = Collections.synchronizedList(ArrayList<Double>()) private val verticalBuffer = Collections.synchronizedList(ArrayList<Double>())
@@ -86,7 +80,7 @@ class GestureDetector(
private var isRunning = false private var isRunning = false
private var detectionJob: Job? = null private var detectionJob: Job? = null
private var gestureDetectedCallback: ((Boolean) -> Unit)? = null private var processingJob: Job? = null
private var significantMotion = false private var significantMotion = false
private var lastSignificantMotionTime = 0L private var lastSignificantMotionTime = 0L
@@ -96,48 +90,51 @@ class GestureDetector(
while (verticalAvgBuffer.size < 3) verticalAvgBuffer.add(0.0) while (verticalAvgBuffer.size < 3) verticalAvgBuffer.add(0.0)
} }
fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> Unit) { fun startDetection(acceleration: StateFlow<Acceleration>, callback: (Boolean) -> Unit) {
if (isRunning) return if (isRunning) return
Log.d(TAG, "Starting gesture detection...") Log.d(TAG, "Starting gesture detection...")
isRunning = true isRunning = true
gestureDetectedCallback = onGestureDetected
// Log.d(TAG, "started: ${airPodsService.startHeadTracking()}")
clearData() clearData()
prevHorizontal = 0.0 prevHorizontal = 0.0
prevVertical = 0.0 prevVertical = 0.0
processingJob = CoroutineScope(Dispatchers.Default).launch {
acceleration.collect {
processHeadOrientation(it.horizontal.toInt(), it.vertical.toInt())
}
}
detectionJob = CoroutineScope(Dispatchers.Default).launch { detectionJob = CoroutineScope(Dispatchers.Default).launch {
while (isRunning) { while (isRunning) {
delay(50) delay(50.milliseconds)
val gesture = detectGestures() val gesture = detectGestures()
if (gesture != null) { if (gesture != null) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
// audio.playConfirmation(gesture) GestureFeedback.playConfirmation(gesture)
gestureDetectedCallback?.invoke(gesture) callback.invoke(gesture)
stopDetection(doNotStop) stopDetection()
} }
break break
} }
} }
} }
} }
fun stopDetection(doNotStop: Boolean = false) { fun stopDetection() {
if (!isRunning) return if (!isRunning) return
Log.d(TAG, "Stopping gesture detection") Log.d(TAG, "Stopping gesture detection")
isRunning = false isRunning = false
// if (!doNotStop) airPodsService.stopHeadTracking()
detectionJob?.cancel() detectionJob?.cancel()
detectionJob = null detectionJob = null
gestureDetectedCallback = null
processingJob?.cancel()
processingJob = null
} }
fun processHeadOrientation(horizontal: Int, vertical: Int) { fun processHeadOrientation(horizontal: Int, vertical: Int) {
@@ -156,7 +153,7 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U
if (significantHorizontal && (!significantVertical || abs(horizontalDelta) > abs(verticalDelta))) { if (significantHorizontal && (!significantVertical || abs(horizontalDelta) > abs(verticalDelta))) {
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.Main).launch {
// audio.playDirectional(isVertical = false, value = horizontalDelta) GestureFeedback.playDirectional(isVertical = false, value = horizontalDelta)
} }
significantMotion = true significantMotion = true
lastSignificantMotionTime = System.currentTimeMillis() lastSignificantMotionTime = System.currentTimeMillis()
@@ -164,7 +161,7 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U
} }
else if (significantVertical) { else if (significantVertical) {
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.Main).launch {
// audio.playDirectional(isVertical = true, value = verticalDelta) GestureFeedback.playDirectional(isVertical = true, value = verticalDelta)
} }
significantMotion = true significantMotion = true
lastSignificantMotionTime = System.currentTimeMillis() lastSignificantMotionTime = System.currentTimeMillis()
@@ -363,7 +360,7 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U
private fun detectGestures(): Boolean? { private fun detectGestures(): Boolean? {
val requiredExtremes = getRequiredExtremes() val requiredExtremes = getRequiredExtremes()
Log.d(TAG, "Current required extremes: $requiredExtremes") // Log.d(TAG, "Current required extremes: $requiredExtremes")
if (verticalPeaks.size + verticalTroughs.size >= requiredExtremes) { if (verticalPeaks.size + verticalTroughs.size >= requiredExtremes) {
val allExtremes = (verticalPeaks + verticalTroughs).sortedBy { it.first } val allExtremes = (verticalPeaks + verticalTroughs).sortedBy { it.first }
@@ -23,14 +23,12 @@ package me.kavishdevar.librepods.utils
import android.content.Context import android.content.Context
import android.media.AudioAttributes import android.media.AudioAttributes
import android.media.SoundPool import android.media.SoundPool
import android.os.Build
import android.os.SystemClock import android.os.SystemClock
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
class GestureFeedback(context: Context) { object GestureFeedback {
private val TAG = "GestureFeedback" private val TAG = "GestureFeedback"
@@ -71,7 +69,7 @@ class GestureFeedback(context: Context) {
private val RIGHT_VOLUME = Pair(0.0f, 1.0f) private val RIGHT_VOLUME = Pair(0.0f, 1.0f)
private val VERTICAL_VOLUME = Pair(1.0f, 1.0f) private val VERTICAL_VOLUME = Pair(1.0f, 1.0f)
init { fun init (context: Context) {
soundId = soundPool.load(context, R.raw.blip_no, 1) soundId = soundPool.load(context, R.raw.blip_no, 1)
confirmYesId = soundPool.load(context, R.raw.confirm_yes, 1) confirmYesId = soundPool.load(context, R.raw.confirm_yes, 1)
confirmNoId = soundPool.load(context, R.raw.confirm_no, 1) confirmNoId = soundPool.load(context, R.raw.confirm_no, 1)
@@ -84,7 +82,6 @@ class GestureFeedback(context: Context) {
} }
} }
@RequiresApi(Build.VERSION_CODES.R)
fun playDirectional(isVertical: Boolean, value: Double) { fun playDirectional(isVertical: Boolean, value: Double) {
if (!soundsLoaded.get()) { if (!soundsLoaded.get()) {
Log.d(TAG, "Sounds not yet loaded, skipping playback") Log.d(TAG, "Sounds not yet loaded, skipping playback")
@@ -20,82 +20,19 @@ package me.kavishdevar.librepods.utils
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlin.math.roundToInt
data class Orientation(val pitch: Float = 0f, val yaw: Float = 0f)
data class Acceleration(val vertical: Float = 0f, val horizontal: Float = 0f) data class Acceleration(val vertical: Float = 0f, val horizontal: Float = 0f)
// TODO: remove
object HeadTracking { object HeadTracking {
private val _orientation = MutableStateFlow(Orientation())
val orientation = _orientation.asStateFlow()
private val _acceleration = MutableStateFlow(Acceleration()) private val _acceleration = MutableStateFlow(Acceleration())
val acceleration = _acceleration.asStateFlow() val acceleration = _acceleration.asStateFlow()
private val calibrationSamples = mutableListOf<Triple<Int, Int, Int>>() fun addAccel(vert: Float, horiz: Float) {
private var isCalibrated = false _acceleration.value = Acceleration(vert, horiz)
private var o1Neutral = 19000
private var o2Neutral = 0
private var o3Neutral = 0
private const val CALIBRATION_SAMPLE_COUNT = 10
private const val ORIENTATION_OFFSET = 5500
fun processPacket(packet: ByteArray) {
val o1 = bytesToInt(packet[43], packet[44])
val o2 = bytesToInt(packet[45], packet[46])
val o3 = bytesToInt(packet[47], packet[48])
val horizontalAccel = bytesToInt(packet[51], packet[52]).toFloat()
val verticalAccel = bytesToInt(packet[53], packet[54]).toFloat()
if (!isCalibrated) {
calibrationSamples.add(Triple(o1, o2, o3))
if (calibrationSamples.size >= CALIBRATION_SAMPLE_COUNT) {
calibrate()
}
return
}
val orientation = calculateOrientation(o1, o2, o3)
_orientation.value = orientation
_acceleration.value = Acceleration(verticalAccel, horizontalAccel)
}
private fun calibrate() {
if (calibrationSamples.size < 3) return
// Add offset during calibration
o1Neutral = calibrationSamples.map { it.first + ORIENTATION_OFFSET }.average().roundToInt()
o2Neutral = calibrationSamples.map { it.second + ORIENTATION_OFFSET }.average().roundToInt()
o3Neutral = calibrationSamples.map { it.third + ORIENTATION_OFFSET }.average().roundToInt()
isCalibrated = true
}
@Suppress("UnusedVariable")
private fun calculateOrientation(o1: Int, o2: Int, o3: Int): Orientation {
if (!isCalibrated) return Orientation()
val o1Norm = (o1 + ORIENTATION_OFFSET) - o1Neutral
val o2Norm = (o2 + ORIENTATION_OFFSET) - o2Neutral
val o3Norm = (o3 + ORIENTATION_OFFSET) - o3Neutral
val pitch = (o2Norm + o3Norm) / 2f / 32000f * 180f
val yaw = (o2Norm - o3Norm) / 2f / 32000f * 180f
return Orientation(pitch, yaw)
}
private fun bytesToInt(b1: Byte, b2: Byte): Int {
return (b2.toInt() shl 8) or (b1.toInt() and 0xFF)
} }
fun reset() { fun reset() {
calibrationSamples.clear()
isCalibrated = false
_orientation.value = Orientation()
_acceleration.value = Acceleration() _acceleration.value = Acceleration()
} }
} }
@@ -30,6 +30,7 @@ import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
// TODO: fragile play/pause, don't refactor unless needed. userPlayedMedia and iPaused etc. probably should not be here
object MediaController { object MediaController {
private var initialVolume: Int? = null private var initialVolume: Int? = null
private lateinit var audioManager: AudioManager private lateinit var audioManager: AudioManager
+70
View File
@@ -0,0 +1,70 @@
syntax = "proto3";
package rtbuddy;
option java_package = "me.kavishdevar.librepods.bluetooth.aacp.rtbuddy.proto";
option java_multiple_files = true;
enum SensorServiceType {
SENSOR_SERVICE_UNKNOWN = 0;
DRVCOMM = 1;
TTR = 2;
ANALYTICS = 3;
ACTIN = 4;
KADABRA = 5;
MANDO = 6;
IED = 7;
NEO = 8;
NEORELAY = 9;
COMM = 10;
ACCEL = 11;
GYRO = 12;
PDR = 13;
ACTIVITY = 14;
CMA = 15;
DEVMOTION6 = 16;
SPL0 = 17;
HOSTLIBHID = 18;
HEARTRATE = 19;
HEARTRATEv2 = 20;
SENSOR_SERVICE_UNKNOWN_82 = 82;
HEARTRATE_COMMAND = 84;
}
message SensorDataWX {
int32 seq = 1;
int32 log_type = 2;
bytes another_sensor_stream = 3;
RequestAllDescriptors request_all_descriptors = 4;
SensorDescriptor sensor_descriptor = 5;
SensorCommand command = 7;
SensorServiceSetting service_settings = 8;
SensorTypeAck start_ack = 9;
SensorTypeAck command_ack = 12;
}
message RequestAllDescriptors {
}
message SensorCommand {
SensorServiceType service = 1;
bytes payload = 3;
}
message SensorDescriptor {
SensorServiceType service = 1;
bytes sensor_descriptor = 2;
}
message SensorTypeAck {
SensorServiceType service = 1;
}
message SensorServiceSetting {
SensorServiceType service = 1;
int32 setting = 2;
bytes configuration = 3;
}
@@ -320,4 +320,24 @@
<string name="enable_debug_mode">Enable debug mode</string> <string name="enable_debug_mode">Enable debug mode</string>
<string name="ble_report_delay_description">Any value greater than 0 enables batching.</string> <string name="ble_report_delay_description">Any value greater than 0 enables batching.</string>
<string name="ble_report_delay">Report delay (in ms)</string> <string name="ble_report_delay">Report delay (in ms)</string>
<string name="interval">Interval</string>
<string name="heart_rate_interval_description">Interval between heart rate measurements</string>
<string name="no_heart_rate_data">No heart rate data</string>
<string name="one_second">1 second</string>
<string name="one_minute">1 minute</string>
<string name="permission_healthconnect">Write heart rate to Health Connect</string>
<string name="permission_description_healthconnect">Allows the app to write heart rate data to Android\'s Health Connect platform.</string>
<string name="required_permissions">Required Permissions</string>
<string name="bluetooth">Bluetooth</string>
<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_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>
<string name="swipe_anywhere_to_go_back">Swipe anywhere to go back</string>
<string name="use_highest_refresh_rate">Use highest refresh rate available</string>
<string name="debug_mode_description">Shows extra information and settings.</string>
<string name="interaction">Interaction</string>
</resources> </resources>
+1
View File
@@ -6,5 +6,6 @@ plugins {
alias(libs.plugins.ksp) apply false alias(libs.plugins.ksp) apply false
alias(libs.plugins.room3) apply false alias(libs.plugins.room3) apply false
alias(libs.plugins.android.library) apply false alias(libs.plugins.android.library) apply false
alias (libs.plugins.protobuf) apply false
// alias(libs.plugins.hilt) apply false // alias(libs.plugins.hilt) apply false
} }
+9 -11
View File
@@ -26,12 +26,10 @@ navevent = "1.1.2"
m3 = "1.5.0-alpha23" m3 = "1.5.0-alpha23"
ksp = "2.3.10" ksp = "2.3.10"
room = "3.0.0" room = "3.0.0"
ktxSerialization = "1.11.0" protobufPlugin = "0.10.0"
junit = "4.13.2" protobuf = "4.35.1"
junitVersion = "1.1.5" healthConnect = "1.2.0-alpha05"
espressoCore = "3.5.1" foundationLayout = "1.12.0"
appcompat = "1.6.1"
material = "1.10.0"
[libraries] [libraries]
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" }
@@ -71,11 +69,10 @@ androidx-navigationevent = { module = "androidx.navigationevent:navigationevent"
androidx-room3-runtime = { group = "androidx.room3", name = "room3-runtime", version.ref = "room" } androidx-room3-runtime = { group = "androidx.room3", name = "room3-runtime", version.ref = "room" }
androidx-room3-compiler = { group = "androidx.room3", name = "room3-compiler", version.ref = "room" } androidx-room3-compiler = { group = "androidx.room3", name = "room3-compiler", version.ref = "room" }
kotlinx-serialization-cbor = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-cbor", version = "ktxSerialization" } kotlinx-serialization-cbor = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-cbor", version = "ktxSerialization" }
junit = { group = "junit", name = "junit", version.ref = "junit" } protobuf-kotlin = { group = "com.google.protobuf", name = "protobuf-kotlin", version.ref = "protobuf" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-healthconnect-client = { group = "androidx.health.connect", name = "connect-client", version.ref = "healthConnect" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayout" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
@@ -86,3 +83,4 @@ ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
room3 = { id = "androidx.room3", version.ref = "room" } room3 = { id = "androidx.room3", version.ref = "room" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
android-library = { id = "com.android.library", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" }
protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" }