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

reimplement recording mainily
This commit is contained in:
Kavish Devar
2026-09-01 18:41:11 +05:30
parent 1f55d4eafb
commit 2eb29745e4
17 changed files with 262 additions and 133 deletions
+2 -1
View File
@@ -55,7 +55,8 @@ kotlin {
"kotlinx.coroutines.FlowPreview", "kotlinx.coroutines.FlowPreview",
"kotlinx.serialization.ExperimentalSerializationApi", "kotlinx.serialization.ExperimentalSerializationApi",
"kotlinx.coroutines.ExperimentalCoroutinesApi", "kotlinx.coroutines.ExperimentalCoroutinesApi",
"dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi" "dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi",
"com.google.accompanist.permissions.ExperimentalPermissionsApi"
) )
} }
} }
@@ -6,7 +6,7 @@ import android.media.MediaCodecInfo
import android.media.MediaFormat import android.media.MediaFormat
import java.nio.ByteBuffer import java.nio.ByteBuffer
class EldDecoder { class EldDecoder: AutoCloseable{
companion object { companion object {
private val ASC = byteArrayOf( private val ASC = byteArrayOf(
@@ -98,7 +98,7 @@ class EldDecoder {
} }
} }
fun close() { override fun close() {
codec.stop() codec.stop()
codec.release() codec.release()
} }
@@ -10,7 +10,11 @@ class WavWriter(
private val bitsPerSample: Int = 16 private val bitsPerSample: Int = 16
) : AutoCloseable { ) : AutoCloseable {
private val raf = RandomAccessFile(file, "rw") private val raf = try {
RandomAccessFile(file, "rw")
} catch (e: Exception) {
throw Exception("Failed to open file for writing", e)
}
private var dataSize = 0L private var dataSize = 0L
init { init {
@@ -522,9 +522,9 @@ class AACPManager(private val device: AppleDevice) {
device.updateState { device.updateState {
it.copy( it.copy(
headphoneAccomodation = eqData, headphoneAccommodation = eqData,
headphoneAccomodationEnabledForMedia = eqOnMedia, headphoneAccommodationEnabledForMedia = eqOnMedia,
headphoneAccomodationEnabledForPhone = eqOnPhone, headphoneAccommodationEnabledForPhone = eqOnPhone,
aacpPackets = it.aacpPackets + packet aacpPackets = it.aacpPackets + packet
) )
} }
@@ -561,10 +561,8 @@ class AACPManager(private val device: AppleDevice) {
MessageOpcode.MICROPHONE_STREAM -> { MessageOpcode.MICROPHONE_STREAM -> {
try { try {
MicrophoneFrame.parsePacket(packet).forEach{ frame -> MicrophoneFrame.parsePacket(packet).forEach{ frame ->
device.updateState { CoroutineScope(Dispatchers.IO).launch {
it.copy( device.emitMicrophoneFrame(frame)
microphoneFrames = it.microphoneFrames + frame
)
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -1007,9 +1005,9 @@ class AACPManager(private val device: AppleDevice) {
device.updateState { device.updateState {
it.copy( it.copy(
headphoneAccomodation = eq.copyOf(), headphoneAccommodation = eq.copyOf(),
headphoneAccomodationEnabledForMedia = media == 0x01.toByte(), headphoneAccommodationEnabledForMedia = media == 0x01.toByte(),
headphoneAccomodationEnabledForPhone = phone == 0x01.toByte() headphoneAccommodationEnabledForPhone = phone == 0x01.toByte()
) )
} }
} }
@@ -1042,7 +1040,6 @@ class AACPManager(private val device: AppleDevice) {
it.copy( it.copy(
battery = emptySet(), battery = emptySet(),
connectedDevices = emptyList(), connectedDevices = emptyList(),
microphoneFrames = emptyList(),
controlStates = emptyMap(), controlStates = emptyMap(),
aacpPackets = emptyList(), aacpPackets = emptyList(),
headTrackingState = BuddyState.INACTIVE, headTrackingState = BuddyState.INACTIVE,
@@ -1072,7 +1069,7 @@ class AACPManager(private val device: AppleDevice) {
fun requestMicrophoneStream(): Boolean { fun requestMicrophoneStream(): Boolean {
val payload = byteArrayOf( val payload = byteArrayOf(
0x00, 0x00, 0x00, 0x00,
0x09, 0x00, 0x09, 0x00, // length
0x00, 0x01, 0x00, 0x01,
0x82.toByte(), 0x00, 0x82.toByte(), 0x00,
0x00, 0x00, 0x00, 0x00,
@@ -1090,9 +1087,8 @@ class AACPManager(private val device: AppleDevice) {
fun endMicrophoneStream(): Boolean { fun endMicrophoneStream(): Boolean {
val payload = byteArrayOf( val payload = byteArrayOf(
MessageOpcode.MICROPHONE_STREAM.value, 0x00,
0x00, 0x00, 0x00, 0x00,
0x02, 0x00, 0x02, 0x00, // length
0x03, 0x01 0x03, 0x01
) )
@@ -35,8 +35,6 @@ class RTBuddyManager(
is SensorDataWxBuddyPayload -> { is SensorDataWxBuddyPayload -> {
val data = payload.data val data = payload.data
observeSequence(data) observeSequence(data)
// handleSensorData(data)
} }
else -> { else -> {
@@ -48,28 +46,6 @@ class RTBuddyManager(
} }
} }
// 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( fun sendSensorData(
data: SensorDataWX, data: SensorDataWX,
): Boolean { ): Boolean {
@@ -1,5 +1,7 @@
package me.kavishdevar.librepods.bluetooth.aacp.types package me.kavishdevar.librepods.bluetooth.aacp.types
import me.kavishdevar.librepods.data.audio.MicrophoneFrame
sealed interface AppleEvent { sealed interface AppleEvent {
data class StemPress( data class StemPress(
val pressType: StemPressType, val pressType: StemPressType,
@@ -15,7 +17,7 @@ sealed interface AppleEvent {
val reverseTapped: Boolean val reverseTapped: Boolean
): AppleEvent ): AppleEvent
data class HeadGesturesResult( data class MicrophoneFrameEvent(
val yes: Boolean, val frame: MicrophoneFrame
) )
} }
@@ -1,6 +1,6 @@
package me.kavishdevar.librepods.data.recording package me.kavishdevar.librepods.data.recording
class RecordingState { data class RecordingState(
val isRecording: Boolean = false val isRecording: Boolean = false,
val recording: Recording? = null val recording: Recording? = null
} )
@@ -3,8 +3,10 @@ package me.kavishdevar.librepods.devices
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothDevice
import android.content.Context
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import com.google.protobuf.copy
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -25,6 +27,10 @@ import me.kavishdevar.librepods.bluetooth.att.ATTHandle
import me.kavishdevar.librepods.bluetooth.att.ATTManager import me.kavishdevar.librepods.bluetooth.att.ATTManager
import me.kavishdevar.librepods.data.StemAction import me.kavishdevar.librepods.data.StemAction
import me.kavishdevar.librepods.data.apple.BuddyState import me.kavishdevar.librepods.data.apple.BuddyState
import me.kavishdevar.librepods.data.audio.MicrophoneFrame
import me.kavishdevar.librepods.data.audio.MicrophoneState
import me.kavishdevar.librepods.data.recording.Recording
import me.kavishdevar.librepods.data.recording.RecordingState
import me.kavishdevar.librepods.utils.GestureDetector import me.kavishdevar.librepods.utils.GestureDetector
import me.kavishdevar.librepods.utils.HeadTracking import me.kavishdevar.librepods.utils.HeadTracking
import kotlin.time.Duration import kotlin.time.Duration
@@ -35,6 +41,7 @@ private const val TAG = "AppleDevice"
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
class AppleDevice( class AppleDevice(
override val context: Context,
override val bluetoothAdapter: BluetoothAdapter, override val bluetoothAdapter: BluetoothAdapter,
override val bluetoothDevice: BluetoothDevice, override val bluetoothDevice: BluetoothDevice,
currentState: ConnectionState currentState: ConnectionState
@@ -55,6 +62,11 @@ class AppleDevice(
private val _events = MutableSharedFlow<AppleEvent>() private val _events = MutableSharedFlow<AppleEvent>()
val events = _events.asSharedFlow() val events = _events.asSharedFlow()
private val _microphoneFrames = MutableSharedFlow<MicrophoneFrame>(
extraBufferCapacity = 64
)
val microphoneFrames = _microphoneFrames.asSharedFlow()
private val _connectionNumber = MutableStateFlow(0) private val _connectionNumber = MutableStateFlow(0)
override val connectionNumber = _connectionNumber.asStateFlow() override val connectionNumber = _connectionNumber.asStateFlow()
@@ -86,6 +98,10 @@ class AppleDevice(
_events.emit(event) _events.emit(event)
} }
internal suspend fun emitMicrophoneFrame(frame: MicrophoneFrame) {
_microphoneFrames.emit(frame)
}
val aacp = AACPManager(this) val aacp = AACPManager(this)
val att = ATTManager(this) val att = ATTManager(this)
@@ -191,9 +207,35 @@ class AppleDevice(
suspend fun readATTCharacteristic(handle: ATTHandle): ByteArray? = att.readCharacteristic(handle) suspend fun readATTCharacteristic(handle: ATTHandle): ByteArray? = att.readCharacteristic(handle)
// TODO: handle recording session fun requestMicrophoneStream() = aacp.requestMicrophoneStream()
fun startRecording() = aacp.requestMicrophoneStream() fun endMicrophoneStream() {
fun stopRecording() = aacp.endMicrophoneStream() aacp.endMicrophoneStream()
reconnectA2dp()
}
fun startRecording(recording: Recording) {
requestMicrophoneStream()
updateState {
it.copy(
recordingState = RecordingState(
isRecording = true,
recording = recording
)
)
}
}
fun stopRecording() {
endMicrophoneStream()
updateState {
it.copy(
recordingState = RecordingState(
isRecording = false,
recording = null
),
microphoneState = MicrophoneState(),
)
}
}
fun toggleListeningMode(modeBit: Int) { fun toggleListeningMode(modeBit: Int) {
val currentByte = state.value.controlStates[ControlCommandIdentifier.LISTENING_MODE_CONFIGS]?.get(0)?.toInt() ?: 0 val currentByte = state.value.controlStates[ControlCommandIdentifier.LISTENING_MODE_CONFIGS]?.get(0)?.toInt() ?: 0
@@ -41,7 +41,6 @@ data class AppleState(
val customEq: CustomEq = CustomEq(1, 50, 50, 50), val customEq: CustomEq = CustomEq(1, 50, 50, 50),
val microphoneFrames: List<MicrophoneFrame> = emptyList(),
val microphoneState: MicrophoneState = MicrophoneState(), val microphoneState: MicrophoneState = MicrophoneState(),
val recordingState: RecordingState = RecordingState(), val recordingState: RecordingState = RecordingState(),
@@ -50,9 +49,9 @@ data class AppleState(
val leftIsPrimary: Boolean = true, val leftIsPrimary: Boolean = true,
val headphoneAccomodation: FloatArray = FloatArray(8), val headphoneAccommodation: FloatArray = FloatArray(8),
val headphoneAccomodationEnabledForMedia: Boolean = false, val headphoneAccommodationEnabledForMedia: Boolean = false,
val headphoneAccomodationEnabledForPhone: Boolean = false, val headphoneAccommodationEnabledForPhone: Boolean = false,
val currentHeartRate: HeartRateSample? = null, val currentHeartRate: HeartRateSample? = null,
val hrmState: BuddyState = BuddyState.INACTIVE, val hrmState: BuddyState = BuddyState.INACTIVE,
@@ -13,14 +13,22 @@ import android.content.IntentFilter
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.ParcelUuid import android.os.ParcelUuid
import android.util.Log import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.bluetooth.MacAddress import me.kavishdevar.librepods.bluetooth.MacAddress
import me.kavishdevar.librepods.bluetooth.createBluetoothSocket import me.kavishdevar.librepods.bluetooth.createBluetoothSocket
import kotlin.time.Duration.Companion.milliseconds
sealed interface Device<S: DeviceState, T: DeviceSettings, M: DeviceMetadata> { sealed interface Device<S: DeviceState, T: DeviceSettings, M: DeviceMetadata> {
private val TAG: String private val TAG: String
get() = "LibrePodsDevice<${macAddress.toRedactedString()}>" get() = "LibrePodsDevice<${macAddress.toRedactedString()}>"
// todo: somehow remove context from here. maybe get Profile from service?
val context: Context
val macAddress: MacAddress val macAddress: MacAddress
val bluetoothAdapter: BluetoothAdapter val bluetoothAdapter: BluetoothAdapter
@@ -45,12 +53,12 @@ sealed interface Device<S: DeviceState, T: DeviceSettings, M: DeviceMetadata> {
psm = psm psm = psm
) )
fun disableAudio(context: Context) { fun disableAudio() {
disableA2dp(context) disableA2dp()
disableHeadset(context) disableHeadset()
} }
fun disableA2dp(context: Context) { fun disableA2dp() {
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
if (context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) { if (context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) {
@@ -80,7 +88,7 @@ sealed interface Device<S: DeviceState, T: DeviceSettings, M: DeviceMetadata> {
} }
} }
fun disableHeadset(context: Context) { fun disableHeadset() {
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
if (context.checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) { if (context.checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) {
@@ -105,12 +113,12 @@ sealed interface Device<S: DeviceState, T: DeviceSettings, M: DeviceMetadata> {
} }
} }
fun enableAudio(context: Context) { fun enableAudio() {
enableA2dp(context) enableA2dp()
enableHeadset(context) enableHeadset()
} }
fun enableA2dp(context: Context) { fun enableA2dp() {
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
@@ -137,7 +145,7 @@ sealed interface Device<S: DeviceState, T: DeviceSettings, M: DeviceMetadata> {
}, BluetoothProfile.A2DP) }, BluetoothProfile.A2DP)
} }
fun enableHeadset(context: Context) { fun enableHeadset() {
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
@@ -167,12 +175,12 @@ sealed interface Device<S: DeviceState, T: DeviceSettings, M: DeviceMetadata> {
}, BluetoothProfile.HEADSET) }, BluetoothProfile.HEADSET)
} }
fun connectAudio(context: Context) { fun connectAudio() {
connectA2dp(context) connectA2dp()
connectHeadset(context) connectHeadset()
} }
fun connectA2dp(context: Context) { fun connectA2dp() {
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
@@ -193,7 +201,7 @@ sealed interface Device<S: DeviceState, T: DeviceSettings, M: DeviceMetadata> {
}, BluetoothProfile.A2DP) }, BluetoothProfile.A2DP)
} }
fun connectHeadset(context: Context) { fun connectHeadset() {
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
@@ -214,12 +222,12 @@ sealed interface Device<S: DeviceState, T: DeviceSettings, M: DeviceMetadata> {
}, BluetoothProfile.HEADSET) }, BluetoothProfile.HEADSET)
} }
fun disconnectAudio(context: Context) { fun disconnectAudio() {
disconnectA2dp(context) disconnectA2dp()
disconnectHeadset(context) disconnectHeadset()
} }
fun disconnectA2dp(context: Context) { fun disconnectA2dp() {
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
@@ -240,7 +248,7 @@ sealed interface Device<S: DeviceState, T: DeviceSettings, M: DeviceMetadata> {
}, BluetoothProfile.A2DP) }, BluetoothProfile.A2DP)
} }
fun disconnectHeadset(context: Context) { fun disconnectHeadset() {
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
@@ -299,6 +307,46 @@ sealed interface Device<S: DeviceState, T: DeviceSettings, M: DeviceMetadata> {
return receiver return receiver
} }
fun reconnectA2dp() {
bluetoothAdapter.getProfileProxy(
context,
object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(
profile: Int,
proxy: BluetoothProfile
) {
if (profile != BluetoothProfile.A2DP) return
val a2dp = proxy as BluetoothA2dp
try {
val connectMethod = proxy.javaClass.getMethod("connect", BluetoothDevice::class.java)
val disconnectMethod = proxy.javaClass.getMethod("disconnect", BluetoothDevice::class.java)
disconnectMethod.invoke(proxy, bluetoothDevice)
CoroutineScope(Dispatchers.IO).launch {
delay(500.milliseconds)
connectMethod.invoke(proxy, bluetoothDevice)
bluetoothAdapter.closeProfileProxy(
BluetoothProfile.A2DP,
a2dp
)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to reconnect A2DP", e)
}
}
override fun onServiceDisconnected(profile: Int) {
}
},
BluetoothProfile.A2DP
)
}
} }
interface DeviceState interface DeviceState
@@ -163,8 +163,7 @@ fun NoiseControlSettings(
options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes() options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes()
else -> ButtonGroupDefaults.connectedMiddleButtonShapes() else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
}, },
colors = ToggleButtonDefaults.toggleButtonColors() colors = ToggleButtonDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
.copy(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Icon( Icon(
@@ -46,7 +46,6 @@ import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.health.connect.client.permission.HealthPermission import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.HeartRateRecord import androidx.health.connect.client.records.HeartRateRecord
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
@@ -60,7 +59,6 @@ import me.kavishdevar.librepods.presentation.components.primitives.StyledScaffol
import me.kavishdevar.librepods.presentation.components.primitives.StyledSlider import me.kavishdevar.librepods.presentation.components.primitives.StyledSlider
import me.kavishdevar.librepods.presentation.components.primitives.StyledToggle import me.kavishdevar.librepods.presentation.components.primitives.StyledToggle
import me.kavishdevar.librepods.presentation.icons.LocalIcons 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.AppleUiState import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState
@@ -88,7 +86,6 @@ fun HeartRateRoute(
) )
} }
@OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun HeartRateScreen( fun HeartRateScreen(
uiState: AppleUiState, uiState: AppleUiState,
@@ -108,6 +105,11 @@ fun HeartRateScreen(
navigateBack = navigateBack, navigateBack = navigateBack,
actionButtons = listOf( actionButtons = listOf(
{ scaffoldBackdrop -> { scaffoldBackdrop ->
val icon = when (state.hrmState) {
BuddyState.INACTIVE -> LocalIcons.current.Play
BuddyState.WAITING -> LocalIcons.current.Circle
BuddyState.ACTIVE -> LocalIcons.current.Pause
}
if (LocalDesignSystem.current == DesignSystem.Material) { if (LocalDesignSystem.current == DesignSystem.Material) {
FilledTonalIconToggleButton( FilledTonalIconToggleButton(
checked = state.hrmState == BuddyState.ACTIVE, checked = state.hrmState == BuddyState.ACTIVE,
@@ -116,26 +118,32 @@ fun HeartRateScreen(
.minimumInteractiveComponentSize() .minimumInteractiveComponentSize()
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)), .size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)),
shape = IconButtonDefaults.mediumRoundShape, shape = IconButtonDefaults.mediumRoundShape,
enabled = state.hrmState != BuddyState.WAITING
) { ) {
Icon( AnimatedContent(
imageVector = if (state.hrmState == BuddyState.ACTIVE) MaterialIcons.Pause else Icons.Default.PlayArrow, icon
contentDescription = "Start/Stop", ) {
modifier = Modifier.size(IconButtonDefaults.mediumIconSize), Icon(
) imageVector = it,
contentDescription = "Start/Stop",
modifier = Modifier.size(IconButtonDefaults.mediumIconSize),
)
}
} }
} else { } else {
StyledIconButton( StyledIconButton(
onClick = { if (state.hrmState == BuddyState.INACTIVE) startHr() else stopHr() }, onClick = { if (state.hrmState == BuddyState.INACTIVE) startHr() else stopHr() },
backdrop = scaffoldBackdrop, backdrop = scaffoldBackdrop,
enabled = state.hrmState != BuddyState.WAITING
) { ) {
Icon( AnimatedContent(
imageVector = if (state.hrmState == BuddyState.ACTIVE) LocalIcons.current.Pause else LocalIcons.current.Play, icon
contentDescription = "Start/Stop", ) {
modifier = Modifier.size(16.dp), Icon(
tint = MaterialTheme.colorScheme.onBackground imageVector = it,
) contentDescription = "Start/Stop",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onBackground
)
}
} }
} }
} }
@@ -297,6 +305,9 @@ fun HeartRateScreen(
updateSettings { updateSettings {
it.copy(hrmAlertEnabled = enabled) it.copy(hrmAlertEnabled = enabled)
} }
if (enabled && state.hrmState == BuddyState.INACTIVE) {
startHr()
}
}, },
) )
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Spacer
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.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -34,6 +35,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLocale import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.drawText
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.core.content.FileProvider import androidx.core.content.FileProvider
@@ -65,12 +67,6 @@ fun RecordingScreenRoute(
startRecording = viewModel::startRecording, startRecording = viewModel::startRecording,
stopRecording = viewModel::stopRecording, stopRecording = viewModel::stopRecording,
) )
DisposableEffect(Unit) {
onDispose {
viewModel.stopRecording()
}
}
} }
@Composable @Composable
@@ -140,18 +136,19 @@ fun RecordingScreen(
) { recording -> ) { recording ->
if (recording) { if (recording) {
Column( Column(
modifier = Modifier modifier = Modifier.fillMaxSize()
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.surface,
RoundedCornerShape(28.dp)
)
.padding(24.dp)
) { ) {
// AI-generated // AI-generated
Canvas( Canvas(
modifier = Modifier modifier = Modifier
.fillMaxWidth()
.fillMaxSize() .fillMaxSize()
.weight(1f)
.background(
MaterialTheme.colorScheme.surfaceContainerHigh,
RoundedCornerShape(28.dp)
)
.padding(vertical = 24.dp)
) { ) {
if (history.isEmpty()) return@Canvas if (history.isEmpty()) return@Canvas
@@ -196,24 +193,20 @@ fun RecordingScreen(
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
Text( // todo: duration is wrong, don't do +30ms
text = buildString { // Text(
val total = state.microphoneState.durationMs // text = buildString {
// val total = state.microphoneState.durationMs
append((total / 60000).toString().padStart(2, '0')) //
append(':') // append((total / 60000).toString().padStart(2, '0'))
// append(':')
append(((total / 1000) % 60).toString().padStart(2, '0')) //
append('.') // append(((total / 1000) % 60).toString().padStart(2, '0'))
// },
append(((total % 1000) / 10).toString().padStart(2, '0')) // modifier = Modifier.fillMaxWidth(),
}, // textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(), // style = MaterialTheme.typography.displaySmall
textAlign = TextAlign.Center, // )
style = MaterialTheme.typography.displaySmall
)
Spacer(Modifier.height(12.dp))
} }
} else { } else {
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
@@ -40,7 +40,6 @@ 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 com.google.accompanist.permissions.ExperimentalPermissionsApi
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.DesignSystem
@@ -49,7 +48,6 @@ import me.kavishdevar.librepods.utils.XposedState
import me.kavishdevar.librepods.utils.bypassDeviceCheck import me.kavishdevar.librepods.utils.bypassDeviceCheck
import me.kavishdevar.librepods.utils.isSupported import me.kavishdevar.librepods.utils.isSupported
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun OnboardingScreen( fun OnboardingScreen(
onOnboardingComplete: () -> Unit, onOnboardingComplete: () -> Unit,
@@ -215,8 +213,6 @@ fun OnboardingScreen(
} }
} }
@OptIn(ExperimentalPermissionsApi::class)
@Preview @Preview
@Composable @Composable
fun OnboardingScreenPreview(){ fun OnboardingScreenPreview(){
@@ -55,7 +55,6 @@ import me.kavishdevar.librepods.presentation.components.primitives.StyledListIte
import me.kavishdevar.librepods.presentation.components.primitives.StyledListItemOrientation import me.kavishdevar.librepods.presentation.components.primitives.StyledListItemOrientation
import me.kavishdevar.librepods.presentation.icons.MaterialIcons import me.kavishdevar.librepods.presentation.icons.MaterialIcons
@OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun PermissionsPage( fun PermissionsPage(
onBackward: () -> Unit, onBackward: () -> Unit,
@@ -168,7 +168,7 @@ class AppleViewModel(
attObserveJob = null attObserveJob = null
} }
fun startRecording() = device.startRecording() fun startRecording() = device.startRecording(recordingRepository.createRecording())
fun stopRecording() = device.stopRecording() fun stopRecording() = device.stopRecording()
fun toggleListeningMode(modeBit: Int) = device.toggleListeningMode(modeBit) fun toggleListeningMode(modeBit: Int) = device.toggleListeningMode(modeBit)
@@ -48,11 +48,14 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.LibrePodsApplication import me.kavishdevar.librepods.LibrePodsApplication
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.audio.EldDecoder
import me.kavishdevar.librepods.audio.WavWriter
import me.kavishdevar.librepods.bluetooth.MacAddress 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.data.heartrate.HeartRateSample
import me.kavishdevar.librepods.data.recording.Recording
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
@@ -68,6 +71,7 @@ import me.kavishdevar.librepods.presentation.overlays.IslandType
import me.kavishdevar.librepods.presentation.overlays.IslandWindow 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.calculateLevel
import me.kavishdevar.librepods.utils.redactMac import me.kavishdevar.librepods.utils.redactMac
import java.time.ZoneOffset import java.time.ZoneOffset
import kotlin.time.Duration import kotlin.time.Duration
@@ -96,6 +100,10 @@ class LibrePodsService: Service() {
private var islandWindow: IslandWindow? = null private var islandWindow: IslandWindow? = null
private val eldDecoder = EldDecoder()
private val wavWriters = mutableMapOf<Recording, WavWriter>()
private val appleRepository by lazy { private val appleRepository by lazy {
(application as LibrePodsApplication).appleRepository (application as LibrePodsApplication).appleRepository
} }
@@ -112,6 +120,10 @@ class LibrePodsService: Service() {
(application as LibrePodsApplication).heartRateRepository (application as LibrePodsApplication).heartRateRepository
} }
private val recordingRepository by lazy {
(application as LibrePodsApplication).recordingRepository
}
private val healthConnectClient by lazy { private val healthConnectClient by lazy {
(application as LibrePodsApplication).healthConnectClient (application as LibrePodsApplication).healthConnectClient
} }
@@ -301,6 +313,7 @@ class LibrePodsService: Service() {
deviceJobs[MacAddress(bluetoothDevice.address)]?.add(observeAppleState(device)) deviceJobs[MacAddress(bluetoothDevice.address)]?.add(observeAppleState(device))
deviceJobs[MacAddress(bluetoothDevice.address)]?.add(observeAppleSettings(device)) deviceJobs[MacAddress(bluetoothDevice.address)]?.add(observeAppleSettings(device))
deviceJobs[MacAddress(bluetoothDevice.address)]?.add(observeAppleMetadata(device)) deviceJobs[MacAddress(bluetoothDevice.address)]?.add(observeAppleMetadata(device))
deviceJobs[MacAddress(bluetoothDevice.address)]?.add(observeAppleMicrophoneFrames(device))
} }
} }
@@ -544,6 +557,7 @@ class LibrePodsService: Service() {
bluetoothDevice.uuids?.contains(aacpUuid) == true -> { bluetoothDevice.uuids?.contains(aacpUuid) == true -> {
Log.i(TAG, "Apple device detected: ${bluetoothDevice.address.redactMac()}") Log.i(TAG, "Apple device detected: ${bluetoothDevice.address.redactMac()}")
AppleDevice( AppleDevice(
context = this,
bluetoothAdapter = bluetoothAdapter, bluetoothAdapter = bluetoothAdapter,
bluetoothDevice = bluetoothDevice, bluetoothDevice = bluetoothDevice,
currentState = if (isConnectedMethod.invoke(bluetoothDevice) as Boolean) ConnectionState.AVAILABLE else ConnectionState.DISCONNECTED currentState = if (isConnectedMethod.invoke(bluetoothDevice) as Boolean) ConnectionState.AVAILABLE else ConnectionState.DISCONNECTED
@@ -809,6 +823,30 @@ class LibrePodsService: Service() {
) )
} }
} }
state.recordingState != previousState.recordingState -> {
Log.i(
TAG,
"recording state changed for device ${device.macAddress.toRedactedString()}"
)
Log.d(TAG, "recording state: ${state.recordingState}")
if (previousState.recordingState.isRecording && !state.recordingState.isRecording) {
previousState.recordingState.recording?.let { recording ->
wavWriters[recording]?.close()
}
} else if (!previousState.recordingState.isRecording && state.recordingState.isRecording) {
state.recordingState.recording?.let { recording ->
try {
val wavWriter = WavWriter(recording.file)
wavWriters[recording] = wavWriter
} catch (e: Exception) {
Log.e(TAG, "Failed to create WavWriter for recording: ${recording.file}", e)
}
}
} else {
Log.w(TAG, "Recording state changed but no action taken. previous: ${previousState.recordingState}, current: ${state.recordingState}")
}
}
} }
previousState = state previousState = state
} }
@@ -850,6 +888,31 @@ class LibrePodsService: Service() {
} }
} }
fun observeAppleMicrophoneFrames(device: AppleDevice): Job = CoroutineScope(Dispatchers.IO).launch {
device.microphoneFrames.collect { frame ->
val state = device.state.value
if (state.recordingState.isRecording) {
eldDecoder.decode(
accessUnit = frame.accessUnit
) { pcm ->
wavWriters[state.recordingState.recording]?.write(pcm)
device.updateState {
it.copy(
microphoneState = state.microphoneState.copy(
isActive = true,
packetsReceived = it.microphoneState.packetsReceived + 1,
durationMs = it.microphoneState.durationMs + 30,
level = calculateLevel(pcm)
)
)
}
}
}
}
}
fun showIsland( fun showIsland(
device: Device<*, *, *>, device: Device<*, *, *>,
type: IslandType = IslandType.CONNECTED, type: IslandType = IslandType.CONNECTED,
@@ -1169,8 +1232,8 @@ class LibrePodsService: Service() {
TAG, TAG,
"User put in at least one component, enabling audio for device ${device.macAddress.toRedactedString()}" "User put in at least one component, enabling audio for device ${device.macAddress.toRedactedString()}"
) )
device.enableAudio(this) device.enableAudio()
device.connectA2dp(this) device.connectA2dp()
justEnabledA2dp = true justEnabledA2dp = true
device.waitForA2dpConnection(this) { device.waitForA2dpConnection(this) {
@@ -1194,8 +1257,8 @@ class LibrePodsService: Service() {
TAG, TAG,
"Disconnecting audio for device ${device.macAddress.toRedactedString()} because user took out all components and disconnectWhenNotWearing is true" "Disconnecting audio for device ${device.macAddress.toRedactedString()} because user took out all components and disconnectWhenNotWearing is true"
) )
device.disableAudio(this) device.disableAudio()
device.disconnectAudio(this) device.disconnectAudio()
} }
} }
} }