mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-09-02 17:29:57 +02:00
android: refactor... stuff (5, see description)
reimplement recording mainily
This commit is contained in:
@@ -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 {
|
||||
|
||||
+10
-14
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+1
-2
@@ -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(
|
||||
|
||||
+27
-16
@@ -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()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
+24
-31
@@ -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()
|
||||
|
||||
-4
@@ -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(){
|
||||
|
||||
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user