Compare commits

...
7 Commits
Author SHA1 Message Date
d4rken-org-releaser[bot] 39922df792 Release: 5.1.3-rc0 2026-05-01 19:52:12 +00:00
darken 5a8c4b94ef fix: Correct wear detection on AirPods Max 2
Bit 5 of pubStatus is always set on A3454 and no longer carries the wear flag (unlike Max gen 1). Read bits 1 and 3 instead — the per-earcup sensors. OR rather than AND so phones that only see one bit reliably still report worn correctly.

Closes #548
2026-05-01 21:50:51 +02:00
darken 0ffa87deea CI: Skip release bump-only workflow runs 2026-05-01 20:08:42 +02:00
darken fc98db09a2 fix: Correct PodModel feature flags for ear detection and call controls
Sync flags with what the BLE classes actually report and what iOS exposes:

- AirPods Gen 1/2/3: enable hasEarDetection (already parsed via DualApplePods) and hasEarDetectionToggle

- AirPods Gen 3, Pro 1: enable hasEndCallMuteMic (force-sensor stems)

- Powerbeats Pro, Beats Fit Pro: enable hasEarDetectionToggle (iOS exposes it)

- Beats Solo Pro, Studio 3: drop hasEarDetection (over-ear, BLE class is bare SingleApplePods)

- FAKE_AIRPODS_GEN1/2/3: enable hasEarDetection to match HasEarDetectionDual

- Generalize microphone mode description from 'AirPod' to 'earbud'

