Refactor packages

This commit is contained in:
darken
2026-04-17 13:02:14 +02:00
committed by Matthias Urhahn
parent f141ce3f2a
commit 74e48e79eb
16 changed files with 568 additions and 270 deletions
@@ -0,0 +1,219 @@
package eu.darken.capod.main.ui.overview.cards.components
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.BatteryUnknown
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
@Composable
fun CompactBatterySummary(
device: PodDevice,
modifier: Modifier = Modifier,
) {
val hasAnyBattery = device.batteryLeft != null
|| device.batteryRight != null
|| device.batteryHeadset != null
|| device.batteryCase != null
Surface(
modifier = modifier
.fillMaxWidth()
.padding(top = 8.dp)
.then(if (!device.isLive) Modifier.alpha(0.7f) else Modifier),
shape = RoundedCornerShape(12.dp),
tonalElevation = 4.dp,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
when {
!hasAnyBattery -> EmptyBatteryRow()
device.hasDualPods -> DualPodsRow(device)
else -> SinglePodRow(device)
}
}
}
}
@Composable
private fun RowScope.DualPodsRow(device: PodDevice) {
MiniPodRing(
iconRes = device.leftPodIcon,
percent = device.batteryLeft,
)
Spacer(modifier = Modifier.width(16.dp))
MiniPodRing(
iconRes = device.rightPodIcon,
percent = device.batteryRight,
)
if (device.hasCase && device.batteryCase != null) {
Spacer(modifier = Modifier.weight(1f))
MiniCaseCluster(device = device)
}
}
@Composable
private fun RowScope.SinglePodRow(device: PodDevice) {
Spacer(modifier = Modifier.weight(1f))
MiniPodRing(
iconRes = null,
percent = device.batteryHeadset,
)
if (device.hasCase && device.batteryCase != null) {
Spacer(modifier = Modifier.weight(1f))
MiniCaseCluster(device = device)
}
Spacer(modifier = Modifier.weight(1f))
}
@Composable
private fun RowScope.EmptyBatteryRow() {
Spacer(modifier = Modifier.weight(1f))
Icon(
imageVector = Icons.AutoMirrored.TwoTone.BatteryUnknown,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.battery_unavailable_label),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.weight(1f))
}
@Composable
private fun MiniPodRing(
iconRes: Int?,
percent: Float?,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val clamped = percent?.coerceIn(0f, 1f)
val animatedProgress by animateFloatAsState(
targetValue = clamped ?: 0f,
animationSpec = tween(600, easing = FastOutSlowInEasing),
label = "miniGaugeProgress",
)
val ringColor = when {
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
clamped > 0.30f -> MaterialTheme.colorScheme.primary
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error
}
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(28.dp),
) {
CircularProgressIndicator(
progress = { 1f },
modifier = Modifier.size(28.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
strokeWidth = 3.dp,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
strokeCap = StrokeCap.Round,
)
if (clamped != null) {
CircularProgressIndicator(
progress = { animatedProgress },
modifier = Modifier.size(28.dp),
color = ringColor,
strokeWidth = 3.dp,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
strokeCap = StrokeCap.Round,
)
}
if (iconRes != null) {
Image(
painter = painterResource(iconRes),
contentDescription = null,
modifier = Modifier.size(16.dp),
)
}
}
Spacer(modifier = Modifier.width(6.dp))
Text(
text = formatBatteryPercent(context, percent),
style = MaterialTheme.typography.titleSmall,
color = if (percent != null) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
@Composable
private fun MiniCaseCluster(
device: PodDevice,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = painterResource(device.caseIcon),
contentDescription = null,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(6.dp))
BatteryCapsule(
percent = device.batteryCase ?: -1f,
modifier = Modifier
.width(36.dp)
.height(6.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = formatBatteryPercent(context, device.batteryCase),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
@@ -3,11 +3,12 @@ package eu.darken.capod.pods.core.apple.aap
import android.bluetooth.BluetoothDevice
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.bluetooth.l2cap.L2capSocketFactory
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.engine.AapConnection
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
@@ -78,7 +79,7 @@ class AapConnectionManager @Inject constructor(
// Clear stale intentional-disconnect flag from previous connection lifecycle
intentionalDisconnects.remove(address)
val profile = AapDeviceProfile.Companion.forModel(model)
val profile = AapDeviceProfile.forModel(model)
val connection = AapConnection(device, profile, socketFactory, timeSource = timeSource)
connections[address] = connection
@@ -1,5 +1,6 @@
package eu.darken.capod.pods.core.apple.aap
package eu.darken.capod.pods.core.apple.aap.engine
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import java.time.Instant
import kotlin.reflect.KClass
@@ -35,11 +36,11 @@ internal class AapAncController {
val previous = podState.settings[key]
val updatedRuntime = runtimeState.copy(latestObservedAncMode = value)
val timerActions = mutableListOf<EngineTimerAction>()
timerActions += planAllowOffInferenceTimer(podState, updatedRuntime)
timerActions.plusAssign(planAllowOffInferenceTimer(podState, updatedRuntime))
val isFirstAncMode = podState.setting<AapSetting.AncMode>() == null
return if (isFirstAncMode || isRecentAncSend) {
timerActions += EngineTimerAction.Cancel(EngineTimerKey.AncDebounce)
timerActions.plusAssign(EngineTimerAction.Cancel(EngineTimerKey.AncDebounce))
AncDecision(
podState = applyAncSetting(podState, key, value, now, isRecentAncSend),
runtimeState = updatedRuntime.copy(pendingDebouncedAnc = null),
@@ -47,7 +48,7 @@ internal class AapAncController {
logs = listOf("Setting: ${key.simpleName} = $value [was: $previous]"),
)
} else {
timerActions += EngineTimerAction.Start(EngineTimerKey.AncDebounce, 1500L)
timerActions.plusAssign(EngineTimerAction.Start(EngineTimerKey.AncDebounce, 1500L))
AncDecision(
podState = podState,
runtimeState = updatedRuntime.copy(
@@ -1,15 +1,14 @@
package eu.darken.capod.pods.core.apple.aap
package eu.darken.capod.pods.core.apple.aap.engine
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.bluetooth.l2cap.L2capSocketFactory
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
import eu.darken.capod.pods.core.apple.aap.protocol.AapFramer
@@ -31,7 +30,7 @@ import java.io.IOException
/**
* Manages a single AAP L2CAP connection to a device.
* Thin socket wrapper all session logic lives in [AapSessionEngine].
* Internal not exposed outside [AapConnectionManager].
* Internal not exposed outside [eu.darken.capod.pods.core.apple.aap.AapConnectionManager].
*/
@SuppressLint("MissingPermission")
internal class AapConnection(
@@ -67,7 +66,7 @@ internal class AapConnection(
val sock = socketFactory.createSocket(device, PSM)
sock.connect()
socket = sock
log(TAG, INFO) { "Connected to ${device.address}" }
log(TAG, Logging.Priority.INFO) { "Connected to ${device.address}" }
engine.onHandshakeSent()
@@ -99,7 +98,7 @@ internal class AapConnection(
// Launch read loop in the provided scope — connect() returns immediately
readerJob = scope.launch(Dispatchers.IO) { readLoop(sock) }
} catch (e: Exception) {
log(TAG, ERROR) { "Connection failed: $e" }
log(TAG, Logging.Priority.ERROR) { "Connection failed: $e" }
cleanupSocket()
engine.reset()
throw e
@@ -107,7 +106,7 @@ internal class AapConnection(
}
suspend fun disconnect() = withContext(Dispatchers.IO) {
log(TAG, INFO) { "Disconnecting" }
log(TAG, Logging.Priority.INFO) { "Disconnecting" }
readerJob?.cancel()
readerJob = null
engine.reset()
@@ -127,7 +126,7 @@ internal class AapConnection(
sock.outputStream.write(bytes)
sock.outputStream.flush()
val hex = bytes.joinToString(" ") { "%02X".format(it) }
log(TAG, VERBOSE) { "SEND cmd=$command len=${bytes.size} raw=$hex" }
log(TAG, Logging.Priority.VERBOSE) { "SEND cmd=$command len=${bytes.size} raw=$hex" }
}
}
}
@@ -145,13 +144,13 @@ internal class AapConnection(
// L2CAP SEQPACKET: each read() returns exactly one complete message
val raw = buf.copyOfRange(0, len)
val message = AapMessage.Companion.parse(raw)
val message = AapMessage.parse(raw)
if (message != null) {
engine.processMessage(message)
}
}
} catch (e: IOException) {
if (isActive) log(TAG, ERROR) { "Read error: $e" }
if (isActive) log(TAG, Logging.Priority.ERROR) { "Read error: $e" }
} finally {
engine.reset()
cleanupSocket()
@@ -170,4 +169,4 @@ internal class AapConnection(
private const val PSM = 0x1001
private val TAG = logTag("AAP", "Connection")
}
}
}
@@ -1,4 +1,7 @@
package eu.darken.capod.pods.core.apple.aap
package eu.darken.capod.pods.core.apple.aap.engine
import java.nio.ByteBuffer
import java.nio.charset.CodingErrorAction
/**
* Diagnostic-only NUL-delimited segmentation of a 0x1D INFORMATION payload, used for
@@ -28,11 +31,11 @@ internal object AapDeviceInfoDiagnostics {
val utf8: String? = try {
Charsets.UTF_8.newDecoder()
.onMalformedInput(java.nio.charset.CodingErrorAction.REPORT)
.onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT)
.decode(java.nio.ByteBuffer.wrap(segBytes))
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(segBytes))
.toString()
} catch (_: java.nio.charset.CharacterCodingException) {
} catch (_: CharacterCodingException) {
null
}
val hex = segBytes.joinToString("") { "%02X".format(it) }
@@ -1,5 +1,6 @@
package eu.darken.capod.pods.core.apple.aap
package eu.darken.capod.pods.core.apple.aap.engine
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage
@@ -1,6 +1,7 @@
package eu.darken.capod.pods.core.apple.aap
package eu.darken.capod.pods.core.apple.aap.engine
import eu.darken.capod.common.TimeSource
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
@@ -1,4 +1,4 @@
package eu.darken.capod.pods.core.apple.aap
package eu.darken.capod.pods.core.apple.aap.engine
import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
@@ -6,6 +6,7 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage
@@ -1,6 +1,7 @@
package eu.darken.capod.pods.core.apple.aap
package eu.darken.capod.pods.core.apple.aap.engine
import eu.darken.capod.common.TimeSource
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import kotlin.reflect.KClass
@@ -95,38 +96,47 @@ internal class AapSettingsCoordinator(
val cur = baseState.setting<AapSetting.ConversationalAwareness>() ?: return null
AapSetting.ConversationalAwareness::class to cur.copy(enabled = command.enabled)
}
is AapCommand.SetNcWithOneAirPod -> {
val cur = baseState.setting<AapSetting.NcWithOneAirPod>() ?: return null
AapSetting.NcWithOneAirPod::class to cur.copy(enabled = command.enabled)
}
is AapCommand.SetVolumeSwipe -> {
val cur = baseState.setting<AapSetting.VolumeSwipe>() ?: return null
AapSetting.VolumeSwipe::class to cur.copy(enabled = command.enabled)
}
is AapCommand.SetPersonalizedVolume -> {
val cur = baseState.setting<AapSetting.PersonalizedVolume>() ?: return null
AapSetting.PersonalizedVolume::class to cur.copy(enabled = command.enabled)
}
is AapCommand.SetToneVolume -> {
baseState.setting<AapSetting.ToneVolume>() ?: return null
AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = command.level)
}
is AapCommand.SetAdaptiveAudioNoise -> {
baseState.setting<AapSetting.AdaptiveAudioNoise>() ?: return null
AapSetting.AdaptiveAudioNoise::class to AapSetting.AdaptiveAudioNoise(level = command.level)
}
is AapCommand.SetPressSpeed -> {
baseState.setting<AapSetting.PressSpeed>() ?: return null
AapSetting.PressSpeed::class to AapSetting.PressSpeed(value = command.value)
}
is AapCommand.SetPressHoldDuration -> {
baseState.setting<AapSetting.PressHoldDuration>() ?: return null
AapSetting.PressHoldDuration::class to AapSetting.PressHoldDuration(value = command.value)
}
is AapCommand.SetVolumeSwipeLength -> {
baseState.setting<AapSetting.VolumeSwipeLength>() ?: return null
AapSetting.VolumeSwipeLength::class to AapSetting.VolumeSwipeLength(value = command.value)
}
is AapCommand.SetEndCallMuteMic -> {
baseState.setting<AapSetting.EndCallMuteMic>() ?: return null
AapSetting.EndCallMuteMic::class to AapSetting.EndCallMuteMic(
@@ -134,24 +144,31 @@ internal class AapSettingsCoordinator(
endCall = command.endCall,
)
}
is AapCommand.SetMicrophoneMode -> {
AapSetting.MicrophoneMode::class to AapSetting.MicrophoneMode(mode = command.mode)
}
is AapCommand.SetEarDetectionEnabled -> {
AapSetting.EarDetectionEnabled::class to AapSetting.EarDetectionEnabled(enabled = command.enabled)
}
is AapCommand.SetListeningModeCycle -> {
AapSetting.ListeningModeCycle::class to AapSetting.ListeningModeCycle(modeMask = command.modeMask)
}
is AapCommand.SetAllowOffOption -> {
AapSetting.AllowOffOption::class to AapSetting.AllowOffOption(enabled = command.enabled)
}
is AapCommand.SetStemConfig -> {
AapSetting.StemConfig::class to AapSetting.StemConfig(claimedPressMask = command.claimedPressMask)
}
is AapCommand.SetSleepDetection -> {
AapSetting.SleepDetection::class to AapSetting.SleepDetection(enabled = command.enabled)
}
is AapCommand.SetDeviceName -> {
val currentInfo = baseState.deviceInfo ?: return null
return baseState.copy(
@@ -178,6 +195,7 @@ internal class AapSettingsCoordinator(
val cur = s.setting<AapSetting.EndCallMuteMic>()
cur != null && cur.muteMic == command.muteMic && cur.endCall == command.endCall
}
is AapCommand.SetMicrophoneMode -> { s -> s.setting<AapSetting.MicrophoneMode>()?.mode == command.mode }
is AapCommand.SetEarDetectionEnabled -> { s -> s.setting<AapSetting.EarDetectionEnabled>()?.enabled == command.enabled }
is AapCommand.SetListeningModeCycle -> { s -> s.setting<AapSetting.ListeningModeCycle>()?.modeMask == command.modeMask }
@@ -186,4 +204,4 @@ internal class AapSettingsCoordinator(
is AapCommand.SetSleepDetection -> { s -> s.setting<AapSetting.SleepDetection>()?.enabled == command.enabled }
is AapCommand.SetDeviceName -> null
}
}
}
@@ -1,4 +1,4 @@
package eu.darken.capod.pods.core.apple.aap
package eu.darken.capod.pods.core.apple.aap.engine
/**
* Batches cmd 0x0017 HID descriptor frames and emits structured summaries.
@@ -21,6 +21,7 @@ internal class HidTracker(private val log: (String) -> Unit) {
val names = type.services.joinToString(", ")
log("HID: services=[$names] (${payload.size}B)")
}
is HidFrameType.Descriptor -> {
if (bulkCount == 0) {
bulkPhase = type.phase
@@ -35,10 +36,12 @@ internal class HidTracker(private val log: (String) -> Unit) {
bulkCount = 1
}
}
is HidFrameType.Terminator -> {
flush()
log("HID: terminator (${type.payloadSize}B)")
}
is HidFrameType.Other -> {
flush()
val hex = payload.joinToString(" ") { "%02X".format(it) }
@@ -111,4 +114,4 @@ internal class HidTracker(private val log: (String) -> Unit) {
return HidFrameType.Other
}
}
}
}
@@ -19,7 +19,7 @@ data class AapMessage(
companion object {
/**
* Parse a complete AAP message from raw bytes.
* AAP messages have the format: [4-byte header] [2-byte command type] [payload...]
* AAP messages have the format: [4-byte header] [2-byte command type] [payload]
* Minimum message size is 6 bytes (header + command type with no payload).
*/
fun parse(raw: ByteArray): AapMessage? {