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.serialization.ExperimentalSerializationApi",
"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 java.nio.ByteBuffer
class EldDecoder {
class EldDecoder: AutoCloseable{
companion object {
private val ASC = byteArrayOf(
@@ -98,7 +98,7 @@ class EldDecoder {
}
}
fun close() {
override fun close() {
codec.stop()
codec.release()
}
@@ -10,7 +10,11 @@ class WavWriter(
private val bitsPerSample: Int = 16
) : 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
init {
@@ -522,9 +522,9 @@ class AACPManager(private val device: AppleDevice) {
device.updateState {
it.copy(
headphoneAccomodation = eqData,
headphoneAccomodationEnabledForMedia = eqOnMedia,
headphoneAccomodationEnabledForPhone = eqOnPhone,
headphoneAccommodation = eqData,
headphoneAccommodationEnabledForMedia = eqOnMedia,
headphoneAccommodationEnabledForPhone = eqOnPhone,
aacpPackets = it.aacpPackets + packet
)
}
@@ -561,10 +561,8 @@ class AACPManager(private val device: AppleDevice) {
MessageOpcode.MICROPHONE_STREAM -> {
try {
MicrophoneFrame.parsePacket(packet).forEach{ frame ->
device.updateState {
it.copy(
microphoneFrames = it.microphoneFrames + frame
)
CoroutineScope(Dispatchers.IO).launch {
device.emitMicrophoneFrame(frame)
}
}
} catch (e: Exception) {
@@ -1007,9 +1005,9 @@ class AACPManager(private val device: AppleDevice) {
device.updateState {
it.copy(
headphoneAccomodation = eq.copyOf(),
headphoneAccomodationEnabledForMedia = media == 0x01.toByte(),
headphoneAccomodationEnabledForPhone = phone == 0x01.toByte()
headphoneAccommodation = eq.copyOf(),
headphoneAccommodationEnabledForMedia = media == 0x01.toByte(),
headphoneAccommodationEnabledForPhone = phone == 0x01.toByte()
)
}
}
@@ -1042,7 +1040,6 @@ class AACPManager(private val device: AppleDevice) {
it.copy(
battery = emptySet(),
connectedDevices = emptyList(),
microphoneFrames = emptyList(),
controlStates = emptyMap(),
aacpPackets = emptyList(),
headTrackingState = BuddyState.INACTIVE,
@@ -1072,7 +1069,7 @@ class AACPManager(private val device: AppleDevice) {
fun requestMicrophoneStream(): Boolean {
val payload = byteArrayOf(
0x00, 0x00,
0x09, 0x00,
0x09, 0x00, // length
0x00, 0x01,
0x82.toByte(), 0x00,
0x00, 0x00,
@@ -1090,9 +1087,8 @@ class AACPManager(private val device: AppleDevice) {
fun endMicrophoneStream(): Boolean {
val payload = byteArrayOf(
MessageOpcode.MICROPHONE_STREAM.value, 0x00,
0x00, 0x00,
0x02, 0x00,
0x02, 0x00, // length
0x03, 0x01
)
@@ -35,8 +35,6 @@ class RTBuddyManager(
is SensorDataWxBuddyPayload -> {
val data = payload.data
observeSequence(data)
// handleSensorData(data)
}
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(
data: SensorDataWX,
): Boolean {
@@ -1,5 +1,7 @@
package me.kavishdevar.librepods.bluetooth.aacp.types
import me.kavishdevar.librepods.data.audio.MicrophoneFrame
sealed interface AppleEvent {
data class StemPress(
val pressType: StemPressType,
@@ -15,7 +17,7 @@ sealed interface AppleEvent {
val reverseTapped: Boolean
): AppleEvent
data class HeadGesturesResult(
val yes: Boolean,
data class MicrophoneFrameEvent(
val frame: MicrophoneFrame
)
}
@@ -1,6 +1,6 @@
package me.kavishdevar.librepods.data.recording
class RecordingState {
val isRecording: Boolean = false
data class RecordingState(
val isRecording: Boolean = false,
val recording: Recording? = null
}
)
@@ -3,8 +3,10 @@ package me.kavishdevar.librepods.devices
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.content.Context
import android.os.Build
import android.util.Log
import com.google.protobuf.copy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
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.data.StemAction
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.HeadTracking
import kotlin.time.Duration
@@ -35,6 +41,7 @@ private const val TAG = "AppleDevice"
@SuppressLint("MissingPermission")
class AppleDevice(
override val context: Context,
override val bluetoothAdapter: BluetoothAdapter,
override val bluetoothDevice: BluetoothDevice,
currentState: ConnectionState
@@ -55,6 +62,11 @@ class AppleDevice(
private val _events = MutableSharedFlow<AppleEvent>()
val events = _events.asSharedFlow()
private val _microphoneFrames = MutableSharedFlow<MicrophoneFrame>(
extraBufferCapacity = 64
)
val microphoneFrames = _microphoneFrames.asSharedFlow()
private val _connectionNumber = MutableStateFlow(0)
override val connectionNumber = _connectionNumber.asStateFlow()
@@ -86,6 +98,10 @@ class AppleDevice(
_events.emit(event)
}
internal suspend fun emitMicrophoneFrame(frame: MicrophoneFrame) {
_microphoneFrames.emit(frame)
}
val aacp = AACPManager(this)
val att = ATTManager(this)
@@ -191,9 +207,35 @@ class AppleDevice(
suspend fun readATTCharacteristic(handle: ATTHandle): ByteArray? = att.readCharacteristic(handle)
// TODO: handle recording session
fun startRecording() = aacp.requestMicrophoneStream()
fun stopRecording() = aacp.endMicrophoneStream()
fun requestMicrophoneStream() = aacp.requestMicrophoneStream()
fun 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) {
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 microphoneFrames: List<MicrophoneFrame> = emptyList(),
val microphoneState: MicrophoneState = MicrophoneState(),
val recordingState: RecordingState = RecordingState(),
@@ -50,9 +49,9 @@ data class AppleState(
val leftIsPrimary: Boolean = true,
val headphoneAccomodation: FloatArray = FloatArray(8),
val headphoneAccomodationEnabledForMedia: Boolean = false,
val headphoneAccomodationEnabledForPhone: Boolean = false,
val headphoneAccommodation: FloatArray = FloatArray(8),
val headphoneAccommodationEnabledForMedia: Boolean = false,
val headphoneAccommodationEnabledForPhone: Boolean = false,
val currentHeartRate: HeartRateSample? = null,
val hrmState: BuddyState = BuddyState.INACTIVE,
@@ -13,14 +13,22 @@ import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.ParcelUuid
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.bluetooth.MacAddress
import me.kavishdevar.librepods.bluetooth.createBluetoothSocket
import kotlin.time.Duration.Companion.milliseconds
sealed interface Device<S: DeviceState, T: DeviceSettings, M: DeviceMetadata> {
private val TAG: String
get() = "LibrePodsDevice<${macAddress.toRedactedString()}>"
// todo: somehow remove context from here. maybe get Profile from service?
val context: Context
val macAddress: MacAddress
val bluetoothAdapter: BluetoothAdapter
@@ -45,12 +53,12 @@ sealed interface Device<S: DeviceState, T: DeviceSettings, M: DeviceMetadata> {
psm = psm
)
fun disableAudio(context: Context) {
disableA2dp(context)
disableHeadset(context)
fun disableAudio() {
disableA2dp()
disableHeadset()
}
fun disableA2dp(context: Context) {
fun disableA2dp() {
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
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
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) {
enableA2dp(context)
enableHeadset(context)
fun enableAudio() {
enableA2dp()
enableHeadset()
}
fun enableA2dp(context: Context) {
fun enableA2dp() {
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
@@ -137,7 +145,7 @@ sealed interface Device<S: DeviceState, T: DeviceSettings, M: DeviceMetadata> {
}, BluetoothProfile.A2DP)
}
fun enableHeadset(context: Context) {
fun enableHeadset() {
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
@@ -167,12 +175,12 @@ sealed interface Device<S: DeviceState, T: DeviceSettings, M: DeviceMetadata> {
}, BluetoothProfile.HEADSET)
}
fun connectAudio(context: Context) {
connectA2dp(context)
connectHeadset(context)
fun connectAudio() {
connectA2dp()
connectHeadset()
}
fun connectA2dp(context: Context) {
fun connectA2dp() {
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
@@ -193,7 +201,7 @@ sealed interface Device<S: DeviceState, T: DeviceSettings, M: DeviceMetadata> {
}, BluetoothProfile.A2DP)
}
fun connectHeadset(context: Context) {
fun connectHeadset() {
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
@@ -214,12 +222,12 @@ sealed interface Device<S: DeviceState, T: DeviceSettings, M: DeviceMetadata> {
}, BluetoothProfile.HEADSET)
}
fun disconnectAudio(context: Context) {
disconnectA2dp(context)
disconnectHeadset(context)
fun disconnectAudio() {
disconnectA2dp()
disconnectHeadset()
}
fun disconnectA2dp(context: Context) {
fun disconnectA2dp() {
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
@@ -240,7 +248,7 @@ sealed interface Device<S: DeviceState, T: DeviceSettings, M: DeviceMetadata> {
}, BluetoothProfile.A2DP)
}
fun disconnectHeadset(context: Context) {
fun disconnectHeadset() {
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
@@ -299,6 +307,46 @@ sealed interface Device<S: DeviceState, T: DeviceSettings, M: DeviceMetadata> {
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
@@ -163,8 +163,7 @@ fun NoiseControlSettings(
options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes()
else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
},
colors = ToggleButtonDefaults.toggleButtonColors()
.copy(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
colors = ToggleButtonDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
modifier = Modifier.fillMaxWidth()
) {
Icon(
@@ -46,7 +46,6 @@ import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.HeartRateRecord
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
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.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.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState
@@ -88,7 +86,6 @@ fun HeartRateRoute(
)
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun HeartRateScreen(
uiState: AppleUiState,
@@ -108,6 +105,11 @@ fun HeartRateScreen(
navigateBack = navigateBack,
actionButtons = listOf(
{ 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) {
FilledTonalIconToggleButton(
checked = state.hrmState == BuddyState.ACTIVE,
@@ -116,26 +118,32 @@ fun HeartRateScreen(
.minimumInteractiveComponentSize()
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)),
shape = IconButtonDefaults.mediumRoundShape,
enabled = state.hrmState != BuddyState.WAITING
) {
Icon(
imageVector = if (state.hrmState == BuddyState.ACTIVE) MaterialIcons.Pause else Icons.Default.PlayArrow,
contentDescription = "Start/Stop",
modifier = Modifier.size(IconButtonDefaults.mediumIconSize),
)
AnimatedContent(
icon
) {
Icon(
imageVector = it,
contentDescription = "Start/Stop",
modifier = Modifier.size(IconButtonDefaults.mediumIconSize),
)
}
}
} else {
StyledIconButton(
onClick = { if (state.hrmState == BuddyState.INACTIVE) startHr() else stopHr() },
backdrop = scaffoldBackdrop,
enabled = state.hrmState != BuddyState.WAITING
) {
Icon(
imageVector = if (state.hrmState == BuddyState.ACTIVE) LocalIcons.current.Pause else LocalIcons.current.Play,
contentDescription = "Start/Stop",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onBackground
)
AnimatedContent(
icon
) {
Icon(
imageVector = it,
contentDescription = "Start/Stop",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onBackground
)
}
}
}
}
@@ -297,6 +305,9 @@ fun HeartRateScreen(
updateSettings {
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
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.LocalLocale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
@@ -65,12 +67,6 @@ fun RecordingScreenRoute(
startRecording = viewModel::startRecording,
stopRecording = viewModel::stopRecording,
)
DisposableEffect(Unit) {
onDispose {
viewModel.stopRecording()
}
}
}
@Composable
@@ -140,18 +136,19 @@ fun RecordingScreen(
) { recording ->
if (recording) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(
MaterialTheme.colorScheme.surface,
RoundedCornerShape(28.dp)
)
.padding(24.dp)
modifier = Modifier.fillMaxSize()
) {
// AI-generated
Canvas(
modifier = Modifier
.fillMaxWidth()
.fillMaxSize()
.weight(1f)
.background(
MaterialTheme.colorScheme.surfaceContainerHigh,
RoundedCornerShape(28.dp)
)
.padding(vertical = 24.dp)
) {
if (history.isEmpty()) return@Canvas
@@ -196,24 +193,20 @@ fun RecordingScreen(
Spacer(Modifier.height(24.dp))
Text(
text = buildString {
val total = state.microphoneState.durationMs
append((total / 60000).toString().padStart(2, '0'))
append(':')
append(((total / 1000) % 60).toString().padStart(2, '0'))
append('.')
append(((total % 1000) / 10).toString().padStart(2, '0'))
},
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.displaySmall
)
Spacer(Modifier.height(12.dp))
// todo: duration is wrong, don't do +30ms
// Text(
// text = buildString {
// val total = state.microphoneState.durationMs
//
// append((total / 60000).toString().padStart(2, '0'))
// append(':')
//
// append(((total / 1000) % 60).toString().padStart(2, '0'))
// },
// modifier = Modifier.fillMaxWidth(),
// textAlign = TextAlign.Center,
// style = MaterialTheme.typography.displaySmall
// )
}
} else {
val scrollState = rememberScrollState()
@@ -40,7 +40,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
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.isSupported
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
@Composable
fun OnboardingScreen(
onOnboardingComplete: () -> Unit,
@@ -215,8 +213,6 @@ fun OnboardingScreen(
}
}
@OptIn(ExperimentalPermissionsApi::class)
@Preview
@Composable
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.icons.MaterialIcons
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun PermissionsPage(
onBackward: () -> Unit,
@@ -168,7 +168,7 @@ class AppleViewModel(
attObserveJob = null
}
fun startRecording() = device.startRecording()
fun startRecording() = device.startRecording(recordingRepository.createRecording())
fun stopRecording() = device.stopRecording()
fun toggleListeningMode(modeBit: Int) = device.toggleListeningMode(modeBit)
@@ -48,11 +48,14 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.LibrePodsApplication
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.aacp.types.ControlCommandIdentifier
import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType
import me.kavishdevar.librepods.bluetooth.verifyRPA
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.devices.AppleDevice
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.widgets.BatteryWidget
import me.kavishdevar.librepods.utils.MediaController
import me.kavishdevar.librepods.utils.calculateLevel
import me.kavishdevar.librepods.utils.redactMac
import java.time.ZoneOffset
import kotlin.time.Duration
@@ -96,6 +100,10 @@ class LibrePodsService: Service() {
private var islandWindow: IslandWindow? = null
private val eldDecoder = EldDecoder()
private val wavWriters = mutableMapOf<Recording, WavWriter>()
private val appleRepository by lazy {
(application as LibrePodsApplication).appleRepository
}
@@ -112,6 +120,10 @@ class LibrePodsService: Service() {
(application as LibrePodsApplication).heartRateRepository
}
private val recordingRepository by lazy {
(application as LibrePodsApplication).recordingRepository
}
private val healthConnectClient by lazy {
(application as LibrePodsApplication).healthConnectClient
}
@@ -301,6 +313,7 @@ class LibrePodsService: Service() {
deviceJobs[MacAddress(bluetoothDevice.address)]?.add(observeAppleState(device))
deviceJobs[MacAddress(bluetoothDevice.address)]?.add(observeAppleSettings(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 -> {
Log.i(TAG, "Apple device detected: ${bluetoothDevice.address.redactMac()}")
AppleDevice(
context = this,
bluetoothAdapter = bluetoothAdapter,
bluetoothDevice = bluetoothDevice,
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
}
@@ -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(
device: Device<*, *, *>,
type: IslandType = IslandType.CONNECTED,
@@ -1169,8 +1232,8 @@ class LibrePodsService: Service() {
TAG,
"User put in at least one component, enabling audio for device ${device.macAddress.toRedactedString()}"
)
device.enableAudio(this)
device.connectA2dp(this)
device.enableAudio()
device.connectA2dp()
justEnabledA2dp = true
device.waitForA2dpConnection(this) {
@@ -1194,8 +1257,8 @@ class LibrePodsService: Service() {
TAG,
"Disconnecting audio for device ${device.macAddress.toRedactedString()} because user took out all components and disconnectWhenNotWearing is true"
)
device.disableAudio(this)
device.disconnectAudio(this)
device.disableAudio()
device.disconnectAudio()
}
}
}