diff --git a/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt index 2aa9158c3..21f445f80 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt @@ -92,6 +92,7 @@ import me.kavishdevar.librepods.presentation.components.VerticalVolumeSlider import me.kavishdevar.librepods.data.AirPodsNotifications import me.kavishdevar.librepods.data.NoiseControlMode import me.kavishdevar.librepods.services.AirPodsService +import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme import me.kavishdevar.librepods.bluetooth.AACPManager import kotlin.io.encoding.ExperimentalEncodingApi @@ -311,15 +312,14 @@ fun NewControlCenterDialogContent( var isConvAwarenessEnabled by remember { mutableStateOf(false) } val isOffModeEnabled = remember { sharedPreferences.getBoolean("off_listening_mode", true) } - val availableModes = remember(isOffModeEnabled) { - mutableListOf( - NoiseControlMode.TRANSPARENCY, - NoiseControlMode.ADAPTIVE, - NoiseControlMode.NOISE_CANCELLATION - ).apply { - if (isOffModeEnabled) { - add(0, NoiseControlMode.OFF) - } + // The AirPods Max have no Adaptive mode. + val isAdaptiveSupported = remember { ServiceManager.getService()?.supportsAdaptiveMode != false } + val availableModes = remember(isOffModeEnabled, isAdaptiveSupported) { + buildList { + if (isOffModeEnabled) add(NoiseControlMode.OFF) + add(NoiseControlMode.TRANSPARENCY) + if (isAdaptiveSupported) add(NoiseControlMode.ADAPTIVE) + add(NoiseControlMode.NOISE_CANCELLATION) } } @@ -427,7 +427,9 @@ fun NewControlCenterDialogContent( verticalArrangement = Arrangement.Center ) { Icon( - painter = painterResource(id = R.drawable.airpods), + painter = painterResource( + id = ServiceManager.getService()?.currentIconRes ?: R.drawable.airpods + ), contentDescription = "Device Icon", tint = textColor.copy(alpha = 0.8f), modifier = Modifier.size(48.dp) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BLEManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BLEManager.kt index 52fa05512..0d6072b1a 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BLEManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BLEManager.kt @@ -48,6 +48,8 @@ class BLEManager(private val context: Context) { val lastSeen: Long = System.currentTimeMillis(), val paired: Boolean = false, val model: String = "Unknown", + /** Raw proximity-pairing model id, e.g. 0x0A20 for the AirPods Max. */ + val modelId: Int = 0, val leftBattery: Int? = null, val rightBattery: Int? = null, val caseBattery: Int? = null, @@ -375,6 +377,7 @@ class BLEManager(private val context: Context) { lastSeen = System.currentTimeMillis(), paired = paired, model = model, + modelId = modelId, leftBattery = leftBattery, rightBattery = rightBattery, caseBattery = caseBattery, @@ -475,6 +478,7 @@ class BLEManager(private val context: Context) { lastSeen = System.currentTimeMillis(), paired = paired, model = model, + modelId = modelId, leftBattery = decodeBattery(leftBatteryNibble), rightBattery = decodeBattery(rightBatteryNibble), caseBattery = decodeBattery(caseBattery), diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/AirPods.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/AirPods.kt index 9d83f05f5..4557a4c6d 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/data/AirPods.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/data/AirPods.kt @@ -20,6 +20,14 @@ package me.kavishdevar.librepods.data import me.kavishdevar.librepods.R +enum class FormFactor { + /** Earbuds that live in a charging case, reporting a left, right and case battery. */ + IN_EAR, + + /** Over-ear headphones: a single body, a single battery and a case that does not charge. */ + OVER_EAR +} + open class AirPodsBase( val modelNumber: List, val name: String, @@ -30,8 +38,19 @@ open class AirPodsBase( val leftBudsRes: Int, val rightBudsRes: Int, val caseRes: Int, - val capabilities: Set -) + val capabilities: Set, + val formFactor: FormFactor = FormFactor.IN_EAR, + /** Monochrome icon used for the notification, the QS tile and the popup. */ + val iconRes: Int = R.drawable.airpods, + /** Proximity-pairing model ids advertised over BLE, used when the model number is unknown. */ + val bleModelIds: Set = emptySet() +) { + /** Over-ear models have a case, but it holds no battery and is never reported. */ + val hasCase: Boolean get() = formFactor == FormFactor.IN_EAR + + /** Over-ear models are a single unit and report one battery instead of a left/right pair. */ + val isSingleBattery: Boolean get() = formFactor == FormFactor.OVER_EAR +} enum class Capability { LISTENING_MODE, CONVERSATION_AWARENESS, @@ -60,7 +79,8 @@ class AirPods: AirPodsBase( rightBudsRes = R.drawable.airpods_pro_2_right, // caseRes = R.drawable.airpods_1_case caseRes = R.drawable.airpods_pro_2_case, - capabilities = emptySet() + capabilities = emptySet(), + bleModelIds = setOf(0x0220) ) class AirPods2: AirPodsBase( @@ -76,7 +96,8 @@ class AirPods2: AirPodsBase( rightBudsRes = R.drawable.airpods_pro_2_right, // caseRes = R.drawable.airpods_2_case caseRes = R.drawable.airpods_pro_2_case, - capabilities = emptySet() + capabilities = emptySet(), + bleModelIds = setOf(0x0F20) ) class AirPods3: AirPodsBase( @@ -94,7 +115,8 @@ class AirPods3: AirPodsBase( caseRes = R.drawable.airpods_pro_2_case, capabilities = setOf( Capability.HEAD_GESTURES - ) + ), + bleModelIds = setOf(0x1320) ) class AirPods4: AirPodsBase( @@ -114,7 +136,8 @@ class AirPods4: AirPodsBase( Capability.HEAD_GESTURES, Capability.SLEEP_DETECTION, Capability.ADAPTIVE_VOLUME - ) + ), + bleModelIds = setOf(0x1920) ) class AirPods4ANC: AirPodsBase( @@ -138,7 +161,8 @@ class AirPods4ANC: AirPodsBase( Capability.SLEEP_DETECTION, Capability.ADAPTIVE_VOLUME, Capability.STEM_CONFIG - ) + ), + bleModelIds = setOf(0x1B20) ) class AirPodsPro1: AirPodsBase( @@ -157,7 +181,8 @@ class AirPodsPro1: AirPodsBase( caseRes = R.drawable.airpods_pro_2_case, capabilities = setOf( Capability.LISTENING_MODE - ) + ), + bleModelIds = setOf(0x0E20) ) class AirPodsPro2Lightning: AirPodsBase( @@ -185,7 +210,8 @@ class AirPodsPro2Lightning: AirPodsBase( Capability.ADAPTIVE_VOLUME, Capability.SWIPE_FOR_VOLUME, Capability.HEAD_GESTURES - ) + ), + bleModelIds = setOf(0x1420) ) class AirPodsPro2USBC: AirPodsBase( @@ -213,7 +239,8 @@ class AirPodsPro2USBC: AirPodsBase( Capability.ADAPTIVE_VOLUME, Capability.SWIPE_FOR_VOLUME, Capability.HEAD_GESTURES - ) + ), + bleModelIds = setOf(0x2420) ) class AirPodsPro3: AirPodsBase( @@ -246,6 +273,61 @@ class AirPodsPro3: AirPodsBase( ) ) +class AirPodsMax: AirPodsBase( + modelNumber = listOf("A2096"), + name = "AirPods Max", + displayName = "AirPods Max", + budCaseRes = R.drawable.airpods_max_device, + budsRes = R.drawable.airpods_max_buds, + leftBudsRes = R.drawable.airpods_max_left, + rightBudsRes = R.drawable.airpods_max_right, + // AirPods Max ship with a Smart Case that holds no battery, so this is never shown. + caseRes = R.drawable.airpods_max_case, + capabilities = setOf( + Capability.LISTENING_MODE + ), + formFactor = FormFactor.OVER_EAR, + iconRes = R.drawable.airpods_max_icon, + bleModelIds = setOf(0x0A20) +) + +class AirPodsMaxUSBC: AirPodsBase( + modelNumber = listOf("A3184"), + name = "AirPods Max (USB-C)", + displayName = "AirPods Max", + budCaseRes = R.drawable.airpods_max_usbc_device, + budsRes = R.drawable.airpods_max_usbc_buds, + leftBudsRes = R.drawable.airpods_max_usbc_left, + rightBudsRes = R.drawable.airpods_max_usbc_right, + caseRes = R.drawable.airpods_max_usbc_case, + capabilities = setOf( + Capability.LISTENING_MODE + ), + formFactor = FormFactor.OVER_EAR, + iconRes = R.drawable.airpods_max_usbc_icon, + bleModelIds = setOf(0x1F20) +) + +class AirPodsMax2: AirPodsBase( + modelNumber = listOf("A3454"), + name = "AirPods Max 2", + displayName = "AirPods Max", + // No separate product render is published yet; the two generations look the same. + budCaseRes = R.drawable.airpods_max_usbc_device, + budsRes = R.drawable.airpods_max_usbc_buds, + leftBudsRes = R.drawable.airpods_max_usbc_left, + rightBudsRes = R.drawable.airpods_max_usbc_right, + caseRes = R.drawable.airpods_max_usbc_case, + capabilities = setOf( + Capability.LISTENING_MODE, + Capability.CONVERSATION_AWARENESS, + Capability.ADAPTIVE_AUDIO, + Capability.ADAPTIVE_VOLUME + ), + formFactor = FormFactor.OVER_EAR, + iconRes = R.drawable.airpods_max_usbc_icon +) + data class AirPodsInstance( val name: String, val model: AirPodsBase, @@ -268,10 +350,47 @@ object AirPodsModels { AirPodsPro1(), AirPodsPro2Lightning(), AirPodsPro2USBC(), - AirPodsPro3() + AirPodsPro3(), + AirPodsMax(), + AirPodsMaxUSBC(), + AirPodsMax2() ) fun getModelByModelNumber(modelNumber: String): AirPodsBase? { return models.find { modelNumber in it.modelNumber } } + + /** Looks up a model by the proximity-pairing id it advertises over BLE. */ + fun getModelByBleModelId(bleModelId: Int): AirPodsBase? { + return models.find { bleModelId in it.bleModelIds } + } + + /** + * Last-resort match on the Bluetooth name, for firmware that reports a model number we do not + * know yet. Only distinctive names are matched; anything ambiguous is left unresolved so the + * caller can fall back to its own default. + */ + fun getModelByName(name: String): AirPodsBase? { + val n = name.lowercase() + if (!n.contains("airpod")) return null + return when { + n.contains("max") -> if (n.contains("usb")) AirPodsMaxUSBC() else AirPodsMax() + else -> null + } + } + + /** + * Resolves a model from whatever identifying information is available, most reliable first. + * Returns null when nothing matches, so callers keep control over the fallback. + */ + fun resolveModel( + modelNumber: String? = null, + bleModelId: Int? = null, + name: String? = null + ): AirPodsBase? { + modelNumber?.takeIf { it.isNotBlank() }?.let { getModelByModelNumber(it)?.let { m -> return m } } + bleModelId?.let { getModelByBleModelId(it)?.let { m -> return m } } + name?.takeIf { it.isNotBlank() }?.let { getModelByName(it)?.let { m -> return m } } + return null + } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/Packets.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/Packets.kt index fe0232f3a..a6f1ec3bc 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/data/Packets.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/data/Packets.kt @@ -154,22 +154,49 @@ class AirPodsNotifications { } class BatteryNotification { + private companion object { + const val HEADER_HEX = "040004000400" + + /** 6 byte prefix followed by the number of battery entries in the packet. */ + const val HEADER_SIZE = 7 + const val ENTRY_SIZE = 5 + } + private var first: Battery = Battery(BatteryComponent.LEFT, 0, BatteryStatus.DISCONNECTED) private var second: Battery = Battery(BatteryComponent.RIGHT, 0, BatteryStatus.DISCONNECTED) private var case: Battery = Battery(BatteryComponent.CASE, 0, BatteryStatus.DISCONNECTED) + /** + * Set when the last packet carried a single battery, as over-ear models such as the + * AirPods Max do. Earbuds report one entry per bud plus the case. + */ + var isSingleBattery: Boolean = false + private set + fun isBatteryData(data: ByteArray): Boolean { - if (data.joinToString("") { "%02x".format(it) }.startsWith("040004000400")) { - Log.d("BatteryNotification", "Battery data starts with 040004000400. Most likely is a battery packet.") - } else { + if (!data.joinToString("") { "%02x".format(it) }.startsWith(HEADER_HEX)) { + return false + } + if (data.size < HEADER_SIZE) { + Log.d("BatteryNotification", "Battery packet too short: ${data.size} bytes.") return false } - if (data.size != 22) { - Log.d("BatteryNotification", "Battery data size is not 22, probably being used with Airpods with fewer or more battery count.") + // One entry per battery: three for earbuds (left, right, case) and one for the + // AirPods Max, which are a single unit with no case battery. + val count = data[6].toInt() + if (count !in 1..3) { + Log.d("BatteryNotification", "Unexpected battery entry count: $count.") return false } - Log.d("BatteryNotification", data.joinToString("") { "%02x".format(it) }.startsWith("040004000400").toString()) - return data.joinToString("") { "%02x".format(it) }.startsWith("040004000400") + val expected = HEADER_SIZE + count * ENTRY_SIZE + if (data.size != expected) { + Log.d( + "BatteryNotification", + "Battery packet size ${data.size} does not match $count entries (expected $expected)." + ) + return false + } + return true } fun setBatteryDirect( @@ -180,40 +207,38 @@ class AirPodsNotifications { caseLevel: Int, caseCharging: Boolean ) { + isSingleBattery = false first = Battery(BatteryComponent.LEFT, leftLevel, if (leftCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING) second = Battery(BatteryComponent.RIGHT, rightLevel, if (rightCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING) case = Battery(BatteryComponent.CASE, caseLevel, if (caseCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING) } fun setBattery(data: ByteArray) { - if (data.size != 22) { + if (!isBatteryData(data)) { return } -// first = if (data[10].toInt() == BatteryStatus.DISCONNECTED) { -// Battery(first.component, first.level, data[10].toInt()) -// } else { -// Battery(data[7].toInt(), data[9].toInt(), data[10].toInt()) -// } -// second = if (data[15].toInt() == BatteryStatus.DISCONNECTED) { -// Battery(second.component, second.level, data[15].toInt()) -// } else { -// Battery(data[12].toInt(), data[14].toInt(), data[15].toInt()) -// } -// case = if (data[20].toInt() == BatteryStatus.DISCONNECTED && case.status != BatteryStatus.DISCONNECTED) { -// Battery(case.component, case.level, data[20].toInt()) -// } else { -// Battery(data[17].toInt(), data[19].toInt(), data[20].toInt()) -// } -// sometimes it shows battery as -1%, just skip all that and set it normally - first = Battery( - data[7].toInt(), data[9].toInt(), data[10].toInt() - ) - second = Battery( - data[12].toInt(), data[14].toInt(), data[15].toInt() - ) - case = Battery( - data[17].toInt(), data[19].toInt(), data[20].toInt() - ) + val count = data[6].toInt() + val entries = (0 until count).map { i -> + val base = HEADER_SIZE + i * ENTRY_SIZE + // sometimes it shows battery as -1%, just skip all that and set it normally + Battery(data[base].toInt(), data[base + 2].toInt(), data[base + 3].toInt()) + } + + isSingleBattery = count == 1 + if (isSingleBattery) { + // The AirPods Max are one unit with one battery. Mirror it onto both sides so + // callers that expect a left/right pair keep working; the UI collapses the two + // equal readings back into a single percentage. + val only = entries[0] + first = Battery(BatteryComponent.LEFT, only.level, only.status) + second = Battery(BatteryComponent.RIGHT, only.level, only.status) + case = Battery(BatteryComponent.CASE, 0, BatteryStatus.DISCONNECTED) + return + } + + first = entries[0] + second = entries.getOrElse(1) { second } + case = entries.getOrElse(2) { Battery(BatteryComponent.CASE, 0, BatteryStatus.DISCONNECTED) } } fun getBattery(): List { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/BatteryView.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/BatteryView.kt index a3f9dffa5..9e689395f 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/BatteryView.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/BatteryView.kt @@ -39,8 +39,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.res.imageResource +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -54,7 +53,9 @@ import kotlin.io.encoding.ExperimentalEncodingApi fun BatteryView( batteryList: List, budsRes: Int, - caseRes: Int + caseRes: Int, + /** Over-ear models such as the AirPods Max have no case battery to show. */ + hasCase: Boolean = true ) { val left = batteryList.find { it.component == BatteryComponent.LEFT } val right = batteryList.find { it.component == BatteryComponent.RIGHT } @@ -75,11 +76,11 @@ fun BatteryView( horizontalArrangement = Arrangement.Center ) { Column( - modifier = Modifier.weight(1f), + modifier = if (hasCase) Modifier.weight(1f) else Modifier.fillMaxWidth(0.55f), horizontalAlignment = Alignment.CenterHorizontally ) { Image( - bitmap = ImageBitmap.imageResource(budsRes), + painter = painterResource(budsRes), contentDescription = stringResource(R.string.buds), modifier = Modifier .fillMaxWidth() @@ -125,24 +126,26 @@ fun BatteryView( } } - Column( - modifier = Modifier.weight(1f), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Image( - bitmap = ImageBitmap.imageResource(caseRes), - contentDescription = stringResource(R.string.case_alt), - modifier = Modifier - .fillMaxWidth() - .padding(8.dp) - ) - - if (caseLevel > 0 || case?.status != BatteryStatus.DISCONNECTED) { - BatteryIndicator( - caseLevel, - case?.status ?: BatteryStatus.NOT_CHARGING, - prefix = if (!singleDisplayed.value) "\uDBC3\uDE6C" else "" + if (hasCase) { + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(caseRes), + contentDescription = stringResource(R.string.case_alt), + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) ) + + if (caseLevel > 0 || case?.status != BatteryStatus.DISCONNECTED) { + BatteryIndicator( + caseLevel, + case?.status ?: BatteryStatus.NOT_CHARGING, + prefix = if (!singleDisplayed.value) "\uDBC3\uDE6C" else "" + ) + } } } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NoiseControlSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NoiseControlSettings.kt index 4fbcf332d..532b75553 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NoiseControlSettings.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NoiseControlSettings.kt @@ -88,7 +88,9 @@ import kotlin.math.roundToInt fun NoiseControlSettings( showOffListeningMode: Boolean, noiseControlModeValue: Int, - onNoiseControlModeChanged: (Int) -> Unit + onNoiseControlModeChanged: (Int) -> Unit, + /** The AirPods Max and the first-generation Pro have no Adaptive listening mode. */ + showAdaptiveMode: Boolean = true ) { when (LocalDesignSystem.current) { DesignSystem.Material -> { @@ -110,13 +112,15 @@ fun NoiseControlSettings( R.drawable.transparency ) ) - add( - Triple( - NoiseControlMode.ADAPTIVE, - R.string.adaptive, - R.drawable.adaptive + if (showAdaptiveMode) { + add( + Triple( + NoiseControlMode.ADAPTIVE, + R.string.adaptive, + R.drawable.adaptive + ) ) - ) + } add( Triple( NoiseControlMode.NOISE_CANCELLATION, @@ -193,56 +197,41 @@ fun NoiseControlSettings( val textColorSelected = if (isDarkTheme) Color.White else Color.Black val selectedBackground = if (isDarkTheme) Color(0xBF5C5A5F) else Color(0xFFFFFFFF) - val noiseControlMode = remember { mutableStateOf(NoiseControlMode.OFF) } + // The segments this model actually offers. Off is optional, and the AirPods Max have + // no Adaptive mode, so the control is built from this list instead of a fixed four. + val modes = buildList { + if (showOffListeningMode) add(NoiseControlMode.OFF) + add(NoiseControlMode.TRANSPARENCY) + if (showAdaptiveMode) add(NoiseControlMode.ADAPTIVE) + add(NoiseControlMode.NOISE_CANCELLATION) + } - val d1a = remember { mutableFloatStateOf(0f) } - val d2a = remember { mutableFloatStateOf(0f) } - val d3a = remember { mutableFloatStateOf(0f) } + val noiseControlMode = remember { mutableStateOf(modes.first()) } - // this function exists solely for the dividers, should get rid of it fun onModeSelected(mode: NoiseControlMode, received: Boolean = false) { val previousMode = noiseControlMode.value - - val targetMode = if (!showOffListeningMode && mode == NoiseControlMode.OFF) { - NoiseControlMode.TRANSPARENCY - } else { - mode + // The device can report a mode we do not show; snap it to the nearest one we do. + val targetMode = when { + mode in modes -> mode + mode == NoiseControlMode.OFF -> NoiseControlMode.TRANSPARENCY + else -> NoiseControlMode.NOISE_CANCELLATION } noiseControlMode.value = targetMode - if (!received && targetMode != previousMode) onNoiseControlModeChanged(targetMode.ordinal + 1) - - - when (noiseControlMode.value) { - NoiseControlMode.NOISE_CANCELLATION -> { - d1a.floatValue = 1f - d2a.floatValue = 1f - d3a.floatValue = 0f - } - NoiseControlMode.OFF -> { - d1a.floatValue = 0f - d2a.floatValue = 1f - d3a.floatValue = 1f - } - NoiseControlMode.ADAPTIVE -> { - d1a.floatValue = 1f - d2a.floatValue = 0f - d3a.floatValue = 0f - } - NoiseControlMode.TRANSPARENCY -> { - d1a.floatValue = 0f - d2a.floatValue = 0f - d3a.floatValue = 1f - } + if (!received && targetMode != previousMode) { + onNoiseControlModeChanged(targetMode.ordinal + 1) } } + val reportedIndex = + (noiseControlModeValue - 1).coerceIn(0, NoiseControlMode.entries.size - 1) + onModeSelected(NoiseControlMode.entries[reportedIndex], received = true) - val index = (noiseControlModeValue - 1).coerceIn(0, NoiseControlMode.entries.size - 1) - noiseControlMode.value = NoiseControlMode.entries[index] - - onModeSelected(noiseControlMode.value, received = true) + val selectedIndex = modes.indexOf(noiseControlMode.value).coerceAtLeast(0) + // A divider is hidden while it touches the selected pill. + val dividerAlpha = + { i: Int -> if (selectedIndex == i || selectedIndex == i + 1) 0f else 1f } Box( modifier = Modifier @@ -262,21 +251,12 @@ fun NoiseControlSettings( .padding(vertical = 8.dp) ) { val density = LocalDensity.current - val buttonCount = if (showOffListeningMode) 4 else 3 + val buttonCount = modes.size val buttonWidth = maxWidth / buttonCount val isDragging = remember { mutableStateOf(false) } var dragOffset by remember { - mutableFloatStateOf( - with(density) { - when(noiseControlMode.value) { - NoiseControlMode.OFF -> if (showOffListeningMode) 0f else buttonWidth.toPx() - NoiseControlMode.TRANSPARENCY -> if (showOffListeningMode) buttonWidth.toPx() else 0f - NoiseControlMode.ADAPTIVE -> if (showOffListeningMode) (buttonWidth * 2).toPx() else buttonWidth.toPx() - NoiseControlMode.NOISE_CANCELLATION -> if (showOffListeningMode) (buttonWidth * 3).toPx() else (buttonWidth * 2).toPx() - } - } - ) + mutableFloatStateOf(with(density) { (buttonWidth * selectedIndex).toPx() }) } val animationSpec: AnimationSpec = SpringSpec( @@ -285,12 +265,7 @@ fun NoiseControlSettings( visibilityThreshold = 0.01f ) - val targetOffset = buttonWidth * when(noiseControlMode.value) { - NoiseControlMode.OFF -> if (showOffListeningMode) 0 else 1 - NoiseControlMode.TRANSPARENCY -> if (showOffListeningMode) 1 else 0 - NoiseControlMode.ADAPTIVE -> if (showOffListeningMode) 2 else 1 - NoiseControlMode.NOISE_CANCELLATION -> if (showOffListeningMode) 3 else 2 - } + val targetOffset = buttonWidth * selectedIndex val animatedOffset by animateFloatAsState( targetValue = with(density) { @@ -309,61 +284,14 @@ fun NoiseControlSettings( .height(60.dp) .background(backgroundColor, RoundedCornerShape(28.dp)) ) { - Row( - modifier = Modifier.fillMaxWidth() - ) { - if (showOffListeningMode) { - NoiseControlButton( - icon = ImageBitmap.imageResource(R.drawable.noise_cancellation), - onClick = { onModeSelected(NoiseControlMode.OFF) }, - textColor = if (noiseControlMode.value == NoiseControlMode.OFF) textColorSelected else textColor, - modifier = Modifier.weight(1f), - usePadding = false - ) - VerticalDivider( - thickness = 1.dp, - modifier = Modifier - .padding(vertical = 10.dp) - .alpha(d1a.floatValue), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) - ) - } - NoiseControlButton( - icon = ImageBitmap.imageResource(R.drawable.transparency), - onClick = { onModeSelected(NoiseControlMode.TRANSPARENCY) }, - textColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) textColorSelected else textColor, - modifier = Modifier.weight(1f), - usePadding = false - ) - VerticalDivider( - thickness = 1.dp, - modifier = Modifier - .padding(vertical = 10.dp) - .alpha(d2a.floatValue), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) - ) - NoiseControlButton( - icon = ImageBitmap.imageResource(R.drawable.adaptive), - onClick = { onModeSelected(NoiseControlMode.ADAPTIVE) }, - textColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) textColorSelected else textColor, - modifier = Modifier.weight(1f), - usePadding = false - ) - VerticalDivider( - thickness = 1.dp, - modifier = Modifier - .padding(vertical = 10.dp) - .alpha(d3a.floatValue), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) - ) - NoiseControlButton( - icon = ImageBitmap.imageResource(R.drawable.noise_cancellation), - onClick = { onModeSelected(NoiseControlMode.NOISE_CANCELLATION) }, - textColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) textColorSelected else textColor, - modifier = Modifier.weight(1f), - usePadding = false - ) - } + NoiseControlSegments( + modes = modes, + selectedMode = noiseControlMode.value, + textColor = textColor, + textColorSelected = textColorSelected, + dividerAlpha = dividerAlpha, + onModeSelected = { onModeSelected(it) } + ) Box( modifier = Modifier @@ -376,7 +304,9 @@ fun NoiseControlSettings( state = rememberDraggableState { delta -> dragOffset = (dragOffset + delta).coerceIn( 0f, - with(density) { (buttonWidth * (buttonCount - 1)).toPx() } + with(density) { + (buttonWidth * (buttonCount - 1)).toPx() + } ) }, onDragStarted = { isDragging.value = true }, @@ -384,15 +314,11 @@ fun NoiseControlSettings( isDragging.value = false val position = dragOffset / with(density) { buttonWidth.toPx() } - val newIndex = position.roundToInt() - val newMode = when (newIndex) { - 0 -> if (showOffListeningMode) NoiseControlMode.OFF else NoiseControlMode.TRANSPARENCY - 1 -> if (showOffListeningMode) NoiseControlMode.TRANSPARENCY else NoiseControlMode.ADAPTIVE - 2 -> if (showOffListeningMode) NoiseControlMode.ADAPTIVE else NoiseControlMode.NOISE_CANCELLATION - 3 -> NoiseControlMode.NOISE_CANCELLATION - else -> noiseControlMode.value // Keep current if index is invalid - } - onModeSelected(newMode) + onModeSelected( + modes.getOrElse(position.roundToInt()) { + noiseControlMode.value + } + ) } ) ) { @@ -404,63 +330,15 @@ fun NoiseControlSettings( ) } - Row( - modifier = Modifier - .fillMaxWidth() - .zIndex(1f) - ) { - if (showOffListeningMode) { - NoiseControlButton( - icon = ImageBitmap.imageResource(R.drawable.noise_cancellation), - onClick = { onModeSelected(NoiseControlMode.OFF) }, - textColor = if (noiseControlMode.value == NoiseControlMode.OFF) textColorSelected else textColor, - modifier = Modifier.weight(1f), - usePadding = false - ) - VerticalDivider( - thickness = 1.dp, - modifier = Modifier - .padding(vertical = 10.dp) - .alpha(d1a.floatValue), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) - ) - } - NoiseControlButton( - icon = ImageBitmap.imageResource(R.drawable.transparency), - onClick = { onModeSelected(NoiseControlMode.TRANSPARENCY) }, - textColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) textColorSelected else textColor, - modifier = Modifier.weight(1f), - usePadding = false - ) - VerticalDivider( - thickness = 1.dp, - modifier = Modifier - .padding(vertical = 10.dp) - .alpha(d2a.floatValue), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) - ) - NoiseControlButton( - icon = ImageBitmap.imageResource(R.drawable.adaptive), - onClick = { onModeSelected(NoiseControlMode.ADAPTIVE) }, - textColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) textColorSelected else textColor, - modifier = Modifier.weight(1f), - usePadding = false - ) - VerticalDivider( - thickness = 1.dp, - modifier = Modifier - .padding(vertical = 10.dp) - .alpha(d3a.floatValue), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) - ) - NoiseControlButton( - icon = ImageBitmap.imageResource(R.drawable.noise_cancellation), - onClick = { onModeSelected(NoiseControlMode.NOISE_CANCELLATION) }, - textColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) textColorSelected else textColor, - modifier = Modifier.weight(1f), - usePadding = false - ) - } + NoiseControlSegments( + modes = modes, + selectedMode = noiseControlMode.value, + textColor = textColor, + textColorSelected = textColorSelected, + dividerAlpha = dividerAlpha, + onModeSelected = { onModeSelected(it) }, + modifier = Modifier.zIndex(1f) + ) } Row( @@ -468,32 +346,14 @@ fun NoiseControlSettings( .fillMaxWidth() .padding(top = 4.dp) ) { - if (showOffListeningMode) { + modes.forEach { mode -> Text( - text = stringResource(R.string.off), + text = stringResource(noiseControlLabelRes(mode)), style = TextStyle(fontSize = 12.sp, color = textColor), textAlign = TextAlign.Center, modifier = Modifier.weight(1f) ) } - Text( - text = stringResource(R.string.transparency), - style = TextStyle(fontSize = 12.sp, color = textColor), - textAlign = TextAlign.Center, - modifier = Modifier.weight(1f) - ) - Text( - text = stringResource(R.string.adaptive), - style = TextStyle(fontSize = 12.sp, color = textColor), - textAlign = TextAlign.Center, - modifier = Modifier.weight(1f) - ) - Text( - text = stringResource(R.string.noise_cancellation), - style = TextStyle(fontSize = 12.sp, color = textColor), - textAlign = TextAlign.Center, - modifier = Modifier.weight(1f) - ) } } } @@ -502,6 +362,53 @@ fun NoiseControlSettings( } +private fun noiseControlIconRes(mode: NoiseControlMode): Int = when (mode) { + NoiseControlMode.OFF -> R.drawable.noise_cancellation + NoiseControlMode.TRANSPARENCY -> R.drawable.transparency + NoiseControlMode.ADAPTIVE -> R.drawable.adaptive + NoiseControlMode.NOISE_CANCELLATION -> R.drawable.noise_cancellation +} + +private fun noiseControlLabelRes(mode: NoiseControlMode): Int = when (mode) { + NoiseControlMode.OFF -> R.string.off + NoiseControlMode.TRANSPARENCY -> R.string.transparency + NoiseControlMode.ADAPTIVE -> R.string.adaptive + NoiseControlMode.NOISE_CANCELLATION -> R.string.noise_cancellation +} + +/** One row of segment buttons, drawn twice: once under the sliding pill and once above it. */ +@Composable +private fun NoiseControlSegments( + modes: List, + selectedMode: NoiseControlMode, + textColor: Color, + textColorSelected: Color, + dividerAlpha: (Int) -> Float, + onModeSelected: (NoiseControlMode) -> Unit, + modifier: Modifier = Modifier +) { + Row(modifier = modifier.fillMaxWidth()) { + modes.forEachIndexed { index, mode -> + if (index > 0) { + VerticalDivider( + thickness = 1.dp, + modifier = Modifier + .padding(vertical = 10.dp) + .alpha(dividerAlpha(index - 1)), + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) + ) + } + NoiseControlButton( + icon = ImageBitmap.imageResource(noiseControlIconRes(mode)), + onClick = { onModeSelected(mode) }, + textColor = if (selectedMode == mode) textColorSelected else textColor, + modifier = Modifier.weight(1f), + usePadding = false + ) + } + } +} + @Preview @Composable fun NoiseControlSettingsPreview() { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/PopupWindow.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/PopupWindow.kt index 4247ea47a..607880b31 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/PopupWindow.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/PopupWindow.kt @@ -43,10 +43,13 @@ import android.view.animation.AccelerateInterpolator import android.view.animation.DecelerateInterpolator import android.widget.ImageButton import android.widget.LinearLayout +import android.widget.ImageView import android.widget.TextView import android.widget.VideoView import me.kavishdevar.librepods.R import me.kavishdevar.librepods.data.AirPodsNotifications +import me.kavishdevar.librepods.data.AirPodsBase +import me.kavishdevar.librepods.data.FormFactor import me.kavishdevar.librepods.data.Battery import me.kavishdevar.librepods.data.BatteryComponent import me.kavishdevar.librepods.data.BatteryStatus @@ -129,21 +132,41 @@ class PopupWindow( mWindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager } + /** Over-ear models report one battery and are not what the connection animation shows. */ + private var isSingleBattery = false + @SuppressLint("InlinedApi", "SetTextI18s") - fun open(name: String = "AirPods Pro", batteryNotification: AirPodsNotifications.BatteryNotification) { + fun open( + name: String = "AirPods Pro", + batteryNotification: AirPodsNotifications.BatteryNotification, + model: AirPodsBase? = null + ) { try { if (mView.windowToken == null && mView.parent == null && !isClosing) { mView.findViewById(R.id.name).text = name + isSingleBattery = model?.isSingleBattery == true updateBatteryStatus(batteryNotification) val vid = mView.findViewById(R.id.video) - vid.setAudioFocusRequest(AudioManager.AUDIOFOCUS_NONE) - vid.setVideoPath("android.resource://me.kavishdevar.librepods/" + R.raw.connected) - vid.resolveAdjustedSize(vid.width, vid.height) - vid.start() - vid.setOnCompletionListener { + val deviceImage = mView.findViewById(R.id.device_image) + + // The bundled animation is of AirPods Pro, so show a still of the real model + // instead of pretending the AirPods Max are earbuds. + if (model != null && model.formFactor != FormFactor.IN_EAR) { + vid.visibility = View.GONE + deviceImage.setImageResource(model.budsRes) + deviceImage.visibility = View.VISIBLE + } else { + deviceImage.visibility = View.GONE + vid.visibility = View.VISIBLE + vid.setAudioFocusRequest(AudioManager.AUDIOFOCUS_NONE) + vid.setVideoPath("android.resource://${context.packageName}/" + R.raw.connected) + vid.resolveAdjustedSize(vid.width, vid.height) vid.start() + vid.setOnCompletionListener { + vid.start() + } } try { @@ -219,6 +242,16 @@ class PopupWindow( val batteryRightText = mView.findViewById(R.id.right_battery) val batteryCaseText = mView.findViewById(R.id.case_battery) + if (isSingleBattery) { + // One unit, one battery: show it once rather than as a mirrored pair plus a case. + val battery = batteryList.find { it.component == BatteryComponent.LEFT } + batteryLeftText.text = battery?.takeIf { it.status != BatteryStatus.DISCONNECTED } + ?.let { "${it.level}%" } ?: "" + batteryRightText.text = "" + batteryCaseText.text = "" + return + } + batteryLeftText.text = batteryList.find { it.component == BatteryComponent.LEFT }?.let { if (it.status != BatteryStatus.DISCONNECTED) { "\uDBC3\uDC8E ${it.level}%" diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt index b607817c3..35b991ed9 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt @@ -312,7 +312,8 @@ fun AirPodsSettingsScreen( BatteryView( batteryList = state.battery, budsRes = state.instance?.model?.budsRes ?: R.drawable.airpods_pro_2_buds, - caseRes = state.instance?.model?.caseRes ?: R.drawable.airpods_pro_2_case + caseRes = state.instance?.model?.caseRes ?: R.drawable.airpods_pro_2_case, + hasCase = state.instance?.model?.hasCase ?: true ) } item(key = "spacer_battery") { @@ -364,6 +365,7 @@ fun AirPodsSettingsScreen( AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE, it ) }, + showAdaptiveMode = capabilities.contains(Capability.ADAPTIVE_AUDIO), ) } } @@ -561,14 +563,16 @@ fun AirPodsSettingsScreen( ) } - if (capabilities.contains(Capability.LOUD_SOUND_REDUCTION)) { + if (capabilities.contains(Capability.LISTENING_MODE)) { item(key = "spacer_off_listening") { Spacer(modifier = Modifier.height(16.dp)) } item(key = "off_listening") { val id = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION StyledToggle( label = stringResource(R.string.off_listening_mode), description = stringResource(R.string.off_listening_mode_description), - checked = state.controlStates[id]?.getOrNull(0) == 0x01.toByte(), + // Not every model reports this control state; fall back to the setting. + checked = state.controlStates[id]?.getOrNull(0)?.let { it == 0x01.toByte() } + ?: state.offListeningMode, onCheckedChange = setOffListeningMode ) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt index 0b43370e0..53211658d 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt @@ -592,10 +592,14 @@ class AirPodsViewModel( } private fun loadInstance() { + // Falling straight back to a Pro 2 would mislabel anything we could not identify - most + // visibly the AirPods Max, which would show up as earbuds. Try to resolve the real model + // from the BLE id or the Bluetooth name first. + val fallbackModel = service.resolveModel() val instance = service.airpodsInstance ?: AirPodsInstance( - name = "AirPods", - model = AirPodsModels.getModelByModelNumber("A3049")!!, - actualModelNumber = "A3049", + name = fallbackModel?.displayName ?: "AirPods", + model = fallbackModel ?: AirPodsModels.getModelByModelNumber("A3049")!!, + actualModelNumber = fallbackModel?.modelNumber?.firstOrNull() ?: "A3049", serialNumber = null, leftSerialNumber = null, rightSerialNumber = null, diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt index 8a7080163..2b90fb980 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt @@ -216,7 +216,9 @@ class AirPodsQSService : TileService() { tile.state = Tile.STATE_UNAVAILABLE tile.label = "AirPods" tile.subtitle = "Disconnected" - tile.icon = Icon.createWithResource(this, R.drawable.airpods) + tile.icon = Icon.createWithResource( + this, ServiceManager.getService()?.currentIconRes ?: R.drawable.airpods + ) } try { @@ -232,11 +234,12 @@ class AirPodsQSService : TileService() { } private fun getAvailableModes(): List { - val modes = mutableListOf( - NoiseControlMode.TRANSPARENCY.ordinal + 1, - NoiseControlMode.ADAPTIVE.ordinal + 1, - NoiseControlMode.NOISE_CANCELLATION.ordinal + 1 - ) + val modes = mutableListOf(NoiseControlMode.TRANSPARENCY.ordinal + 1) + // The AirPods Max have no Adaptive mode, so the tile must not cycle through it. + if (ServiceManager.getService()?.supportsAdaptiveMode != false) { + modes.add(NoiseControlMode.ADAPTIVE.ordinal + 1) + } + modes.add(NoiseControlMode.NOISE_CANCELLATION.ordinal + 1) if (isOffModeEnabled()) { modes.add(0, NoiseControlMode.OFF.ordinal + 1) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt index 0cf08c11d..58c0716d9 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -91,6 +91,7 @@ import me.kavishdevar.librepods.bluetooth.BLEManager import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager import me.kavishdevar.librepods.bluetooth.createBluetoothSocket import me.kavishdevar.librepods.data.AirPodsInstance +import me.kavishdevar.librepods.data.AirPodsBase import me.kavishdevar.librepods.data.AirPodsModels import me.kavishdevar.librepods.data.AirPodsNotifications import me.kavishdevar.librepods.data.Battery @@ -110,6 +111,7 @@ import me.kavishdevar.librepods.utils.GestureDetector import me.kavishdevar.librepods.utils.HeadTracking import me.kavishdevar.librepods.utils.MediaController import me.kavishdevar.librepods.utils.SystemApisUtils +import me.kavishdevar.librepods.utils.SystemApisUtils.DEVICE_TYPE_DEFAULT import me.kavishdevar.librepods.utils.SystemApisUtils.DEVICE_TYPE_UNTETHERED_HEADSET import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_COMPANION_APP import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_DEVICE_TYPE @@ -1050,7 +1052,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList config.airpodsHardwareRevision = deviceInformation.hardwareRevision config.airpodsUpdaterIdentifier = deviceInformation.updaterIdentifier - val model = AirPodsModels.getModelByModelNumber(config.airpodsModelNumber) + val model = resolveModel() if (model != null) { airpodsInstance = AirPodsInstance( name = config.airpodsName, @@ -1668,7 +1670,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList return } val popupWindow = PopupWindow(service.applicationContext) - popupWindow.open(name, batteryNotification) + popupWindow.open(name, batteryNotification, airpodsInstance?.model ?: resolveModel()) popupShown = true } @@ -1947,6 +1949,22 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList if (caseBattery?.status == BatteryStatus.CHARGING || caseBattery?.status == BatteryStatus.OPTIMIZED_CHARGING ) View.VISIBLE else View.GONE ) + // The AirPods Max report one battery, mirrored onto both sides. Show it once, under a + // headphone icon, instead of three identical-looking readings. + val model = airpodsInstance?.model ?: resolveModel() + val singleBattery = model?.isSingleBattery == true || batteryNotification.isSingleBattery + it.setViewVisibility( + R.id.right_battery_container, if (singleBattery) View.GONE else View.VISIBLE + ) + it.setViewVisibility( + R.id.case_battery_container, if (singleBattery) View.GONE else View.VISIBLE + ) + it.setImageViewResource( + R.id.left_battery_icon, + if (singleBattery) (model?.iconRes ?: R.drawable.airpods_max_icon) + else R.drawable.airpods_pro_left_notification + ) + it.setViewVisibility( R.id.phone_battery_widget_container, if (widgetMobileBatteryEnabled) View.VISIBLE else View.GONE @@ -2054,33 +2072,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList if (BluetoothConnectionManager.aacpSocket?.isConnected == true) { val updatedNotificationBuilder = NotificationCompat.Builder(this, "airpods_connection_status") - .setSmallIcon(R.drawable.airpods) + .setSmallIcon(currentIconRes) .setContentTitle(airpodsName ?: config.deviceName).setContentText( - """${ - batteryList?.find { it.component == BatteryComponent.LEFT }?.let { - if (it.status != BatteryStatus.DISCONNECTED) { - "L: ${if (it.status == BatteryStatus.CHARGING) "⚡" else ""} ${it.level}%" - } else { - "" - } - } ?: "" - } ${ - batteryList?.find { it.component == BatteryComponent.RIGHT }?.let { - if (it.status != BatteryStatus.DISCONNECTED) { - "R: ${if (it.status == BatteryStatus.CHARGING) "⚡" else ""} ${it.level}%" - } else { - "" - } - } ?: "" - } ${ - batteryList?.find { it.component == BatteryComponent.CASE }?.let { - if (it.status != BatteryStatus.DISCONNECTED) { - "Case: ${if (it.status == BatteryStatus.CHARGING) "⚡" else ""} ${it.level}%" - } else { - "" - } - } ?: "" - }""").setContentIntent(pendingIntent).setCategory(Notification.CATEGORY_STATUS) + buildBatteryText(batteryList)).setContentIntent(pendingIntent).setCategory(Notification.CATEGORY_STATUS) .setPriority(NotificationCompat.PRIORITY_LOW).setOngoing(true) if (disconnectedBecauseReversed) { @@ -2210,7 +2204,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList private fun resToUri(resId: Int): Uri? { return try { Uri.Builder().scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) - .authority("me.kavishdevar.librepods") + .authority(packageName) .appendPath(applicationContext.resources.getResourceTypeName(resId)) .appendPath(applicationContext.resources.getResourceEntryName(resId)).build() } catch (_: Resources.NotFoundException) { @@ -2325,6 +2319,59 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList Log.d(TAG, "Broadcast battery level $batteryUnified% to system") } + /** + * Battery line for the status notification. Over-ear models report a single battery which is + * mirrored onto both sides, so collapse it instead of printing the same value twice. + */ + private fun buildBatteryText(batteryList: List?): String { + if (batteryList == null) return "" + + fun format(prefix: String, battery: Battery?): String { + if (battery == null || battery.status == BatteryStatus.DISCONNECTED) return "" + val bolt = if (battery.status == BatteryStatus.CHARGING) "⚡" else "" + return "$prefix$bolt ${battery.level}%".trim() + } + + val singleBattery = (airpodsInstance?.model ?: resolveModel())?.isSingleBattery == true || + batteryNotification.isSingleBattery + if (singleBattery) { + return format("", batteryList.find { it.component == BatteryComponent.LEFT }) + } + + return listOf( + format("L: ", batteryList.find { it.component == BatteryComponent.LEFT }), + format("R: ", batteryList.find { it.component == BatteryComponent.RIGHT }), + format("Case: ", batteryList.find { it.component == BatteryComponent.CASE }) + ).filter { it.isNotEmpty() }.joinToString(" ") + } + + /** + * Whether the connected model offers the Adaptive listening mode. The AirPods Max and the + * first-generation Pro do not. Defaults to true while the model is still unknown. + */ + val supportsAdaptiveMode: Boolean + get() = (airpodsInstance?.model ?: resolveModel()) + ?.capabilities?.contains(Capability.ADAPTIVE_AUDIO) ?: true + + /** Icon for the connected model, so the AirPods Max show headphones rather than earbuds. */ + val currentIconRes: Int + get() = (airpodsInstance?.model ?: resolveModel())?.iconRes ?: R.drawable.airpods + + /** + * Resolves the connected model. The AACP model number is authoritative, but firmware we have + * no entry for still identifies itself through its BLE proximity-pairing id or its Bluetooth + * name, which is what lets the AirPods Max be told apart from the earbuds. + */ + fun resolveModel(): AirPodsBase? { + return AirPodsModels.resolveModel( + modelNumber = config.airpodsModelNumber, + bleModelId = if (::bleManager.isInitialized) { + bleManager.getMostRecentStatus()?.modelId?.takeIf { it != 0 } + } else null, + name = config.airpodsName.ifBlank { config.deviceName } + ) + } + private fun setMetadatas(d: BluetoothDevice) { if (checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") != PackageManager.PERMISSION_GRANTED) { Log.d(TAG, "no permission BLUETOOTH_PRIVILEGED, returning") @@ -2334,47 +2381,58 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList d.let { device -> val instance = airpodsInstance if (instance != null) { - val metadataSet = SystemApisUtils.setMetadata( + val model = instance.model + var metadataSet = SystemApisUtils.setMetadata( device, device.METADATA_MAIN_ICON, - resToUri(instance.model.budCaseRes).toString().toByteArray() + resToUri(model.budCaseRes).toString().toByteArray() ) && SystemApisUtils.setMetadata( - device, device.METADATA_MODEL_NAME, instance.model.name.toByteArray() + device, device.METADATA_MODEL_NAME, model.name.toByteArray() ) && SystemApisUtils.setMetadata( device, device.METADATA_DEVICE_TYPE, - device.DEVICE_TYPE_UNTETHERED_HEADSET.toByteArray() - ) && SystemApisUtils.setMetadata( - device, - device.METADATA_UNTETHERED_CASE_ICON, - resToUri(instance.model.caseRes).toString().toByteArray() - ) && SystemApisUtils.setMetadata( - device, - device.METADATA_UNTETHERED_RIGHT_ICON, - resToUri(instance.model.rightBudsRes).toString().toByteArray() - ) && SystemApisUtils.setMetadata( - device, - device.METADATA_UNTETHERED_LEFT_ICON, - resToUri(instance.model.leftBudsRes).toString().toByteArray() + // Over-ear models are a single device: reporting them as untethered makes the + // system show three battery readings that the AirPods Max never send. + if (model.hasCase) { + device.DEVICE_TYPE_UNTETHERED_HEADSET.toByteArray() + } else { + device.DEVICE_TYPE_DEFAULT.toByteArray() + } ) && SystemApisUtils.setMetadata( device, device.METADATA_MANUFACTURER_NAME, - instance.model.manufacturer.toByteArray() - ) && SystemApisUtils.setMetadata( - device, device.METADATA_COMPANION_APP, "me.kavishdevar.librepods".toByteArray() - ) && SystemApisUtils.setMetadata( - device, - device.METADATA_UNTETHERED_CASE_LOW_BATTERY_THRESHOLD, - "20".toByteArray() - ) && SystemApisUtils.setMetadata( - device, - device.METADATA_UNTETHERED_LEFT_LOW_BATTERY_THRESHOLD, - "20".toByteArray() + model.manufacturer.toByteArray() ) && SystemApisUtils.setMetadata( - device, - device.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD, - "20".toByteArray() + device, device.METADATA_COMPANION_APP, packageName.toByteArray() ) + + if (model.hasCase) { + metadataSet = metadataSet && SystemApisUtils.setMetadata( + device, + device.METADATA_UNTETHERED_CASE_ICON, + resToUri(model.caseRes).toString().toByteArray() + ) && SystemApisUtils.setMetadata( + device, + device.METADATA_UNTETHERED_RIGHT_ICON, + resToUri(model.rightBudsRes).toString().toByteArray() + ) && SystemApisUtils.setMetadata( + device, + device.METADATA_UNTETHERED_LEFT_ICON, + resToUri(model.leftBudsRes).toString().toByteArray() + ) && SystemApisUtils.setMetadata( + device, + device.METADATA_UNTETHERED_CASE_LOW_BATTERY_THRESHOLD, + "20".toByteArray() + ) && SystemApisUtils.setMetadata( + device, + device.METADATA_UNTETHERED_LEFT_LOW_BATTERY_THRESHOLD, + "20".toByteArray() + ) && SystemApisUtils.setMetadata( + device, + device.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD, + "20".toByteArray() + ) + } Log.d(TAG, "Metadata set: $metadataSet") } else { Log.w( @@ -2677,9 +2735,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList BluetoothConnectionManager.attSocket = attSocket // Create AirPodsInstance from stored config if available - if (airpodsInstance == null && config.airpodsModelNumber.isNotEmpty()) { - val model = - AirPodsModels.getModelByModelNumber(config.airpodsModelNumber) + if (airpodsInstance == null) { + val model = resolveModel() if (model != null) { airpodsInstance = AirPodsInstance( name = config.airpodsName, diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/SystemAPIUtils.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/SystemAPIUtils.kt index cd91e24c3..3d6f48f8b 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/SystemAPIUtils.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/SystemAPIUtils.kt @@ -13,6 +13,15 @@ object SystemApisUtils { val BluetoothDevice.DEVICE_TYPE_UNTETHERED_HEADSET: String get() = "Untethered Headset" + /** + * Device type which is used in METADATA_DEVICE_TYPE + * Indicates this Bluetooth device is a standard single-battery device, such as over-ear + * headphones. AOSP has no dedicated headset type. + * @hide + */ + val BluetoothDevice.DEVICE_TYPE_DEFAULT: String + get() = "Default" + /** * Maximum length of a metadata entry, this is to avoid exploding Bluetooth * disk usage diff --git a/android/app/src/main/res-apple/drawable/airpods_max_buds.png b/android/app/src/main/res-apple/drawable/airpods_max_buds.png new file mode 100644 index 000000000..d563a5edf Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_max_buds.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_max_case.png b/android/app/src/main/res-apple/drawable/airpods_max_case.png new file mode 100644 index 000000000..ddc682a35 Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_max_case.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_max_device.png b/android/app/src/main/res-apple/drawable/airpods_max_device.png new file mode 100644 index 000000000..07fd1d6ea Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_max_device.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_max_left.png b/android/app/src/main/res-apple/drawable/airpods_max_left.png new file mode 100644 index 000000000..36aa631b2 Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_max_left.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_max_right.png b/android/app/src/main/res-apple/drawable/airpods_max_right.png new file mode 100644 index 000000000..47901a20a Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_max_right.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_max_usbc_buds.png b/android/app/src/main/res-apple/drawable/airpods_max_usbc_buds.png new file mode 100644 index 000000000..0d233a809 Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_max_usbc_buds.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_max_usbc_case.png b/android/app/src/main/res-apple/drawable/airpods_max_usbc_case.png new file mode 100644 index 000000000..935d357cc Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_max_usbc_case.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_max_usbc_device.png b/android/app/src/main/res-apple/drawable/airpods_max_usbc_device.png new file mode 100644 index 000000000..66cab453d Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_max_usbc_device.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_max_usbc_left.png b/android/app/src/main/res-apple/drawable/airpods_max_usbc_left.png new file mode 100644 index 000000000..8383aafa7 Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_max_usbc_left.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_max_usbc_right.png b/android/app/src/main/res-apple/drawable/airpods_max_usbc_right.png new file mode 100644 index 000000000..c368cca91 Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_max_usbc_right.png differ diff --git a/android/app/src/main/res/drawable/airpods_max_icon.xml b/android/app/src/main/res/drawable/airpods_max_icon.xml new file mode 100644 index 000000000..bc545febc --- /dev/null +++ b/android/app/src/main/res/drawable/airpods_max_icon.xml @@ -0,0 +1,12 @@ + + + + diff --git a/android/app/src/main/res/drawable/airpods_max_usbc_icon.xml b/android/app/src/main/res/drawable/airpods_max_usbc_icon.xml new file mode 100644 index 000000000..18deb976c --- /dev/null +++ b/android/app/src/main/res/drawable/airpods_max_usbc_icon.xml @@ -0,0 +1,12 @@ + + + + diff --git a/android/app/src/main/res/layout/battery_widget.xml b/android/app/src/main/res/layout/battery_widget.xml index df54f5d45..dc1b32c15 100644 --- a/android/app/src/main/res/layout/battery_widget.xml +++ b/android/app/src/main/res/layout/battery_widget.xml @@ -82,6 +82,7 @@ + + +