diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 0e077a75..8dd5392b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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" ) } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/audio/EldDecoder.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/audio/EldDecoder.kt index c2ce9b28..e75eb75a 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/audio/EldDecoder.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/audio/EldDecoder.kt @@ -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() } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/audio/WavWriter.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/audio/WavWriter.kt index 1804846e..49da1a25 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/audio/WavWriter.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/audio/WavWriter.kt @@ -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 { diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/AACPManager.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/AACPManager.kt index 75f7bf24..03b46cb3 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/AACPManager.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/AACPManager.kt @@ -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 ) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/RTBuddyManager.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/RTBuddyManager.kt index 3d08dd28..013d961a 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/RTBuddyManager.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/RTBuddyManager.kt @@ -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 { diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/Events.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/Events.kt index 1a30213a..3a75ad6b 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/Events.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/Events.kt @@ -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 ) } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/data/recording/RecordingState.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/recording/RecordingState.kt index 7812cea8..31d0d89c 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/data/recording/RecordingState.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/recording/RecordingState.kt @@ -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 -} +) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleDevice.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleDevice.kt index 6fb234cc..595a791b 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleDevice.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleDevice.kt @@ -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() val events = _events.asSharedFlow() + private val _microphoneFrames = MutableSharedFlow( + 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 diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleState.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleState.kt index d88893a5..9ee771af 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleState.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleState.kt @@ -41,7 +41,6 @@ data class AppleState( val customEq: CustomEq = CustomEq(1, 50, 50, 50), - val microphoneFrames: List = 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, diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/Device.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/Device.kt index 3338d79b..92eff24e 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/Device.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/Device.kt @@ -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 { 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 { 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 { } } - 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 { } } - 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 { }, 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 { }, 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 { }, 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 { }, 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 { }, 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 { 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 diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/apple/NoiseControlSettings.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/apple/NoiseControlSettings.kt index 865e6aad..aeb6d9b5 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/apple/NoiseControlSettings.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/apple/NoiseControlSettings.kt @@ -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( diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HeartRateScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HeartRateScreen.kt index 87a49cb5..ed66e408 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HeartRateScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HeartRateScreen.kt @@ -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() + } }, ) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/RecordingScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/RecordingScreen.kt index 56d2f339..ce207993 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/RecordingScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/RecordingScreen.kt @@ -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() diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/onboarding/OnboardingScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/onboarding/OnboardingScreen.kt index c828da28..7e16b0d0 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/onboarding/OnboardingScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/onboarding/OnboardingScreen.kt @@ -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(){ diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/onboarding/PermissionsPage.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/onboarding/PermissionsPage.kt index 3785922a..eca9b94b 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/onboarding/PermissionsPage.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/onboarding/PermissionsPage.kt @@ -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, diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/viewmodel/AppleViewModel.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/viewmodel/AppleViewModel.kt index 4ee2c42b..d1dbab65 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/viewmodel/AppleViewModel.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/viewmodel/AppleViewModel.kt @@ -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) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt index cadf113a..df87fe62 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt @@ -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() + 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() } } }