Tests rewritten as exhaustive set assertions plus implication invariants.
2026-05-01 20:08:29 +02:00
darken e1c0a702dd chore(ci): Rename stale step name and use client-id for App auth 2026-05-01 09:15:43 +02:00
d4rken-org-releaser[bot] 3f310016b1 Release: 5.1.2-rc1 2026-05-01 07:04:37 +00:00
darken d399a6d908 fix(ci): Drop redundant explicit dispatch in release-prepare 2026-05-01 09:00:29 +02:00
14 changed files with 463 additions and 82 deletions
+3 -3
View File
@@ -12,7 +12,7 @@ gh workflow run release-prepare.yml -f bump_kind=build -f dry_run=true
gh workflow run release-prepare.yml -f bump_kind=build -f dry_run=false
```
After `dry_run=false`: Job 1 computes + writes the summary, then Job 2 immediately commits/tags/pushes/dispatches (no env gate — cancel the run between Job 1 and Job 2 if the summary looks wrong; you have ~seconds). `release-tag.yml` then runs `validate-tag` and the existing `release-github` (`foss-production` approval) + `release-gplay` (`gplay-production` approval) jobs — those are the two human checkpoints, matching the pre-migration UX.
After `dry_run=false`: Job 1 computes + writes the summary, then Job 2 immediately commits/tags/pushes (no env gate — cancel the run between Job 1 and Job 2 if the summary looks wrong; you have ~seconds). The tag push naturally triggers `release-tag.yml` (the App-token push fires `on: push:` workflows; only `GITHUB_TOKEN`-pushes are suppressed). `release-tag.yml` then runs `validate-tag` and the existing `release-github` (`foss-production` approval) + `release-gplay` (`gplay-production` approval) jobs — those are the two human checkpoints, matching the pre-migration UX.
## Inputs
@@ -60,7 +60,7 @@ bats tools/release/bump.bats
Required org secrets (set on the d4rken-org organization, accessible to `capod`):
- `RELEASE_APP_ID` — numeric ID of the `d4rken-org-releaser` GitHub App
- `RELEASE_APP_CLIENT_ID` — Client ID of the `d4rken-org-releaser` GitHub App (visible on the App's settings page, format `Iv1.<hex>` or similar)
- `RELEASE_APP_PRIVATE_KEY` — full `.pem` contents (including BEGIN/END lines)
The App is installed on this repo and added as a bypass actor to:
@@ -75,4 +75,4 @@ Other apps in the org can reuse the same App + secrets — just install the App
## Stuck-dispatch recovery
If Job 2's atomic push lands but `gh workflow run release-tag.yml` fails (rare — Job 1's auth precheck should prevent it), the tag is public but no pipeline runs. Re-dispatch: `gh workflow run release-tag.yml --ref v<new> -f dry_run=false`.
If Job 2's atomic push lands but the natural `on: push:` trigger doesn't fire `release-tag.yml` (rare — would mean GitHub dropped the event), the tag is public but no pipeline runs. Re-dispatch manually: `gh workflow run release-tag.yml --ref v<new> -f dry_run=false`.
+4 -1
View File
@@ -3,6 +3,9 @@ name: Code tests & eval
on:
push:
branches: [ main ]
paths-ignore:
- VERSION
- version.properties
pull_request:
branches: [ main ]
@@ -101,4 +104,4 @@ jobs:
run: bats tools/release/bump.bats
- name: Verify version.properties + VERSION are consistent
run: ./tools/release/bump.sh --mode=check
run: ./tools/release/bump.sh --mode=check
@@ -4,6 +4,9 @@ on:
push:
branches:
- main
paths-ignore:
- VERSION
- version.properties
pull_request:
branches:
- main
+8
View File
@@ -3,6 +3,14 @@ name: Deploy GitHub Pages
on:
push:
branches: [ main ]
paths:
- _config.yml
- _layouts/**
- README.md
- CHANGELOG.md
- PRIVACY_POLICY.md
- CNAME
- .github/workflows/pages.yml
workflow_dispatch:
permissions:
+3 -15
View File
@@ -39,13 +39,11 @@ jobs:
runs-on: ubuntu-22.04
permissions:
contents: read
actions: read
outputs:
new_name: ${{ steps.plan.outputs.new_name }}
new_code: ${{ steps.plan.outputs.new_code }}
current_name: ${{ steps.plan.outputs.current_name }}
env:
GH_TOKEN: ${{ github.token }}
INPUT_BUMP_KIND: ${{ inputs.bump_kind }}
INPUT_VERSION_TYPE: ${{ inputs.version_type }}
INPUT_VERSION_OVERRIDE: ${{ inputs.version_override }}
@@ -65,9 +63,6 @@ jobs:
fetch-depth: 0
persist-credentials: false
- name: Verify gh auth and dispatch capability
run: gh workflow view release-tag.yml > /dev/null
- name: Compute and validate
id: plan
run: |
@@ -147,7 +142,7 @@ jobs:
id: app-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 #v3.1.1
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
client-id: ${{ secrets.RELEASE_APP_CLIENT_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- name: Resolve bot identity
@@ -169,12 +164,12 @@ jobs:
persist-credentials: true
token: ${{ steps.app-token.outputs.token }}
- name: Re-validate after approval wait
- name: Verify state still matches plan from Job 1
run: |
set -euo pipefail
./tools/release/bump.sh --mode=check --expected-current="${CURRENT_NAME_AT_PLAN}"
- name: Re-check tag collision (state may have moved during approval)
- name: Re-check tag collision (state may have moved between jobs)
run: |
set -euo pipefail
if git rev-parse --verify "refs/tags/v${NEW_NAME}" >/dev/null 2>&1; then
@@ -223,13 +218,6 @@ jobs:
set -euo pipefail
git push --atomic origin "HEAD:refs/heads/main" "refs/tags/v${NEW_NAME}"
- name: Dispatch release-tag.yml
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
set -euo pipefail
gh workflow run release-tag.yml --ref "v${NEW_NAME}" -f dry_run=false
- name: Write step summary
run: |
set -euo pipefail
+1 -1
View File
@@ -1 +1 @@
5.1.2-rc0 50102000
5.1.3-rc0 50103000
@@ -22,7 +22,9 @@ enum class PodModel(
Features(
hasDualPods = true,
hasCase = true,
hasEarDetection = true,
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
),
modelNumbers = setOf("A1523", "A1722"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen1_left,
@@ -37,7 +39,9 @@ enum class PodModel(
Features(
hasDualPods = true,
hasCase = true,
hasEarDetection = true,
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
),
modelNumbers = setOf("A2031", "A2032"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen1_left,
@@ -52,10 +56,13 @@ enum class PodModel(
Features(
hasDualPods = true,
hasCase = true,
hasEarDetection = true,
hasPressSpeed = true,
hasPressHoldDuration = true,
hasToneVolume = true,
hasEndCallMuteMic = true,
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
),
modelNumbers = setOf("A2564", "A2565"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen3_left,
@@ -74,6 +81,7 @@ enum class PodModel(
hasPressSpeed = true,
hasPressHoldDuration = true,
hasToneVolume = true,
hasEndCallMuteMic = true,
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
hasSleepDetection = true,
@@ -314,7 +322,6 @@ enum class PodModel(
"Beats Solo Pro",
R.drawable.device_beats_headphones,
Features(
hasEarDetection = true,
hasAncControl = true,
),
modelNumbers = setOf("A1881"), // headphones
@@ -343,7 +350,6 @@ enum class PodModel(
"Beats Studio 3",
R.drawable.device_beats_studio3,
Features(
hasEarDetection = true,
hasAncControl = true,
),
modelNumbers = setOf("A1914"), // headphones
@@ -410,6 +416,7 @@ enum class PodModel(
hasDualPods = true,
hasCase = true,
hasEarDetection = true,
hasEarDetectionToggle = true,
),
modelNumbers = setOf("A2047", "A2048", "A2453", "A2454"), // L/R earbuds, 2019 + 2020 revisions
leftPodIconRes = R.drawable.device_powerbeats_pro_left,
@@ -426,6 +433,7 @@ enum class PodModel(
hasCase = true,
hasEarDetection = true,
hasAncControl = true,
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
hasSleepDetection = true,
),
@@ -444,6 +452,8 @@ enum class PodModel(
hasCase = true,
hasEarDetection = true,
hasAncControl = true,
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
),
modelNumbers = setOf("A2576", "A2577", "A2578"), // L/R earbuds + case
leftPodIconRes = R.drawable.device_beats_fitpro_left,
@@ -458,6 +468,7 @@ enum class PodModel(
Features(
hasDualPods = true,
hasCase = true,
hasEarDetection = true,
),
leftPodIconRes = R.drawable.device_airpods_gen1_left,
rightPodIconRes = R.drawable.device_airpods_gen1_right,
@@ -471,6 +482,7 @@ enum class PodModel(
Features(
hasDualPods = true,
hasCase = true,
hasEarDetection = true,
),
leftPodIconRes = R.drawable.device_airpods_gen1_left,
rightPodIconRes = R.drawable.device_airpods_gen1_right,
@@ -484,6 +496,7 @@ enum class PodModel(
Features(
hasDualPods = true,
hasCase = true,
hasEarDetection = true,
),
leftPodIconRes = R.drawable.device_airpods_gen3_left,
rightPodIconRes = R.drawable.device_airpods_gen3_right,
@@ -44,8 +44,18 @@ data class AirPodsMax2(
return pubFlags.isBitSet(0)
}
// Aggregate "any wear sensor active" — NOT "both earcups worn".
// Observed on A3454: bit 5 of pubStatus is always set while advertising and
// no longer carries the wear flag (unlike Max gen 1). Bits 1 and 3 of
// pubStatus reflect the two earcup sensors (same byte positions used by
// DualApplePods, but without the primary/flip semantics).
//
// We OR them because some Android pairings only see one of the two bits
// reliably (issue #548: "both worn" advertises as 0x23 — bit 1 only —
// while macOS sees 0x2B with both bits set). AND-ing would falsely report
// "not worn" during normal use on those phones.
override val isBeingWorn: Boolean
get() = pubStatus.isBitSet(5)
get() = pubStatus.isBitSet(1) || pubStatus.isBitSet(3)
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
+1 -1
View File
@@ -515,7 +515,7 @@
<!-- New device settings -->
<string name="device_settings_category_general_label">General</string>
<string name="device_settings_microphone_mode_label">Microphone</string>
<string name="device_settings_microphone_mode_description">Which AirPod is used as the microphone</string>
<string name="device_settings_microphone_mode_description">Which earbud is used as the microphone</string>
<string name="device_settings_microphone_mode_auto">Auto</string>
<string name="device_settings_microphone_mode_right">Right</string>
<string name="device_settings_microphone_mode_left">Left</string>
@@ -12,6 +12,7 @@ import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.devices.ApplePods
import eu.darken.capod.pods.core.apple.ble.devices.SingleApplePods
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
@@ -200,6 +201,23 @@ class PodDeviceTest : BaseTest() {
device.isEitherPodInEar shouldBe true
}
@Test
fun `BLE isBeingWorn delegates to HasEarDetection on single-pod devices`() {
val mock = mockk<SingleApplePods>(relaxed = true, moreInterfaces = arrayOf(HasEarDetection::class)) {
every { model } returns PodModel.AIRPODS_MAX2
every { (this@mockk as HasEarDetection).isBeingWorn } returns true
}
val device = PodDevice(profileId = null, ble = mock, aap = null)
device.isBeingWorn shouldBe true
val mockNotWorn = mockk<SingleApplePods>(relaxed = true, moreInterfaces = arrayOf(HasEarDetection::class)) {
every { model } returns PodModel.AIRPODS_MAX2
every { (this@mockk as HasEarDetection).isBeingWorn } returns false
}
val deviceNotWorn = PodDevice(profileId = null, ble = mockNotWorn, aap = null)
deviceNotWorn.isBeingWorn shouldBe false
}
@Test
fun `AAP ear detection preferred over BLE`() {
val mock = mockk<DualApplePods>(relaxed = true) {
@@ -1,5 +1,6 @@
package eu.darken.capod.pods.core.apple
import io.kotest.assertions.withClue
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
@@ -7,79 +8,364 @@ import testhelpers.BaseTest
class ModelFeaturesTest : BaseTest() {
@Test
fun `AirPods Pro 3 has all features`() {
val f = PodModel.AIRPODS_PRO3.features
f.hasDualPods shouldBe true
f.hasCase shouldBe true
f.hasEarDetection shouldBe true
f.hasAncControl shouldBe true
fun `feature flags match supported model matrix`() {
featureExpectations.forEach { expectation ->
withClue(expectation.name) {
modelsWith(expectation.predicate) shouldBe expectation.models
}
}
}
@Test
fun `AirPods Gen 1 has dual pods and case but no ear detection or ANC`() {
val f = PodModel.AIRPODS_GEN1.features
f.hasDualPods shouldBe true
f.hasCase shouldBe true
f.hasEarDetection shouldBe false
f.hasAncControl shouldBe false
}
@Test
fun `AirPods Max is single device with ANC but no dual pods or case`() {
val f = PodModel.AIRPODS_MAX.features
f.hasDualPods shouldBe false
f.hasCase shouldBe false
f.hasEarDetection shouldBe true
f.hasAncControl shouldBe true
}
@Test
fun `Beats Solo 3 has no features`() {
val f = PodModel.BEATS_SOLO_3.features
f.hasDualPods shouldBe false
f.hasCase shouldBe false
f.hasEarDetection shouldBe false
f.hasAncControl shouldBe false
}
@Test
fun `PowerBeats Pro has dual pods, case, ear detection but no ANC`() {
val f = PodModel.POWERBEATS_PRO.features
f.hasDualPods shouldBe true
f.hasCase shouldBe true
f.hasEarDetection shouldBe true
f.hasAncControl shouldBe false
fun `models without ear detection match intentional matrix`() {
modelsWithout { it.hasEarDetection } shouldBe setOf(
PodModel.BEATS_FLEX,
PodModel.BEATS_SOLO_3,
PodModel.BEATS_SOLO_PRO,
PodModel.BEATS_SOLO_4,
PodModel.BEATS_SOLO_BUDS,
PodModel.BEATS_STUDIO_3,
PodModel.BEATS_STUDIO_BUDS,
PodModel.BEATS_STUDIO_BUDS_PLUS,
PodModel.BEATS_STUDIO_PRO,
PodModel.BEATS_X,
PodModel.POWERBEATS_3,
PodModel.POWERBEATS_4,
PodModel.UNKNOWN,
)
}
@Test
fun `UNKNOWN model has no features`() {
val f = PodModel.UNKNOWN.features
f.hasDualPods shouldBe false
f.hasCase shouldBe false
f.hasEarDetection shouldBe false
f.hasAncControl shouldBe false
PodModel.UNKNOWN.features shouldBe PodModel.Features()
}
@Test
fun `all ANC-capable dual-pod models have ear detection except Studio Buds`() {
val noEarDetectionAncModels = setOf(
PodModel.BEATS_STUDIO_BUDS,
PodModel.BEATS_STUDIO_BUDS_PLUS,
)
fun `ear detection toggle implies ear detection`() {
PodModel.entries
.filter { it.features.hasAncControl && it.features.hasDualPods }
.filter { it !in noEarDetectionAncModels }
.filter { it.features.hasEarDetectionToggle }
.forEach { model ->
model.features.hasEarDetection shouldBe true
withClue(model.name) {
model.features.hasEarDetection shouldBe true
}
}
}
@Test
fun `all models with case also have dual pods`() {
fun `sleep detection implies ear detection`() {
PodModel.entries
.filter { it.features.hasSleepDetection }
.forEach { model ->
withClue(model.name) {
model.features.hasEarDetection shouldBe true
}
}
}
@Test
fun `case implies dual pods`() {
PodModel.entries
.filter { it.features.hasCase }
.forEach { model ->
model.features.hasDualPods shouldBe true
withClue(model.name) {
model.features.hasDualPods shouldBe true
}
}
}
}
@Test
fun `adaptive ANC implies ANC control`() {
PodModel.entries
.filter { it.features.hasAdaptiveAnc }
.forEach { model ->
withClue(model.name) {
model.features.hasAncControl shouldBe true
}
}
}
@Test
fun `adaptive audio noise implies adaptive ANC`() {
PodModel.entries
.filter { it.features.hasAdaptiveAudioNoise }
.forEach { model ->
withClue(model.name) {
model.features.hasAdaptiveAnc shouldBe true
}
}
}
@Test
fun `listening mode cycle implies ANC control`() {
PodModel.entries
.filter { it.features.hasListeningModeCycle }
.forEach { model ->
withClue(model.name) {
model.features.hasAncControl shouldBe true
}
}
}
@Test
fun `allow off option implies listening mode cycle`() {
PodModel.entries
.filter { it.features.hasAllowOffOption }
.forEach { model ->
withClue(model.name) {
model.features.hasListeningModeCycle shouldBe true
}
}
}
private fun modelsWith(predicate: (PodModel.Features) -> Boolean): Set<PodModel> = PodModel.entries
.filter { predicate(it.features) }
.toSet()
private fun modelsWithout(predicate: (PodModel.Features) -> Boolean): Set<PodModel> = PodModel.entries
.filterNot { predicate(it.features) }
.toSet()
private data class FeatureExpectation(
val name: String,
val predicate: (PodModel.Features) -> Boolean,
val models: Set<PodModel>,
)
private fun feature(
name: String,
predicate: (PodModel.Features) -> Boolean,
models: Set<PodModel>,
) = FeatureExpectation(name, predicate, models)
private val dualPodModels = setOf(
PodModel.AIRPODS_GEN1,
PodModel.AIRPODS_GEN2,
PodModel.AIRPODS_GEN3,
PodModel.AIRPODS_GEN4,
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.BEATS_SOLO_BUDS,
PodModel.BEATS_STUDIO_BUDS,
PodModel.BEATS_STUDIO_BUDS_PLUS,
PodModel.POWERBEATS_PRO,
PodModel.POWERBEATS_PRO2,
PodModel.BEATS_FIT_PRO,
PodModel.FAKE_AIRPODS_GEN1,
PodModel.FAKE_AIRPODS_GEN2,
PodModel.FAKE_AIRPODS_GEN3,
PodModel.FAKE_AIRPODS_PRO,
PodModel.FAKE_AIRPODS_PRO2,
)
private val earDetectionModels = setOf(
PodModel.AIRPODS_GEN1,
PodModel.AIRPODS_GEN2,
PodModel.AIRPODS_GEN3,
PodModel.AIRPODS_GEN4,
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX,
PodModel.AIRPODS_MAX_USBC,
PodModel.AIRPODS_MAX2,
PodModel.POWERBEATS_PRO,
PodModel.POWERBEATS_PRO2,
PodModel.BEATS_FIT_PRO,
PodModel.FAKE_AIRPODS_GEN1,
PodModel.FAKE_AIRPODS_GEN2,
PodModel.FAKE_AIRPODS_GEN3,
PodModel.FAKE_AIRPODS_PRO,
PodModel.FAKE_AIRPODS_PRO2,
)
private val ancControlModels = setOf(
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX,
PodModel.AIRPODS_MAX_USBC,
PodModel.AIRPODS_MAX2,
PodModel.BEATS_SOLO_PRO,
PodModel.BEATS_STUDIO_3,
PodModel.BEATS_STUDIO_BUDS,
PodModel.BEATS_STUDIO_BUDS_PLUS,
PodModel.BEATS_STUDIO_PRO,
PodModel.POWERBEATS_PRO2,
PodModel.BEATS_FIT_PRO,
PodModel.FAKE_AIRPODS_PRO,
PodModel.FAKE_AIRPODS_PRO2,
)
private val adaptiveAncModels = setOf(
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX2,
)
private val conversationAwarenessModels = setOf(
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX2,
)
private val ncOneAirpodModels = setOf(
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
)
private val pressSpeedModels = setOf(
PodModel.AIRPODS_GEN3,
PodModel.AIRPODS_GEN4,
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX,
PodModel.AIRPODS_MAX_USBC,
PodModel.AIRPODS_MAX2,
)
private val volumeSwipeModels = setOf(
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
)
private val personalizedVolumeModels = setOf(
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX2,
)
private val toneVolumeModels = setOf(
PodModel.AIRPODS_GEN3,
PodModel.AIRPODS_GEN4,
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX,
PodModel.AIRPODS_MAX_USBC,
PodModel.AIRPODS_MAX2,
)
private val endCallMuteMicModels = setOf(
PodModel.AIRPODS_GEN3,
PodModel.AIRPODS_GEN4,
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
)
private val adaptiveAudioNoiseModels = setOf(
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX2,
)
private val microphoneModeModels = setOf(
PodModel.AIRPODS_GEN1,
PodModel.AIRPODS_GEN2,
PodModel.AIRPODS_GEN3,
PodModel.AIRPODS_GEN4,
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.POWERBEATS_PRO2,
PodModel.BEATS_FIT_PRO,
)
private val earDetectionToggleModels = setOf(
PodModel.AIRPODS_GEN1,
PodModel.AIRPODS_GEN2,
PodModel.AIRPODS_GEN3,
PodModel.AIRPODS_GEN4,
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX,
PodModel.AIRPODS_MAX_USBC,
PodModel.AIRPODS_MAX2,
PodModel.POWERBEATS_PRO,
PodModel.POWERBEATS_PRO2,
PodModel.BEATS_FIT_PRO,
)
private val listeningModeCycleModels = setOf(
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX,
PodModel.AIRPODS_MAX_USBC,
PodModel.AIRPODS_MAX2,
)
private val stemConfigModels = setOf(
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
)
private val sleepDetectionModels = setOf(
PodModel.AIRPODS_GEN4,
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.POWERBEATS_PRO2,
)
private val featureExpectations = listOf(
feature("hasDualPods", { it.hasDualPods }, dualPodModels),
feature("hasCase", { it.hasCase }, dualPodModels),
feature("hasEarDetection", { it.hasEarDetection }, earDetectionModels),
feature("hasAncControl", { it.hasAncControl }, ancControlModels),
feature("hasAdaptiveAnc", { it.hasAdaptiveAnc }, adaptiveAncModels),
feature("hasConversationAwareness", { it.hasConversationAwareness }, conversationAwarenessModels),
feature("hasNcOneAirpod", { it.hasNcOneAirpod }, ncOneAirpodModels),
feature("hasPressSpeed", { it.hasPressSpeed }, pressSpeedModels),
feature("hasPressHoldDuration", { it.hasPressHoldDuration }, pressSpeedModels),
feature("hasVolumeSwipe", { it.hasVolumeSwipe }, volumeSwipeModels),
feature("hasVolumeSwipeLength", { it.hasVolumeSwipeLength }, volumeSwipeModels),
feature("hasPersonalizedVolume", { it.hasPersonalizedVolume }, personalizedVolumeModels),
feature("hasToneVolume", { it.hasToneVolume }, toneVolumeModels),
feature("hasEndCallMuteMic", { it.hasEndCallMuteMic }, endCallMuteMicModels),
feature("hasAdaptiveAudioNoise", { it.hasAdaptiveAudioNoise }, adaptiveAudioNoiseModels),
feature("hasMicrophoneMode", { it.hasMicrophoneMode }, microphoneModeModels),
feature("hasEarDetectionToggle", { it.hasEarDetectionToggle }, earDetectionToggleModels),
feature("hasListeningModeCycle", { it.hasListeningModeCycle }, listeningModeCycleModels),
feature("hasAllowOffOption", { it.hasAllowOffOption }, listeningModeCycleModels),
feature("hasStemConfig", { it.hasStemConfig }, stemConfigModels),
feature("hasSleepDetection", { it.hasSleepDetection }, sleepDetectionModels),
feature("hasDynamicEndOfCharge", { it.hasDynamicEndOfCharge }, setOf(PodModel.AIRPODS_PRO3)),
)
}
@@ -337,6 +337,7 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() {
f.hasPressSpeed shouldBe true
f.hasPressHoldDuration shouldBe true
f.hasToneVolume shouldBe true
f.hasEndCallMuteMic shouldBe true
f.hasListeningModeCycle shouldBe false
f.hasStemConfig shouldBe false
f.hasSleepDetection shouldBe true
@@ -351,14 +352,31 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() {
f.hasStemConfig shouldBe false
}
@Test fun `Gen 1 has mic mode only`() {
@Test fun `Gen 1 has mic mode and ear detection toggle`() {
val f = PodModel.AIRPODS_GEN1.features
f.hasMicrophoneMode shouldBe true
f.hasEarDetectionToggle shouldBe false
f.hasEarDetection shouldBe true
f.hasEarDetectionToggle shouldBe true
f.hasListeningModeCycle shouldBe false
f.hasStemConfig shouldBe false
}
@Test fun `Gen 2 and Gen 3 expose ear detection toggle`() {
val gen2 = PodModel.AIRPODS_GEN2.features
gen2.hasMicrophoneMode shouldBe true
gen2.hasEarDetection shouldBe true
gen2.hasEarDetectionToggle shouldBe true
val gen3 = PodModel.AIRPODS_GEN3.features
gen3.hasMicrophoneMode shouldBe true
gen3.hasEarDetection shouldBe true
gen3.hasEarDetectionToggle shouldBe true
gen3.hasPressSpeed shouldBe true
gen3.hasPressHoldDuration shouldBe true
gen3.hasToneVolume shouldBe true
gen3.hasEndCallMuteMic shouldBe true
}
@Test fun `Max 2 has H2 features`() {
val f = PodModel.AIRPODS_MAX2.features
f.hasAdaptiveAnc shouldBe true
@@ -382,6 +400,18 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() {
f.hasEarDetectionToggle shouldBe true
f.hasAncControl shouldBe true
f.hasEarDetection shouldBe true
f.hasMicrophoneMode shouldBe true
}
@Test fun `Powerbeats Pro and Beats Fit Pro expose ear detection toggle`() {
val powerbeatsPro = PodModel.POWERBEATS_PRO.features
powerbeatsPro.hasEarDetection shouldBe true
powerbeatsPro.hasEarDetectionToggle shouldBe true
val beatsFitPro = PodModel.BEATS_FIT_PRO.features
beatsFitPro.hasEarDetection shouldBe true
beatsFitPro.hasEarDetectionToggle shouldBe true
beatsFitPro.hasMicrophoneMode shouldBe true
}
}
@@ -413,6 +443,14 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() {
}
}
@Test fun `earDetectionToggle implies earDetection`() {
for (model in PodModel.entries) {
if (model.features.hasEarDetectionToggle) {
model.features.hasEarDetection shouldBe true
}
}
}
@Test fun `adaptiveAudioNoise implies adaptiveAnc`() {
for (model in PodModel.entries) {
if (model.features.hasAdaptiveAudioNoise) {
@@ -24,6 +24,7 @@ class AirPodsMax2Test : BaseBlePodsTest() {
batteryHeadsetPercent shouldBe 0.6f
isHeadsetBeingCharged shouldBe false
isBeingWorn shouldBe true
model shouldBe PodModel.AIRPODS_MAX2
}
@@ -35,6 +36,7 @@ class AirPodsMax2Test : BaseBlePodsTest() {
pubStatus shouldBe 0x20.toUByte()
batteryHeadsetPercent shouldBe 0.6f
isHeadsetBeingCharged shouldBe false
isBeingWorn shouldBe false
model shouldBe PodModel.AIRPODS_MAX2
}
@@ -42,6 +44,7 @@ class AirPodsMax2Test : BaseBlePodsTest() {
pubStatus shouldBe 0x21.toUByte()
batteryHeadsetPercent shouldBe 0.6f
isHeadsetBeingCharged shouldBe false
isBeingWorn shouldBe false
model shouldBe PodModel.AIRPODS_MAX2
}
@@ -49,6 +52,17 @@ class AirPodsMax2Test : BaseBlePodsTest() {
pubStatus shouldBe 0x23.toUByte()
batteryHeadsetPercent shouldBe 0.6f
isHeadsetBeingCharged shouldBe false
isBeingWorn shouldBe true
model shouldBe PodModel.AIRPODS_MAX2
}
// Synthetic: one earcup off, the other on (issue #548 sees 0x29 in this state on
// both macOS and Android; we don't claim left vs right side semantics here).
create<AirPodsMax2>("07 19 01 2D 20 29 F6 8F 03 14 04 E1 8B 99 98 20 0B C3 C1 12 2D B0 43 98 94 D6 A0") {
pubStatus shouldBe 0x29.toUByte()
batteryHeadsetPercent shouldBe 0.6f
isHeadsetBeingCharged shouldBe false
isBeingWorn shouldBe true
model shouldBe PodModel.AIRPODS_MAX2
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
### Updated by tools/release/bump.sh ###
project.versioning.major=5
project.versioning.minor=1
project.versioning.patch=2
project.versioning.patch=3
project.versioning.build=0
project.versioning.type=rc
#############################