From 4f65bd69fbe7979f47d006e42767975a0aa87cdd Mon Sep 17 00:00:00 2001 From: FEIYANG CHEN <137134792+cfy114514@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:29:18 +0800 Subject: [PATCH 01/17] feat(android): register AirPods 5 models and capabilities Add AirPods5 (A3531/A3532/A3533) and AirPods5Wireless (A3439/A3440/A3441) to AirPodsModels. Expose existing listening mode, conversation awareness, head gesture, adaptive audio, personalized volume, and stem controls; expose volume swipe only for the wireless-case variant. Use Apple's earbud model numbers, excluding charging case identifiers. Reuse existing placeholder artwork. Leave BLE identifiers, AACP/ATT, connection behavior, and all existing model definitions unchanged. Sources: https://support.apple.com/109525 https://www.apple.com/airpods-5/specs/ https://support.apple.com/en-gu/104979 Validation: standalone Kotlin/JVM compilation with test-only R stubs; 96 local assertions passed for model lookup, capabilities, existing models, registry uniqueness, and rejection of case/unknown identifiers. Existing definitions verified unchanged after removing the additions. Full Android APK build and AirPods 5 hardware validation are not yet done. --- .../me/kavishdevar/librepods/data/AirPods.kt | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) 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..3f1487546 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 @@ -141,6 +141,47 @@ class AirPods4ANC: AirPodsBase( ) ) +// Initial AirPods 5 registration; AACP compatibility still needs hardware validation. +// Only earbud model numbers belong here, not charging case identifiers. +class AirPods5: AirPodsBase( + modelNumber = listOf("A3531", "A3532", "A3533"), + name = "AirPods 5", + // Reuse existing artwork until AirPods 5 assets are available. + budCaseRes = R.drawable.airpods_pro_2, + budsRes = R.drawable.airpods_pro_2_buds, + leftBudsRes = R.drawable.airpods_pro_2_left, + rightBudsRes = R.drawable.airpods_pro_2_right, + caseRes = R.drawable.airpods_pro_2_case, + capabilities = setOf( + Capability.LISTENING_MODE, + Capability.CONVERSATION_AWARENESS, + Capability.HEAD_GESTURES, + Capability.ADAPTIVE_AUDIO, + Capability.ADAPTIVE_VOLUME, + Capability.STEM_CONFIG + ) +) + +class AirPods5Wireless: AirPodsBase( + modelNumber = listOf("A3439", "A3440", "A3441"), + name = "AirPods 5 (Wireless Charging Case)", + // Reuse existing artwork until AirPods 5 assets are available. + budCaseRes = R.drawable.airpods_pro_2, + budsRes = R.drawable.airpods_pro_2_buds, + leftBudsRes = R.drawable.airpods_pro_2_left, + rightBudsRes = R.drawable.airpods_pro_2_right, + caseRes = R.drawable.airpods_pro_2_case, + capabilities = setOf( + Capability.LISTENING_MODE, + Capability.CONVERSATION_AWARENESS, + Capability.HEAD_GESTURES, + Capability.ADAPTIVE_AUDIO, + Capability.ADAPTIVE_VOLUME, + Capability.STEM_CONFIG, + Capability.SWIPE_FOR_VOLUME + ) +) + class AirPodsPro1: AirPodsBase( modelNumber = listOf("A2084", "A2083"), name = "AirPods Pro 1", @@ -265,6 +306,8 @@ object AirPodsModels { AirPods3(), AirPods4(), AirPods4ANC(), + AirPods5(), + AirPods5Wireless(), AirPodsPro1(), AirPodsPro2Lightning(), AirPodsPro2USBC(), From e6a6a98a838502596c3b12700e22755fb9bbe011 Mon Sep 17 00:00:00 2001 From: FEIYANG CHEN <137134792+cfy114514@users.noreply.github.com> Date: Sat, 19 Sep 2026 22:27:43 +0800 Subject: [PATCH 02/17] test(android): share AirPods 4 artwork and add fork-safe debug CI Make both AirPods 5 variants reuse all five AirPods 4 artwork references without inheriting its capabilities. AirPods 4 itself currently uses upstream Pro 2 placeholder images; no missing image files are invented. Add AirPodsModelsTest and a JUnit 4 test dependency. Cover new and existing model lookup, registration uniqueness, rejected case/unknown identifiers, capability separation, and artwork consistency. Convert this fork's Android workflow to read-only, secret-free FOSS debug validation: unit tests, APK build, lint, checksums, and diagnostic artifacts. Include changes to the workflow itself in path filters. Preserve push, PR, manual, and reusable triggers. Do not auto-publish releases or Discord posts. Validation performed before commit: - Baseline source/Gradle copies matched their fetched Git blob SHAs. - Standalone JVM execution: 10 test methods, 130 assertions passed using temporary resource IDs and an offline JUnit shim (not committed). - Workflow YAML and embedded Bash syntax checks passed. - CI SDK/NDK/CMake versions match the current Android build definition. Full Android/Gradle CI and real AirPods 5 hardware behavior remain unverified. Fork Actions may still need activation in GitHub's Actions tab. --- .github/workflows/ci-android.yml | 245 ++++++------------ android/app/build.gradle.kts | 1 + .../me/kavishdevar/librepods/data/AirPods.kt | 26 +- .../librepods/data/AirPodsModelsTest.kt | 109 ++++++++ 4 files changed, 198 insertions(+), 183 deletions(-) create mode 100644 android/app/src/test/java/me/kavishdevar/librepods/data/AirPodsModelsTest.kt diff --git a/.github/workflows/ci-android.yml b/.github/workflows/ci-android.yml index 50922efa1..84c3d9ad8 100644 --- a/.github/workflows/ci-android.yml +++ b/.github/workflows/ci-android.yml @@ -1,212 +1,115 @@ -name: Android CI +name: Android Debug CI on: push: branches: - - '*' + - '**' paths: - 'android/**' - - 'root-module-manual/**' + - '.github/workflows/ci-android.yml' pull_request: paths: - 'android/**' + - '.github/workflows/ci-android.yml' workflow_dispatch: inputs: branch: - description: Branch to build + description: Branch or commit to build required: true default: main workflow_call: +# This fork validates debug builds without upstream release keys or publishing. +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: build: runs-on: ubuntu-latest - outputs: - short_sha: ${{ steps.vars.outputs.short_sha }} - app_version: ${{ steps.version.outputs.app_version }} + timeout-minutes: 45 steps: - uses: actions/checkout@v4 with: submodules: true + persist-credentials: false ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.branch || github.ref }} + - uses: actions/setup-java@v4 with: distribution: zulu java-version: 21 + - uses: gradle/actions/setup-gradle@v4 - - name: Decode keystore - if: github.event_name != 'pull_request' - run: echo "${{ secrets.RELEASE_KEYSTORE_FILE }}" | base64 --decode > android/release.keystore + - name: Setup Android SDK uses: android-actions/setup-android@v3 - - name: Accept Licenses - run: yes | sdkmanager --licenses - - name: Install NDK - run: sdkmanager "ndk;30.0.14904198" - - name: Create local.properties - if: github.event_name != 'pull_request' - run: | - cat < android/local.properties - RELEASE_STORE_FILE=../release.keystore - RELEASE_STORE_PASSWORD=${{ secrets.RELEASE_STORE_PASSWORD }} - RELEASE_KEY_ALIAS=${{ secrets.RELEASE_KEY_ALIAS }} - RELEASE_KEY_PASSWORD=${{ secrets.RELEASE_KEY_PASSWORD }} - EOF - - name: Build debug APK for PRs - if: github.event_name == 'pull_request' - run: ./gradlew assembleFossDebug - working-directory: android - - name: Build release artifacts - if: github.event_name != 'pull_request' - run: ./gradlew packageReleaseArtifacts - working-directory: android - - name: Get app version - id: version - run: echo "app_version=$(grep 'appVersionName =' android/app/build.gradle.kts | sed 's/.*= "\(.*\)"/\1/')" >> $GITHUB_OUTPUT - - id: vars - run: echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT - - uses: actions/upload-artifact@v4 - if: github.event_name != 'pull_request' - with: - name: apk-release - path: release/*release.apk - - uses: actions/upload-artifact@v4 - if: github.event_name == 'pull_request' - with: - name: apk-debug - path: android/app/build/outputs/apk/foss/debug/app-foss-debug.apk - - - uses: actions/upload-artifact@v4 - if: github.event_name != 'pull_request' - with: - name: apk-debug - path: release/*debug.apk - - - uses: actions/upload-artifact@v4 - if: github.event_name != 'pull_request' - with: - name: root-module-release - path: release/*release.zip - - - uses: actions/upload-artifact@v4 - if: github.event_name != 'pull_request' - with: - name: root-module-debug - path: release/*debug.zip - - - uses: actions/upload-artifact@v4 - if: github.event_name != 'pull_request' - with: - name: release-bundle - path: release/*.aab + - name: Install project SDK and native build tools + # Keep these versions in sync with android/app/build.gradle.kts. + run: >- + sdkmanager --channel=3 + "platforms;android-37" + "ndk;30.0.14904198" + "cmake;3.22.1" - release: - if: github.event_name == 'push' - runs-on: ubuntu-latest - needs: build - permissions: - contents: write + - name: Test, build debug APK, and run lint + id: verify + working-directory: android + shell: bash + run: | + chmod +x gradlew + mkdir -p build + ./gradlew --no-daemon --stacktrace --continue \ + testFossDebugUnitTest assembleFossDebug lintFossDebug \ + 2>&1 | tee build/ci-gradle.log - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - fetch-tags: true - - uses: actions/download-artifact@v4 - with: - name: apk-release - path: artifacts/apk-release + - name: Checksum debug APK + if: ${{ !cancelled() }} + shell: bash + run: | + if [[ -f android/app/build/outputs/apk/foss/debug/app-foss-debug.apk ]]; then + cd android/app/build/outputs/apk/foss/debug + sha256sum app-foss-debug.apk > SHA256SUMS + fi - - uses: actions/download-artifact@v4 + - name: Upload debug APK + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 with: - name: apk-debug - path: artifacts/apk-debug + name: apk-foss-debug-${{ github.run_id }} + path: | + android/app/build/outputs/apk/foss/debug/*.apk + android/app/build/outputs/apk/foss/debug/SHA256SUMS + if-no-files-found: warn + retention-days: 14 - - uses: actions/download-artifact@v4 + - name: Upload test reports and diagnostics + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 with: - name: root-module-release - path: artifacts/root-module-release + name: android-ci-reports-${{ github.run_id }} + path: | + android/app/build/reports/ + android/app/build/test-results/ + android/build/ci-gradle.log + if-no-files-found: warn + retention-days: 14 - - uses: actions/download-artifact@v4 - with: - name: root-module-debug - path: artifacts/root-module-debug - - id: prev + - name: Write build summary + if: ${{ !cancelled() }} env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - TAG=$(gh release list \ - --limit 1 \ - --json tagName \ - --jq '.[0].tagName') - - echo "tag=$TAG" >> $GITHUB_OUTPUT - - id: changelog - run: | - if [ -z "${{ steps.prev.outputs.tag }}" ]; then - NOTES=$(git log --pretty=format:"- %s ([%h](https://github.com/kavishdevar/librepods/commit/%H))") - else - NOTES=$(git log ${{ steps.prev.outputs.tag }}..HEAD --pretty=format:"- %s ([%h](https://github.com/kavishdevar/librepods/commit/%H))") - fi - echo "notes<> $GITHUB_OUTPUT - echo "$NOTES" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - - - id: tag - run: echo "tag=nightly-${{ needs.build.outputs.short_sha }}" >> $GITHUB_OUTPUT - - - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release create "${{ steps.tag.outputs.tag }}" \ - artifacts/**/* \ - -t "Nightly ${{ needs.build.outputs.short_sha }}" \ - --notes "${{ steps.changelog.outputs.notes }}" \ - --prerelease - - name: Get timestamp - id: timestamp - run: echo "timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_OUTPUT - - name: Post to Discord + VERIFY_OUTCOME: ${{ steps.verify.outcome }} + shell: bash run: | - curl -X POST "${{ secrets.DISCORD_ANDROID_CI_URL }}?with_components=true" \ - -H "Content-Type: application/json" \ - -d '{ - "embeds": [ - { - "title": "LibrePods Nightly Build", - "description": "Download the latest debug and release APKs.", - "color": 253060, - "fields": [ - { - "name": "Changelog", - "value": "${{ steps.changelog.outputs.notes }}", - "inline": false - } - ], - "timestamp": "${{ steps.timestamp.outputs.timestamp }}", - "footer": { - "text": "GitHub Actions" - } - } - ], - "components": [ - { - "type": 1, - "components": [ - { - "type": 2, - "label": "Download Release APK", - "style": 5, - "url": "https://github.com/kavishdevar/librepods/releases/download/nightly-${{ needs.build.outputs.short_sha }}/LibrePods-FOSS-v${{ needs.build.outputs.app_version }}-release.apk" - }, - { - "type": 2, - "label": "Download Debug APK", - "style": 5, - "url": "https://github.com/kavishdevar/librepods/releases/download/nightly-${{ needs.build.outputs.short_sha }}/LibrePods-FOSS-v${{ needs.build.outputs.app_version }}-debug.apk" - } - ] - } - ] - }' + { + echo '## LibrePods Android debug validation' + echo "Build/test/lint step: ${VERIFY_OUTCOME}" + echo "Checked-out commit: $(git rev-parse HEAD)" + echo 'Artifacts, when produced: debug APK with SHA-256, unit test reports, lint reports, and Gradle log.' + echo 'No release signing keys are used. No release or Discord notification is published.' + echo 'CI does not validate AirPods 5 Bluetooth behavior on real hardware.' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9b20a00ba..cab09ae00 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -123,6 +123,7 @@ android { } dependencies { + testImplementation("junit:junit:4.13.2") implementation(platform(libs.androidx.compose.bom)) implementation(libs.accompanist.permissions) implementation(libs.androidx.compose.ui.text.google.fonts) 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 3f1487546..350563ca9 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 @@ -141,17 +141,20 @@ class AirPods4ANC: AirPodsBase( ) ) +// Share only artwork with AirPods 4; capabilities stay model-specific. +// Upstream AirPods 4 still uses placeholder artwork. Future artwork fixes are inherited. +private val airPods4Artwork = AirPods4() + // Initial AirPods 5 registration; AACP compatibility still needs hardware validation. // Only earbud model numbers belong here, not charging case identifiers. class AirPods5: AirPodsBase( modelNumber = listOf("A3531", "A3532", "A3533"), name = "AirPods 5", - // Reuse existing artwork until AirPods 5 assets are available. - budCaseRes = R.drawable.airpods_pro_2, - budsRes = R.drawable.airpods_pro_2_buds, - leftBudsRes = R.drawable.airpods_pro_2_left, - rightBudsRes = R.drawable.airpods_pro_2_right, - caseRes = R.drawable.airpods_pro_2_case, + budCaseRes = airPods4Artwork.budCaseRes, + budsRes = airPods4Artwork.budsRes, + leftBudsRes = airPods4Artwork.leftBudsRes, + rightBudsRes = airPods4Artwork.rightBudsRes, + caseRes = airPods4Artwork.caseRes, capabilities = setOf( Capability.LISTENING_MODE, Capability.CONVERSATION_AWARENESS, @@ -165,12 +168,11 @@ class AirPods5: AirPodsBase( class AirPods5Wireless: AirPodsBase( modelNumber = listOf("A3439", "A3440", "A3441"), name = "AirPods 5 (Wireless Charging Case)", - // Reuse existing artwork until AirPods 5 assets are available. - budCaseRes = R.drawable.airpods_pro_2, - budsRes = R.drawable.airpods_pro_2_buds, - leftBudsRes = R.drawable.airpods_pro_2_left, - rightBudsRes = R.drawable.airpods_pro_2_right, - caseRes = R.drawable.airpods_pro_2_case, + budCaseRes = airPods4Artwork.budCaseRes, + budsRes = airPods4Artwork.budsRes, + leftBudsRes = airPods4Artwork.leftBudsRes, + rightBudsRes = airPods4Artwork.rightBudsRes, + caseRes = airPods4Artwork.caseRes, capabilities = setOf( Capability.LISTENING_MODE, Capability.CONVERSATION_AWARENESS, diff --git a/android/app/src/test/java/me/kavishdevar/librepods/data/AirPodsModelsTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/data/AirPodsModelsTest.kt new file mode 100644 index 000000000..22f6a7e3d --- /dev/null +++ b/android/app/src/test/java/me/kavishdevar/librepods/data/AirPodsModelsTest.kt @@ -0,0 +1,109 @@ +package me.kavishdevar.librepods.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class AirPodsModelsTest { + private val commonCapabilities = setOf( + Capability.LISTENING_MODE, + Capability.CONVERSATION_AWARENESS, + Capability.HEAD_GESTURES, + Capability.ADAPTIVE_AUDIO, + Capability.ADAPTIVE_VOLUME, + Capability.STEM_CONFIG + ) + + @Test + fun standardModelNumbersResolveToAirPods5() { + val registered = AirPodsModels.models.single { it is AirPods5 } + for (number in listOf("A3531", "A3532", "A3533")) { + assertSame(number, registered, AirPodsModels.getModelByModelNumber(number)) + } + } + + @Test + fun wirelessModelNumbersResolveToAirPods5Wireless() { + val registered = AirPodsModels.models.single { it is AirPods5Wireless } + for (number in listOf("A3439", "A3440", "A3441")) { + assertSame(number, registered, AirPodsModels.getModelByModelNumber(number)) + } + } + + @Test + fun registeredClassesAndModelNumbersAreUnique() { + val models = AirPodsModels.models + assertEquals(models.size, models.map { it.javaClass }.toSet().size) + val numbers = models.flatMap { it.modelNumber } + assertEquals(numbers.size, numbers.toSet().size) + models.forEach { + assertTrue(it.modelNumber.isNotEmpty()) + assertTrue(it.name.isNotBlank()) + assertTrue(it.modelNumber.all { number -> number.matches(Regex("A[0-9]{4}")) }) + } + } + + @Test + fun existingModelsStillResolveToTheirOriginalClasses() { + val existingModels = listOf( + AirPods(), AirPods2(), AirPods3(), AirPods4(), AirPods4ANC(), + AirPodsPro1(), AirPodsPro2Lightning(), AirPodsPro2USBC(), AirPodsPro3() + ) + for (expected in existingModels) { + for (number in expected.modelNumber) { + val actual = AirPodsModels.getModelByModelNumber(number) + assertNotNull(number, actual) + assertEquals(number, expected.javaClass, actual!!.javaClass) + assertEquals(number, expected.capabilities, actual.capabilities) + } + } + } + + @Test + fun caseNumbersAndUnknownNumbersAreNotRegisteredAsEarbuds() { + for (number in listOf("A3530", "A3529", "A0000", "", "AirPods 5")) { + assertNull(number, AirPodsModels.getModelByModelNumber(number)) + } + } + + @Test + fun standardCapabilitiesStaySeparateFromTheArtworkDonor() { + assertEquals(commonCapabilities, AirPods5().capabilities) + assertFalse(AirPods5().capabilities.contains(Capability.SWIPE_FOR_VOLUME)) + } + + @Test + fun wirelessVariantAddsOnlyVolumeSwipe() { + assertEquals(commonCapabilities + Capability.SWIPE_FOR_VOLUME, AirPods5Wireless().capabilities) + } + + @Test + fun proOnlyCapabilitiesAreNotEnabledBySharingArtwork() { + val excluded = setOf(Capability.HRM, Capability.HEARING_AID, Capability.PPE) + for (model in listOf(AirPods5(), AirPods5Wireless())) { + assertTrue(model.capabilities.intersect(excluded).isEmpty()) + } + } + + @Test + fun airPods5UsesAllFiveAirPods4ArtworkReferences() { + assertSameArtwork(AirPods4(), AirPods5()) + } + + @Test + fun airPods5WirelessUsesAllFiveAirPods4ArtworkReferences() { + assertSameArtwork(AirPods4(), AirPods5Wireless()) + } + + private fun assertSameArtwork(expected: AirPodsBase, actual: AirPodsBase) { + assertEquals(expected.budCaseRes, actual.budCaseRes) + assertEquals(expected.budsRes, actual.budsRes) + assertEquals(expected.leftBudsRes, actual.leftBudsRes) + assertEquals(expected.rightBudsRes, actual.rightBudsRes) + assertEquals(expected.caseRes, actual.caseRes) + } +} From a93b5ac94d4f316924bc24b9e9b01a66cf685c48 Mon Sep 17 00:00:00 2001 From: FEIYANG CHEN <137134792+cfy114514@users.noreply.github.com> Date: Sat, 19 Sep 2026 22:43:52 +0800 Subject: [PATCH 03/17] fix(ci): skip unavailable legacy Android SDK tools package Override setup-android v3's default packages with platform-tools so SDK initialization no longer requests the retired tools package. Suppress verbose accepted-license text without changing license acceptance. Keep application code, SDK/NDK/CMake versions, build tasks, permissions, and workflow triggers unchanged. Do not ignore SDK installation errors. Validation: baseline matched the fetched Git blob; YAML parsing and structural comparison passed; all four embedded shell scripts passed bash -n. Full Android build and hardware validation remain pending. --- .github/workflows/ci-android.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci-android.yml b/.github/workflows/ci-android.yml index 84c3d9ad8..72c52a969 100644 --- a/.github/workflows/ci-android.yml +++ b/.github/workflows/ci-android.yml @@ -47,6 +47,10 @@ jobs: - name: Setup Android SDK uses: android-actions/setup-android@v3 + with: + # Override v3's default: Google no longer serves the legacy "tools" package. + packages: 'platform-tools' + log-accepted-android-sdk-licenses: 'false' - name: Install project SDK and native build tools # Keep these versions in sync with android/app/build.gradle.kts. From e19d8db5c3e95f08ae73cc8062e279afa78fa9b1 Mon Sep 17 00:00:00 2001 From: FEIYANG CHEN <137134792+cfy114514@users.noreply.github.com> Date: Sun, 20 Sep 2026 00:48:34 +0800 Subject: [PATCH 04/17] fix(android): align API 37.0 SDK package and Gradle target CI run 35449626415 passed SDK bootstrap but failed to find platforms;android-37. Install platforms;android-37.0 instead and use compileSdk.release(37) with minorApiLevel=0 so Gradle selects the same SDK directory. Keep targetSdk=37, minSdk, NDK, CMake and dependencies unchanged; do not create fake platform aliases or downgrade the API. Retain SDK package-list and install logs as diagnostic artifacts, check required installed files, and report SDK installation separately from the Gradle build/test/lint result. Installation failures remain fatal. Validation: source copies matched fetched Git blob SHAs; YAML parsing, four Bash syntax checks and unchanged-scope checks passed. Mock SDK success, install/list failures and missing-file guards behaved as expected. No real SDK download, Android build, unit tests, lint or hardware tests were run locally; the push-triggered CI must validate the actual build. References: https://github.com/actions/runner-images/blob/ubuntu24/20260907.300/images/ubuntu/Ubuntu2404-Readme.md https://developer.android.com/reference/tools/gradle-api/9.2/com/android/build/api/dsl/CompileSdkSpec https://developer.android.com/reference/tools/gradle-api/9.2/com/android/build/api/dsl/CompileSdkReleaseSpec --- .github/workflows/ci-android.yml | 25 ++++++++++++++++++------- android/app/build.gradle.kts | 7 ++++++- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci-android.yml b/.github/workflows/ci-android.yml index 72c52a969..0f49cf060 100644 --- a/.github/workflows/ci-android.yml +++ b/.github/workflows/ci-android.yml @@ -53,12 +53,21 @@ jobs: log-accepted-android-sdk-licenses: 'false' - name: Install project SDK and native build tools + id: sdk # Keep these versions in sync with android/app/build.gradle.kts. - run: >- - sdkmanager --channel=3 - "platforms;android-37" - "ndk;30.0.14904198" - "cmake;3.22.1" + shell: bash + run: | + set -euo pipefail + mkdir -p android/build + sdkmanager --list --channel=3 > android/build/ci-sdk-packages.log 2>&1 + sdkmanager --channel=3 \ + "platforms;android-37.0" \ + "ndk;30.0.14904198" \ + "cmake;3.22.1" \ + 2>&1 | tee android/build/ci-sdk-install.log + test -f "$ANDROID_HOME/platforms/android-37.0/android.jar" + test -f "$ANDROID_HOME/ndk/30.0.14904198/source.properties" + test -x "$ANDROID_HOME/cmake/3.22.1/bin/cmake" - name: Test, build debug APK, and run lint id: verify @@ -99,21 +108,23 @@ jobs: path: | android/app/build/reports/ android/app/build/test-results/ - android/build/ci-gradle.log + android/build/ci-*.log if-no-files-found: warn retention-days: 14 - name: Write build summary if: ${{ !cancelled() }} env: + SDK_OUTCOME: ${{ steps.sdk.outcome }} VERIFY_OUTCOME: ${{ steps.verify.outcome }} shell: bash run: | { echo '## LibrePods Android debug validation' + echo "SDK installation step: ${SDK_OUTCOME}" echo "Build/test/lint step: ${VERIFY_OUTCOME}" echo "Checked-out commit: $(git rev-parse HEAD)" - echo 'Artifacts, when produced: debug APK with SHA-256, unit test reports, lint reports, and Gradle log.' + echo 'Artifacts, when produced: debug APK with SHA-256, unit test reports, lint reports, SDK installation logs, and Gradle log.' echo 'No release signing keys are used. No release or Discord notification is published.' echo 'CI does not validate AirPods 5 Bluetooth behavior on real hardware.' } >> "$GITHUB_STEP_SUMMARY" diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index cab09ae00..f8222a8b2 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -44,7 +44,12 @@ android { } } namespace = "me.kavishdevar.librepods" - compileSdk = 37 + // API 37 is published as platforms;android-37.0, including the minor version. + compileSdk { + version = release(37) { + minorApiLevel = 0 + } + } defaultConfig { applicationId = "me.kavishdevar.librepods" From b80eccde989533f8ec602c5f75822e0d80c6a9ce Mon Sep 17 00:00:00 2001 From: FEIYANG CHEN <137134792+cfy114514@users.noreply.github.com> Date: Sun, 20 Sep 2026 10:02:21 +0800 Subject: [PATCH 05/17] chore: prepare hash-checked Android compatibility fixes One-shot maintenance workflow: prepare two reviewed source blobs and a patch artifact without updating branches or executing application code. The workflow will be removed when the verified source changes are committed. --- .github/workflows/prepare-source-fixes.yml | 117 +++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 .github/workflows/prepare-source-fixes.yml diff --git a/.github/workflows/prepare-source-fixes.yml b/.github/workflows/prepare-source-fixes.yml new file mode 100644 index 000000000..327c6a180 --- /dev/null +++ b/.github/workflows/prepare-source-fixes.yml @@ -0,0 +1,117 @@ +name: Prepare reviewed Android source fixes +on: + push: + branches: [main] + paths: ['.github/workflows/prepare-source-fixes.yml'] +permissions: + contents: write +jobs: + prepare: + if: github.repository == 'cfy114514/librepods' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Apply exact, hash-checked source edits + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + import hashlib + import json + import subprocess + + out = Path('/tmp/librepods-reviewed-fixes') + out.mkdir() + base = Path('android/app/src/main/java/me/kavishdevar/librepods') + + def load(path, expected): + raw = path.read_bytes() + actual = hashlib.sha1(b'blob ' + str(len(raw)).encode() + b'\0' + raw).hexdigest() + if actual != expected: + raise SystemExit(f'Source changed: {path}: {actual}') + return raw.decode('utf-8') + + def replace(text, old, new, count=1): + if text.count(old) != count: + raise SystemExit(f'Expected {count} occurrences of {old!r}') + return text.replace(old, new) + + service = base / 'services/AirPodsService.kt' + text = load(service, '0cf08c11d2b18b70e5b02d135035319934973f97') + text = replace(text, + 'sendBroadcast(Intent(AirPodsNotifications.AIRPODS_L2CAP_CONNECTED))', + 'sendBroadcast(Intent(AirPodsNotifications.AIRPODS_L2CAP_CONNECTED).apply {\n' + ' setPackage(packageName)\n' + ' })') + text = replace(text, + ' device?.disconnect()\n', + ' val currentDevice = device\n' + ' if (currentDevice != null) {\n' + ' if (Build.VERSION.SDK_INT >= 37) {\n' + ' currentDevice.disconnect()\n' + ' } else {\n' + ' // Older privileged builds may expose this as a hidden API.\n' + ' // Reflection is best-effort; failures are handled below.\n' + ' BluetoothDevice::class.java.getMethod("disconnect").invoke(currentDevice)\n' + ' }\n' + ' }\n') + service.write_text(text, encoding='utf-8') + + ble = base / 'bluetooth/BLEManager.kt' + text = load(ble, '52fa055124501160e686c5feebd01c31c0b10300') + start = text.index(' private val modelNames = mapOf(\n') + end = text.index('\n )\n', start) + len('\n )\n') + old_map = text[start:end] + if old_map.count(' to ') != 10: + raise SystemExit('Unexpected model table') + text = replace(text, old_map, '') + text = replace(text, + ' val modelId = ((data[3].toInt() and 0xFF) shl 8) or (data[4].toInt() and 0xFF)\n' + ' val model = modelNames[modelId] ?: "Unknown ($modelId)"', + ' val model = AirPodsProximityModels.getModelName(data)', 2) + text = replace(text, + ' if (manufacturerData.size <= 20) return', + ' if (!AirPodsProximityModels.isValid(manufacturerData)) return') + ble.write_text(text, encoding='utf-8') + + paths = [service, ble] + records = [] + for index, path in enumerate(paths): + raw = path.read_bytes() + (out / f'blob-{index}.json').write_text(json.dumps({ + 'content': raw.decode('utf-8'), 'encoding': 'utf-8' + }), encoding='utf-8') + records.append({'path': str(path), 'sha': hashlib.sha1( + b'blob ' + str(len(raw)).encode() + b'\0' + raw).hexdigest()}) + target = out / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(raw) + (out / 'manifest.json').write_text(json.dumps(records, indent=2) + '\n') + subprocess.run(['git', 'diff', '--check'], check=True) + (out / 'source-fixes.patch').write_bytes(subprocess.check_output(['git', 'diff', '--', *map(str, paths)])) + print((out / 'manifest.json').read_text()) + print((out / 'source-fixes.patch').read_text()) + PY + - name: Store blobs without updating any branch + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + for index in 0 1; do + sha=$(gh api "repos/${GITHUB_REPOSITORY}/git/blobs" \ + --input "/tmp/librepods-reviewed-fixes/blob-${index}.json" --jq '.sha') + echo "Stored blob ${index}: ${sha}" + python3 -c 'import json,sys; assert json.load(open("/tmp/librepods-reviewed-fixes/manifest.json"))[int(sys.argv[1])]["sha"] == sys.argv[2]' "$index" "$sha" + done + - uses: actions/upload-artifact@v4 + with: + name: reviewed-source-fixes + path: /tmp/librepods-reviewed-fixes/ + if-no-files-found: error + retention-days: 2 From c95402a697fc8a506870cc7d332a2b23e8ab7c76 Mon Sep 17 00:00:00 2001 From: FEIYANG CHEN <137134792+cfy114514@users.noreply.github.com> Date: Sun, 20 Sep 2026 10:06:03 +0800 Subject: [PATCH 06/17] feat(android): recognize AirPods 5 BLE IDs and fix lint blockers Use the two AirPods 5 identifiers documented in AirPodsDesktop commit 71fbb5e961991dd614e2c58bbadfb918b9441e92. Preserve LibrePods' existing byte order (0x3020 / 0x3620 for wire bytes 30 20 / 36 20), all ten old mappings, unknown-model fallback, and IRK/RPA verification. Both encrypted and unencrypted paths now share a header-validated model decoder. Do not infer charging-case variants or AACP capabilities from these IDs. Add seven JUnit tests for sanitized captures, both IDs, old models, unsigned bytes, byte-order reversal, truncated frames and malformed headers. Resolve the two real lint failures from run 35456128231: guard the API-37 BluetoothDevice.disconnect call with a best-effort reflective legacy path, and package-scope the internal connection broadcast. Retain android:tint for the three framework/RemoteViews layouts with a documented, path-scoped UseAppTint suppression; do not disable NewApi or intent safety checks. Separate APK, unit-test and lint steps while keeping all mandatory and preserving diagnostic artifacts. Remove the one-shot source preparation workflow; normal Android CI retains read-only permissions. Validation before commit: reviewed the complete source patch; original and resulting Git blob hashes verified. Production model decoder passed 79 offline smoke assertions. CI YAML, six Bash scripts and lint XML parsed. The preceding CI had built an APK and passed 10 real JUnit tests, failing only lint. This commit's full Android build, new JUnit tests and lint must be validated by the new CI run; Bluetooth hardware behavior is untested. Reference: https://github.com/SpriteOvO/AirPodsDesktop/commit/71fbb5e961991dd614e2c58bbadfb918b9441e92 --- .github/workflows/ci-android.yml | 43 +++++-- .github/workflows/prepare-source-fixes.yml | 117 ------------------ android/app/lint.xml | 12 ++ .../bluetooth/AirPodsProximityModels.kt | 37 ++++++ .../librepods/bluetooth/BLEManager.kt | 20 +-- .../librepods/services/AirPodsService.kt | 15 ++- .../bluetooth/AirPodsProximityModelsTest.kt | 92 ++++++++++++++ 7 files changed, 191 insertions(+), 145 deletions(-) delete mode 100644 .github/workflows/prepare-source-fixes.yml create mode 100644 android/app/lint.xml create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AirPodsProximityModels.kt create mode 100644 android/app/src/test/java/me/kavishdevar/librepods/bluetooth/AirPodsProximityModelsTest.kt diff --git a/.github/workflows/ci-android.yml b/.github/workflows/ci-android.yml index 0f49cf060..28cf57582 100644 --- a/.github/workflows/ci-android.yml +++ b/.github/workflows/ci-android.yml @@ -69,16 +69,36 @@ jobs: test -f "$ANDROID_HOME/ndk/30.0.14904198/source.properties" test -x "$ANDROID_HOME/cmake/3.22.1/bin/cmake" - - name: Test, build debug APK, and run lint - id: verify + - name: Build FOSS debug APK + id: apk working-directory: android shell: bash run: | + set -euo pipefail chmod +x gradlew mkdir -p build - ./gradlew --no-daemon --stacktrace --continue \ - testFossDebugUnitTest assembleFossDebug lintFossDebug \ - 2>&1 | tee build/ci-gradle.log + ./gradlew --no-daemon --stacktrace assembleFossDebug \ + 2>&1 | tee build/ci-apk.log + + - name: Run JVM unit tests + id: tests + if: ${{ !cancelled() && steps.apk.outcome == 'success' }} + working-directory: android + shell: bash + run: | + set -euo pipefail + ./gradlew --no-daemon --stacktrace testFossDebugUnitTest \ + 2>&1 | tee build/ci-tests.log + + - name: Run Android Lint + id: lint + if: ${{ !cancelled() && steps.apk.outcome == 'success' }} + working-directory: android + shell: bash + run: | + set -euo pipefail + ./gradlew --no-daemon --stacktrace lintFossDebug \ + 2>&1 | tee build/ci-lint.log - name: Checksum debug APK if: ${{ !cancelled() }} @@ -116,15 +136,20 @@ jobs: if: ${{ !cancelled() }} env: SDK_OUTCOME: ${{ steps.sdk.outcome }} - VERIFY_OUTCOME: ${{ steps.verify.outcome }} + APK_OUTCOME: ${{ steps.apk.outcome }} + TEST_OUTCOME: ${{ steps.tests.outcome }} + LINT_OUTCOME: ${{ steps.lint.outcome }} shell: bash run: | { echo '## LibrePods Android debug validation' - echo "SDK installation step: ${SDK_OUTCOME}" - echo "Build/test/lint step: ${VERIFY_OUTCOME}" + echo "SDK installation: ${SDK_OUTCOME}" + echo "APK build: ${APK_OUTCOME}" + echo "JVM unit tests: ${TEST_OUTCOME}" + echo "Android Lint: ${LINT_OUTCOME}" echo "Checked-out commit: $(git rev-parse HEAD)" - echo 'Artifacts, when produced: debug APK with SHA-256, unit test reports, lint reports, SDK installation logs, and Gradle log.' + echo 'All three verification steps are required; no errors are ignored.' + echo 'Artifacts, when produced: debug APK with SHA-256, unit test reports, lint reports, SDK installation logs, and Gradle logs.' echo 'No release signing keys are used. No release or Discord notification is published.' echo 'CI does not validate AirPods 5 Bluetooth behavior on real hardware.' } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/prepare-source-fixes.yml b/.github/workflows/prepare-source-fixes.yml deleted file mode 100644 index 327c6a180..000000000 --- a/.github/workflows/prepare-source-fixes.yml +++ /dev/null @@ -1,117 +0,0 @@ -name: Prepare reviewed Android source fixes -on: - push: - branches: [main] - paths: ['.github/workflows/prepare-source-fixes.yml'] -permissions: - contents: write -jobs: - prepare: - if: github.repository == 'cfy114514/librepods' && github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ github.sha }} - persist-credentials: false - - name: Apply exact, hash-checked source edits - shell: bash - run: | - set -euo pipefail - python3 - <<'PY' - from pathlib import Path - import hashlib - import json - import subprocess - - out = Path('/tmp/librepods-reviewed-fixes') - out.mkdir() - base = Path('android/app/src/main/java/me/kavishdevar/librepods') - - def load(path, expected): - raw = path.read_bytes() - actual = hashlib.sha1(b'blob ' + str(len(raw)).encode() + b'\0' + raw).hexdigest() - if actual != expected: - raise SystemExit(f'Source changed: {path}: {actual}') - return raw.decode('utf-8') - - def replace(text, old, new, count=1): - if text.count(old) != count: - raise SystemExit(f'Expected {count} occurrences of {old!r}') - return text.replace(old, new) - - service = base / 'services/AirPodsService.kt' - text = load(service, '0cf08c11d2b18b70e5b02d135035319934973f97') - text = replace(text, - 'sendBroadcast(Intent(AirPodsNotifications.AIRPODS_L2CAP_CONNECTED))', - 'sendBroadcast(Intent(AirPodsNotifications.AIRPODS_L2CAP_CONNECTED).apply {\n' - ' setPackage(packageName)\n' - ' })') - text = replace(text, - ' device?.disconnect()\n', - ' val currentDevice = device\n' - ' if (currentDevice != null) {\n' - ' if (Build.VERSION.SDK_INT >= 37) {\n' - ' currentDevice.disconnect()\n' - ' } else {\n' - ' // Older privileged builds may expose this as a hidden API.\n' - ' // Reflection is best-effort; failures are handled below.\n' - ' BluetoothDevice::class.java.getMethod("disconnect").invoke(currentDevice)\n' - ' }\n' - ' }\n') - service.write_text(text, encoding='utf-8') - - ble = base / 'bluetooth/BLEManager.kt' - text = load(ble, '52fa055124501160e686c5feebd01c31c0b10300') - start = text.index(' private val modelNames = mapOf(\n') - end = text.index('\n )\n', start) + len('\n )\n') - old_map = text[start:end] - if old_map.count(' to ') != 10: - raise SystemExit('Unexpected model table') - text = replace(text, old_map, '') - text = replace(text, - ' val modelId = ((data[3].toInt() and 0xFF) shl 8) or (data[4].toInt() and 0xFF)\n' - ' val model = modelNames[modelId] ?: "Unknown ($modelId)"', - ' val model = AirPodsProximityModels.getModelName(data)', 2) - text = replace(text, - ' if (manufacturerData.size <= 20) return', - ' if (!AirPodsProximityModels.isValid(manufacturerData)) return') - ble.write_text(text, encoding='utf-8') - - paths = [service, ble] - records = [] - for index, path in enumerate(paths): - raw = path.read_bytes() - (out / f'blob-{index}.json').write_text(json.dumps({ - 'content': raw.decode('utf-8'), 'encoding': 'utf-8' - }), encoding='utf-8') - records.append({'path': str(path), 'sha': hashlib.sha1( - b'blob ' + str(len(raw)).encode() + b'\0' + raw).hexdigest()}) - target = out / path - target.parent.mkdir(parents=True, exist_ok=True) - target.write_bytes(raw) - (out / 'manifest.json').write_text(json.dumps(records, indent=2) + '\n') - subprocess.run(['git', 'diff', '--check'], check=True) - (out / 'source-fixes.patch').write_bytes(subprocess.check_output(['git', 'diff', '--', *map(str, paths)])) - print((out / 'manifest.json').read_text()) - print((out / 'source-fixes.patch').read_text()) - PY - - name: Store blobs without updating any branch - env: - GH_TOKEN: ${{ github.token }} - shell: bash - run: | - set -euo pipefail - for index in 0 1; do - sha=$(gh api "repos/${GITHUB_REPOSITORY}/git/blobs" \ - --input "/tmp/librepods-reviewed-fixes/blob-${index}.json" --jq '.sha') - echo "Stored blob ${index}: ${sha}" - python3 -c 'import json,sys; assert json.load(open("/tmp/librepods-reviewed-fixes/manifest.json"))[int(sys.argv[1])]["sha"] == sys.argv[2]' "$index" "$sha" - done - - uses: actions/upload-artifact@v4 - with: - name: reviewed-source-fixes - path: /tmp/librepods-reviewed-fixes/ - if-no-files-found: error - retention-days: 2 diff --git a/android/app/lint.xml b/android/app/lint.xml new file mode 100644 index 000000000..8a75f5f72 --- /dev/null +++ b/android/app/lint.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AirPodsProximityModels.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AirPodsProximityModels.kt new file mode 100644 index 000000000..5e3bf17e7 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AirPodsProximityModels.kt @@ -0,0 +1,37 @@ +/* + * LibrePods - AirPods liberated from Apple's ecosystem + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package me.kavishdevar.librepods.bluetooth + +/** Model identification for Apple's 0x07 proximity manufacturer data (company ID excluded). */ +internal object AirPodsProximityModels { + private val modelNames = mapOf( + 0x0E20 to "AirPods Pro", + 0x1420 to "AirPods Pro 2", + 0x2420 to "AirPods Pro 2 (USB-C)", + 0x0220 to "AirPods 1", + 0x0F20 to "AirPods 2", + 0x1320 to "AirPods 3", + 0x1920 to "AirPods 4", + 0x1B20 to "AirPods 4 (ANC)", + 0x0A20 to "AirPods Max", + 0x1F20 to "AirPods Max (USB-C)", + // Preserve LibrePods' existing byte-order convention: wire bytes 30 20 / 36 20. + // AirPodsDesktop names the same IDs 0x2030 / 0x2036 (little-endian): + // https://github.com/SpriteOvO/AirPodsDesktop/commit/71fbb5e961991dd614e2c58bbadfb918b9441e92 + // The reference maps both to the family, not to individual case/capability variants. + 0x3020 to "AirPods 5", + 0x3620 to "AirPods 5" + ) + + fun isValid(data: ByteArray): Boolean = + data.size >= 27 && data[0] == 0x07.toByte() && data[1] == 0x19.toByte() + + fun getModelName(data: ByteArray): String { + require(isValid(data)) { "Expected a complete AirPods proximity message" } + val modelId = ((data[3].toInt() and 0xFF) shl 8) or (data[4].toInt() and 0xFF) + // Do not reject new models merely because their ID is not in this table. + return modelNames[modelId] ?: "Unknown ($modelId)" + } +} 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..b98690064 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 @@ -85,18 +85,6 @@ class BLEManager(private val context: Context) { private val processedAddresses = mutableSetOf() private val lastValidCaseBatteryMap = mutableMapOf() - private val modelNames = mapOf( - 0x0E20 to "AirPods Pro", - 0x1420 to "AirPods Pro 2", - 0x2420 to "AirPods Pro 2 (USB-C)", - 0x0220 to "AirPods 1", - 0x0F20 to "AirPods 2", - 0x1320 to "AirPods 3", - 0x1920 to "AirPods 4", - 0x1B20 to "AirPods 4 (ANC)", - 0x0A20 to "AirPods Max", - 0x1F20 to "AirPods Max (USB-C)" - ) val colorNames = mapOf( 0x00 to "White", 0x01 to "Black", 0x02 to "Red", 0x03 to "Blue", @@ -259,7 +247,7 @@ class BLEManager(private val context: Context) { } val manufacturerData = scanRecord.getManufacturerSpecificData(76) ?: return - if (manufacturerData.size <= 20) return + if (!AirPodsProximityModels.isValid(manufacturerData)) return if (!verifiedAddresses.contains(address)) { val irk = getIrkFromPreferences() @@ -334,8 +322,7 @@ class BLEManager(private val context: Context) { private fun parseProximityMessageWithDecryption(address: String, data: ByteArray, decrypted: ByteArray): AirPodsStatus { val paired = data[2].toInt() == 1 - val modelId = ((data[3].toInt() and 0xFF) shl 8) or (data[4].toInt() and 0xFF) - val model = modelNames[modelId] ?: "Unknown ($modelId)" + val model = AirPodsProximityModels.getModelName(data) val status = data[5].toInt() and 0xFF // val flagsCase = data[7].toInt() and 0xFF @@ -432,8 +419,7 @@ class BLEManager(private val context: Context) { private fun parseProximityMessage(address: String, data: ByteArray): AirPodsStatus { val paired = data[2].toInt() == 1 - val modelId = ((data[3].toInt() and 0xFF) shl 8) or (data[4].toInt() and 0xFF) - val model = modelNames[modelId] ?: "Unknown ($modelId)" + val model = AirPodsProximityModels.getModelName(data) val status = data[5].toInt() and 0xFF val podsBattery = data[6].toInt() and 0xFF 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..c1c082876 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 @@ -2709,7 +2709,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList ) } } - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_L2CAP_CONNECTED)) + sendBroadcast(Intent(AirPodsNotifications.AIRPODS_L2CAP_CONNECTED).apply { + setPackage(packageName) + }) } catch (e: Exception) { // sharedPreferences.edit { putBoolean("connection_successful", false) } Log.d( @@ -2906,7 +2908,16 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList override fun onServiceDisconnected(profile: Int) {} }, BluetoothProfile.A2DP) try { - device?.disconnect() + val currentDevice = device + if (currentDevice != null) { + if (Build.VERSION.SDK_INT >= 37) { + currentDevice.disconnect() + } else { + // Older privileged builds may expose this as a hidden API. + // Reflection is best-effort; failures are handled below. + BluetoothDevice::class.java.getMethod("disconnect").invoke(currentDevice) + } + } } catch (e: Exception) { Log.w(TAG, "device.disconnect() failed, $e") } diff --git a/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/AirPodsProximityModelsTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/AirPodsProximityModelsTest.kt new file mode 100644 index 000000000..8461afee3 --- /dev/null +++ b/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/AirPodsProximityModelsTest.kt @@ -0,0 +1,92 @@ +/* + * LibrePods - AirPods liberated from Apple's ecosystem + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package me.kavishdevar.librepods.bluetooth + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class AirPodsProximityModelsTest { + private fun frame(id: Int): ByteArray = ByteArray(27).apply { + this[0] = 0x07 + this[1] = 0x19 + this[2] = 0x01 + this[3] = (id ushr 8).toByte() + this[4] = id.toByte() + } + + @Test + fun recognizesBothAirPods5Identifiers() { + for (id in listOf(0x3020, 0x3620)) { + assertEquals("AirPods 5", AirPodsProximityModels.getModelName(frame(id))) + } + } + + @Test + fun recognizesSanitizedAirPods5Captures() { + // Packet facts from AirPodsDesktop commit 71fbb5e. Private trailing bytes + // remain zeroed; this checks identification, not decryption or AACP control. + for (status in listOf(0x35, 0x55)) { + val data = byteArrayOf( + 0x07, 0x19, 0x01, 0x30, 0x20, status.toByte(), + 0xAA.toByte(), 0xBA.toByte(), 0x32, 0x00, 0x05 + ) + ByteArray(16) + assertEquals(27, data.size) + assertTrue(AirPodsProximityModels.isValid(data)) + assertEquals("AirPods 5", AirPodsProximityModels.getModelName(data)) + } + } + + @Test + fun preservesExistingModelMappings() { + val existing = mapOf( + 0x0E20 to "AirPods Pro", 0x1420 to "AirPods Pro 2", + 0x2420 to "AirPods Pro 2 (USB-C)", 0x0220 to "AirPods 1", + 0x0F20 to "AirPods 2", 0x1320 to "AirPods 3", + 0x1920 to "AirPods 4", 0x1B20 to "AirPods 4 (ANC)", + 0x0A20 to "AirPods Max", 0x1F20 to "AirPods Max (USB-C)" + ) + for ((id, name) in existing) { + assertEquals(name, AirPodsProximityModels.getModelName(frame(id))) + } + } + + @Test + fun keepsUnknownIdentifiersWithoutSignedByteCorruption() { + assertEquals("Unknown (65312)", AirPodsProximityModels.getModelName(frame(0xFF20))) + } + + @Test + fun doesNotMistakeReversedWireBytesForAirPods5() { + for (id in listOf(0x2030, 0x2036)) { + assertNotEquals("AirPods 5", AirPodsProximityModels.getModelName(frame(id))) + } + } + + @Test + fun rejectsEveryTruncatedMessageLength() { + for (length in 0 until 27) { + val data = frame(0x3020).copyOf(length) + assertFalse(AirPodsProximityModels.isValid(data)) + assertThrows(IllegalArgumentException::class.java) { + AirPodsProximityModels.getModelName(data) + } + } + } + + @Test + fun rejectsIncorrectTypeOrPayloadLength() { + for (index in listOf(0, 1)) { + val data = frame(0x3020).apply { this[index] = 0 } + assertFalse(AirPodsProximityModels.isValid(data)) + assertThrows(IllegalArgumentException::class.java) { + AirPodsProximityModels.getModelName(data) + } + } + } +} From 4815cf03805b096b9f65566642e9611b00861ccc Mon Sep 17 00:00:00 2001 From: cfy114514 Date: Sun, 20 Sep 2026 13:18:18 +0800 Subject: [PATCH 07/17] fix(android): avoid duplicate SDP registration and unnecessary ATT connections --- android/app/src/main/cpp/l2c_fcr_hook.cpp | 31 ++++++----- .../bluetooth/AttConnectionPolicy.kt | 14 +++++ .../librepods/services/AirPodsService.kt | 52 +++++++++++++++++-- .../bluetooth/AttConnectionPolicyTest.kt | 33 ++++++++++++ android/tests/test_sdp_hook.py | 49 +++++++++++++++++ 5 files changed, 160 insertions(+), 19 deletions(-) create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AttConnectionPolicy.kt create mode 100644 android/app/src/test/java/me/kavishdevar/librepods/bluetooth/AttConnectionPolicyTest.kt create mode 100644 android/tests/test_sdp_hook.py diff --git a/android/app/src/main/cpp/l2c_fcr_hook.cpp b/android/app/src/main/cpp/l2c_fcr_hook.cpp index 529f86270..ee0885691 100644 --- a/android/app/src/main/cpp/l2c_fcr_hook.cpp +++ b/android/app/src/main/cpp/l2c_fcr_hook.cpp @@ -59,23 +59,26 @@ tBTA_STATUS fake_BTA_DmSetLocalDiRecord(tSDP_DI_RECORD *p_device_info, uint32_t LOGI("fake_BTA_DmSetLocalDiRecord called"); - if (original_BTA_DmSetLocalDiRecord && - enableSdpHook.load(std::memory_order_relaxed)) - original_BTA_DmSetLocalDiRecord(p_device_info, p_handle); - - LOGI("fake_BTA_DmSetLocalDiRecord: modifying vendor to 0x004C, vendor_id_source to 0x0001"); + if (!original_BTA_DmSetLocalDiRecord) { + return BTA_FAILURE; + } - if (p_device_info) { - p_device_info->vendor = 0x004C; - p_device_info->vendor_id_source = 0x0001; + if (!p_device_info || !enableSdpHook.load(std::memory_order_relaxed)) { + return original_BTA_DmSetLocalDiRecord(p_device_info, p_handle); } - LOGI("fake_BTA_DmSetLocalDiRecord: returning status %d", - original_BTA_DmSetLocalDiRecord ? original_BTA_DmSetLocalDiRecord(p_device_info, p_handle) - : BTA_FAILURE); - return original_BTA_DmSetLocalDiRecord ? original_BTA_DmSetLocalDiRecord(p_device_info, - p_handle) - : BTA_FAILURE; + // Register one record only. Logging must not register additional SDP records, + // and the caller must retain its original vendor fields after this call. + const auto vendor = p_device_info->vendor; + const auto vendor_id_source = p_device_info->vendor_id_source; + p_device_info->vendor = 0x004C; + p_device_info->vendor_id_source = 0x0001; + const auto status = original_BTA_DmSetLocalDiRecord(p_device_info, p_handle); + p_device_info->vendor = vendor; + p_device_info->vendor_id_source = vendor_id_source; + + LOGI("fake_BTA_DmSetLocalDiRecord: returning status %d", status); + return status; } static bool decompressXZ(const uint8_t *input, size_t input_size, std::vector &output) { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AttConnectionPolicy.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AttConnectionPolicy.kt new file mode 100644 index 000000000..14198a8e7 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AttConnectionPolicy.kt @@ -0,0 +1,14 @@ +package me.kavishdevar.librepods.bluetooth + +import me.kavishdevar.librepods.data.AirPodsBase +import me.kavishdevar.librepods.data.Capability + +/** ATT is used for hearing features, not ordinary AACP controls. */ +internal fun shouldConnectAtt(model: AirPodsBase?, vendorIdHookEnabled: Boolean): Boolean { + if (!vendorIdHookEnabled) return false + // Preserve discovery for an unknown model. Known models without ATT features + // should not open an unused hearing-feature channel (including AirPods 5). + return model == null || model.capabilities.any { + it == Capability.HEARING_AID || it == Capability.LOUD_SOUND_REDUCTION + } +} 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 c1c082876..c40780927 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 @@ -130,6 +130,8 @@ import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_ import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD import java.nio.ByteBuffer import java.nio.ByteOrder +import java.util.concurrent.atomic.AtomicBoolean +import me.kavishdevar.librepods.bluetooth.shouldConnectAtt import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.time.Duration.Companion.milliseconds @@ -2633,9 +2635,25 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList // CrossDevice.isAvailable = false } - @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag") + private val connectionInProgress = AtomicBoolean(false) + fun connectToSocket( adapter: BluetoothAdapter, device: BluetoothDevice, manual: Boolean = false + ) { + if (!connectionInProgress.compareAndSet(false, true)) { + Log.d(TAG, "Connection already in progress; ignoring duplicate request") + return + } + try { + connectToSocketOnce(adapter, device, manual) + } finally { + connectionInProgress.set(false) + } + } + + @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag") + private fun connectToSocketOnce( + adapter: BluetoothAdapter, device: BluetoothDevice, manual: Boolean ) { if (BluetoothConnectionManager.aacpSocket != null && BluetoothConnectionManager.aacpSocket?.isConnected == true) return Log.d(TAG, " Connecting to socket") @@ -2649,6 +2667,18 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList return } + var pendingAttSocket: BluetoothSocket? = null + fun closeFailedAttempt() { + runCatching { pendingAttSocket?.close() } + runCatching { socket.close() } + if (BluetoothConnectionManager.aacpSocket === socket) { + BluetoothConnectionManager.aacpSocket = null + } + if (BluetoothConnectionManager.attSocket === pendingAttSocket) { + BluetoothConnectionManager.attSocket = null + } + } + try { runBlocking { withTimeout(5000.milliseconds) { @@ -2656,7 +2686,13 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList socket.connect() this@AirPodsService.device = device val xposedRemotePref = XposedRemotePrefProvider.create() - val attSocket = if (xposedRemotePref.getBoolean("vendor_id_hook", false)) { + val knownModel = airpodsInstance?.model + ?: AirPodsModels.getModelByModelNumber(config.airpodsModelNumber) + val useAtt = shouldConnectAtt( + knownModel, xposedRemotePref.getBoolean("vendor_id_hook", false) + ) + Log.d(TAG, "ATT connection required: $useAtt (model=${knownModel?.name ?: "unknown"})") + val attSocket = if (useAtt) { createBluetoothSocket( adapter, device, @@ -2664,8 +2700,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList 31 ) } else null + pendingAttSocket = attSocket attSocket?.connect() + // The reader and characteristic requests obtain this socket + // from the manager, so publish it before starting either. + BluetoothConnectionManager.aacpSocket = socket + BluetoothConnectionManager.attSocket = attSocket + if (attSocket != null) { attManager.startReader() attManager.readCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION) @@ -2673,9 +2715,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList attManager.readCharacteristic(ATTHandles.HEARING_AID) } - BluetoothConnectionManager.aacpSocket = socket - BluetoothConnectionManager.attSocket = attSocket - // Create AirPodsInstance from stored config if available if (airpodsInstance == null && config.airpodsModelNumber.isNotEmpty()) { val model = @@ -2713,6 +2752,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList setPackage(packageName) }) } catch (e: Exception) { + closeFailedAttempt() // sharedPreferences.edit { putBoolean("connection_successful", false) } Log.d( TAG, " Socket not connected, ${e.message}" @@ -2730,6 +2770,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } } if (!socket.isConnected) { + closeFailedAttempt() Log.d(TAG, " socket not connected") if (manual) { sendToast( @@ -2833,6 +2874,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } catch (e: Exception) { e.printStackTrace() Log.d(TAG, "Failed to connect to BluetoothConnectionManager.aacpSocket?: ${e.message}") + closeFailedAttempt() showSocketConnectionFailureNotification("Failed to establish connection: ${e.localizedMessage}") // isConnectedLocally = false this@AirPodsService.device = device diff --git a/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/AttConnectionPolicyTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/AttConnectionPolicyTest.kt new file mode 100644 index 000000000..294a2ceb2 --- /dev/null +++ b/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/AttConnectionPolicyTest.kt @@ -0,0 +1,33 @@ +package me.kavishdevar.librepods.bluetooth + +import me.kavishdevar.librepods.data.AirPods4 +import me.kavishdevar.librepods.data.AirPods4ANC +import me.kavishdevar.librepods.data.AirPodsModels +import me.kavishdevar.librepods.data.AirPodsPro2Lightning +import me.kavishdevar.librepods.data.AirPodsPro2USBC +import me.kavishdevar.librepods.data.AirPodsPro3 +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AttConnectionPolicyTest { + @Test fun modelsWithoutHearingFeaturesDoNotOpenUnusedChannel() { + assertFalse(shouldConnectAtt(AirPods4(), true)) + assertFalse(shouldConnectAtt(AirPods4ANC(), true)) + } + + @Test fun hearingModelsRetainAtt() { + assertTrue(shouldConnectAtt(AirPodsPro2Lightning(), true)) + assertTrue(shouldConnectAtt(AirPodsPro2USBC(), true)) + assertTrue(shouldConnectAtt(AirPodsPro3(), true)) + } + + @Test fun disabledVendorHookNeverOpensAtt() { + AirPodsModels.models.forEach { assertFalse(shouldConnectAtt(it, false)) } + assertFalse(shouldConnectAtt(null, false)) + } + + @Test fun unknownModelPreservesExistingDiscoveryBehavior() { + assertTrue(shouldConnectAtt(null, true)) + } +} diff --git a/android/tests/test_sdp_hook.py b/android/tests/test_sdp_hook.py new file mode 100644 index 000000000..984361ad3 --- /dev/null +++ b/android/tests/test_sdp_hook.py @@ -0,0 +1,49 @@ +from pathlib import Path +import subprocess +import os +import tempfile +temporary = tempfile.TemporaryDirectory() +root = Path(temporary.name) +cpp = Path(__file__).resolve().parents[1] / 'app/src/main/cpp' +src = (cpp / 'l2c_fcr_hook.cpp').read_text() +fn=src.split('tBTA_STATUS fake_BTA_DmSetLocalDiRecord',1)[1].split('static bool decompressXZ',1)[0] +harness=r''' +#include +#include +#include +#include "l2c_fcr_hook.h" +void log_sink(const char*, ...) {} +#define LOGI(...) log_sink(__VA_ARGS__) +#define LOGE(...) log_sink(__VA_ARGS__) +static std::atomic enableSdpHook(false); +static tBTA_STATUS (*original_BTA_DmSetLocalDiRecord)(tSDP_DI_RECORD*,uint32_t*)=nullptr; +'''+ 'tBTA_STATUS fake_BTA_DmSetLocalDiRecord'+fn+r''' +int calls=0,failures=0; uint16_t seenVendor=0,seenSource=0; +tBTA_STATUS fake_original(tSDP_DI_RECORD* p,uint32_t* h) { + ++calls; if(p){seenVendor=p->vendor;seenSource=p->vendor_id_source;} + if(h)*h=1234; return BTA_BUSY; +} +void check(bool ok,const char* msg){if(!ok){std::printf("FAIL: %s\n",msg);++failures;}} +int main(){ + for(bool enabled:{false,true}){ + enableSdpHook=enabled;original_BTA_DmSetLocalDiRecord=fake_original; + calls=0;tSDP_DI_RECORD p{};p.vendor=0x75;p.vendor_id_source=2;p.product=7;uint32_t h=0; + check(fake_BTA_DmSetLocalDiRecord(&p,&h)==BTA_BUSY,"return original status"); + check(calls==1,"one original call");check(h==1234,"output handle preserved"); + check(seenVendor==(enabled?0x4c:0x75),"vendor toggle respected"); + check(seenSource==(enabled?1:2),"vendor source toggle respected"); + check(p.vendor==0x75&&p.vendor_id_source==2&&p.product==7,"caller record restored"); + calls=0;check(fake_BTA_DmSetLocalDiRecord(nullptr,nullptr)==BTA_BUSY,"null inputs forwarded"); + check(calls==1,"one original call for null inputs"); + } + original_BTA_DmSetLocalDiRecord=nullptr; tSDP_DI_RECORD p{};p.vendor=0x75; + check(fake_BTA_DmSetLocalDiRecord(&p,nullptr)==BTA_FAILURE,"missing original fails"); + check(p.vendor==0x75,"missing original does not mutate record"); + std::printf("%d failures\n",failures);return failures?1:0; +} +''' +(root/'sdp-regression.cpp').write_text(harness) +subprocess.run([os.environ.get('CXX', 'c++'),'-std=c++17','-I',str(cpp),str(root/'sdp-regression.cpp'),'-o',str(root/'sdp-regression.exe')],check=True) +result=subprocess.run([str(root/'sdp-regression.exe')],capture_output=True,text=True) +print(result.stdout);raise SystemExit(result.returncode) + From 8bb2ac8d5eb69c96d7947f5d0ee41a9bb26b5a2e Mon Sep 17 00:00:00 2001 From: cfy114514 Date: Sun, 20 Sep 2026 13:18:32 +0800 Subject: [PATCH 08/17] feat(android): match AirPods 5 controls and artwork to each model --- .../me/kavishdevar/librepods/data/AirPods.kt | 56 +++++++++++------- .../presentation/overlays/IslandWindow.kt | 26 +++++--- .../presentation/overlays/PopupWindow.kt | 20 +++++-- .../screens/AccessibilitySettingsScreen.kt | 2 +- .../screens/AirPodsSettingsScreen.kt | 16 ++--- .../presentation/screens/AppSettingsScreen.kt | 2 +- .../viewmodel/AirPodsViewModel.kt | 34 ++++------- .../librepods/services/AirPodsService.kt | 7 ++- .../src/main/res-apple/drawable/airpods_4.png | Bin 0 -> 33439 bytes .../res-apple/drawable/airpods_4_buds.png | Bin 0 -> 18825 bytes .../res-apple/drawable/airpods_4_case.png | Bin 0 -> 18570 bytes .../res-apple/drawable/airpods_4_left.png | Bin 0 -> 9780 bytes .../res-apple/drawable/airpods_4_right.png | Bin 0 -> 9286 bytes .../app/src/main/res/layout/island_window.xml | 8 +++ .../app/src/main/res/layout/popup_window.xml | 8 +++ .../src/main/res/values-zh-rCN/strings.xml | 1 + .../src/main/res/values-zh-rTW/strings.xml | 1 + android/app/src/main/res/values/strings.xml | 1 + .../librepods/data/AirPodsModelsTest.kt | 6 +- docs/airpods-4-artwork.md | 26 ++++++++ docs/airpods-5.md | 47 +++++++++++++++ 21 files changed, 192 insertions(+), 69 deletions(-) create mode 100644 android/app/src/main/res-apple/drawable/airpods_4.png create mode 100644 android/app/src/main/res-apple/drawable/airpods_4_buds.png create mode 100644 android/app/src/main/res-apple/drawable/airpods_4_case.png create mode 100644 android/app/src/main/res-apple/drawable/airpods_4_left.png create mode 100644 android/app/src/main/res-apple/drawable/airpods_4_right.png create mode 100644 docs/airpods-4-artwork.md create mode 100644 docs/airpods-5.md 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 350563ca9..5f4b94bbc 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 @@ -30,7 +30,8 @@ open class AirPodsBase( val leftBudsRes: Int, val rightBudsRes: Int, val caseRes: Int, - val capabilities: Set + val capabilities: Set, + val connectionArtworkRes: Int? = null ) enum class Capability { LISTENING_MODE, @@ -44,6 +45,8 @@ enum class Capability { ADAPTIVE_AUDIO, ADAPTIVE_VOLUME, SWIPE_FOR_VOLUME, + CUSTOM_EQ, + CUSTOM_TRANSPARENCY, HRM } @@ -100,17 +103,14 @@ class AirPods3: AirPodsBase( class AirPods4: AirPodsBase( modelNumber = listOf("A3053", "A3050", "A3054"), name = "AirPods 4", - // budCaseRes = R.drawable.airpods_4 - budCaseRes = R.drawable.airpods_pro_2, - // budsRes = R.drawable.airpods_4_buds - budsRes = R.drawable.airpods_pro_2_buds, - // leftBudsRes = R.drawable.airpods_4_left - leftBudsRes = R.drawable.airpods_pro_2_left, - // rightBudsRes = R.drawable.airpods_4_right - rightBudsRes = R.drawable.airpods_pro_2_right, - // caseRes = R.drawable.airpods_4_case - caseRes = R.drawable.airpods_pro_2_case, + budCaseRes = R.drawable.airpods_4, + budsRes = R.drawable.airpods_4_buds, + leftBudsRes = R.drawable.airpods_4_left, + rightBudsRes = R.drawable.airpods_4_right, + caseRes = R.drawable.airpods_4_case, + connectionArtworkRes = R.drawable.airpods_4, capabilities = setOf( + Capability.CUSTOM_EQ, Capability.HEAD_GESTURES, Capability.SLEEP_DETECTION, Capability.ADAPTIVE_VOLUME @@ -120,17 +120,14 @@ class AirPods4: AirPodsBase( class AirPods4ANC: AirPodsBase( modelNumber = listOf("A3056", "A3055", "A3057"), name = "AirPods 4 (ANC)", - // budCaseRes = R.drawable.airpods_4 - budCaseRes = R.drawable.airpods_pro_2, - // budsRes = R.drawable.airpods_4_buds - budsRes = R.drawable.airpods_pro_2_buds, - // leftBudsRes = R.drawable.airpods_4_left - leftBudsRes = R.drawable.airpods_pro_2_left, - // rightBudsRes = R.drawable.airpods_4_right - rightBudsRes = R.drawable.airpods_pro_2_right, - // caseRes = R.drawable.airpods_4_case - caseRes = R.drawable.airpods_pro_2_case, + budCaseRes = R.drawable.airpods_4, + budsRes = R.drawable.airpods_4_buds, + leftBudsRes = R.drawable.airpods_4_left, + rightBudsRes = R.drawable.airpods_4_right, + caseRes = R.drawable.airpods_4_case, + connectionArtworkRes = R.drawable.airpods_4, capabilities = setOf( + Capability.CUSTOM_EQ, Capability.LISTENING_MODE, Capability.CONVERSATION_AWARENESS, Capability.HEAD_GESTURES, @@ -142,20 +139,23 @@ class AirPods4ANC: AirPodsBase( ) // Share only artwork with AirPods 4; capabilities stay model-specific. -// Upstream AirPods 4 still uses placeholder artwork. Future artwork fixes are inherited. +// The same artwork is used on the main screen and connection overlays. private val airPods4Artwork = AirPods4() -// Initial AirPods 5 registration; AACP compatibility still needs hardware validation. +// AirPods 5 capabilities follow docs/airpods-5.md; AACP controls need device validation. // Only earbud model numbers belong here, not charging case identifiers. class AirPods5: AirPodsBase( modelNumber = listOf("A3531", "A3532", "A3533"), name = "AirPods 5", + connectionArtworkRes = airPods4Artwork.connectionArtworkRes, budCaseRes = airPods4Artwork.budCaseRes, budsRes = airPods4Artwork.budsRes, leftBudsRes = airPods4Artwork.leftBudsRes, rightBudsRes = airPods4Artwork.rightBudsRes, caseRes = airPods4Artwork.caseRes, capabilities = setOf( + Capability.SLEEP_DETECTION, + Capability.CUSTOM_EQ, Capability.LISTENING_MODE, Capability.CONVERSATION_AWARENESS, Capability.HEAD_GESTURES, @@ -168,12 +168,15 @@ class AirPods5: AirPodsBase( class AirPods5Wireless: AirPodsBase( modelNumber = listOf("A3439", "A3440", "A3441"), name = "AirPods 5 (Wireless Charging Case)", + connectionArtworkRes = airPods4Artwork.connectionArtworkRes, budCaseRes = airPods4Artwork.budCaseRes, budsRes = airPods4Artwork.budsRes, leftBudsRes = airPods4Artwork.leftBudsRes, rightBudsRes = airPods4Artwork.rightBudsRes, caseRes = airPods4Artwork.caseRes, capabilities = setOf( + Capability.SLEEP_DETECTION, + Capability.CUSTOM_EQ, Capability.LISTENING_MODE, Capability.CONVERSATION_AWARENESS, Capability.HEAD_GESTURES, @@ -199,6 +202,7 @@ class AirPodsPro1: AirPodsBase( // caseRes = R.drawable.airpods_pro_1_case caseRes = R.drawable.airpods_pro_2_case, capabilities = setOf( + Capability.CUSTOM_TRANSPARENCY, Capability.LISTENING_MODE ) ) @@ -218,6 +222,8 @@ class AirPodsPro2Lightning: AirPodsBase( // caseRes = R.drawable.airpods_pro_2_case caseRes = R.drawable.airpods_pro_2_case, capabilities = setOf( + Capability.CUSTOM_TRANSPARENCY, + Capability.CUSTOM_EQ, Capability.LISTENING_MODE, Capability.CONVERSATION_AWARENESS, Capability.STEM_CONFIG, @@ -246,6 +252,8 @@ class AirPodsPro2USBC: AirPodsBase( // caseRes = R.drawable.airpods_pro_2_case caseRes = R.drawable.airpods_pro_2_case, capabilities = setOf( + Capability.CUSTOM_TRANSPARENCY, + Capability.CUSTOM_EQ, Capability.LISTENING_MODE, Capability.CONVERSATION_AWARENESS, Capability.STEM_CONFIG, @@ -274,6 +282,8 @@ class AirPodsPro3: AirPodsBase( // caseRes = R.drawable.airpods_pro_3_case caseRes = R.drawable.airpods_pro_2_case, capabilities = setOf( + Capability.CUSTOM_TRANSPARENCY, + Capability.CUSTOM_EQ, Capability.LISTENING_MODE, Capability.CONVERSATION_AWARENESS, Capability.HEAD_GESTURES, diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/IslandWindow.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/IslandWindow.kt index bf5eff89e..59f05a883 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/IslandWindow.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/IslandWindow.kt @@ -53,6 +53,7 @@ import android.widget.ImageButton import android.widget.LinearLayout import android.widget.ProgressBar import android.widget.TextView +import android.widget.ImageView import android.widget.VideoView import androidx.core.net.toUri import androidx.dynamicanimation.animation.DynamicAnimation @@ -165,7 +166,7 @@ class IslandWindow(private val context: Context) { @SuppressLint("SetTextI18s", "ClickableViewAccessibility", "UnspecifiedRegisterReceiverFlag", "SetTextI18n" ) - fun show(name: String, batteryPercentage: Int, context: Context, type: IslandType = IslandType.CONNECTED, reversed: Boolean = false, otherDeviceName: String? = null) { + fun show(name: String, batteryPercentage: Int, context: Context, type: IslandType = IslandType.CONNECTED, reversed: Boolean = false, otherDeviceName: String? = null, artworkRes: Int? = null) { if (ServiceManager.getService()?.islandOpen == true) return else ServiceManager.getService()?.islandOpen = true @@ -374,12 +375,22 @@ class IslandWindow(private val context: Context) { } val videoView = islandView.findViewById(R.id.island_video_view) - val videoUri = "android.resource://me.kavishdevar.librepods/${R.raw.island}".toUri() - videoView.setAudioFocusRequest(AudioManager.AUDIOFOCUS_NONE) - videoView.setVideoURI(videoUri) - videoView.setOnPreparedListener { mediaPlayer -> - mediaPlayer.isLooping = true - videoView.start() + val artwork = islandView.findViewById(R.id.island_artwork) + if (artworkRes != null) { + videoView.stopPlayback() + videoView.visibility = View.GONE + artwork.setImageResource(artworkRes) + artwork.visibility = View.VISIBLE + } else { + artwork.visibility = View.GONE + videoView.visibility = View.VISIBLE + val videoUri = "android.resource://me.kavishdevar.librepods/${R.raw.island}".toUri() + videoView.setAudioFocusRequest(AudioManager.AUDIOFOCUS_NONE) + videoView.setVideoURI(videoUri) + videoView.setOnPreparedListener { mediaPlayer -> + mediaPlayer.isLooping = true + videoView.start() + } } try { @@ -605,6 +616,7 @@ class IslandWindow(private val context: Context) { if (progress < 0.7f) { islandView.findViewById(R.id.island_video_view).visibility = View.GONE + islandView.findViewById(R.id.island_artwork).visibility = View.GONE } } normalizeAnimator.addListener(object : AnimatorListenerAdapter() { 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..eb3a09009 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 @@ -44,6 +44,7 @@ import android.view.animation.DecelerateInterpolator import android.widget.ImageButton import android.widget.LinearLayout import android.widget.TextView +import android.widget.ImageView import android.widget.VideoView import me.kavishdevar.librepods.R import me.kavishdevar.librepods.data.AirPodsNotifications @@ -130,7 +131,7 @@ class PopupWindow( } @SuppressLint("InlinedApi", "SetTextI18s") - fun open(name: String = "AirPods Pro", batteryNotification: AirPodsNotifications.BatteryNotification) { + fun open(name: String = "AirPods Pro", batteryNotification: AirPodsNotifications.BatteryNotification, artworkRes: Int? = null) { try { if (mView.windowToken == null && mView.parent == null && !isClosing) { mView.findViewById(R.id.name).text = name @@ -138,12 +139,19 @@ class PopupWindow( 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 artwork = mView.findViewById(R.id.connection_artwork) + if (artworkRes != null) { + vid.stopPlayback() + vid.visibility = View.GONE + artwork.setImageResource(artworkRes) + artwork.visibility = View.VISIBLE + } else { + artwork.visibility = View.GONE + vid.visibility = View.VISIBLE + vid.setAudioFocusRequest(AudioManager.AUDIOFOCUS_NONE) + vid.setVideoPath("android.resource://me.kavishdevar.librepods/" + R.raw.connected) vid.start() + vid.setOnCompletionListener { vid.start() } } try { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AccessibilitySettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AccessibilitySettingsScreen.kt index 52e7c1f45..8e5338e40 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AccessibilitySettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AccessibilitySettingsScreen.kt @@ -271,7 +271,7 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navigateToPurchase: ) } - if (!hearingAidEnabled && state.vendorIdHook) { + if (state.capabilities.contains(Capability.CUSTOM_TRANSPARENCY) && !hearingAidEnabled && state.vendorIdHook) { StyledListItem( name = stringResource(R.string.customize_transparency_mode), onClick = navigateToTransparencyCustomization, 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..a615ee6f9 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 @@ -102,7 +102,6 @@ import me.kavishdevar.librepods.BuildConfig import me.kavishdevar.librepods.R import me.kavishdevar.librepods.bluetooth.AACPManager import me.kavishdevar.librepods.bluetooth.ATTHandles -import me.kavishdevar.librepods.data.AirPodsPro3 import me.kavishdevar.librepods.data.Capability import me.kavishdevar.librepods.presentation.MaterialIcons import me.kavishdevar.librepods.presentation.components.AboutCard @@ -148,6 +147,10 @@ fun AirPodsSettingsRoute( ) { val state by viewModel.uiState.collectAsState() + LaunchedEffect(viewModel, viewModel.isReady) { + if (viewModel.isReady) viewModel.loadCurrentStatus() + } + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + if (m3eEnabled) 0.dp else 84.dp val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp @@ -436,15 +439,14 @@ fun AirPodsSettingsScreen( item(key = "spacer_audio") { Spacer(modifier = Modifier.height(16.dp)) } item(key = "audio") { - val model = state.instance?.model ?: AirPodsPro3() val adaptiveVolumeCapability = - model.capabilities.contains(Capability.ADAPTIVE_VOLUME) + capabilities.contains(Capability.ADAPTIVE_VOLUME) val conversationalAwarenessCapability = - model.capabilities.contains(Capability.CONVERSATION_AWARENESS) + capabilities.contains(Capability.CONVERSATION_AWARENESS) val loudSoundReductionCapability = - model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) + capabilities.contains(Capability.LOUD_SOUND_REDUCTION) val adaptiveAudioCapability = - model.capabilities.contains(Capability.ADAPTIVE_VOLUME) + capabilities.contains(Capability.ADAPTIVE_AUDIO) val adaptiveVolumeChecked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG]?.getOrNull( @@ -460,7 +462,7 @@ fun AirPodsSettingsScreen( conversationalAwarenessCapability = conversationalAwarenessCapability, loudSoundReductionCapability = loudSoundReductionCapability, adaptiveAudioCapability = adaptiveAudioCapability, - customEqCapability = true, + customEqCapability = capabilities.contains(Capability.CUSTOM_EQ), adaptiveVolumeChecked = adaptiveVolumeChecked, onAdaptiveVolumeCheckedChange = { checked -> setControlCommandBoolean( diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt index 06436561d..b7c29dc96 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt @@ -374,7 +374,7 @@ fun AppSettingsScreen( R.string.requires_xposed ) })", - description = stringResource(R.string.act_as_an_apple_device_description), + description = stringResource(R.string.apple_identity_model_description), checked = state.vendorIdHook, onCheckedChange = { enabled -> Toast.makeText(context, restartBluetoothText, Toast.LENGTH_SHORT).show() 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..c23da9e80 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 @@ -460,13 +460,15 @@ class AirPodsViewModel( fun loadCurrentStatus() { if (isDemoMode) return + loadInstance() service.let { service -> _uiState.update { it.copy( isLocallyConnected = BluetoothConnectionManager.aacpSocket?.isConnected == true, battery = service.getBattery(), ancMode = controlRepo.getValue(ControlCommandIdentifiers.LISTENING_MODE)?.get(0)?.toInt() ?: 1, - controlStates = controlRepo.getMap() + controlStates = controlRepo.getMap(), + vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false) ) } } @@ -592,32 +594,22 @@ class AirPodsViewModel( } private fun loadInstance() { - val instance = service.airpodsInstance ?: AirPodsInstance( - name = "AirPods", - model = AirPodsModels.getModelByModelNumber("A3049")!!, - actualModelNumber = "A3049", - serialNumber = null, - leftSerialNumber = null, - rightSerialNumber = null, - version1 = null, - version2 = null, - version3 = null, - ) + val instance = service.airpodsInstance _uiState.update { it.copy( - capabilities = instance.model.capabilities, + capabilities = instance?.model?.capabilities.orEmpty(), instance = instance, - modelName = instance.model.displayName, - actualModel = instance.actualModelNumber, + modelName = instance?.model?.name ?: "AirPods", + actualModel = instance?.actualModelNumber ?: "", serialNumbers = listOf( - instance.serialNumber ?: "", - instance.leftSerialNumber ?: "", - instance.rightSerialNumber ?: "" + instance?.serialNumber ?: "", + instance?.leftSerialNumber ?: "", + instance?.rightSerialNumber ?: "" ), - version1 = instance.version1 ?: "", - version2 = instance.version2 ?: "", - version3 = instance.version3 ?: "" + version1 = instance?.version1 ?: "", + version2 = instance?.version2 ?: "", + version3 = instance?.version3 ?: "" ) } } 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 c40780927..fa86a9229 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 @@ -1670,7 +1670,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList return } val popupWindow = PopupWindow(service.applicationContext) - popupWindow.open(name, batteryNotification) + val model = airpodsInstance?.model ?: AirPodsModels.getModelByModelNumber(config.airpodsModelNumber) + popupWindow.open(name, batteryNotification, model?.connectionArtworkRes) popupShown = true } @@ -1701,7 +1702,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList this@AirPodsService, type, reversed, - otherDeviceName + otherDeviceName, + artworkRes = (airpodsInstance?.model + ?: AirPodsModels.getModelByModelNumber(config.airpodsModelNumber))?.connectionArtworkRes ) } } diff --git a/android/app/src/main/res-apple/drawable/airpods_4.png b/android/app/src/main/res-apple/drawable/airpods_4.png new file mode 100644 index 0000000000000000000000000000000000000000..ba699ec54a811033f502809b72f43f8e72049bb4 GIT binary patch literal 33439 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

cx7&T8i`F>FU3B5t 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 c{lK)k_8&-`D^hpxVj&7%3{uP+^Uq>?u&j0`b literal 0 HcmV?d00001 diff --git a/android/app/src/main/res-apple/drawable/airpods_4_case.png b/android/app/src/main/res-apple/drawable/airpods_4_case.png new file mode 100644 index 0000000000000000000000000000000000000000..97e8e24ea635d2d8f151a4e1a2bc776e4aa19051 GIT binary patch literal 18570 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res-apple/drawable/airpods_4_right.png b/android/app/src/main/res-apple/drawable/airpods_4_right.png new file mode 100644 index 0000000000000000000000000000000000000000..f2fe728c2f52434be583a1b66b2ad17e5d1b8393 GIT binary patch literal 9286 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 Q?+=2iqLu|lK=n! literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/layout/island_window.xml b/android/app/src/main/res/layout/island_window.xml index c804737b5..475875021 100644 --- a/android/app/src/main/res/layout/island_window.xml +++ b/android/app/src/main/res/layout/island_window.xml @@ -16,6 +16,14 @@ android:clipToPadding="false" android:clipChildren="false"> + + 允许外部声音进入 动态调整外部噪音 阻隔外部声音 + 将手机的蓝牙身份模拟为苹果设备,以使用兼容的连接功能。可用设置取决于耳机型号,不会为 AirPods 5 解锁 Pro 专属的听力功能。更改后需重启蓝牙。 diff --git a/android/app/src/main/res/values-zh-rTW/strings.xml b/android/app/src/main/res/values-zh-rTW/strings.xml index c3e3ad5c1..024cffdf6 100644 --- a/android/app/src/main/res/values-zh-rTW/strings.xml +++ b/android/app/src/main/res/values-zh-rTW/strings.xml @@ -241,4 +241,5 @@ 你確定你的裝置原生支援或已啟用 Xposed 模組嗎? 不支援 請查看儲存庫以獲取更多資訊。 + 將手機的藍牙身分模擬為 Apple 裝置,以使用相容的連線功能。可用設定取決於耳機型號,不會為 AirPods 5 解鎖 Pro 專屬的聽力功能。變更後需重新啟動藍牙。 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index c2c3f7a38..a0b5e8a59 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -291,4 +291,5 @@ Privacy Policy I agree Compatibility check bypassed. Are you sure your device is compatible? + Identifies this phone as an Apple device for compatible connection features. Available controls depend on your AirPods model; this does not unlock Pro hearing features on AirPods 5. Restart Bluetooth after changing this setting. diff --git a/android/app/src/test/java/me/kavishdevar/librepods/data/AirPodsModelsTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/data/AirPodsModelsTest.kt index 22f6a7e3d..822ca1b45 100644 --- a/android/app/src/test/java/me/kavishdevar/librepods/data/AirPodsModelsTest.kt +++ b/android/app/src/test/java/me/kavishdevar/librepods/data/AirPodsModelsTest.kt @@ -10,6 +10,8 @@ import org.junit.Test class AirPodsModelsTest { private val commonCapabilities = setOf( + Capability.SLEEP_DETECTION, + Capability.CUSTOM_EQ, Capability.LISTENING_MODE, Capability.CONVERSATION_AWARENESS, Capability.HEAD_GESTURES, @@ -83,7 +85,8 @@ class AirPodsModelsTest { @Test fun proOnlyCapabilitiesAreNotEnabledBySharingArtwork() { - val excluded = setOf(Capability.HRM, Capability.HEARING_AID, Capability.PPE) + val excluded = setOf(Capability.HRM, Capability.HEARING_AID, Capability.PPE, + Capability.LOUD_SOUND_REDUCTION, Capability.CUSTOM_TRANSPARENCY) for (model in listOf(AirPods5(), AirPods5Wireless())) { assertTrue(model.capabilities.intersect(excluded).isEmpty()) } @@ -100,6 +103,7 @@ class AirPodsModelsTest { } private fun assertSameArtwork(expected: AirPodsBase, actual: AirPodsBase) { + assertEquals(expected.connectionArtworkRes, actual.connectionArtworkRes) assertEquals(expected.budCaseRes, actual.budCaseRes) assertEquals(expected.budsRes, actual.budsRes) assertEquals(expected.leftBudsRes, actual.leftBudsRes) diff --git a/docs/airpods-4-artwork.md b/docs/airpods-4-artwork.md new file mode 100644 index 000000000..c63df2201 --- /dev/null +++ b/docs/airpods-4-artwork.md @@ -0,0 +1,26 @@ +# AirPods 4 artwork + +AirPods 4, AirPods 4 ANC, and both AirPods 5 variants share the AirPods 4 artwork. +This is an appearance choice only; model capabilities remain independent. + +Source: [SpriteOvO/AirPodsDesktop](https://github.com/SpriteOvO/AirPodsDesktop), +commit `56b04001f75044dbd15507303a9438c71e93c6e4`, +[`Source/Resource/Image/Animation/AirPods_4.png`](https://github.com/SpriteOvO/AirPodsDesktop/blob/56b04001f75044dbd15507303a9438c71e93c6e4/Source/Resource/Image/Animation/AirPods_4.png). +The upstream project is distributed under GPL-3.0; see its +[LICENSE](https://github.com/SpriteOvO/AirPodsDesktop/blob/56b04001f75044dbd15507303a9438c71e93c6e4/LICENSE). +Credit to the AirPodsDesktop contributors; Apple product designs and marks belong +to Apple. The GPL license text is also included in this repository's LICENSE. + +`airpods_4.png` is the unmodified 488 x 244 transparent source image. The other +four PNGs extract parts of that image, without redrawing or changing their colors: + +| Resource suffix | Source rectangle (x, y, width, height) | +| --- | --- | +| buds | 30, 55, 190, 150 | +| left | 30, 55, 108, 150 | +| right | 138, 55, 82, 150 | +| case | 260, 15, 220, 210 | + +Both connection overlays show the complete static artwork for these models, +instead of playing the bundled Pro animation. Other models retain their existing +animation. Transparent backgrounds allow the artwork to follow the popup theme. diff --git a/docs/airpods-5.md b/docs/airpods-5.md new file mode 100644 index 000000000..49fcabd59 --- /dev/null +++ b/docs/airpods-5.md @@ -0,0 +1,47 @@ +# AirPods 5 on Android + +Capability review: 2026-09-20. Model capabilities determine the settings menu; +advertised Apple features do not by themselves prove Android protocol support. + +| Setting | AirPods 5 | AirPods 5 with Wireless Charging Case | +| --- | --- | --- | +| ANC, transparency, adaptive audio, off | Yes | Yes | +| Personalized volume and conversation awareness | Yes | Yes | +| Press-and-hold settings and head gestures | Yes | Yes | +| Pause media when falling asleep | Yes | Yes | +| Three-band custom EQ | Yes | Yes | +| Volume swipe | No | Yes | +| Pro custom transparency, loud sound reduction, hearing aid, hearing protection, heart rate | No | No | + +The Android implementation uses existing AACP controls for these settings. Sleep +detection was missing from the AirPods 5 capability lists and is now exposed. +Custom EQ uses AACP opcode 0x63; it is separate from automatic Adaptive EQ. +These controls still require functional testing on the connected firmware. + +Apple identity emulation changes the phone's Bluetooth identity. It does not add +Pro capabilities or implement Siri, Apple Live Translation, or Find My on Android. +AirPods 5 continues to skip the unused ATT connection, including with emulation +enabled, preserving the disconnect fix. Changing emulation requires a Bluetooth +restart to reload the hook. + +Unknown device information no longer defaults to Pro 2/Pro 3. The About section +shows the recognized generation and variant. Main-screen and connection-popup +artwork is shared with AirPods 4 (see [artwork sources](airpods-4-artwork.md)) and +is not used to determine capabilities. + +## Sources + +- [Apple AirPods 5 specifications](https://www.apple.com/airpods-5/specs/) +- [Listening mode settings](https://support.apple.com/guide/airpods/adjust-listening-mode-settings-dev6d977ff21/27/web/27) +- [Sleep detection and custom EQ](https://support.apple.com/en-is/108764) +- [Pro custom transparency](https://support.apple.com/guide/airpods/use-and-customize-transparency-mode-dev966f5f818/27/web/27) + +## Device acceptance checks + +For A3441, confirm the About section identifies the wireless charging case variant, +sleep detection and EQ are visible, and Accessibility shows volume swipe but no +custom transparency. Confirm the same model capabilities with Apple identity +emulation enabled and disabled. Test setting changes and readback, including EQ +and adaptive strength, before treating those controls as verified on this firmware. +Retest playback stability after installing the APK; menu correctness alone does +not establish Bluetooth stability or successful setting application. From b48c381038e25532bbd7b6bc4ae9f3b28aa1b4c8 Mon Sep 17 00:00:00 2001 From: cfy114514 Date: Sun, 20 Sep 2026 13:19:22 +0800 Subject: [PATCH 09/17] chore(release): prepare AirPods 5 fork release 1.0.0-airpods5.1 --- android/app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f8222a8b2..7ff278dc4 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,6 +1,6 @@ import java.util.Properties -val appVersionName = "1.0.0-rc2" +val appVersionName = "1.0.0-airpods5.1" plugins { alias(libs.plugins.android.application) @@ -54,7 +54,7 @@ android { defaultConfig { applicationId = "me.kavishdevar.librepods" targetSdk = 37 - versionCode = 63 + versionCode = 64 versionName = appVersionName } buildTypes { From cc227156eb2ae642b1ebeccf6699cb88365cc363 Mon Sep 17 00:00:00 2001 From: cfy114514 Date: Sun, 20 Sep 2026 13:22:04 +0800 Subject: [PATCH 10/17] chore(release): label the fork root module and remove upstream updates --- root-module-manual/module.prop | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/root-module-manual/module.prop b/root-module-manual/module.prop index aeaa4129c..882399d9d 100644 --- a/root-module-manual/module.prop +++ b/root-module-manual/module.prop @@ -1,7 +1,6 @@ id=librepods name=LibrePods -version=v0.2.6 -versionCode=46 -author=@kavishdevar +version=v1.0.0-airpods5.1 +versionCode=64 +author=@kavishdevar, fork release @cfy114514 description=Installs LibrePods as a system app for granting BLUETOOTH_PRIVILEGED and MODIFY_PHONE_STATE permission for better integraion with android. -updateJson=https://raw.githubusercontent.com/kavishdevar/librepods/main/extras/update_nonpatch.json From f12bc95aad0f34cd84b6c545c07f7b4839595e50 Mon Sep 17 00:00:00 2001 From: cfy114514 Date: Sun, 20 Sep 2026 13:26:00 +0800 Subject: [PATCH 11/17] fix(android): preserve ATT for Pro 1 custom transparency --- .../me/kavishdevar/librepods/bluetooth/AttConnectionPolicy.kt | 4 +++- .../librepods/bluetooth/AttConnectionPolicyTest.kt | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AttConnectionPolicy.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AttConnectionPolicy.kt index 14198a8e7..5b7833441 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AttConnectionPolicy.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AttConnectionPolicy.kt @@ -1,6 +1,7 @@ package me.kavishdevar.librepods.bluetooth import me.kavishdevar.librepods.data.AirPodsBase +import me.kavishdevar.librepods.data.AirPodsPro1 import me.kavishdevar.librepods.data.Capability /** ATT is used for hearing features, not ordinary AACP controls. */ @@ -8,7 +9,8 @@ internal fun shouldConnectAtt(model: AirPodsBase?, vendorIdHookEnabled: Boolean) if (!vendorIdHookEnabled) return false // Preserve discovery for an unknown model. Known models without ATT features // should not open an unused hearing-feature channel (including AirPods 5). - return model == null || model.capabilities.any { + // Pro 1 uses ATT for custom transparency even without hearing-aid capabilities. + return model == null || model is AirPodsPro1 || model.capabilities.any { it == Capability.HEARING_AID || it == Capability.LOUD_SOUND_REDUCTION } } diff --git a/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/AttConnectionPolicyTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/AttConnectionPolicyTest.kt index 294a2ceb2..562826e28 100644 --- a/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/AttConnectionPolicyTest.kt +++ b/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/AttConnectionPolicyTest.kt @@ -3,6 +3,7 @@ package me.kavishdevar.librepods.bluetooth import me.kavishdevar.librepods.data.AirPods4 import me.kavishdevar.librepods.data.AirPods4ANC import me.kavishdevar.librepods.data.AirPodsModels +import me.kavishdevar.librepods.data.AirPodsPro1 import me.kavishdevar.librepods.data.AirPodsPro2Lightning import me.kavishdevar.librepods.data.AirPodsPro2USBC import me.kavishdevar.librepods.data.AirPodsPro3 @@ -17,6 +18,7 @@ class AttConnectionPolicyTest { } @Test fun hearingModelsRetainAtt() { + assertTrue(shouldConnectAtt(AirPodsPro1(), true)) assertTrue(shouldConnectAtt(AirPodsPro2Lightning(), true)) assertTrue(shouldConnectAtt(AirPodsPro2USBC(), true)) assertTrue(shouldConnectAtt(AirPodsPro3(), true)) From a64485dc623ea9343bcd557558d96648d80fd861 Mon Sep 17 00:00:00 2001 From: cfy114514 Date: Sun, 20 Sep 2026 13:57:18 +0800 Subject: [PATCH 12/17] fix(android): keep release behavior aligned with debug --- android/app/build.gradle.kts | 11 +++++++---- root-module-manual/module.prop | 4 ++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 7ff278dc4..ef8d44a8b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,6 +1,6 @@ import java.util.Properties -val appVersionName = "1.0.0-airpods5.1" +val appVersionName = "1.0.0-airpods5.2" plugins { alias(libs.plugins.android.application) @@ -54,13 +54,16 @@ android { defaultConfig { applicationId = "me.kavishdevar.librepods" targetSdk = 37 - versionCode = 64 + versionCode = 65 versionName = appVersionName } buildTypes { release { - isMinifyEnabled = true - isShrinkResources = true + // This APK is also loaded as an Xposed module. Its entrypoint, JNI + // bridge, reflection hooks, and android.resource URIs cross process + // boundaries, so shrinking must not change the runtime surface. + isMinifyEnabled = false + isShrinkResources = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) diff --git a/root-module-manual/module.prop b/root-module-manual/module.prop index 882399d9d..e95aad4e7 100644 --- a/root-module-manual/module.prop +++ b/root-module-manual/module.prop @@ -1,6 +1,6 @@ id=librepods name=LibrePods -version=v1.0.0-airpods5.1 -versionCode=64 +version=v1.0.0-airpods5.2 +versionCode=65 author=@kavishdevar, fork release @cfy114514 description=Installs LibrePods as a system app for granting BLUETOOTH_PRIVILEGED and MODIFY_PHONE_STATE permission for better integraion with android. From fa2af556456791e5f0f6256ae8c816edf4c90c58 Mon Sep 17 00:00:00 2001 From: cfy114514 Date: Mon, 21 Sep 2026 00:22:55 +0800 Subject: [PATCH 13/17] feat(android): add configurable listening mode timer --- android/app/src/main/AndroidManifest.xml | 4 + .../components/SleepTimerSettings.kt | 158 ++++++++++++++++++ .../screens/AirPodsSettingsScreen.kt | 18 +- .../librepods/receivers/BootReceiver.kt | 13 +- .../librepods/services/AirPodsService.kt | 19 ++- .../kavishdevar/librepods/utils/SleepTimer.kt | 120 +++++++++++++ android/app/src/main/res/values/strings.xml | 10 ++ 7 files changed, 332 insertions(+), 10 deletions(-) create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/presentation/components/SleepTimerSettings.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/utils/SleepTimer.kt diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0474dfd88..bef57c299 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -144,6 +144,10 @@ + + Long, + onCancelTimer: () -> Unit, +) { + var timerEndAt by remember(endAt) { mutableLongStateOf(endAt) } + var now by remember { mutableLongStateOf(System.currentTimeMillis()) } + var showDialog by remember { mutableStateOf(false) } + var selectedMode by remember(targetMode) { mutableStateOf(targetMode) } + var durationText by remember { mutableStateOf("90") } + + LaunchedEffect(timerEndAt) { + while (timerEndAt > 0L && timerEndAt > System.currentTimeMillis()) { + now = System.currentTimeMillis() + delay(1000L) + } + if (timerEndAt > 0L) timerEndAt = 0L + } + + val description = if (timerEndAt > now) { + val minutes = SleepTimerManager.remainingMinutes(timerEndAt, now) + if (minutes >= 60) { + stringResource( + R.string.sleep_timer_active_hours, + modeLabel(selectedMode), + minutes / 60, + minutes % 60 + ) + } else { + stringResource( + R.string.sleep_timer_active_minutes, + modeLabel(selectedMode), + minutes + ) + } + } else { + stringResource(R.string.sleep_timer_description) + } + + StyledListItem( + name = stringResource(R.string.sleep_timer), + description = description, + onClick = { showDialog = true } + ) + + if (showDialog) { + AlertDialog( + onDismissRequest = { showDialog = false }, + title = { Text(stringResource(R.string.sleep_timer)) }, + text = { + Column { + Text( + stringResource( + R.string.sleep_timer_current_mode, + modeLabel(currentMode) + ), + fontSize = 14.sp + ) + Text( + stringResource(R.string.sleep_timer_choose_mode), + fontSize = 14.sp + ) + listOf(1, 3, 4, 2).forEach { mode -> + TextButton( + modifier = Modifier.fillMaxWidth(), + onClick = { + selectedMode = mode + } + ) { + Text( + text = if (selectedMode == mode) { + "✓ ${modeLabel(mode)}" + } else { + modeLabel(mode) + } + ) + } + } + OutlinedTextField( + value = durationText, + onValueChange = { durationText = it.filter(Char::isDigit) }, + label = { Text(stringResource(R.string.sleep_timer_duration)) }, + suffix = { Text(stringResource(R.string.minutes_short)) }, + singleLine = true, + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( + keyboardType = KeyboardType.Number + ), + modifier = Modifier.fillMaxWidth() + ) + if (timerEndAt > 0L) { + TextButton( + modifier = Modifier.fillMaxWidth(), + onClick = { + onCancelTimer() + timerEndAt = 0L + showDialog = false + } + ) { + Text(stringResource(R.string.cancel_sleep_timer)) + } + } + } + }, + confirmButton = { + TextButton( + onClick = { + val minutes = durationText.toIntOrNull()?.coerceIn(1, 24 * 60) + ?: return@TextButton + timerEndAt = onStartTimer(minutes, selectedMode) + showDialog = false + } + ) { + Text(stringResource(R.string.start_timer)) + } + }, + dismissButton = { + TextButton(onClick = { showDialog = false }) { + Text(stringResource(android.R.string.cancel)) + } + } + ) + } +} + +@Composable +private fun modeLabel(mode: Int): String = when (mode) { + 1 -> stringResource(R.string.off) + 2 -> stringResource(R.string.noise_cancellation) + 3 -> stringResource(R.string.transparency) + 4 -> stringResource(R.string.adaptive) + else -> stringResource(R.string.off) +} 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 a615ee6f9..fa2ea2587 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 @@ -113,6 +113,7 @@ import me.kavishdevar.librepods.presentation.components.HearingHealthSettings import me.kavishdevar.librepods.presentation.components.MaterialButtonStyle import me.kavishdevar.librepods.presentation.components.NoiseControlSettings import me.kavishdevar.librepods.presentation.components.PressAndHoldSettings +import me.kavishdevar.librepods.presentation.components.SleepTimerSettings import me.kavishdevar.librepods.presentation.components.StyledButton import me.kavishdevar.librepods.presentation.components.StyledListItem import me.kavishdevar.librepods.presentation.components.StyledToggle @@ -122,6 +123,7 @@ import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.presentation.viewmodel.AirPodsUiState import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import me.kavishdevar.librepods.presentation.viewmodel.demoState +import me.kavishdevar.librepods.utils.SleepTimerManager import java.util.concurrent.TimeUnit import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.min @@ -239,7 +241,8 @@ fun AirPodsSettingsScreen( activateDemoMode: () -> Unit, reconnectFromSavedMac: () -> Unit, ) { - val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE) + val context = LocalContext.current + val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE) var deviceName by remember { mutableStateOf( TextFieldValue( @@ -369,6 +372,19 @@ fun AirPodsSettingsScreen( }, ) } + item(key = "sleep_timer") { + SleepTimerSettings( + endAt = SleepTimerManager.endAt(context), + currentMode = state.controlStates[ + AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE + ]?.getOrNull(0)?.toInt() ?: 1, + targetMode = SleepTimerManager.targetMode(context), + onStartTimer = { minutes, targetMode -> + SleepTimerManager.start(context, minutes, targetMode) + }, + onCancelTimer = { SleepTimerManager.cancel(context) } + ) + } } if (capabilities.contains(Capability.STEM_CONFIG)) { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/receivers/BootReceiver.kt b/android/app/src/main/java/me/kavishdevar/librepods/receivers/BootReceiver.kt index 180a7e953..1bbdfade0 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/receivers/BootReceiver.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/receivers/BootReceiver.kt @@ -25,22 +25,25 @@ import android.content.Context import android.content.Intent import kotlin.io.encoding.ExperimentalEncodingApi import me.kavishdevar.librepods.services.AirPodsService +import me.kavishdevar.librepods.utils.SleepTimerManager class BootReceiver: BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { + val appContext = context ?: return when (intent?.action) { - Intent.ACTION_MY_PACKAGE_REPLACED -> try { context?.startForegroundService( + Intent.ACTION_MY_PACKAGE_REPLACED -> try { appContext.startForegroundService( Intent( - context, + appContext, AirPodsService::class.java ) ) } catch (e: Exception) { e.printStackTrace() } - Intent.ACTION_BOOT_COMPLETED -> try { context?.startForegroundService( + Intent.ACTION_BOOT_COMPLETED -> try { appContext.startForegroundService( Intent( - context, + appContext, AirPodsService::class.java ) ) } catch (e: Exception) { e.printStackTrace() } } + SleepTimerManager.restore(appContext) } -} \ No newline at end of file +} 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 fa86a9229..ce9d80e03 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 @@ -109,6 +109,7 @@ import me.kavishdevar.librepods.presentation.widgets.NoiseControlWidget import me.kavishdevar.librepods.utils.GestureDetector import me.kavishdevar.librepods.utils.HeadTracking import me.kavishdevar.librepods.utils.MediaController +import me.kavishdevar.librepods.utils.SleepTimerManager import me.kavishdevar.librepods.utils.SystemApisUtils import me.kavishdevar.librepods.utils.SystemApisUtils.DEVICE_TYPE_UNTETHERED_HEADSET import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_COMPANION_APP @@ -539,10 +540,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList if (intent.hasExtra("mode")) { val mode = intent.getIntExtra("mode", -1) if (mode in 1..4) { - aacpManager.sendControlCommand( - AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, - mode - ) + setListeningMode(mode) } } else { val currentMode = ancNotification.status @@ -2444,6 +2442,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { Log.d(TAG, "Service started with intent action: ${intent?.action}") + if (intent?.action == SleepTimerManager.ACTION_APPLY_MODE) { + setListeningMode(intent.getIntExtra("mode", -1)) + } + if (intent?.action == "me.kavishdevar.librepods.RECONNECT_AFTER_REVERSE") { Log.d(TAG, "reconnect after reversed received, taking over") disconnectedBecauseReversed = false @@ -2454,6 +2456,15 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList return START_STICKY } + fun setListeningMode(mode: Int) { + if (mode in 1..4) { + aacpManager.sendControlCommand( + AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, + mode + ) + } + } + @RequiresApi(Build.VERSION_CODES.R) @SuppressLint("MissingPermission", "HardwareIds") fun takeOver( diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/SleepTimer.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/SleepTimer.kt new file mode 100644 index 000000000..f453fac76 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/SleepTimer.kt @@ -0,0 +1,120 @@ +package me.kavishdevar.librepods.utils + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.core.content.ContextCompat +import androidx.core.content.edit +import me.kavishdevar.librepods.services.AirPodsService +import me.kavishdevar.librepods.services.ServiceManager + +object SleepTimerManager { + const val ACTION_APPLY_MODE = "me.kavishdevar.librepods.APPLY_SLEEP_TIMER_MODE" + + private const val TAG = "SleepTimer" + private const val END_AT = "sleep_timer_end_at" + private const val TARGET_MODE = "sleep_timer_target_mode" + private const val REQUEST_CODE = 741 + + fun start(context: Context, durationMinutes: Int, targetMode: Int): Long { + require(durationMinutes > 0) + require(targetMode in 1..4) + val endAt = System.currentTimeMillis() + durationMinutes * 60_000L + preferences(context).edit { + putLong(END_AT, endAt) + putInt(TARGET_MODE, targetMode) + } + schedule(context, endAt) + return endAt + } + + fun cancel(context: Context) { + alarmManager(context).cancel(pendingIntent(context)) + preferences(context).edit { + remove(END_AT) + remove(TARGET_MODE) + } + } + + fun endAt(context: Context): Long { + val endAt = preferences(context).getLong(END_AT, 0L) + return endAt.takeIf { it > System.currentTimeMillis() } ?: 0L + } + + fun targetMode(context: Context): Int = + preferences(context).getInt(TARGET_MODE, 1).takeIf { it in 1..4 } ?: 1 + + fun restore(context: Context) { + val endAt = preferences(context).getLong(END_AT, 0L) + if (endAt == 0L) return + if (endAt <= System.currentTimeMillis()) { + fire(context) + } else { + schedule(context, endAt) + } + } + + fun remainingMinutes(endAt: Long, now: Long = System.currentTimeMillis()): Long = + ((endAt - now).coerceAtLeast(0L) + 59_999L) / 60_000L + + fun fire(context: Context) { + val endAt = preferences(context).getLong(END_AT, 0L) + if (endAt == 0L) return + if (endAt > System.currentTimeMillis()) { + schedule(context, endAt) + return + } + + val targetMode = preferences(context).getInt(TARGET_MODE, 1).takeIf { it in 1..4 } ?: 1 + cancel(context) + val service = ServiceManager.getService() + if (service != null) { + service.setListeningMode(targetMode) + } else { + try { + ContextCompat.startForegroundService( + context, + Intent(context, AirPodsService::class.java).apply { + action = ACTION_APPLY_MODE + putExtra("mode", targetMode) + } + ) + } catch (e: Exception) { + Log.e(TAG, "Unable to start AirPods service for sleep timer", e) + } + } + } + + private fun schedule(context: Context, endAt: Long) { + alarmManager(context).setAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + endAt, + pendingIntent(context) + ) + } + + private fun preferences(context: Context) = + context.getSharedPreferences("settings", Context.MODE_PRIVATE) + + private fun alarmManager(context: Context) = + context.getSystemService(AlarmManager::class.java) + + private fun pendingIntent(context: Context): PendingIntent = + PendingIntent.getBroadcast( + context, + REQUEST_CODE, + Intent(context, SleepTimerReceiver::class.java).setAction(ACTION_APPLY_MODE), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) +} + +class SleepTimerReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent?) { + if (intent?.action == SleepTimerManager.ACTION_APPLY_MODE) { + SleepTimerManager.fire(context) + } + } +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index a0b5e8a59..cde5d4957 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -222,6 +222,16 @@ Battery View accurate battery status in the app and notification. Switch between listening modes directly from the app or Quick Settings. + Sleep Timer + Keep the current mode and switch to a selected mode after a set time. + Current mode: %1$s (unchanged) + Mode after the timer: + Duration + min + Start timer + Switches to %1$s in %2$d min + Switches to %1$s in %2$dh %3$d min + Cancel timer Advanced device settings Customize settings like Personalized Volume, Adaptive Audio, Pause media when falling asleep, and other Accessibility settings. Automatic Connection From c68fb02eccc20f5d9333662fce6c753382640f9b Mon Sep 17 00:00:00 2001 From: cfy114514 Date: Mon, 21 Sep 2026 00:30:29 +0800 Subject: [PATCH 14/17] ci(android): optionally build signed release APK --- .github/workflows/ci-android.yml | 55 ++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/.github/workflows/ci-android.yml b/.github/workflows/ci-android.yml index 28cf57582..20d7503b8 100644 --- a/.github/workflows/ci-android.yml +++ b/.github/workflows/ci-android.yml @@ -69,6 +69,31 @@ jobs: test -f "$ANDROID_HOME/ndk/30.0.14904198/source.properties" test -x "$ANDROID_HOME/cmake/3.22.1/bin/cmake" + - name: Prepare release signing + id: signing + shell: bash + env: + RELEASE_KEYSTORE_BASE64: ${{ secrets.RELEASE_KEYSTORE_BASE64 }} + RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }} + RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }} + RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} + run: | + set -euo pipefail + if [[ -z "${RELEASE_KEYSTORE_BASE64:-}" || -z "${RELEASE_STORE_PASSWORD:-}" || -z "${RELEASE_KEY_ALIAS:-}" || -z "${RELEASE_KEY_PASSWORD:-}" ]]; then + echo "available=false" >> "$GITHUB_OUTPUT" + echo "Release signing secrets are not configured; skipping signed Release APK." + exit 0 + fi + printf '%s' "$RELEASE_KEYSTORE_BASE64" | base64 --decode > "$RUNNER_TEMP/librepods-release.jks" + cat > android/local.properties <> "$GITHUB_OUTPUT" + - name: Build FOSS debug APK id: apk working-directory: android @@ -80,6 +105,16 @@ jobs: ./gradlew --no-daemon --stacktrace assembleFossDebug \ 2>&1 | tee build/ci-apk.log + - name: Build signed FOSS release APK + id: release + if: ${{ steps.signing.outputs.available == 'true' }} + working-directory: android + shell: bash + run: | + set -euo pipefail + ./gradlew --no-daemon --stacktrace assembleFossRelease \ + 2>&1 | tee build/ci-release.log + - name: Run JVM unit tests id: tests if: ${{ !cancelled() && steps.apk.outcome == 'success' }} @@ -109,6 +144,13 @@ jobs: sha256sum app-foss-debug.apk > SHA256SUMS fi + - name: Checksum release APK + if: ${{ !cancelled() && steps.release.outcome == 'success' }} + shell: bash + run: | + cd android/app/build/outputs/apk/foss/release + sha256sum *.apk > SHA256SUMS + - name: Upload debug APK if: ${{ !cancelled() }} uses: actions/upload-artifact@v4 @@ -120,6 +162,17 @@ jobs: if-no-files-found: warn retention-days: 14 + - name: Upload release APK + if: ${{ !cancelled() && steps.release.outcome == 'success' }} + uses: actions/upload-artifact@v4 + with: + name: apk-foss-release-${{ github.run_id }} + path: | + android/app/build/outputs/apk/foss/release/*.apk + android/app/build/outputs/apk/foss/release/SHA256SUMS + if-no-files-found: error + retention-days: 14 + - name: Upload test reports and diagnostics if: ${{ !cancelled() }} uses: actions/upload-artifact@v4 @@ -145,6 +198,8 @@ jobs: echo '## LibrePods Android debug validation' echo "SDK installation: ${SDK_OUTCOME}" echo "APK build: ${APK_OUTCOME}" + echo "Release signing setup: ${SIGNING_OUTCOME}" + echo "Signed Release APK: ${RELEASE_OUTCOME}" echo "JVM unit tests: ${TEST_OUTCOME}" echo "Android Lint: ${LINT_OUTCOME}" echo "Checked-out commit: $(git rev-parse HEAD)" From f9fa0fd8fda8affea9a3b5df75642f0fcb4fb82e Mon Sep 17 00:00:00 2001 From: cfy114514 Date: Mon, 21 Sep 2026 00:41:52 +0800 Subject: [PATCH 15/17] fix(android): show sleep timer on main screen --- .../screens/AirPodsSettingsScreen.kt | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) 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 fa2ea2587..d77b21589 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 @@ -276,6 +276,19 @@ fun AirPodsSettingsScreen( .padding(horizontal = 16.dp) ) { item(key = "top_padding") { Spacer(modifier = Modifier.height(topPadding)) } + item(key = "sleep_timer") { + SleepTimerSettings( + endAt = SleepTimerManager.endAt(context), + currentMode = state.controlStates[ + AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE + ]?.getOrNull(0)?.toInt() ?: 1, + targetMode = SleepTimerManager.targetMode(context), + onStartTimer = { minutes, targetMode -> + SleepTimerManager.start(context, minutes, targetMode) + }, + onCancelTimer = { SleepTimerManager.cancel(context) } + ) + } item(key = "play_update_banner") { if (state.timeUntilFOSSPremiumExpiry > 0L) { val context = LocalContext.current @@ -372,19 +385,6 @@ fun AirPodsSettingsScreen( }, ) } - item(key = "sleep_timer") { - SleepTimerSettings( - endAt = SleepTimerManager.endAt(context), - currentMode = state.controlStates[ - AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE - ]?.getOrNull(0)?.toInt() ?: 1, - targetMode = SleepTimerManager.targetMode(context), - onStartTimer = { minutes, targetMode -> - SleepTimerManager.start(context, minutes, targetMode) - }, - onCancelTimer = { SleepTimerManager.cancel(context) } - ) - } } if (capabilities.contains(Capability.STEM_CONFIG)) { From d5544badfa7c166f7b31c90d73af59fc63ed7b52 Mon Sep 17 00:00:00 2001 From: cfy114514 Date: Mon, 21 Sep 2026 22:58:30 +0800 Subject: [PATCH 16/17] fix(android): refine sleep timer UI --- .../components/SleepTimerSettings.kt | 356 ++++++++++++++---- .../components/StyledBottomSheet.kt | 46 ++- .../screens/AirPodsSettingsScreen.kt | 28 +- .../src/main/res/values-zh-rCN/strings.xml | 11 + android/app/src/main/res/values/strings.xml | 3 +- 5 files changed, 341 insertions(+), 103 deletions(-) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/SleepTimerSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/SleepTimerSettings.kt index cc32cb587..748fd97f1 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/SleepTimerSettings.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/SleepTimerSettings.kt @@ -1,9 +1,24 @@ package me.kavishdevar.librepods.presentation.components +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.OutlinedTextField +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.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -13,12 +28,21 @@ import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.kyant.backdrop.backdrops.rememberLayerBackdrop import kotlinx.coroutines.delay import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme import me.kavishdevar.librepods.utils.SleepTimerManager @Composable @@ -28,10 +52,12 @@ fun SleepTimerSettings( targetMode: Int, onStartTimer: (Int, Int) -> Long, onCancelTimer: () -> Unit, + initiallyOpen: Boolean = false, ) { var timerEndAt by remember(endAt) { mutableLongStateOf(endAt) } + var activeTargetMode by remember(targetMode) { mutableStateOf(targetMode) } var now by remember { mutableLongStateOf(System.currentTimeMillis()) } - var showDialog by remember { mutableStateOf(false) } + var showSheet by remember(initiallyOpen) { mutableStateOf(initiallyOpen) } var selectedMode by remember(targetMode) { mutableStateOf(targetMode) } var durationText by remember { mutableStateOf("90") } @@ -43,108 +69,251 @@ fun SleepTimerSettings( if (timerEndAt > 0L) timerEndAt = 0L } - val description = if (timerEndAt > now) { + val isActive = timerEndAt > now + val description = if (isActive) { val minutes = SleepTimerManager.remainingMinutes(timerEndAt, now) if (minutes >= 60) { stringResource( R.string.sleep_timer_active_hours, - modeLabel(selectedMode), + modeLabel(activeTargetMode), minutes / 60, minutes % 60 ) } else { stringResource( R.string.sleep_timer_active_minutes, - modeLabel(selectedMode), + modeLabel(activeTargetMode), minutes ) } } else { - stringResource(R.string.sleep_timer_description) + stringResource(R.string.sleep_timer_not_set) } - StyledListItem( - name = stringResource(R.string.sleep_timer), - description = description, - onClick = { showDialog = true } - ) - - if (showDialog) { - AlertDialog( - onDismissRequest = { showDialog = false }, - title = { Text(stringResource(R.string.sleep_timer)) }, - text = { - Column { - Text( - stringResource( - R.string.sleep_timer_current_mode, - modeLabel(currentMode) - ), - fontSize = 14.sp - ) + StyledList(title = stringResource(R.string.sleep_timer)) { + StyledListItem( + name = stringResource(R.string.sleep_timer), + description = description, + onClick = { showSheet = true }, + leadingContent = { + Text( + text = "◷", + color = MaterialTheme.colorScheme.primary, + fontSize = 25.sp, + textAlign = TextAlign.Center, + modifier = Modifier.size(28.dp) + ) + } + ) + } + + if (showSheet) { + StyledBottomSheet( + visible = true, + onDismiss = { showSheet = false }, + backdrop = rememberLayerBackdrop(), + opaque = true + ) { _, _ -> + val duration = durationText.toIntOrNull() + val canStart = duration != null && duration in 1..(24 * 60) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + ) { + Text( + text = stringResource(R.string.sleep_timer), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource( + R.string.sleep_timer_current_mode, + modeLabel(currentMode) + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.65f), + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { Text( - stringResource(R.string.sleep_timer_choose_mode), - fontSize = 14.sp + text = stringResource(R.string.sleep_timer_duration), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) ) - listOf(1, 3, 4, 2).forEach { mode -> - TextButton( - modifier = Modifier.fillMaxWidth(), + Row( + modifier = Modifier + .width(244.dp) + .height(56.dp) + .clip(RoundedCornerShape(28.dp)) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.outlineVariant, + shape = RoundedCornerShape(28.dp) + ) + .padding(horizontal = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + IconButton( onClick = { - selectedMode = mode - } + durationText = ((duration ?: 90) - 5) + .coerceAtLeast(1) + .toString() + }, + enabled = (duration ?: 1) > 1 ) { Text( - text = if (selectedMode == mode) { - "✓ ${modeLabel(mode)}" - } else { - modeLabel(mode) - } + text = "−", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } - } - OutlinedTextField( - value = durationText, - onValueChange = { durationText = it.filter(Char::isDigit) }, - label = { Text(stringResource(R.string.sleep_timer_duration)) }, - suffix = { Text(stringResource(R.string.minutes_short)) }, - singleLine = true, - keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( - keyboardType = KeyboardType.Number - ), - modifier = Modifier.fillMaxWidth() - ) - if (timerEndAt > 0L) { - TextButton( - modifier = Modifier.fillMaxWidth(), + + Box( + modifier = Modifier + .width(1.dp) + .height(24.dp) + .background(MaterialTheme.colorScheme.outlineVariant) + ) + + BasicTextField( + value = durationText, + onValueChange = { + durationText = it.filter(Char::isDigit).take(4) + }, + singleLine = true, + textStyle = MaterialTheme.typography.titleMedium.copy( + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.width(52.dp) + ) + + Box( + modifier = Modifier + .width(1.dp) + .height(24.dp) + .background(MaterialTheme.colorScheme.outlineVariant) + ) + + Text( + text = stringResource(R.string.minutes_short), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp) + ) + + IconButton( onClick = { - onCancelTimer() - timerEndAt = 0L - showDialog = false - } + durationText = ((duration ?: 0) + 5) + .coerceAtMost(24 * 60) + .toString() + }, + enabled = (duration ?: 0) < 24 * 60 + ) { + Text( + text = "+", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = stringResource(R.string.sleep_timer_choose_mode), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Medium + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + listOf(1, 3, 4, 2).forEach { mode -> + val selected = selectedMode == mode + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(14.dp)) + .background( + if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) + else Color.Transparent + ) + .clickable { selectedMode = mode } + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center ) { - Text(stringResource(R.string.cancel_sleep_timer)) + Text( + text = modeLabel(mode), + style = MaterialTheme.typography.labelMedium, + color = if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + textAlign = TextAlign.Center, + maxLines = 1 + ) } } } - }, - confirmButton = { - TextButton( + + Spacer(modifier = Modifier.height(20.dp)) + + if (isActive) { + TextButton( + onClick = { + onCancelTimer() + timerEndAt = 0L + showSheet = false + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.cancel_sleep_timer)) + } + } + + Button( onClick = { - val minutes = durationText.toIntOrNull()?.coerceIn(1, 24 * 60) - ?: return@TextButton + if (!canStart) return@Button + val minutes = duration + activeTargetMode = selectedMode timerEndAt = onStartTimer(minutes, selectedMode) - showDialog = false - } + showSheet = false + }, + enabled = canStart, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(28.dp) ) { Text(stringResource(R.string.start_timer)) } - }, - dismissButton = { - TextButton(onClick = { showDialog = false }) { - Text(stringResource(android.R.string.cancel)) - } } - ) + } } } @@ -156,3 +325,46 @@ private fun modeLabel(mode: Int): String = when (mode) { 4 -> stringResource(R.string.adaptive) else -> stringResource(R.string.off) } + +@Preview(name = "Sleep timer card", showBackground = true) +@Composable +private fun SleepTimerSettingsCardPreview() { + LibrePodsTheme(m3eEnabled = false) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(16.dp) + ) { + SleepTimerSettings( + endAt = 0L, + currentMode = 3, + targetMode = 1, + onStartTimer = { _, _ -> 0L }, + onCancelTimer = {} + ) + } + } +} + +@Preview(name = "Sleep timer sheet", showBackground = true) +@Composable +private fun SleepTimerSettingsSheetPreview() { + LibrePodsTheme(m3eEnabled = false) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(16.dp) + ) { + SleepTimerSettings( + endAt = 0L, + currentMode = 3, + targetMode = 1, + onStartTimer = { _, _ -> 0L }, + onCancelTimer = {}, + initiallyOpen = true + ) + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledBottomSheet.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledBottomSheet.kt index b98139810..7ba54d66d 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledBottomSheet.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledBottomSheet.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SheetValue import androidx.compose.material3.rememberModalBottomSheetState @@ -31,6 +32,7 @@ fun StyledBottomSheet( visible: Boolean, onDismiss: () -> Unit, backdrop: LayerBackdrop, + opaque: Boolean = false, content: @Composable (innerBackdrop: LayerBackdrop, progress: Float) -> Unit ) { if (!visible) return @@ -46,16 +48,22 @@ fun StyledBottomSheet( ) val animatedCorner = lerp(48.dp, 42.dp, progress) + val surfaceColor = MaterialTheme.colorScheme.surface + val scrimColor = if (opaque) { + MaterialTheme.colorScheme.onBackground.copy(alpha = 0.18f) + } else { + Color.Transparent + } - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - containerColor = Color.Transparent, - dragHandle = { }, - shape = RoundedCornerShape(animatedCorner), - scrimColor = Color.Transparent, - modifier = Modifier.padding(4.dp) - ) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = if (opaque) surfaceColor else Color.Transparent, + dragHandle = { }, + shape = RoundedCornerShape(animatedCorner), + scrimColor = scrimColor, + modifier = Modifier.padding(4.dp) + ) { val innerBackdrop = rememberLayerBackdrop() Box( modifier = Modifier @@ -69,14 +77,18 @@ fun StyledBottomSheet( vibrancy() blur(4f.dp.toPx()) lens(12f.dp.toPx(), 48f.dp.toPx(), true) - }, - onDrawSurface = { - drawRect( - if (isDarkTheme) Color.DarkGray.copy(alpha = 0.3f) else Color( - 0xFFE0E0E0 - ).copy(alpha = 0.45f) - ) - } + }, + onDrawSurface = { + drawRect( + if (opaque) { + surfaceColor + } else if (isDarkTheme) { + Color.DarkGray.copy(alpha = 0.3f) + } else { + Color(0xFFE0E0E0).copy(alpha = 0.45f) + } + ) + } ) .padding(top = 24.dp) .padding(horizontal = 16.dp) 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 d77b21589..13e9ac896 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 @@ -276,19 +276,6 @@ fun AirPodsSettingsScreen( .padding(horizontal = 16.dp) ) { item(key = "top_padding") { Spacer(modifier = Modifier.height(topPadding)) } - item(key = "sleep_timer") { - SleepTimerSettings( - endAt = SleepTimerManager.endAt(context), - currentMode = state.controlStates[ - AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE - ]?.getOrNull(0)?.toInt() ?: 1, - targetMode = SleepTimerManager.targetMode(context), - onStartTimer = { minutes, targetMode -> - SleepTimerManager.start(context, minutes, targetMode) - }, - onCancelTimer = { SleepTimerManager.cancel(context) } - ) - } item(key = "play_update_banner") { if (state.timeUntilFOSSPremiumExpiry > 0L) { val context = LocalContext.current @@ -579,6 +566,21 @@ fun AirPodsSettingsScreen( ) } + item(key = "spacer_sleep_timer") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "sleep_timer") { + SleepTimerSettings( + endAt = SleepTimerManager.endAt(context), + currentMode = state.controlStates[ + AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE + ]?.getOrNull(0)?.toInt() ?: 1, + targetMode = SleepTimerManager.targetMode(context), + onStartTimer = { minutes, targetMode -> + SleepTimerManager.start(context, minutes, targetMode) + }, + onCancelTimer = { SleepTimerManager.cancel(context) } + ) + } + if (capabilities.contains(Capability.LOUD_SOUND_REDUCTION)) { item(key = "spacer_off_listening") { Spacer(modifier = Modifier.height(16.dp)) } item(key = "off_listening") { diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index fcb6da1a0..e38bc422d 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -195,6 +195,17 @@ 故障排除步骤 请输入 dbHL 中的损失值 关于 + 睡眠定时器 + 设置时长和结束时切换的模式。 + 未设置 + 当前模式:%1$s(保持不变) + 结束时切换到 + 时长 + 分钟 + 开始定时 + %2$d 分钟后切换到%1$s + %2$d 小时 %3$d 分钟后切换到%1$s + 取消定时 型号名称 型号编号 序列号 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index cde5d4957..d253411ee 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -223,7 +223,8 @@ View accurate battery status in the app and notification. Switch between listening modes directly from the app or Quick Settings. Sleep Timer - Keep the current mode and switch to a selected mode after a set time. + Set a duration and the mode to switch to. + Not set Current mode: %1$s (unchanged) Mode after the timer: Duration From 560e3a94b49a55650749240effc06d743df142db Mon Sep 17 00:00:00 2001 From: cfy114514 Date: Sun, 27 Sep 2026 00:32:43 +0800 Subject: [PATCH 17/17] Add unit tests for Bluetooth RPA verification, hearing settings encoding, device settings editor, head tracking, log preview, resource management, and utility functions - Implement tests for CachedRpaVerifier to ensure correct RPA validation and caching behavior. - Create tests for encoding and decoding hearing aid settings, ensuring compatibility with legacy formats. - Add tests for DeviceSettingsEditor to verify debounce behavior and state management during user interactions. - Introduce tests for HeadTrackingPlotHistory to validate data retention and order. - Implement LogPreview tests to ensure proper handling of log files and line preservation. - Add tests for CloseableResourceScope to verify resource management and cleanup behavior. - Introduce PacketLogBuffer tests to ensure distinct packet retention and snapshot behavior. - Add tests for ConflatedCallbackSession to validate callback management and session behavior. - Implement DiagnosticLogStream tests to ensure proper log formatting and handling of long sessions. - Add OwnedCallbackLifecycle tests to verify callback registration and lifecycle management. - Introduce RecentGestureExtremes tests to validate extreme value retention and session behavior. - Add VolumeTransition tests to ensure proper volume ramping and cancellation behavior. - Create gradle-daemon-jvm.properties for toolchain configuration. --- .gitignore | 15 + .../me/kavishdevar/librepods/MainActivity.kt | 93 +- .../librepods/bluetooth/AACPManager.kt | 96 +- .../librepods/bluetooth/ATTManager.kt | 144 ++- .../librepods/bluetooth/AttResponseMailbox.kt | 52 + .../librepods/bluetooth/BLEManager.kt | 122 +- .../bluetooth/BleAdvertisementDeduplicator.kt | 22 + .../librepods/bluetooth/BleMaintenanceTask.kt | 34 + .../bluetooth/CachedProximityDecryptor.kt | 48 + .../librepods/bluetooth/CachedRpaVerifier.kt | 45 + .../kavishdevar/librepods/data/HearingAid.kt | 56 +- .../librepods/data/Transparency.kt | 46 +- .../presentation/components/StyledSwitch.kt | 21 +- .../screens/DeviceSettingsEditor.kt | 75 ++ .../screens/HeadTrackingPlotHistory.kt | 41 + .../screens/HeadTrackingScreen.kt | 77 +- .../screens/HearingAidAdjustmentsScreen.kt | 63 +- .../presentation/screens/LogPreview.kt | 54 + .../screens/TransparencySettingsScreen.kt | 79 +- .../screens/TroubleshootingScreen.kt | 133 +- .../screens/UpdateHearingTestScreen.kt | 52 +- .../viewmodel/AirPodsViewModel.kt | 146 ++- .../librepods/services/AirPodsService.kt | 1103 ++++++++--------- .../librepods/services/AppListenerService.kt | 6 +- .../services/CloseableResourceScope.kt | 52 + .../librepods/services/PacketLogBuffer.kt | 22 + .../utils/ConflatedCallbackSession.kt | 31 + .../librepods/utils/DiagnosticLogStream.kt | 33 + .../librepods/utils/GestureDetector.kt | 122 +- .../librepods/utils/GestureFeedback.kt | 50 +- .../librepods/utils/LogCollector.kt | 186 ++- .../librepods/utils/MediaController.kt | 196 +-- .../librepods/utils/OwnedCallbackLifecycle.kt | 39 + .../librepods/utils/RecentGestureExtremes.kt | 21 + .../librepods/utils/VolumeTransition.kt | 42 + .../librepods/bluetooth/AacpStateTest.kt | 189 +++ .../librepods/bluetooth/AirPodsStatusTest.kt | 38 + .../bluetooth/AttResponseMailboxTest.kt | 79 ++ .../bluetooth/BleScanLifecycleTest.kt | 110 ++ .../bluetooth/CachedProximityDecryptorTest.kt | 128 ++ .../bluetooth/CachedRpaVerifierTest.kt | 129 ++ .../data/HearingSettingsEncodingTest.kt | 65 + .../screens/DeviceSettingsEditorTest.kt | 120 ++ .../screens/HeadTrackingPlotHistoryTest.kt | 64 + .../presentation/screens/LogPreviewTest.kt | 67 + .../services/CloseableResourceScopeTest.kt | 104 ++ .../librepods/services/PacketLogBufferTest.kt | 34 + .../utils/ConflatedCallbackSessionTest.kt | 72 ++ .../utils/DiagnosticLogStreamTest.kt | 68 + .../utils/OwnedCallbackLifecycleTest.kt | 90 ++ .../utils/RecentGestureExtremesTest.kt | 44 + .../librepods/utils/VolumeTransitionTest.kt | 110 ++ android/gradle/gradle-daemon-jvm.properties | 12 + 53 files changed, 3572 insertions(+), 1368 deletions(-) create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AttResponseMailbox.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BleAdvertisementDeduplicator.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BleMaintenanceTask.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/bluetooth/CachedProximityDecryptor.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/bluetooth/CachedRpaVerifier.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/DeviceSettingsEditor.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingPlotHistory.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/LogPreview.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/services/CloseableResourceScope.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/services/PacketLogBuffer.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/utils/ConflatedCallbackSession.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/utils/DiagnosticLogStream.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/utils/OwnedCallbackLifecycle.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/utils/RecentGestureExtremes.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/utils/VolumeTransition.kt create mode 100644 android/app/src/test/java/me/kavishdevar/librepods/bluetooth/AacpStateTest.kt create mode 100644 android/app/src/test/java/me/kavishdevar/librepods/bluetooth/AirPodsStatusTest.kt create mode 100644 android/app/src/test/java/me/kavishdevar/librepods/bluetooth/AttResponseMailboxTest.kt create mode 100644 android/app/src/test/java/me/kavishdevar/librepods/bluetooth/BleScanLifecycleTest.kt create mode 100644 android/app/src/test/java/me/kavishdevar/librepods/bluetooth/CachedProximityDecryptorTest.kt create mode 100644 android/app/src/test/java/me/kavishdevar/librepods/bluetooth/CachedRpaVerifierTest.kt create mode 100644 android/app/src/test/java/me/kavishdevar/librepods/data/HearingSettingsEncodingTest.kt create mode 100644 android/app/src/test/java/me/kavishdevar/librepods/presentation/screens/DeviceSettingsEditorTest.kt create mode 100644 android/app/src/test/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingPlotHistoryTest.kt create mode 100644 android/app/src/test/java/me/kavishdevar/librepods/presentation/screens/LogPreviewTest.kt create mode 100644 android/app/src/test/java/me/kavishdevar/librepods/services/CloseableResourceScopeTest.kt create mode 100644 android/app/src/test/java/me/kavishdevar/librepods/services/PacketLogBufferTest.kt create mode 100644 android/app/src/test/java/me/kavishdevar/librepods/utils/ConflatedCallbackSessionTest.kt create mode 100644 android/app/src/test/java/me/kavishdevar/librepods/utils/DiagnosticLogStreamTest.kt create mode 100644 android/app/src/test/java/me/kavishdevar/librepods/utils/OwnedCallbackLifecycleTest.kt create mode 100644 android/app/src/test/java/me/kavishdevar/librepods/utils/RecentGestureExtremesTest.kt create mode 100644 android/app/src/test/java/me/kavishdevar/librepods/utils/VolumeTransitionTest.kt create mode 100644 android/gradle/gradle-daemon-jvm.properties diff --git a/.gitignore b/.gitignore index c63c246ac..a0978f8a4 100644 --- a/.gitignore +++ b/.gitignore @@ -654,3 +654,18 @@ obj/ # End of https://www.toptal.com/developers/gitignore/api/qt,c++,clion,kotlin,python,android,pycharm,androidstudio,visualstudiocode,linux linux/.qmlls.ini +phone-main-after-opaque-install.png +phone-main-after-reconnect.png +phone-main-after-relaunch.png +phone-main-after-ui.png +phone-main-before.png +phone-main-bottom-1.png +phone-main-bottom-2.png +phone-main-bottom-after-ui.png +phone-main-bottom-opaque-2.png +phone-main-bottom-opaque.png +phone-main-bottom.png +phone-timer-current.png +phone-timer-opaque.png +docs/performance-audit-2026-09-26.md +release-test-35523018608/SHA256SUMS diff --git a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt index 2e7b49a98..2d4860ca4 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt @@ -25,7 +25,6 @@ package me.kavishdevar.librepods //import dagger.hilt.android.AndroidEntryPoint import android.annotation.SuppressLint import android.app.Activity -import android.content.BroadcastReceiver import android.content.ComponentName import android.content.Context import android.content.Context.MODE_PRIVATE @@ -34,7 +33,6 @@ import android.content.ServiceConnection import android.content.SharedPreferences import android.os.Bundle import android.os.IBinder -import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge @@ -42,10 +40,15 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.core.content.edit +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewmodel.compose.viewModel import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.android.play.core.review.ReviewManagerFactory @@ -59,10 +62,6 @@ import me.kavishdevar.librepods.services.AirPodsService import me.kavishdevar.librepods.utils.XposedState import kotlin.io.encoding.ExperimentalEncodingApi -lateinit var serviceConnection: ServiceConnection -lateinit var connectionStatusReceiver: BroadcastReceiver -lateinit var testReviewReceiver: BroadcastReceiver - //@AndroidEntryPoint @ExperimentalMaterial3Api class MainActivity : ComponentActivity() { @@ -109,37 +108,9 @@ class MainActivity : ComponentActivity() { } override fun onDestroy() { - try { - unbindService(serviceConnection) - Log.d("MainActivity", "Unbound service") - } catch (e: Exception) { - Log.e("MainActivity", "Error while unbinding service: $e") - } - try { - unregisterReceiver(connectionStatusReceiver) - Log.d("MainActivity", "Unregistered receiver") - } catch (e: Exception) { - Log.e("MainActivity", "Error while unregistering receiver: $e") - } sendBroadcast(Intent(AirPodsNotifications.DISCONNECT_RECEIVERS)) super.onDestroy() } - - override fun onStop() { - try { - unbindService(serviceConnection) - Log.d("MainActivity", "Unbound service") - } catch (e: Exception) { - Log.e("MainActivity", "Error while unbinding service: $e") - } - try { - unregisterReceiver(connectionStatusReceiver) - Log.d("MainActivity", "Unregistered receiver") - } catch (e: Exception) { - Log.e("MainActivity", "Error while unregistering receiver: $e") - } - super.onStop() - } } @ExperimentalHazeMaterialsApi @@ -148,9 +119,10 @@ class MainActivity : ComponentActivity() { @Composable fun Main() { val context = LocalContext.current - val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE) - - val airPodsService = remember { mutableStateOf(null) } + val lifecycleOwner = LocalLifecycleOwner.current + val sharedPreferences = remember(context) { + context.getSharedPreferences("settings", MODE_PRIVATE) + } val airPodsViewModel: AirPodsViewModel = viewModel() @@ -179,18 +151,19 @@ fun Main() { } } - val onboardingComplete = sharedPreferences.getBoolean("onboarding_complete", false) + var onboardingComplete by remember(sharedPreferences) { + mutableStateOf(sharedPreferences.getBoolean("onboarding_complete", false)) + } val releaseNotesShownPrefKey = "release_notes_shown_${BuildConfig.VERSION_NAME.removeSuffix("-debug").removeSuffix("-play")}" val releaseNotesShown = sharedPreferences.getBoolean(releaseNotesShownPrefKey, false) - fun bindService() { - context.startForegroundService(Intent(context, AirPodsService::class.java)) - serviceConnection = object: ServiceConnection { + DisposableEffect(context, lifecycleOwner, airPodsViewModel, onboardingComplete) { + var bound = false + val serviceConnection = object: ServiceConnection { override fun onServiceConnected(name: ComponentName?, service: IBinder?) { val binder = service as AirPodsService.LocalBinder val service = binder.getService() - airPodsService.value = service airPodsViewModel.init( service = service, controlRepo = ControlCommandRepository(service.aacpManager), @@ -206,19 +179,35 @@ fun Main() { } override fun onServiceDisconnected(name: ComponentName?) { - airPodsService.value = null + // Android reconnects an existing binding if the service restarts. } } - context.bindService( - Intent(context, AirPodsService::class.java), - serviceConnection, - Context.BIND_AUTO_CREATE - ) - } + fun unbindService() { + if (bound) { + context.unbindService(serviceConnection) + bound = false + } + } + + val observer = object : DefaultLifecycleObserver { + override fun onStart(owner: LifecycleOwner) { + if (!onboardingComplete || bound) return + val intent = Intent(context, AirPodsService::class.java) + context.startForegroundService(intent) + bound = context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) + } - if (onboardingComplete) { - bindService() + override fun onStop(owner: LifecycleOwner) { + unbindService() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + unbindService() + } } NavigationRoot( @@ -227,7 +216,7 @@ fun Main() { showOnboarding = !onboardingComplete, onboardingComplete = { sharedPreferences.edit { putBoolean("onboarding_complete", true) } - bindService() + onboardingComplete = true }, airPodsViewModel = airPodsViewModel ) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt index ac6d356b7..3b2a5ba49 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt @@ -62,6 +62,14 @@ class AACPManager { private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00) + internal fun hasExpectedHeader(packet: ByteArray): Boolean { + if (packet.size < HEADER_BYTES.size) return false + for (index in HEADER_BYTES.indices) { + if (packet[index] != HEADER_BYTES[index]) return false + } + return true + } + data class ControlCommandStatus( val identifier: ControlCommandIdentifiers, val value: ByteArray ) { @@ -207,25 +215,36 @@ class AACPManager { var customEqCallback: ((CustomEq) -> Unit)? = null + @Synchronized fun getControlCommandStatus(identifier: ControlCommandIdentifiers): ControlCommandStatus? { return controlCommandStatusList.find { it.identifier == identifier } } - private fun setControlCommandStatusValue( + @Synchronized + internal fun setControlCommandStatusValue( identifier: ControlCommandIdentifiers, value: ByteArray ) { - val existingStatus = getControlCommandStatus(identifier) - if (existingStatus?.value.contentEquals(value)) { - controlCommandStatusList.remove(existingStatus) + val index = controlCommandStatusList.indexOfFirst { it.identifier == identifier } + val status = ControlCommandStatus(identifier, value) + if (index >= 0) { + controlCommandStatusList[index] = status + } else { + controlCommandStatusList.add(status) } - controlCommandListeners[identifier]?.forEach { listener -> - listener.onControlCommandReceived(ControlCommand(identifier.value, value)) - } - controlCommandStatusList.add(ControlCommandStatus(identifier, value)) if (identifier == ControlCommandIdentifiers.OWNS_CONNECTION) { owns = value.isNotEmpty() && value[0] == 0x01.toByte() } + controlCommandListeners[identifier]?.toList()?.forEach { listener -> + listener.onControlCommandReceived(ControlCommand(identifier.value, value)) + } + } + + internal fun parseAndStoreControlCommand(packet: ByteArray): ControlCommand? { + val command = ControlCommand.fromByteArray(packet) + val identifier = ControlCommandIdentifiers.fromByte(command.identifier) ?: return null + setControlCommandStatusValue(identifier, command.value) + return command } interface PacketCallback { @@ -267,12 +286,14 @@ class AACPManager { fun onControlCommandReceived(controlCommand: ControlCommand) } + @Synchronized fun registerControlCommandListener( identifier: ControlCommandIdentifiers, callback: ControlCommandListener ) { controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback) } + @Synchronized fun unregisterControlCommandListener( identifier: ControlCommandIdentifiers, callback: ControlCommandListener ) { @@ -308,39 +329,20 @@ class AACPManager { fun sendControlCommand(identifier: Byte, value: ByteArray): Boolean { val controlPacket = createControlCommandPacket(identifier, value) - setControlCommandStatusValue( - ControlCommandIdentifiers.fromByte(identifier) ?: return false, value - ) return sendDataPacket(controlPacket) } @OptIn(ExperimentalStdlibApi::class) fun sendControlCommand(identifier: Byte, value: Byte): Boolean { - val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value)) - setControlCommandStatusValue( - ControlCommandIdentifiers.fromByte(identifier) ?: return false, byteArrayOf(value) - ) - return sendDataPacket(controlPacket) + return sendControlCommand(identifier, byteArrayOf(value)) } fun sendControlCommand(identifier: Byte, value: Boolean): Boolean { - val controlPacket = createControlCommandPacket( - identifier, if (value) byteArrayOf(0x01) else byteArrayOf(0x02) - ) - setControlCommandStatusValue( - ControlCommandIdentifiers.fromByte(identifier) ?: return false, - if (value) byteArrayOf(0x01) else byteArrayOf(0x02) - ) - return sendDataPacket(controlPacket) + return sendControlCommand(identifier, if (value) byteArrayOf(0x01) else byteArrayOf(0x02)) } fun sendControlCommand(identifier: Byte, value: Int): Boolean { - val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value.toByte())) - setControlCommandStatusValue( - ControlCommandIdentifiers.fromByte(identifier) ?: return false, - byteArrayOf(value.toByte()) - ) - return sendDataPacket(controlPacket) + return sendControlCommand(identifier, byteArrayOf(value.toByte())) } fun parseProximityKeysResponse(data: ByteArray): Map { @@ -399,7 +401,7 @@ class AACPManager { @OptIn(ExperimentalStdlibApi::class) fun receivePacket(packet: ByteArray) { - if (!packet.toHexString().startsWith("04000400")) { + if (!hasExpectedHeader(packet)) { Log.w( TAG, "Received packet does not start with expected header: ${ packet.joinToString(" ") { @@ -422,16 +424,12 @@ class AACPManager { Opcodes.CONTROL_COMMAND -> { val controlCommand = try { - ControlCommand.fromByteArray(packet) + parseAndStoreControlCommand(packet) ?: return } catch (e: Exception) { Log.w(TAG, "Failed to parse control command: ${e.message}") callback?.onUnknownPacketReceived(packet) return } - setControlCommandStatusValue( - ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return, - controlCommand.value - ) Log.d( TAG, "Control command received: ${controlCommand.identifier.toHexString()} - ${ @@ -456,18 +454,6 @@ class AACPManager { val controlCommandIdentifier = ControlCommandIdentifiers.fromByte(controlCommand.identifier) - if (controlCommandIdentifier != null) { - controlCommandListeners[controlCommandIdentifier]?.forEach { listener -> - Log.d(TAG, "calling listener for ${controlCommandIdentifier.name}") - listener.onControlCommandReceived(controlCommand) - } - } else { - Log.w( - TAG, - "Unknown control command identifier: ${controlCommand.identifier.toHexString()}" - ) - } - if (controlCommandIdentifier == ControlCommandIdentifiers.OWNS_CONNECTION) { callback?.onOwnershipChangeReceived(owns) } @@ -1143,7 +1129,7 @@ class AACPManager { if (packet[4] == Opcodes.CONTROL_COMMAND) { val controlCommand = try { - ControlCommand.fromByteArray(packet) + parseAndStoreControlCommand(packet) ?: return false } catch (e: Exception) { Log.w(TAG, "Invalid control command: ${e.message}") callback?.onUnknownPacketReceived(packet) @@ -1153,10 +1139,6 @@ class AACPManager { TAG, "Control command: ${controlCommand.identifier.toHexString()} - ${ controlCommand.value.joinToString(" ") { "%02X".format(it) } }") - setControlCommandStatusValue( - ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return false, - controlCommand.value - ) } val socket = BluetoothConnectionManager.aacpSocket ?: return false @@ -1271,8 +1253,14 @@ class AACPManager { fun disconnected() { Log.d(TAG, "Disconnected, clearing state") + resetDeviceState() + } + + @Synchronized + internal fun resetDeviceState() { controlCommandStatusList.clear() - controlCommandListeners.clear() + // Subscriptions belong to their observers, which explicitly unregister. + // The same service and ViewModel survive a Bluetooth reconnection. owns = false oldConnectedDevices = listOf() connectedDevices = listOf() diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/ATTManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/ATTManager.kt index 753c4d3d7..7b88b151b 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/ATTManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/ATTManager.kt @@ -18,11 +18,8 @@ package me.kavishdevar.librepods.bluetooth +import android.bluetooth.BluetoothSocket import android.util.Log -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.LinkedBlockingQueue -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicBoolean private const val TAG = "ATTManager" @@ -41,33 +38,62 @@ enum class ATTCCCDHandles(val value: Int) { class ATTManagerv2 { val characteristicList = mutableMapOf() - private val responseQueues = ConcurrentHashMap>() + private val requestLock = Any() + private val readerLock = Any() - private val readerRunning = AtomicBoolean(false) - private var readerThread: Thread? = null + private class ReaderSession(val socket: BluetoothSocket) { + val responses = AttResponseMailbox() + var thread: Thread? = null + } + + @Volatile + private var activeReader: ReaderSession? = null private var onNotificationReceived: ((handle: Byte, value: ByteArray) -> Unit)? = null fun startReader() { - if (readerRunning.getAndSet(true)) return - - readerThread = Thread { - try { - runReaderLoop() - } catch (t: Throwable) { - Log.e(TAG, "reader thread crashed: ${t.message}", t) - } finally { - readerRunning.set(false) - Log.d(TAG, "reader thread stopped") - } - }.also { it.name = "ATT-Reader"; it.isDaemon = true; it.start() } - Log.d(TAG, "reader started") + synchronized(readerLock) { + val socket = BluetoothConnectionManager.attSocket ?: return + if (activeReader?.socket === socket) return + // A reconnect can install the next socket before the old blocking + // reader has exited. Retire that reader before starting this one. + if (activeReader != null) stopReader() + val session = ReaderSession(socket) + activeReader = session + session.thread = Thread { + try { + runReaderLoop(session) + } catch (t: Throwable) { + Log.e(TAG, "reader thread crashed: ${t.message}", t) + } finally { + synchronized(readerLock) { + if (activeReader === session) activeReader = null + // EOF and read failures release requests too. An old reader + // only clears its own mailbox when a new session has started. + session.responses.clear() + } + Log.d(TAG, "reader thread stopped") + } + }.also { it.name = "ATT-Reader"; it.isDaemon = true; it.start() } + Log.d(TAG, "reader started") + } } fun stopReader() { - readerRunning.set(false) - readerThread?.interrupt() - readerThread = null + val session = synchronized(readerLock) { + val reader = activeReader ?: return + activeReader = null + reader.responses.clear() + reader + } + session.thread?.interrupt() + // Bluetooth reads may not respond to interruption; closing this session's + // socket unblocks them without touching a subsequently connected socket. + try { + session.socket.close() + } catch (e: Exception) { + Log.w(TAG, "error closing reader socket: ${e.message}") + } } fun setOnNotificationReceived(listener: ((handle: Byte, value: ByteArray) -> Unit)?) { @@ -86,17 +112,9 @@ class ATTManagerv2 { } fun readCharacteristic(handle: ATTHandles, timeoutMillis: Long = 2000): ByteArray? { - val socket = BluetoothConnectionManager.attSocket ?: return null try { - val output = socket.outputStream val pdu = byteArrayOf(0x0A, handle.value.toByte(), 0x00) - synchronized(output) { - output.write(pdu) - output.flush() - } - Log.d(TAG, "sending read request: ${pdu.joinToString(" ") { String.format("%02X", it) }}") - - val resp = waitForResponse(0x0B, timeoutMillis) ?: run { + val resp = sendRequest(pdu, 0x0B, timeoutMillis) ?: run { Log.e(TAG, "Timeout waiting for Read Response (0x0B) for handle ${handle.value}") return null } @@ -117,17 +135,9 @@ class ATTManagerv2 { } fun writeCharacteristic(handle: Byte, data: ByteArray, timeoutMillis: Long = 2000) { - val socket = BluetoothConnectionManager.attSocket ?: return try { - val output = socket.outputStream val pdu = byteArrayOf(0x12, handle, 0x00) + data // 0x00 for LE - synchronized(output) { - output.write(pdu) - output.flush() - } - Log.d(TAG, "sending write request: ${pdu.joinToString(" ") { String.format("%02X", it) }}") - - val resp = waitForResponse(0x13, timeoutMillis) ?: run { + val resp = sendRequest(pdu, 0x13, timeoutMillis) ?: run { Log.e(TAG, "timeout waiting for response (0x13) for handle ${String.format("%02X", handle)}") return } @@ -140,41 +150,35 @@ class ATTManagerv2 { fun disconnected() { characteristicList.clear() + val socket = BluetoothConnectionManager.attSocket stopReader() - val socket = BluetoothConnectionManager.attSocket?: return try { - socket.close() + socket?.close() } catch (e: Exception) { Log.w(TAG, "error closing socket: ${e.message}") } Log.d(TAG, "ATT disconnected") } - private fun runReaderLoop() { - val socket = BluetoothConnectionManager.attSocket ?: run { - Log.w(TAG, "ATT socket not available. stopping reader") - readerRunning.set(false) - return - } - - val input = socket.inputStream + private fun runReaderLoop(session: ReaderSession) { + val input = session.socket.inputStream val buffer = ByteArray(512) - while (readerRunning.get()) { + while (activeReader === session) { try { val len = input.read(buffer) if (len == -1) { Log.w(TAG, "ATT input stream ended") break } + if (activeReader !== session) break val data = buffer.copyOfRange(0, len) if (data.isEmpty()) continue val opcode = data[0] Log.d(TAG, "pdu received ${data.joinToString(" ") { String.format("%02X", it) }}") - val queue = responseQueues.computeIfAbsent(opcode) { LinkedBlockingQueue() } - queue.offer(data) + session.responses.offer(data) if (opcode == 0x1B.toByte()) { if (data.size >= 3) { @@ -195,17 +199,29 @@ class ATTManagerv2 { break } } - - readerRunning.set(false) } - private fun waitForResponse(opcode: Byte, timeoutMillis: Long): ByteArray? { - val queue = responseQueues.computeIfAbsent(opcode) { LinkedBlockingQueue() } - return try { - queue.poll(timeoutMillis, TimeUnit.MILLISECONDS) - } catch (e: Exception) { - e.printStackTrace() - null + private fun sendRequest(pdu: ByteArray, responseOpcode: Byte, timeoutMillis: Long): ByteArray? = + synchronized(requestLock) { + val request = synchronized(readerLock) register@{ + val session = activeReader ?: return@register null + // Register before writing: a fast reply may arrive before await() starts. + session to session.responses.expect(responseOpcode) + } ?: return@synchronized null + val (session, pending) = request + try { + val output = session.socket.outputStream + synchronized(output) { + output.write(pdu) + output.flush() + } + Log.d(TAG, "sending request: ${pdu.joinToString(" ") { String.format("%02X", it) }}") + pending.await(timeoutMillis) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + null + } finally { + session.responses.finish(pending) + } } - } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AttResponseMailbox.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AttResponseMailbox.kt new file mode 100644 index 000000000..9be4a77b1 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AttResponseMailbox.kt @@ -0,0 +1,52 @@ +package me.kavishdevar.librepods.bluetooth + +import java.util.concurrent.ArrayBlockingQueue +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +/** Holds only the reply to the one ATT request currently in flight. */ +internal class AttResponseMailbox { + internal class PendingResponse internal constructor(val opcode: Byte) { + private val reply = ArrayBlockingQueue(1) + + @Volatile + private var cancelled = false + + fun await(timeoutMillis: Long): ByteArray? { + val value = reply.poll(timeoutMillis, TimeUnit.MILLISECONDS) + return value?.takeUnless { cancelled || it.isEmpty() } + } + + internal fun offer(data: ByteArray) { + if (!cancelled) reply.offer(data) + } + + internal fun cancel() { + cancelled = true + reply.clear() + // Wake an in-flight request on disconnect instead of waiting for its timeout. + reply.offer(ByteArray(0)) + } + } + + private val pending = AtomicReference() + + fun expect(opcode: Byte): PendingResponse { + val response = PendingResponse(opcode) + check(pending.compareAndSet(null, response)) { "An ATT request is already pending" } + return response + } + + fun offer(data: ByteArray) { + val response = pending.get() ?: return + if (data.isNotEmpty() && data[0] == response.opcode) response.offer(data) + } + + fun finish(response: PendingResponse) { + pending.compareAndSet(response, null) + } + + fun clear() { + pending.getAndSet(null)?.cancel() + } +} 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 b98690064..68d8076f0 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 @@ -30,17 +30,11 @@ import android.content.SharedPreferences import android.os.Handler import android.os.Looper import android.util.Log -import me.kavishdevar.librepods.utils.BluetoothCryptography -import javax.crypto.Cipher -import javax.crypto.spec.SecretKeySpec import kotlin.collections.iterator -import kotlin.io.encoding.Base64 -import kotlin.io.encoding.ExperimentalEncodingApi /** * Manager for Bluetooth Low Energy scanning operations specifically for AirPods */ -@OptIn(ExperimentalEncodingApi::class) class BLEManager(private val context: Context) { data class AirPodsStatus( @@ -59,7 +53,24 @@ class BLEManager(private val context: Context) { val lidOpen: Boolean = false, val color: String = "Unknown", val connectionState: String = "Unknown" - ) + ) { + /** Reception time keeps the device alive, but is not a device state change. */ + internal fun hasSameStateAs(other: AirPodsStatus): Boolean = + address == other.address && + paired == other.paired && + model == other.model && + leftBattery == other.leftBattery && + rightBattery == other.rightBattery && + caseBattery == other.caseBattery && + isLeftInEar == other.isLeftInEar && + isRightInEar == other.isRightInEar && + isLeftCharging == other.isLeftCharging && + isRightCharging == other.isRightCharging && + isCaseCharging == other.isCaseCharging && + lidOpen == other.lidOpen && + color == other.color && + connectionState == other.connectionState + } fun getMostRecentStatus(): AirPodsStatus? { return deviceStatusMap.values.maxByOrNull { it.lastSeen } @@ -78,11 +89,14 @@ class BLEManager(private val context: Context) { private var mScanCallback: ScanCallback? = null private var airPodsStatusListener: AirPodsStatusListener? = null private val deviceStatusMap = mutableMapOf() - private val verifiedAddresses = mutableSetOf() private val sharedPreferences: SharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE) + private val rpaVerifier = CachedRpaVerifier(onInvalidKey = { Log.e(TAG, "Invalid IRK", it) }) + private val proximityDecryptor = CachedProximityDecryptor(onError = { + Log.e(TAG, "Unable to decrypt proximity data", it) + }) private var currentGlobalLidState: Boolean? = null private var lastBroadcastTime: Long = 0 - private val processedAddresses = mutableSetOf() + private val advertisementDeduplicator = BleAdvertisementDeduplicator() private val lastValidCaseBatteryMap = mutableMapOf() @@ -99,13 +113,14 @@ class BLEManager(private val context: Context) { ) private val cleanupHandler = Handler(Looper.getMainLooper()) - private val cleanupRunnable = object : Runnable { - override fun run() { + private val cleanupTask = BleMaintenanceTask( + schedule = { cleanupHandler.postDelayed(it, CLEANUP_INTERVAL_MS) }, + cancel = { cleanupHandler.removeCallbacks(it) }, + maintain = { cleanupStaleDevices() checkLidStateTimeout() - cleanupHandler.postDelayed(this, CLEANUP_INTERVAL_MS) } - } + ) fun setAirPodsStatusListener(listener: AirPodsStatusListener) { airPodsStatusListener = listener @@ -113,6 +128,7 @@ class BLEManager(private val context: Context) { @SuppressLint("MissingPermission") fun startScanning() { + cleanupTask.stop() try { Log.d(TAG, "Starting BLE scanner") @@ -163,13 +179,15 @@ class BLEManager(private val context: Context) { } override fun onBatchScanResults(results: List) { - processedAddresses.clear() - for (result in results) { - processScanResult(result) + advertisementDeduplicator.inBatch { + for (result in results) { + processScanResult(result) + } } } override fun onScanFailed(errorCode: Int) { + if (mScanCallback === this) cleanupTask.stop() Log.e(TAG, "BLE scan failed with error code: $errorCode") } } @@ -177,7 +195,7 @@ class BLEManager(private val context: Context) { mBluetoothLeScanner?.startScan(listOf(scanFilter), scanSettings, mScanCallback) Log.d(TAG, "BLE scanner started successfully") - cleanupHandler.postDelayed(cleanupRunnable, CLEANUP_INTERVAL_MS) + cleanupTask.start() } catch (t: Throwable) { Log.e(TAG, "Error starting BLE scanner", t) } @@ -185,6 +203,7 @@ class BLEManager(private val context: Context) { @SuppressLint("MissingPermission") fun stopScanning() { + cleanupTask.stop() try { if (mBluetoothLeScanner != null && mScanCallback != null) { Log.d(TAG, "Stopping BLE scanner") @@ -192,45 +211,11 @@ class BLEManager(private val context: Context) { mScanCallback = null } - cleanupHandler.removeCallbacks(cleanupRunnable) } catch (t: Throwable) { Log.e(TAG, "Error stopping BLE scanner", t) } } - @OptIn(ExperimentalEncodingApi::class) - private fun getEncryptionKeyFromPreferences(): ByteArray? { - val keyBase64 = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, null) - return if (keyBase64 != null) { - try { - Base64.decode(keyBase64) - } catch (e: Exception) { - Log.e(TAG, "Failed to decode encryption key", e) - null - } - } else { - null - } - } - - @SuppressLint("GetInstance") - private fun decryptLastBytes(data: ByteArray, key: ByteArray): ByteArray? { - return try { - if (data.size < 16) { - return null - } - - val block = data.copyOfRange(data.size - 16, data.size) - val cipher = Cipher.getInstance("AES/ECB/NoPadding") - val secretKey = SecretKeySpec(key, "AES") - cipher.init(Cipher.DECRYPT_MODE, secretKey) - cipher.doFinal(block) - } catch (e: Exception) { - Log.e(TAG, "Error decrypting data", e) - null - } - } - private fun formatBattery(byteVal: Int): Pair { val charging = (byteVal and 0x80) != 0 val level = byteVal and 0x7F @@ -242,27 +227,21 @@ class BLEManager(private val context: Context) { val scanRecord = result.scanRecord ?: return val address = result.device.address - if (processedAddresses.contains(address)) { + if (advertisementDeduplicator.wasProcessed(address)) { return } val manufacturerData = scanRecord.getManufacturerSpecificData(76) ?: return if (!AirPodsProximityModels.isValid(manufacturerData)) return - if (!verifiedAddresses.contains(address)) { - val irk = getIrkFromPreferences() - if (irk == null || !BluetoothCryptography.verifyRPA(address, irk)) { - return - } - verifiedAddresses.add(address) - Log.d(TAG, "RPA verified and added to trusted list: $address") - } + val irkBase64 = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.IRK.name, null) + if (!rpaVerifier.verify(address, irkBase64)) return - processedAddresses.add(address) + advertisementDeduplicator.markProcessed(address) lastBroadcastTime = System.currentTimeMillis() - val encryptionKey = getEncryptionKeyFromPreferences() - val decryptedData = if (encryptionKey != null) decryptLastBytes(manufacturerData, encryptionKey) else null + val encryptionKey = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, null) + val decryptedData = proximityDecryptor.decryptLastBlock(manufacturerData, encryptionKey) val parsedStatus = if (decryptedData != null && decryptedData.size == 16) { parseProximityMessageWithDecryption(address, manufacturerData, decryptedData) } else { @@ -283,7 +262,7 @@ class BLEManager(private val context: Context) { Log.d(TAG, "Lid state ${if (parsedStatus.lidOpen) "opened" else "closed"} (detected from new device)") } } else { - if (parsedStatus != previousStatus) { + if (!parsedStatus.hasSameStateAs(previousStatus)) { listener.onDeviceStatusChanged(parsedStatus, previousStatus) } @@ -402,21 +381,6 @@ class BLEManager(private val context: Context) { } } - @OptIn(ExperimentalEncodingApi::class) - private fun getIrkFromPreferences(): ByteArray? { - val irkBase64 = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.IRK.name, null) - return if (irkBase64 != null) { - try { - Base64.decode(irkBase64) - } catch (e: Exception) { - Log.e(TAG, "Failed to decode IRK", e) - null - } - } else { - null - } - } - private fun parseProximityMessage(address: String, data: ByteArray): AirPodsStatus { val paired = data[2].toInt() == 1 val model = AirPodsProximityModels.getModelName(data) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BleAdvertisementDeduplicator.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BleAdvertisementDeduplicator.kt new file mode 100644 index 000000000..244fe4521 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BleAdvertisementDeduplicator.kt @@ -0,0 +1,22 @@ +package me.kavishdevar.librepods.bluetooth + +/** Ignore repeated addresses only while processing a single scan batch. */ +internal class BleAdvertisementDeduplicator { + private var batchAddresses: MutableSet? = null + + fun inBatch(block: () -> T): T { + val previousBatch = batchAddresses + batchAddresses = HashSet() + return try { + block() + } finally { + batchAddresses = previousBatch + } + } + + fun wasProcessed(address: String): Boolean = batchAddresses?.contains(address) == true + + fun markProcessed(address: String) { + batchAddresses?.add(address) + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BleMaintenanceTask.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BleMaintenanceTask.kt new file mode 100644 index 000000000..31dcf9e15 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BleMaintenanceTask.kt @@ -0,0 +1,34 @@ +package me.kavishdevar.librepods.bluetooth + +/** A restartable scan-maintenance loop with at most one scheduled callback. */ +internal class BleMaintenanceTask( + private val schedule: (Runnable) -> Unit, + private val cancel: (Runnable) -> Unit, + private val maintain: () -> Unit +) { + private var activeCallback: Runnable? = null + + @Synchronized + fun start() { + activeCallback?.let(cancel) + val callback = object : Runnable { + override fun run() { + synchronized(this@BleMaintenanceTask) { + // A removed callback may already have been dispatched by Handler. + // Its identity must not become valid again when scanning restarts. + if (activeCallback !== this) return + maintain() + if (activeCallback === this) schedule(this) + } + } + } + activeCallback = callback + schedule(callback) + } + + @Synchronized + fun stop() { + activeCallback?.let(cancel) + activeCallback = null + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/CachedProximityDecryptor.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/CachedProximityDecryptor.kt new file mode 100644 index 000000000..a9882a8b8 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/CachedProximityDecryptor.kt @@ -0,0 +1,48 @@ +package me.kavishdevar.librepods.bluetooth + +import javax.crypto.Cipher +import javax.crypto.spec.SecretKeySpec +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** Reuse the initialized cipher while the configured proximity key remains unchanged. */ +@OptIn(ExperimentalEncodingApi::class) +internal class CachedProximityDecryptor( + private val createCipher: (ByteArray) -> Cipher = { key -> + // The AirPods proximity wire format encrypts a single AES-ECB block. + @Suppress("GetInstance") + val cipher = Cipher.getInstance("AES/ECB/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES")) + cipher + }, + private val onError: (Exception) -> Unit = {} +) { + private var encodedKey: String? = null + private var cipher: Cipher? = null + + @Synchronized + fun decryptLastBlock(data: ByteArray, currentEncodedKey: String?): ByteArray? { + if (data.size < 16) return null + if (encodedKey != currentEncodedKey) { + encodedKey = currentEncodedKey + cipher = try { + currentEncodedKey?.let { createCipher(Base64.decode(it)) } + } catch (e: Exception) { + onError(e) + null + } + } + val currentCipher = cipher ?: return null + return try { + // doFinal resets a successful cipher to its initialized state. Its offset + // overload avoids copying the ciphertext out of the advertisement first. + currentCipher.doFinal(data, data.size - 16, 16) + } catch (e: Exception) { + // A provider failure may leave cipher state undefined; rebuild on the next packet. + encodedKey = null + cipher = null + onError(e) + null + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/CachedRpaVerifier.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/CachedRpaVerifier.kt new file mode 100644 index 000000000..5be6442ed --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/CachedRpaVerifier.kt @@ -0,0 +1,45 @@ +package me.kavishdevar.librepods.bluetooth + +import me.kavishdevar.librepods.utils.BluetoothCryptography +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** RPA validation depends only on the address and IRK, not on advertisement contents. */ +@OptIn(ExperimentalEncodingApi::class) +internal class CachedRpaVerifier( + private val capacity: Int = 256, + private val verifyRpa: (String, ByteArray) -> Boolean = BluetoothCryptography::verifyRPA, + private val onInvalidKey: (Exception) -> Unit = {} +) { + private var encodedIrk: String? = null + private var irk: ByteArray? = null + private val addresses = LinkedHashMap(capacity, 0.75f, true) + + init { + require(capacity > 0) + } + + @Synchronized + fun verify(address: String, currentEncodedIrk: String?): Boolean { + if (encodedIrk != currentEncodedIrk) { + encodedIrk = currentEncodedIrk + addresses.clear() + irk = try { + currentEncodedIrk?.let { encoded -> + Base64.decode(encoded).also { require(it.size == 16) { "IRK must contain 16 bytes" } } + } + } catch (e: Exception) { + onInvalidKey(e) + null + } + } + val key = irk ?: return false + addresses[address]?.let { return it } + val verified = verifyRpa(address, key) + addresses[address] = verified + if (addresses.size > capacity) { + addresses.entries.iterator().apply { next(); remove() } + } + return verified + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/HearingAid.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/HearingAid.kt index 7b989d6dd..c42a3e615 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/data/HearingAid.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/data/HearingAid.kt @@ -152,41 +152,31 @@ fun sendHearingAidSettings( Log.w(TAG, "Current data size ${currentData.size} too small, cannot send settings") return@launch } - val buffer = ByteBuffer.wrap(currentData).order(ByteOrder.LITTLE_ENDIAN) - - // for some reason - buffer.put(2, 0x64) - - // Left EQ - for (i in 0..7) { - buffer.putFloat(4 + i * 4, hearingAidSettings.leftEQ[i]) - } - - // Left ear adjustments - buffer.putFloat(36, hearingAidSettings.leftAmplification) - buffer.putFloat(40, hearingAidSettings.leftTone) - buffer.putFloat(44, if (hearingAidSettings.leftConversationBoost) 1.0f else 0.0f) - buffer.putFloat(48, hearingAidSettings.leftAmbientNoiseReduction) - - // Right EQ - for (i in 0..7) { - buffer.putFloat(52 + i * 4, hearingAidSettings.rightEQ[i]) - } - - // Right ear adjustments - buffer.putFloat(84, hearingAidSettings.rightAmplification) - buffer.putFloat(88, hearingAidSettings.rightTone) - buffer.putFloat(92, if (hearingAidSettings.rightConversationBoost) 1.0f else 0.0f) - buffer.putFloat(96, hearingAidSettings.rightAmbientNoiseReduction) - - // Own voice amplification - buffer.putFloat(100, hearingAidSettings.ownVoiceAmplification) - - Log.d(TAG, "Sending updated settings: ${currentData.joinToString(" ") { String.format("%02X", it) }}") - - sender(ATTHandles.HEARING_AID, currentData) + val data = encodeHearingAidSettings(currentData, hearingAidSettings) ?: return@launch + Log.d(TAG, "Sending updated settings: ${data.joinToString(" ") { String.format("%02X", it) }}") + sender(ATTHandles.HEARING_AID, data) } catch (e: IOException) { e.printStackTrace() } } } + +/** Preserve the device header and any trailing fields without mutating its cached state. */ +fun encodeHearingAidSettings(currentData: ByteArray, settings: HearingAidSettings): ByteArray? { + if (currentData.size < 104) return null + val data = currentData.copyOf() + val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN) + buffer.put(2, 0x64) + for (i in 0..7) buffer.putFloat(4 + i * 4, settings.leftEQ[i]) + buffer.putFloat(36, settings.leftAmplification) + buffer.putFloat(40, settings.leftTone) + buffer.putFloat(44, if (settings.leftConversationBoost) 1f else 0f) + buffer.putFloat(48, settings.leftAmbientNoiseReduction) + for (i in 0..7) buffer.putFloat(52 + i * 4, settings.rightEQ[i]) + buffer.putFloat(84, settings.rightAmplification) + buffer.putFloat(88, settings.rightTone) + buffer.putFloat(92, if (settings.rightConversationBoost) 1f else 0f) + buffer.putFloat(96, settings.rightAmbientNoiseReduction) + buffer.putFloat(100, settings.ownVoiceAmplification) + return data +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/Transparency.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/Transparency.kt index c43e1cff0..f21c1446b 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/data/Transparency.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/data/Transparency.kt @@ -146,36 +146,28 @@ fun sendTransparencySettings(writer: (ATTHandles, ByteArray) -> Unit, transparen debounceJob = CoroutineScope(Dispatchers.IO).launch { delay(100) try { - val buffer = ByteBuffer.allocate( - if (transparencySettings.ownVoiceAmplification != null) 104 else 100 - ).order(ByteOrder.LITTLE_ENDIAN) - - buffer.putFloat(if (transparencySettings.enabled) 1.0f else 0.0f) - - for (eq in transparencySettings.leftEQ) { - buffer.putFloat(eq) - } - buffer.putFloat(transparencySettings.leftAmplification) - buffer.putFloat(transparencySettings.leftTone) - buffer.putFloat(if (transparencySettings.leftConversationBoost) 1.0f else 0.0f) - buffer.putFloat(transparencySettings.leftAmbientNoiseReduction) - - for (eq in transparencySettings.rightEQ) { - buffer.putFloat(eq) - } - buffer.putFloat(transparencySettings.rightAmplification) - buffer.putFloat(transparencySettings.rightTone) - buffer.putFloat(if (transparencySettings.rightConversationBoost) 1.0f else 0.0f) - buffer.putFloat(transparencySettings.rightAmbientNoiseReduction) - - if (transparencySettings.ownVoiceAmplification != null) { - buffer.putFloat(transparencySettings.ownVoiceAmplification) - } - - val data = buffer.array() + val data = encodeTransparencySettings(transparencySettings) writer(ATTHandles.TRANSPARENCY, data) } catch (e: IOException) { e.printStackTrace() } } } + +fun encodeTransparencySettings(settings: TransparencySettings): ByteArray { + val buffer = ByteBuffer.allocate(if (settings.ownVoiceAmplification != null) 104 else 100) + .order(ByteOrder.LITTLE_ENDIAN) + buffer.putFloat(if (settings.enabled) 1f else 0f) + for (eq in settings.leftEQ) buffer.putFloat(eq) + buffer.putFloat(settings.leftAmplification) + buffer.putFloat(settings.leftTone) + buffer.putFloat(if (settings.leftConversationBoost) 1f else 0f) + buffer.putFloat(settings.leftAmbientNoiseReduction) + for (eq in settings.rightEQ) buffer.putFloat(eq) + buffer.putFloat(settings.rightAmplification) + buffer.putFloat(settings.rightTone) + buffer.putFloat(if (settings.rightConversationBoost) 1f else 0f) + buffer.putFloat(settings.rightAmbientNoiseReduction) + settings.ownVoiceAmplification?.let { buffer.putFloat(it) } + return buffer.array() +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSwitch.kt index d479da0d2..e6d896d61 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSwitch.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSwitch.kt @@ -48,10 +48,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.BlurEffect import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Paint import androidx.compose.ui.graphics.TileMode import androidx.compose.ui.graphics.drawOutline -import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.drawscope.scale import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.graphics.graphicsLayer @@ -227,20 +225,11 @@ fun StyledSwitch( scaleY = scale }, onDrawBackdrop = { drawScope -> - drawIntoCanvas { canvas -> - canvas.save() - canvas.drawRect( - left = 0f, - top = 0f, - right = size.width, - bottom = size.height, - paint = Paint().apply { - color = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7) - } - ) - scale(0.7f) { - drawScope() - } + drawRect( + color = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7) + ) + scale(0.7f) { + drawScope() } }, onDrawSurface = { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/DeviceSettingsEditor.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/DeviceSettingsEditor.kt new file mode 100644 index 000000000..1e2986a48 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/DeviceSettingsEditor.kt @@ -0,0 +1,75 @@ +package me.kavishdevar.librepods.presentation.screens + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** All calls, including scheduled callbacks, run on the UI thread. */ +internal class DeviceSettingsEditor( + private val schedule: (Long, () -> Unit) -> (() -> Unit), + private val write: (T) -> Unit, + private val nowMillis: () -> Long = { System.nanoTime() / 1_000_000 }, + private val settlingMillis: Long = 2500 +) { + private var pending: T? = null + private var cancelScheduled: (() -> Unit)? = null + private var generation = 0L + private var editedAt: Long? = null + + fun userEdited(value: T) { + editedAt = nowMillis() + pending = value + val currentGeneration = ++generation + cancelScheduled?.invoke() + cancelScheduled = schedule(100) { + if (generation == currentGeneration) flush() + } + } + + fun applyDeviceUpdate(update: () -> Unit): Boolean { + // Protect the draft through the 100 ms debounce and the 2 s ATT timeout. + // Even matching echoes may normalize gain/balance or quantize EQ values. + // Only user edits extend this window; notifications cannot lock it forever. + val lastEdit = editedAt + if (lastEdit != null && nowMillis() - lastEdit < settlingMillis) return false + update() + return true + } + + fun flush() { + val value = pending ?: return + pending = null + generation++ + cancelScheduled?.invoke() + cancelScheduled = null + write(value) + } +} + +@Composable +internal fun rememberDeviceSettingsEditor(write: (T) -> Unit): DeviceSettingsEditor { + val scope = rememberCoroutineScope() + val currentWrite = rememberUpdatedState(write) + val editor = remember(scope) { + DeviceSettingsEditor( + schedule = { delayMillis, action -> + val job = scope.launch { + delay(delayMillis) + action() + } + val cancel: () -> Unit = { job.cancel() } + cancel + }, + write = { currentWrite.value(it) } + ) + } + DisposableEffect(editor) { + // Preserve the final user edit when navigation cancels the debounce scope. + onDispose { editor.flush() } + } + return editor +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingPlotHistory.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingPlotHistory.kt new file mode 100644 index 000000000..c4e3b890a --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingPlotHistory.kt @@ -0,0 +1,41 @@ +package me.kavishdevar.librepods.presentation.screens + +import kotlin.math.abs + +/** Fixed-size sample history, accessed by the plot's main-thread collector and draw pass. */ +internal class HeadTrackingPlotHistory(val capacity: Int) { + init { + require(capacity > 0) + } + + private val horizontal = FloatArray(capacity) + private val vertical = FloatArray(capacity) + private var nextIndex = 0 + + var size = 0 + private set + + fun add(horizontalValue: Float, verticalValue: Float) { + horizontal[nextIndex] = horizontalValue + vertical[nextIndex] = verticalValue + nextIndex = (nextIndex + 1) % capacity + if (size < capacity) size++ + } + + fun horizontalAt(index: Int): Float = horizontal[storageIndex(index)] + + fun verticalAt(index: Int): Float = vertical[storageIndex(index)] + + fun maxMagnitude(): Float { + var maximum = 0f + for (index in 0 until size) { + maximum = maxOf(maximum, abs(horizontal[index]), abs(vertical[index])) + } + return maximum + } + + private fun storageIndex(index: Int): Int { + require(index in 0 until size) + return if (size < capacity) index else (nextIndex + index) % capacity + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingScreen.kt index 79a14af1f..49e825471 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingScreen.kt @@ -58,9 +58,8 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -78,6 +77,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi @@ -91,9 +93,9 @@ import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import me.kavishdevar.librepods.services.ServiceManager +import me.kavishdevar.librepods.utils.Acceleration import me.kavishdevar.librepods.utils.HeadTracking import kotlin.io.encoding.ExperimentalEncodingApi -import kotlin.math.abs @ExperimentalHazeMaterialsApi @OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class) @@ -273,21 +275,28 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Un @Composable private fun Plot() { - val acceleration by HeadTracking.acceleration.collectAsState() val maxPoints = 100 - val points = remember { mutableStateListOf>() } + val points = remember { HeadTrackingPlotHistory(maxPoints) } + val drawVersion = remember { mutableIntStateOf(0) } + val lifecycleOwner = LocalLifecycleOwner.current val darkTheme = isSystemInDarkTheme() - - var maxAbs by remember { mutableFloatStateOf(1000f) } - - LaunchedEffect(acceleration) { - points.add(Pair(acceleration.horizontal, acceleration.vertical)) - if (points.size > maxPoints) { - points.removeAt(0) + val labelPaint = remember(darkTheme) { + Paint().apply { + color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK } + } - val currentMax = points.maxOf { maxOf(abs(it.first), abs(it.second)) } - maxAbs = maxOf(currentMax * 1.2f, 1000f) + LaunchedEffect(lifecycleOwner, points) { + var previousAcceleration: Acceleration? = null + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + HeadTracking.acceleration.collect { acceleration -> + if (acceleration != previousAcceleration) { + points.add(acceleration.horizontal, acceleration.vertical) + drawVersion.intValue++ + previousAcceleration = acceleration + } + } + } } Card( @@ -308,6 +317,10 @@ private fun Plot() { Canvas( modifier = Modifier.fillMaxSize() ) { + // Observe samples only during drawing, so a packet invalidates the + // canvas without recomposing the plot or restarting its collector. + drawVersion.intValue + val maxAbs = maxOf(points.maxMagnitude() * 1.2f, 1000f) val width = size.width val height = size.height val xScale = width / maxPoints @@ -351,30 +364,27 @@ private fun Plot() { drawLine( color = horizontalColor, - start = Offset(x1, zeroY - points[i].first * yScale), - end = Offset(x2, zeroY - points[i + 1].first * yScale), + start = Offset(x1, zeroY - points.horizontalAt(i) * yScale), + end = Offset(x2, zeroY - points.horizontalAt(i + 1) * yScale), strokeWidth = 2.dp.toPx() ) drawLine( color = verticalColor, - start = Offset(x1, zeroY - points[i].second * yScale), - end = Offset(x2, zeroY - points[i + 1].second * yScale), + start = Offset(x1, zeroY - points.verticalAt(i) * yScale), + end = Offset(x2, zeroY - points.verticalAt(i + 1) * yScale), strokeWidth = 2.dp.toPx() ) } } drawContext.canvas.nativeCanvas.apply { - val paint = Paint().apply { - color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK - textSize = 12.sp.toPx() - textAlign = Paint.Align.RIGHT - } + labelPaint.textSize = 12.sp.toPx() + labelPaint.textAlign = Paint.Align.RIGHT - drawText("${maxAbs.toInt()}", 30.dp.toPx(), 20.dp.toPx(), paint) - drawText("0", 30.dp.toPx(), height/2, paint) - drawText("-${maxAbs.toInt()}", 30.dp.toPx(), height - 10.dp.toPx(), paint) + drawText("${maxAbs.toInt()}", 30.dp.toPx(), 20.dp.toPx(), labelPaint) + drawText("0", 30.dp.toPx(), height/2, labelPaint) + drawText("-${maxAbs.toInt()}", 30.dp.toPx(), height - 10.dp.toPx(), labelPaint) } val legendY = 15.dp.toPx() @@ -382,22 +392,13 @@ private fun Plot() { drawCircle(horizontalColor, 5.dp.toPx(), Offset(width - 150.dp.toPx(), legendY)) drawContext.canvas.nativeCanvas.apply { - val paint = Paint().apply { - color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK - textSize = 12.sp.toPx() - textAlign = Paint.Align.LEFT - } - drawText("Horizontal", width - 140.dp.toPx(), textOffsetY, paint) + labelPaint.textAlign = Paint.Align.LEFT + drawText("Horizontal", width - 140.dp.toPx(), textOffsetY, labelPaint) } drawCircle(verticalColor, 5.dp.toPx(), Offset(width - 70.dp.toPx(), legendY)) drawContext.canvas.nativeCanvas.apply { - val paint = Paint().apply { - color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK - textSize = 12.sp.toPx() - textAlign = Paint.Align.LEFT - } - drawText("Vertical", width - 60.dp.toPx(), textOffsetY, paint) + drawText("Vertical", width - 60.dp.toPx(), textOffsetY, labelPaint) } } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidAdjustmentsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidAdjustmentsScreen.kt index e4b78c4b5..bd7fb763e 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidAdjustmentsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidAdjustmentsScreen.kt @@ -19,7 +19,6 @@ package me.kavishdevar.librepods.presentation.screens import android.annotation.SuppressLint -import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -47,12 +46,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import kotlinx.coroutines.Job import me.kavishdevar.librepods.R import me.kavishdevar.librepods.bluetooth.AACPManager +import me.kavishdevar.librepods.bluetooth.ATTHandles import me.kavishdevar.librepods.data.HearingAidSettings import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse -import me.kavishdevar.librepods.data.sendHearingAidSettings +import me.kavishdevar.librepods.data.encodeHearingAidSettings import me.kavishdevar.librepods.presentation.components.StyledSlider import me.kavishdevar.librepods.presentation.components.StyledToggle import me.kavishdevar.librepods.presentation.theme.DesignSystem @@ -60,8 +59,6 @@ import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import kotlin.io.encoding.ExperimentalEncodingApi -private const val TAG = "HearingAidAdjustments" - @SuppressLint("DefaultLocale") @ExperimentalHazeMaterialsApi @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @@ -70,8 +67,6 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) { val verticalScrollState = rememberScrollState() val state by viewModel.uiState.collectAsState() - val debounceJob = remember { mutableStateOf(null) } - val amplificationSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) } val balanceSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) } val toneSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) } @@ -83,48 +78,30 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) { val initialized = rememberSaveable { mutableStateOf(false) } - val hearingAidSettings = remember { mutableStateOf( - HearingAidSettings( - leftEQ = leftEQ.value, - rightEQ = rightEQ.value, - leftAmplification = 0f, - rightAmplification = 0f, - leftTone = 0f, - rightTone = 0f, - leftConversationBoost = false, - rightConversationBoost = false, - leftAmbientNoiseReduction = 0f, - rightAmbientNoiseReduction = 0f, - netAmplification = 0f, - balance = 0f, - ownVoiceAmplification = 0f - ) - ) } + val editor = rememberDeviceSettingsEditor> { (source, settings) -> + encodeHearingAidSettings(source, settings)?.let { data -> + viewModel.setATTCharacteristicValue(ATTHandles.HEARING_AID, data) + } + } LaunchedEffect(state.hearingAidData) { - parseHearingAidSettingsResponse(state.hearingAidData)?.let { parsed -> + val parsed = parseHearingAidSettingsResponse(state.hearingAidData) ?: return@LaunchedEffect + editor.applyDeviceUpdate { amplificationSliderValue.floatValue = parsed.netAmplification balanceSliderValue.floatValue = parsed.balance toneSliderValue.floatValue = parsed.leftTone ambientNoiseReductionSliderValue.floatValue = parsed.leftAmbientNoiseReduction conversationBoostEnabled.value = parsed.leftConversationBoost - leftEQ.value = parsed.leftEQ.copyOf() - rightEQ.value = parsed.rightEQ.copyOf() + if (!leftEQ.value.contentEquals(parsed.leftEQ)) leftEQ.value = parsed.leftEQ.copyOf() + if (!rightEQ.value.contentEquals(parsed.rightEQ)) rightEQ.value = parsed.rightEQ.copyOf() ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification initialized.value = true } } - LaunchedEffect( - amplificationSliderValue.floatValue, - balanceSliderValue.floatValue, - toneSliderValue.floatValue, - conversationBoostEnabled.value, - ambientNoiseReductionSliderValue.floatValue, - ownVoiceAmplification.floatValue - ) { - if (!initialized.value) return@LaunchedEffect - hearingAidSettings.value = HearingAidSettings( + fun sendUserEdit() { + if (!initialized.value) return + val settings = HearingAidSettings( leftEQ = leftEQ.value, rightEQ = rightEQ.value, leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f, @@ -139,8 +116,7 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) { balance = balanceSliderValue.floatValue, ownVoiceAmplification = ownVoiceAmplification.floatValue ) - Log.d(TAG, "Updated settings: ${hearingAidSettings.value}") - sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, viewModel::setATTCharacteristicValue) + editor.userEdited(state.hearingAidData.copyOf() to settings) } val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material @@ -163,6 +139,7 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) { value = amplificationSliderValue.floatValue, onValueChange = { amplificationSliderValue.floatValue = it + sendUserEdit() }, startIcon = "􀊥", endIcon = "􀊩", @@ -182,6 +159,7 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) { value = balanceSliderValue.floatValue, onValueChange = { balanceSliderValue.floatValue = it + sendUserEdit() }, snapPoints = listOf(-1f, 0f, 1f), startLabel = stringResource(R.string.left), @@ -195,6 +173,7 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) { value = toneSliderValue.floatValue, onValueChange = { toneSliderValue.floatValue = it + sendUserEdit() }, startLabel = stringResource(R.string.darker), endLabel = stringResource(R.string.brighter), @@ -207,6 +186,7 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) { value = ambientNoiseReductionSliderValue.floatValue, onValueChange = { ambientNoiseReductionSliderValue.floatValue = it + sendUserEdit() }, startLabel = stringResource(R.string.less), endLabel = stringResource(R.string.more), @@ -216,7 +196,10 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) { StyledToggle( label = stringResource(R.string.conversation_boost), checked = conversationBoostEnabled.value, - onCheckedChange = { conversationBoostEnabled.value = it }, + onCheckedChange = { + conversationBoostEnabled.value = it + sendUserEdit() + }, description = stringResource(R.string.conversation_boost_description) ) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/LogPreview.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/LogPreview.kt new file mode 100644 index 000000000..36f6aa8d6 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/LogPreview.kt @@ -0,0 +1,54 @@ +package me.kavishdevar.librepods.presentation.screens + +import java.io.File +import java.io.RandomAccessFile + +internal data class LogPreview( + val lines: List = emptyList(), + val isTruncated: Boolean = false +) + +// The full log remains on disk for saving and sharing. Bound both the amount read +// and the size of individual text layouts, even for logs with very long lines. +internal fun readLogPreview(file: File): LogPreview { + val maxBytes = 256 * 1024 + val maxLines = 2000 + val maxLineLength = 4096 + + return RandomAccessFile(file, "r").use { input -> + val length = input.length() + val start = (length - maxBytes).coerceAtLeast(0) + val startsAtLineBoundary = if (start == 0L) { + true + } else { + input.seek(start - 1) + input.read() == '\n'.code + } + input.seek(start) + val bytes = ByteArray((length - start).toInt()) + input.readFully(bytes) + val text = bytes.toString(Charsets.UTF_8) + val firstNewline = text.indexOf('\n') + val content = if (!startsAtLineBoundary && firstNewline >= 0) { + text.substring(firstNewline + 1) + } else { + text + } + + var truncated = start > 0 + val lines = ArrayDeque() + content.lineSequence().forEach { line -> + if (lines.size == maxLines) { + lines.removeFirst() + truncated = true + } + lines.addLast(if (line.length > maxLineLength) { + truncated = true + line.take(maxLineLength) + "…" + } else { + line + }) + } + LogPreview(lines.toList(), truncated) + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TransparencySettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TransparencySettingsScreen.kt index c6700e57f..eca191f9f 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TransparencySettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TransparencySettingsScreen.kt @@ -68,9 +68,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.bluetooth.ATTHandles import me.kavishdevar.librepods.data.TransparencySettings import me.kavishdevar.librepods.data.parseTransparencySettingsResponse -import me.kavishdevar.librepods.data.sendTransparencySettings +import me.kavishdevar.librepods.data.encodeTransparencySettings import me.kavishdevar.librepods.presentation.components.StyledSlider import me.kavishdevar.librepods.presentation.components.StyledToggle import me.kavishdevar.librepods.presentation.theme.DesignSystem @@ -131,37 +132,13 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) { val initialized = rememberSaveable { mutableStateOf(false) } - val transparencySettings = remember { - mutableStateOf( - TransparencySettings( - enabled = enabled.value, - leftEQ = eq.value, - rightEQ = eq.value, - leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2, - rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2, - leftTone = toneSliderValue.floatValue, - rightTone = toneSliderValue.floatValue, - leftConversationBoost = conversationBoostEnabled.value, - rightConversationBoost = conversationBoostEnabled.value, - leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue, - rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue, - netAmplification = amplificationSliderValue.floatValue, - balance = balanceSliderValue.floatValue - ) - ) + val editor = rememberDeviceSettingsEditor { settings -> + viewModel.setATTCharacteristicValue(ATTHandles.TRANSPARENCY, encodeTransparencySettings(settings)) } - LaunchedEffect( - enabled.value, - amplificationSliderValue.floatValue, - balanceSliderValue.floatValue, - toneSliderValue.floatValue, - conversationBoostEnabled.value, - ambientNoiseReductionSliderValue.floatValue, - eq.value - ) { - if (!initialized.value) return@LaunchedEffect - transparencySettings.value = TransparencySettings( + fun sendUserEdit() { + if (!initialized.value) return + val settings = TransparencySettings( enabled = enabled.value, leftEQ = eq.value, rightEQ = eq.value, @@ -176,24 +153,25 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) { netAmplification = amplificationSliderValue.floatValue, balance = balanceSliderValue.floatValue ) - Log.d("TransparencySettings", "Updated settings: ${transparencySettings.value}") - sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value) + editor.userEdited(settings) } LaunchedEffect(state.transparencyData) { val parsedSettings = parseTransparencySettingsResponse(data = state.transparencyData) ?: return@LaunchedEffect - Log.d(TAG, "Initial transparency settings: $parsedSettings") - enabled.value = parsedSettings.enabled - amplificationSliderValue.floatValue = parsedSettings.netAmplification - balanceSliderValue.floatValue = parsedSettings.balance - toneSliderValue.floatValue = parsedSettings.leftTone - ambientNoiseReductionSliderValue.floatValue = - parsedSettings.leftAmbientNoiseReduction - conversationBoostEnabled.value = parsedSettings.leftConversationBoost - if (!eq.value.contentEquals(parsedSettings.leftEQ)) { - eq.value = parsedSettings.leftEQ.copyOf() + editor.applyDeviceUpdate { + Log.d(TAG, "Initial transparency settings: $parsedSettings") + enabled.value = parsedSettings.enabled + amplificationSliderValue.floatValue = parsedSettings.netAmplification + balanceSliderValue.floatValue = parsedSettings.balance + toneSliderValue.floatValue = parsedSettings.leftTone + ambientNoiseReductionSliderValue.floatValue = + parsedSettings.leftAmbientNoiseReduction + conversationBoostEnabled.value = parsedSettings.leftConversationBoost + if (!eq.value.contentEquals(parsedSettings.leftEQ)) { + eq.value = parsedSettings.leftEQ.copyOf() + } + initialized.value = true } - initialized.value = true } if (state.vendorIdHook) { @@ -201,7 +179,10 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) { label = stringResource(R.string.transparency_mode), checked = enabled.value, description = stringResource(R.string.customize_transparency_mode_description), - onCheckedChange = { enabled.value = it } + onCheckedChange = { + enabled.value = it + sendUserEdit() + } ) Spacer(modifier = Modifier.height(4.dp)) StyledSlider( @@ -210,6 +191,7 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) { value = amplificationSliderValue.floatValue, onValueChange = { amplificationSliderValue.floatValue = it + sendUserEdit() }, startIcon = "􀊥", endIcon = "􀊩", @@ -222,6 +204,7 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) { value = balanceSliderValue.floatValue, onValueChange = { balanceSliderValue.floatValue = it + sendUserEdit() }, snapPoints = listOf(-1f, 0f, 1f), startLabel = stringResource(R.string.left), @@ -235,6 +218,7 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) { value = toneSliderValue.floatValue, onValueChange = { toneSliderValue.floatValue = it + sendUserEdit() }, startLabel = stringResource(R.string.darker), endLabel = stringResource(R.string.brighter), @@ -247,6 +231,7 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) { value = ambientNoiseReductionSliderValue.floatValue, onValueChange = { ambientNoiseReductionSliderValue.floatValue = it + sendUserEdit() }, startLabel = stringResource(R.string.less), endLabel = stringResource(R.string.more), @@ -257,7 +242,10 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) { label = stringResource(R.string.conversation_boost), checked = conversationBoostEnabled.value, description = stringResource(R.string.conversation_boost_description), - onCheckedChange = { conversationBoostEnabled.value = it } + onCheckedChange = { + conversationBoostEnabled.value = it + sendUserEdit() + } ) Text( @@ -302,6 +290,7 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) { val newEQ = eq.value.copyOf() newEQ[i] = eqValue.floatValue eq.value = newEQ + sendUserEdit() }, valueRange = 0f..100f, modifier = Modifier diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TroubleshootingScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TroubleshootingScreen.kt index 38d299e36..664b4c7b1 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TroubleshootingScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TroubleshootingScreen.kt @@ -47,6 +47,8 @@ import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -91,13 +93,16 @@ import androidx.core.content.FileProvider import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import me.kavishdevar.librepods.R import me.kavishdevar.librepods.utils.LogCollector import java.io.File +import java.io.IOException import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -128,22 +133,16 @@ fun TroubleshootingScreen() { val savedLogs = remember { mutableStateListOf() } var isCollectingLogs by remember { mutableStateOf(false) } + val collectionAttempt = remember { mutableIntStateOf(0) } var showTroubleshootingSteps by remember { mutableStateOf(false) } var currentStep by remember { mutableIntStateOf(0) } - var logContent by remember { mutableStateOf("") } + var logPreview by remember { mutableStateOf(LogPreview()) } var selectedLogFile by remember { mutableStateOf(null) } var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteAllDialog by remember { mutableStateOf(false) } var isLoadingLogContent by remember { mutableStateOf(false) } var logContentLoaded by remember { mutableStateOf(false) } - LaunchedEffect(isCollectingLogs) { - while (isCollectingLogs) { - delay(250) - delay(250) - } - } - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) var showBottomSheet by remember { mutableStateOf(false) } @@ -170,10 +169,14 @@ fun TroubleshootingScreen() { contract = ActivityResultContracts.CreateDocument("text/plain") ) { uri -> if (uri != null) { + val fileToSave = selectedLogFile coroutineScope.launch(Dispatchers.IO) { try { - context.contentResolver.openOutputStream(uri)?.use { outputStream -> - outputStream.write(logContent.toByteArray()) + val source = fileToSave ?: throw IOException("No log file selected") + val output = context.contentResolver.openOutputStream(uri) + ?: throw IOException("Cannot open destination") + output.use { outputStream -> + source.inputStream().use { it.copyTo(outputStream) } } withContext(Dispatchers.Main) { Toast.makeText(context, "Log saved successfully", Toast.LENGTH_SHORT).show() @@ -204,8 +207,8 @@ fun TroubleshootingScreen() { fun openLogBottomSheet(file: File) { selectedLogFile = file - logContent = "" - isLoadingLogContent = false + logPreview = LogPreview() + isLoadingLogContent = true logContentLoaded = false showBottomSheet = true } @@ -432,8 +435,10 @@ fun TroubleshootingScreen() { onClick = { currentStep = 2 isCollectingLogs = true + val attempt = ++collectionAttempt.intValue coroutineScope.launch { + var connectionStopJob: Job? = null try { logCollector.clearLogs() @@ -441,24 +446,23 @@ fun TroubleshootingScreen() { logCollector.killBluetoothService() - withContext(Dispatchers.Main) { - delay(500) - currentStep = 3 - } + delay(500) + if (!isCollectingLogs || collectionAttempt.intValue != attempt) return@launch + currentStep = 3 val timestamp = SimpleDateFormat( "yyyyMMdd_HHmmss", Locale.US ).format(Date()) - logContent = + val logFile = logCollector.startLogCollection( - listener = { /* Removed live log display */ }, + fileName = "airpods_log_$timestamp.txt", connectionDetectedCallback = { - launch { + connectionStopJob = launch { delay(5000) withContext(Dispatchers.Main) { - if (isCollectingLogs) { + if (isCollectingLogs && collectionAttempt.intValue == attempt) { logCollector.stopLogCollection() currentStep = 4 isCollectingLogs = @@ -469,32 +473,35 @@ fun TroubleshootingScreen() { } ) - val logFile = - logCollector.saveLogToInternalStorage( - "airpods_log_$timestamp.txt", - logContent - ) - logFile?.let { + logFile.let { withContext(Dispatchers.Main) { savedLogs.add(0, it) - selectedLogFile = it + if (collectionAttempt.intValue == attempt) { + selectedLogFile = it + Toast.makeText( + context, + "Log saved: ${it.name}", + Toast.LENGTH_SHORT + ).show() + } + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + withContext(Dispatchers.Main) { + if (collectionAttempt.intValue == attempt) { Toast.makeText( context, - "Log saved: ${it.name}", + "Error collecting logs: ${e.message}", Toast.LENGTH_SHORT ).show() + isCollectingLogs = false + currentStep = 0 } } - } catch (e: Exception) { - withContext(Dispatchers.Main) { - Toast.makeText( - context, - "Error collecting logs: ${e.message}", - Toast.LENGTH_SHORT - ).show() - isCollectingLogs = false - currentStep = 0 - } + } finally { + connectionStopJob?.cancel() } } }, @@ -531,16 +538,19 @@ fun TroubleshootingScreen() { Button( onClick = { + val attempt = collectionAttempt.intValue coroutineScope.launch { logCollector.addLogMarker( LogCollector.LogMarkerType.CUSTOM, "Manual stop requested by user" ) delay(1000) + if (collectionAttempt.intValue != attempt) return@launch logCollector.stopLogCollection() delay(500) withContext(Dispatchers.Main) { + if (collectionAttempt.intValue != attempt) return@withContext currentStep = 4 isCollectingLogs = false Toast.makeText( @@ -760,17 +770,18 @@ fun TroubleshootingScreen() { ) { LaunchedEffect(selectedLogFile) { if (!logContentLoaded) { + isLoadingLogContent = true delay(300) - withContext(Dispatchers.IO) { - isLoadingLogContent = true - logContent = try { - selectedLogFile?.readText() ?: "" - } catch (e: Exception) { - "Error loading log content: ${e.message}" + val file = selectedLogFile + logPreview = withContext(Dispatchers.IO) { + try { + file?.let(::readLogPreview) ?: LogPreview() + } catch (e: IOException) { + LogPreview(listOf("Error loading log content: ${e.message}")) } - isLoadingLogContent = false - logContentLoaded = true } + isLoadingLogContent = false + logContentLoaded = true } } @@ -813,6 +824,14 @@ fun TroubleshootingScreen() { CircularProgressIndicator(color = accentColor) } } else { + if (logPreview.isTruncated) { + Text( + text = "Showing a shortened preview of recent log entries. Save or share for the complete log.", + color = textColor.copy(alpha = 0.7f), + fontSize = 12.sp, + modifier = Modifier.padding(bottom = 8.dp) + ) + } Box( modifier = Modifier .fillMaxWidth() @@ -823,23 +842,23 @@ fun TroubleshootingScreen() { ) ) { val horizontalScrollState = rememberScrollState() - val verticalScrollState = rememberScrollState() - Box( + LazyColumn( modifier = Modifier .fillMaxSize() .padding(8.dp) .horizontalScroll(horizontalScrollState) - .verticalScroll(verticalScrollState) ) { - Text( - text = logContent, - fontSize = 14.sp, - color = Color.LightGray, - lineHeight = 20.sp, - fontFamily = FontFamily.Monospace, - softWrap = false - ) + items(logPreview.lines) { line -> + Text( + text = line, + fontSize = 14.sp, + color = Color.LightGray, + lineHeight = 20.sp, + fontFamily = FontFamily.Monospace, + softWrap = false + ) + } } } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/UpdateHearingTestScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/UpdateHearingTestScreen.kt index 514f3943a..2660840d3 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/UpdateHearingTestScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/UpdateHearingTestScreen.kt @@ -62,12 +62,11 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import kotlinx.coroutines.Job import me.kavishdevar.librepods.R import me.kavishdevar.librepods.bluetooth.ATTHandles import me.kavishdevar.librepods.data.HearingAidSettings import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse -import me.kavishdevar.librepods.data.sendHearingAidSettings +import me.kavishdevar.librepods.data.encodeHearingAidSettings import me.kavishdevar.librepods.presentation.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem @@ -144,34 +143,19 @@ fun UpdateHearingTestScreen( mutableStateOf(FloatArray(8)) } - val debounceJob = remember { mutableStateOf(null) } val initialized = rememberSaveable { mutableStateOf(false) } - val hearingAidSettings = remember { - mutableStateOf( - HearingAidSettings( - leftEQ = leftEQ.value, - rightEQ = rightEQ.value, - leftAmplification = leftAmplification.floatValue, - rightAmplification = rightAmplification.floatValue, - leftTone = tone.floatValue, - rightTone = tone.floatValue, - leftConversationBoost = conversationBoostEnabled.value, - rightConversationBoost = conversationBoostEnabled.value, - leftAmbientNoiseReduction = ambientNoiseReduction.floatValue, - rightAmbientNoiseReduction = ambientNoiseReduction.floatValue, - netAmplification = leftAmplification.floatValue + rightAmplification.floatValue / 2, - balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2, - ownVoiceAmplification = ownVoiceAmplification.floatValue - ) - ) + val editor = rememberDeviceSettingsEditor> { (source, settings) -> + encodeHearingAidSettings(source, settings)?.let { data -> + setATTCharacteristicValue(ATTHandles.HEARING_AID, data) + } } LaunchedEffect(state.hearingAidData) { val parsed = parseHearingAidSettingsResponse(state.hearingAidData) - if (parsed != null) { - leftEQ.value = parsed.leftEQ.copyOf() - rightEQ.value = parsed.rightEQ.copyOf() + if (parsed != null) editor.applyDeviceUpdate { + if (!leftEQ.value.contentEquals(parsed.leftEQ)) leftEQ.value = parsed.leftEQ.copyOf() + if (!rightEQ.value.contentEquals(parsed.rightEQ)) rightEQ.value = parsed.rightEQ.copyOf() conversationBoostEnabled.value = parsed.leftConversationBoost tone.floatValue = parsed.leftTone ambientNoiseReduction.floatValue = parsed.leftAmbientNoiseReduction @@ -185,18 +169,9 @@ fun UpdateHearingTestScreen( } } - LaunchedEffect( - leftEQ.value, - rightEQ.value, - conversationBoostEnabled.value, - leftAmplification.floatValue, - rightAmplification.floatValue, - tone.floatValue, - ambientNoiseReduction.floatValue, - ownVoiceAmplification.floatValue - ) { - if (!initialized.value) return@LaunchedEffect - hearingAidSettings.value = HearingAidSettings( + fun sendUserEdit() { + if (!initialized.value) return + val settings = HearingAidSettings( leftEQ = leftEQ.value, rightEQ = rightEQ.value, leftAmplification = leftAmplification.floatValue, @@ -211,8 +186,7 @@ fun UpdateHearingTestScreen( balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2, ownVoiceAmplification = ownVoiceAmplification.floatValue ) - Log.d(TAG, "Updated settings: ${hearingAidSettings.value}") - sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, setATTCharacteristicValue) + editor.userEdited(state.hearingAidData.copyOf() to settings) } val frequencies = @@ -258,6 +232,7 @@ fun UpdateHearingTestScreen( val newArray = leftEQ.value.copyOf() newArray[index] = parsed leftEQ.value = newArray + sendUserEdit() Log.d(TAG, "Left EQ updated at index $index to $parsed") } }, @@ -277,6 +252,7 @@ fun UpdateHearingTestScreen( val newArray = rightEQ.value.copyOf() newArray[index] = parsed rightEQ.value = newArray + sendUserEdit() Log.d(TAG, "Right EQ updated at index $index to $parsed") } }, 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 c23da9e80..a29a45cf5 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 @@ -18,6 +18,7 @@ package me.kavishdevar.librepods.presentation.viewmodel +import android.bluetooth.BluetoothSocket import android.content.BroadcastReceiver import android.content.Context import android.content.Intent @@ -32,10 +33,14 @@ import androidx.core.content.edit import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.runInterruptible import me.kavishdevar.librepods.BuildConfig import me.kavishdevar.librepods.billing.BillingManager import me.kavishdevar.librepods.bluetooth.AACPManager @@ -198,6 +203,18 @@ class AirPodsViewModel( private set fun init(service: AirPodsService, controlRepo: ControlCommandRepository, sharedPreferences: SharedPreferences, appContext: Context) { + // The Activity can reconnect or be recreated while this ViewModel survives. + // Keep one set of observers for a service, and release the old set on replacement. + if (isReady && this.service === service) { + if (!isDemoMode) { + loadCurrentStatus() + loadATT() + enableATTNotifications() + loadEq() + } + return + } + clearObservers() this.service = service this.controlRepo = controlRepo this.sharedPreferences = sharedPreferences @@ -229,7 +246,13 @@ class AirPodsViewModel( private val xposedRemotePref = XposedRemotePrefProvider.create() - private lateinit var broadcastReceiver: BroadcastReceiver + private var broadcastReceiver: BroadcastReceiver? = null + private var preferenceListener: SharedPreferences.OnSharedPreferenceChangeListener? = null + private var billingJob: Job? = null + private var attLoadJob: Job? = null + private var attLoadSocket: BluetoothSocket? = null + private var attNotificationJob: Job? = null + @Volatile private var attNotificationSocket: BluetoothSocket? = null // private val _cameraAction = MutableStateFlow( // sharedPreferences.getString("camera_action", null) @@ -268,11 +291,27 @@ class AirPodsViewModel( } override fun onCleared() { + clearObservers() + super.onCleared() + } + + private fun clearObservers() { + billingJob?.cancel() + billingJob = null + clearATTJobs() listeners.forEach { (id, listener) -> controlRepo.remove(id, listener) } - service.aacpManager.customEqCallback = null - appContext.unregisterReceiver(broadcastReceiver) + listeners.clear() + if (::service.isInitialized) { + service.aacpManager.customEqCallback = null + service.attManager.setOnNotificationReceived(null) + } + preferenceListener?.let { sharedPreferences.unregisterOnSharedPreferenceChangeListener(it) } + preferenceListener = null + broadcastReceiver?.let { appContext.unregisterReceiver(it) } + broadcastReceiver = null + isReady = false } private fun loadName() { @@ -282,7 +321,8 @@ class AirPodsViewModel( private fun observeBilling() { if (isDemoMode) return - viewModelScope.launch { + billingJob?.cancel() + billingJob = viewModelScope.launch { BillingManager.provider.isPremium.collect { premium -> if (premium) { sharedPreferences.edit { @@ -305,6 +345,7 @@ class AirPodsViewModel( } private fun observeSharedPreferences() { + preferenceListener?.let { sharedPreferences.unregisterOnSharedPreferenceChangeListener(it) } val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> when (key) { "name" -> loadName() @@ -313,6 +354,7 @@ class AirPodsViewModel( "dynamic_end_of_charge", "foss_upgraded", "premium_expiry_time" -> loadSharedPreferences() } } + preferenceListener = listener sharedPreferences.registerOnSharedPreferenceChangeListener(listener) } @@ -325,9 +367,15 @@ class AirPodsViewModel( _uiState.update { it.copy(isLocallyConnected = true) } + loadATT() + enableATTNotifications() } AirPodsNotifications.AIRPODS_DISCONNECTED -> { + // A queued disconnect from the previous transport must not + // cancel observers already attached to its replacement. + if (BluetoothConnectionManager.aacpSocket?.isConnected == true) return + clearATTJobs() _uiState.update { it.copy(isLocallyConnected = false) } @@ -399,6 +447,7 @@ class AirPodsViewModel( } fun observeControl(identifier: ControlCommandIdentifiers) { + if (identifier in listeners) return val listener = controlRepo.observe(identifier) { value -> _uiState.update { state -> val current = state.controlStates[identifier] @@ -655,27 +704,81 @@ class AirPodsViewModel( } fun loadATT() { - val loudSoundReduction = service.attManager.getCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION) ?: byteArrayOf() - val loudSoundReductionEnabled = if (loudSoundReduction.isNotEmpty()) { - loudSoundReduction[0].toInt() == 1 - } else false - val hearingAidData = service.attManager.getCharacteristic(ATTHandles.HEARING_AID) ?: byteArrayOf() - val transparencyData = service.attManager.getCharacteristic(ATTHandles.TRANSPARENCY) ?: byteArrayOf() - _uiState.update { - it.copy( - loudSoundReductionEnabled = loudSoundReductionEnabled, - transparencyData = transparencyData, - hearingAidData = hearingAidData - ) + if (isDemoMode) return + val socket = BluetoothConnectionManager.attSocket?.takeIf { it.isConnected } ?: return + if (attLoadSocket === socket && attLoadJob?.isActive == true) return + attLoadJob?.cancel() + attLoadSocket = socket + val currentService = service + val attManager = currentService.attManager + attLoadJob = viewModelScope.launch { + val jobContext = currentCoroutineContext() + val values = runInterruptible(Dispatchers.IO) { + // Cache misses perform blocking ATT requests. Cancellation interrupts + // the wait, and each subsequent request still belongs to this socket. + fun read(handle: ATTHandles): ByteArray? { + jobContext.ensureActive() + if (service !== currentService || BluetoothConnectionManager.attSocket !== socket) return null + return attManager.getCharacteristic(handle) + } + Triple( + read(ATTHandles.LOUD_SOUND_REDUCTION), + read(ATTHandles.HEARING_AID), + read(ATTHandles.TRANSPARENCY) + ) + } + if (service !== currentService || BluetoothConnectionManager.attSocket !== socket || + !socket.isConnected || isDemoMode + ) return@launch + _uiState.update { + it.copy( + loudSoundReductionEnabled = values.first?.firstOrNull() == 0x01.toByte(), + hearingAidData = values.second ?: byteArrayOf(), + transparencyData = values.third ?: byteArrayOf() + ) + } } } - fun observeATT() { - viewModelScope.launch(Dispatchers.IO) { - service.attManager.enableNotification(ATTCCCDHandles.HEARING_AID) - service.attManager.enableNotification(ATTCCCDHandles.TRANSPARENCY) + private fun clearATTJobs() { + attLoadJob?.cancel() + attLoadJob = null + attLoadSocket = null + attNotificationJob?.cancel() + attNotificationJob = null + attNotificationSocket = null + } + + private fun enableATTNotifications() { + if (isDemoMode) return + val socket = BluetoothConnectionManager.attSocket?.takeIf { it.isConnected } ?: return + if (attNotificationSocket === socket) return + attNotificationJob?.cancel() + attNotificationSocket = socket + val currentService = service + val attManager = currentService.attManager + attNotificationJob = viewModelScope.launch { + val jobContext = currentCoroutineContext() + runInterruptible(Dispatchers.IO) { + for (handle in listOf(ATTCCCDHandles.HEARING_AID, ATTCCCDHandles.TRANSPARENCY)) { + jobContext.ensureActive() + if (service !== currentService || BluetoothConnectionManager.attSocket !== socket || !socket.isConnected) { + return@runInterruptible + } + attManager.enableNotification(handle) + } + } } - service.attManager.setOnNotificationReceived { handle, value -> + } + + fun observeATT() { + val currentService = service + val attManager = service.attManager + attManager.setOnNotificationReceived { handle, value -> + val socket = attNotificationSocket + if (service !== currentService || socket == null || + BluetoothConnectionManager.attSocket !== socket || !socket.isConnected || isDemoMode + ) return@setOnNotificationReceived when (handle) { ATTHandles.LOUD_SOUND_REDUCTION.value.toByte() -> { val loudSoundReductionEnabled = if (value.isNotEmpty()) { @@ -697,6 +800,7 @@ class AirPodsViewModel( } } } + enableATTNotifications() } fun setAutomaticEarDetectionEnabled(enabled: Boolean) { 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 ce9d80e03..9aa58c027 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 @@ -66,20 +66,24 @@ import android.view.View import android.widget.RemoteViews import android.widget.Toast import androidx.annotation.RequiresApi -import androidx.annotation.RequiresPermission import androidx.compose.material3.ExperimentalMaterial3Api import androidx.core.app.NotificationCompat import androidx.core.content.edit import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withContext import me.kavishdevar.librepods.BuildConfig import me.kavishdevar.librepods.MainActivity import me.kavishdevar.librepods.R @@ -131,11 +135,14 @@ import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_ import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD import java.nio.ByteBuffer import java.nio.ByteOrder +import java.io.Closeable +import java.io.IOException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import me.kavishdevar.librepods.bluetooth.shouldConnectAtt import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi -import kotlin.time.Duration.Companion.milliseconds private const val TAG = "AirPodsService" @@ -160,7 +167,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList lateinit var aacpManager: AACPManager lateinit var attManager: ATTManagerv2 var airpodsInstance: AirPodsInstance? = null - var cameraActive = false + var cameraActive = cameraOpen private var disconnectedBecauseReversed = false private var otherDeviceTookOver = false @@ -231,10 +238,26 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList private lateinit var telephonyManager: TelephonyManager private lateinit var phoneStateListener: TelephonyCallback - private val maxLogEntries = 1000 - private val inMemoryLogs = mutableSetOf() + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val serviceResources = CloseableResourceScope() + private val localMacLookupFinished = CountDownLatch(1) + private val inMemoryLogs = PacketLogBuffer() + private val packetLogWrites = Channel(Channel.CONFLATED) + private val packetHexFormat = HexFormat { upperCase = true; bytes.byteSeparator = " " } + private var lastBatterySnapshot: Pair>? = null + + private data class ConnectionNotificationState( + val name: String, + val batteries: List, + val canReconnect: Boolean + ) + private var lastNotificationState: ConnectionNotificationState? = null private var handleIncomingCallOnceConnected = false + private var showIslandReceiver: BroadcastReceiver? = null + private var a2dpConnectionStateReceiver: BroadcastReceiver? = null + private var phoneBatteryReceiverRegistered = false + private var ownershipResetJob: Job? = null lateinit var bleManager: BLEManager @@ -277,7 +300,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList caseLevel = caseLevel, caseCharging = caseCharging == true ) - updateBattery() + updateBattery(force = false) } override fun onBroadcastFromNewAddress(device: BLEManager.AirPodsStatus) { @@ -344,15 +367,15 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList caseLevel = caseLevel, caseCharging = caseCharging == true ) - updateBattery() + updateBattery(force = false) Log.d(TAG, "Battery changed") } override fun onDeviceDisappeared() { Log.d(TAG, "All disappeared") - updateNotificationContent( - false - ) + if (BluetoothConnectionManager.aacpSocket?.isConnected != true) { + updateNotificationContent(false) + } } } @@ -374,10 +397,20 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList sharedPreferencesLogs = getSharedPreferences("packet_logs", MODE_PRIVATE) - inMemoryLogs.addAll( - sharedPreferencesLogs.getStringSet(packetLogKey, emptySet()) ?: emptySet() - ) - _packetLogsFlow.value = inMemoryLogs.toSet() + sharedPreferencesLogs.getStringSet(packetLogKey, emptySet())?.forEach(inMemoryLogs::add) + _packetLogsFlow.value = inMemoryLogs.snapshot() + serviceScope.launch { + for (ignored in packetLogWrites) { + // Coalesce bursts instead of copying and serializing the entire history per packet. + delay(1000) + synchronized(inMemoryLogs) { + while (packetLogWrites.tryReceive().isSuccess) { /* merged into this snapshot */ } + val snapshot = inMemoryLogs.snapshot() + _packetLogsFlow.value = snapshot + sharedPreferencesLogs.edit { putStringSet(packetLogKey, snapshot) } + } + } + } sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE) initializeConfig() @@ -389,41 +422,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList sharedPreferences.registerOnSharedPreferenceChangeListener(this) - localMac = config.selfMacAddress - if (localMac.isEmpty()) { - if (checkSelfPermission("android.permission.LOCAL_MAC_ADDRESS") == PackageManager.PERMISSION_GRANTED) { - val bluetoothManager = getSystemService(BluetoothManager::class.java) - val bluetoothAdapter = bluetoothManager.adapter - localMac = bluetoothAdapter.address - } else { - localMac = try { - val process = Runtime.getRuntime().exec( - arrayOf("su", "-c", "settings get secure bluetooth_address") - ) - - val exitCode = process.waitFor() - - if (exitCode == 0) { - process.inputStream.bufferedReader().use { it.readLine()?.trim().orEmpty() } - } else { - "" - } - } catch (e: Exception) { - Log.e( - TAG, - "Error retrieving local MAC address: ${e.message}. We probably aren't rooted." - ) - "" - } - } - config.selfMacAddress = localMac - sharedPreferences.edit { - putString("self_mac_address", localMac) - } - } - ServiceManager.setService(this) startForegroundNotification() + initializeLocalMac() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { initGestureDetector() } else { @@ -583,7 +584,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList MediaController.initialize( audioManager, this@AirPodsService.getSharedPreferences( "settings", MODE_PRIVATE - ) + ), + owner = this@AirPodsService ) // Log.d(TAG, "Initializing CrossDevice") // CoroutineScope(Dispatchers.IO).launch { @@ -602,7 +604,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList val leAvailableForAudio = bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true // if ((CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) || leAvailableForAudio) CoroutineScope(Dispatchers.IO).launch { - if (leAvailableForAudio) runBlocking { + if (leAvailableForAudio) serviceScope.launch { takeOver("call") } if (config.headGestures) { @@ -614,9 +616,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList val leAvailableForAudio = bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true // if ((CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) || leAvailableForAudio) CoroutineScope( - if (leAvailableForAudio) CoroutineScope( - Dispatchers.IO - ).launch { + if (leAvailableForAudio) serviceScope.launch { takeOver("call") } isInCall = true @@ -633,20 +633,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList telephonyManager.registerTelephonyCallback(mainExecutor, phoneStateListener) } - if (config.showPhoneBatteryInWidget) { - widgetMobileBatteryEnabled = true - val batteryChangedIntentFilter = IntentFilter(Intent.ACTION_BATTERY_CHANGED) - batteryChangedIntentFilter.addAction(AirPodsNotifications.DISCONNECT_RECEIVERS) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver( - BatteryChangedIntentReceiver, batteryChangedIntentFilter, RECEIVER_EXPORTED - ) - } else { - @Suppress("UnspecifiedRegisterReceiverFlag") registerReceiver( - BatteryChangedIntentReceiver, batteryChangedIntentFilter - ) - } - } + widgetMobileBatteryEnabled = config.showPhoneBatteryInWidget + updatePhoneBatteryReceiver() val serviceIntentFilter = IntentFilter().apply { addAction("android.bluetooth.device.action.ACL_CONNECTED") addAction("android.bluetooth.device.action.ACL_DISCONNECTED") @@ -678,7 +666,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList // Log.d("AirPodsCrossDevice", CrossDevice.isAvailable.toString()) // if (!CrossDevice.isAvailable) { Log.d(TAG, "${config.deviceName} connected") - CoroutineScope(Dispatchers.IO).launch { + serviceScope.launch { val bluetoothManager = getSystemService(BluetoothManager::class.java) connectToSocket(bluetoothManager.adapter, device!!) } @@ -692,17 +680,24 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList // } } else if (intent?.action == AirPodsNotifications.AIRPODS_DISCONNECTED) { - device = null -// isConnectedLocally = false - popupShown = false - updateNotificationContent(false) - aacpManager.disconnected() - BluetoothConnectionManager.aacpSocket = null - BluetoothConnectionManager.attSocket = null + synchronized(BluetoothConnectionManager) { + if (BluetoothConnectionManager.aacpSocket?.isConnected == true) { + Log.d(TAG, "Ignoring a stale disconnect broadcast after reconnection") + return + } + device = null +// isConnectedLocally = false + popupShown = false + updateNotificationContent(false) + aacpManager.disconnected() + BluetoothConnectionManager.aacpSocket = null + BluetoothConnectionManager.attSocket = null + clearA2dpConnectionReceiver() + } } } } - val showIslandReceiver = object : BroadcastReceiver() { + showIslandReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action == "me.kavishdevar.librepods.cross_device_island") { showIsland( @@ -713,19 +708,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList .find { it.component == BatteryComponent.RIGHT }?.level!! ) ) - } else if (intent?.action == AirPodsNotifications.DISCONNECT_RECEIVERS) { - try { - context?.unregisterReceiver(this) - } catch (e: Exception) { - e.printStackTrace() - } } } } val showIslandIntentFilter = IntentFilter().apply { addAction("me.kavishdevar.librepods.cross_device_island") - addAction(AirPodsNotifications.DISCONNECT_RECEIVERS) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { @@ -753,39 +741,22 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter - bluetoothAdapter.bondedDevices.forEach { device -> - device.fetchUuidsWithSdp() - if (device.uuids != null) { - if (device.uuids.contains(ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a"))) { - bluetoothAdapter.getProfileProxy( - this, object : BluetoothProfile.ServiceListener { - @SuppressLint("NewApi") - override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { - if (profile == BluetoothProfile.A2DP) { - val connectedDevices = proxy.connectedDevices - if (connectedDevices.isNotEmpty()) { -// if (!CrossDevice.isAvailable) { - CoroutineScope(Dispatchers.IO).launch { - connectToSocket(bluetoothAdapter, device) - } - setMetadatas(device) - macAddress = device.address - sharedPreferences.edit { - putString("mac_address", macAddress) - } -// } - sendBroadcast( - Intent(AirPodsNotifications.AIRPODS_CONNECTED).apply { - setPackage(packageName) - }) - } - } - bluetoothAdapter.closeProfileProxy(profile, proxy) - } - - override fun onServiceDisconnected(profile: Int) {} - }, BluetoothProfile.A2DP - ) + val aacpUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") + val knownAirPods = bluetoothAdapter.bondedDevices.filter { candidate -> + val uuids = candidate.uuids + if (uuids == null) candidate.fetchUuidsWithSdp() + uuids?.contains(aacpUuid) == true + } + if (knownAirPods.isNotEmpty()) { + // Query the profile once, instead of opening one proxy per bonded device. + withAudioProfile(this, BluetoothProfile.A2DP) { proxy -> + val connectedDevices = proxy.connectedDevices + knownAirPods.filter { it in connectedDevices }.forEach { connectedDevice -> + serviceScope.launch { connectToSocket(bluetoothAdapter, connectedDevice) } + setMetadatas(connectedDevice) + macAddress = connectedDevice.address + sharedPreferences.edit { putString("mac_address", macAddress) } + sendBroadcast(Intent(AirPodsNotifications.AIRPODS_CONNECTED).setPackage(packageName)) } } } @@ -794,13 +765,71 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList // clearPacketLogs() // } - CoroutineScope(Dispatchers.IO).launch { + serviceScope.launch { bleManager.startScanning() } } + @SuppressLint("HardwareIds", "MissingPermission") + private fun initializeLocalMac() { + localMac = config.selfMacAddress + if (localMac.isNotEmpty()) { + localMacLookupFinished.countDown() + return + } + if (checkSelfPermission("android.permission.LOCAL_MAC_ADDRESS") == PackageManager.PERMISSION_GRANTED) { + localMac = getSystemService(BluetoothManager::class.java).adapter.address + config.selfMacAddress = localMac + sharedPreferences.edit { putString("self_mac_address", localMac) } + localMacLookupFinished.countDown() + return + } + + serviceScope.launch { + var ownedProcess: Closeable? = null + try { + val process = ProcessBuilder("su", "-c", "settings get secure bluetooth_address") + .redirectErrorStream(true) + .start() + val resource = Closeable { + process.destroy() + runCatching { process.inputStream.close() } + runCatching { process.errorStream.close() } + runCatching { process.outputStream.close() } + } + ownedProcess = resource + serviceResources.track(resource) + val resolved = if (process.waitFor(5, TimeUnit.SECONDS) && process.exitValue() == 0) { + process.inputStream.bufferedReader().use { it.readLine()?.trim().orEmpty() } + } else "" + if (BluetoothAdapter.checkBluetoothAddress(resolved)) { + withContext(Dispatchers.Main) { + serviceResources.whileOpen { + localMac = resolved + config.selfMacAddress = resolved + sharedPreferences.edit { putString("self_mac_address", resolved) } + } + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (!serviceResources.isClosed) { + Log.w(TAG, "Could not resolve the local Bluetooth address: ${e.message}") + } + } finally { + ownedProcess?.let { + runCatching { it.close() } + serviceResources.release(it) + } + localMacLookupFinished.countDown() + } + }.invokeOnCompletion { localMacLookupFinished.countDown() } + } + @Suppress("unused") fun cameraOpened() { + if (cameraActive || serviceResources.isClosed) return Log.d(TAG, "Camera opened, gonna handle stem presses and take action if visible") cameraActive = true setupStemActions() @@ -808,6 +837,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList @Suppress("unused") fun cameraClosed() { + if (!cameraActive || serviceResources.isClosed) return cameraActive = false setupStemActions() } @@ -859,17 +889,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList @SuppressLint("MissingPermission") override fun onBatteryInfoReceived(batteryInfo: ByteArray) { batteryNotification.setBattery(batteryInfo) - sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply { - putParcelableArrayListExtra("data", ArrayList(batteryNotification.getBattery())) - setPackage(packageName) - }) - updateBattery() - updateNotificationContent( - true, - this@AirPodsService.getSharedPreferences("settings", MODE_PRIVATE) - .getString("name", device?.name), - batteryNotification.getBattery() - ) + updateBattery(force = false) // CrossDevice.sendRemotePacket(batteryInfo) // CrossDevice.batteryBytes = batteryInfo @@ -937,9 +957,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList override fun onOwnershipChangeReceived(owns: Boolean) { if (!owns) { MediaController.recentlyLostOwnership = true - Handler(Looper.getMainLooper()).postDelayed({ + ownershipResetJob?.cancel() + ownershipResetJob = serviceScope.launch(Dispatchers.Main) { + delay(3000) MediaController.recentlyLostOwnership = false - }, 3000) + } Log.d(TAG, "ownership lost") MediaController.sendPause() MediaController.pausedForOtherDevice = true @@ -971,6 +993,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList if (reasonReverseTapped) { Log.d(TAG, "reverse tapped, disconnecting audio") disconnectedBecauseReversed = true + sendBatteryNotification() disconnectAudio(this@AirPodsService, device) showIsland( this@AirPodsService, @@ -1272,6 +1295,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList MediaController.userPlayedTheMedia = true } } else if (newInEarData == listOf(false, false)) { + serviceScope.launch(Dispatchers.Main.immediate) { clearA2dpConnectionReceiver() } MediaController.sendPause(force = true) if (config.disconnectWhenNotWearing) { disconnectAudio(this@AirPodsService, device) @@ -1314,42 +1338,58 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } } + private fun clearA2dpConnectionReceiver() { + a2dpConnectionStateReceiver?.let { receiver -> + a2dpConnectionStateReceiver = null + runCatching { unregisterReceiver(receiver) } + } + } + private fun registerA2dpConnectionReceiver() { - val a2dpConnectionStateReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (intent.action == "android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED") { - val state = intent.getIntExtra( - BluetoothProfile.EXTRA_STATE, BluetoothProfile.STATE_DISCONNECTED - ) - val previousState = intent.getIntExtra( - BluetoothProfile.EXTRA_PREVIOUS_STATE, BluetoothProfile.STATE_DISCONNECTED - ) - val device = - intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) + serviceScope.launch(Dispatchers.Main.immediate) { + clearA2dpConnectionReceiver() + if (serviceResources.isClosed) return@launch + val expectedAddress = device?.address ?: return@launch + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (a2dpConnectionStateReceiver !== this || serviceResources.isClosed) return + if (intent.action == "android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED") { + val state = intent.getIntExtra( + BluetoothProfile.EXTRA_STATE, BluetoothProfile.STATE_DISCONNECTED + ) + val previousState = intent.getIntExtra( + BluetoothProfile.EXTRA_PREVIOUS_STATE, BluetoothProfile.STATE_DISCONNECTED + ) + val device = + intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) - Log.d( - "MediaController", - "A2DP state changed: $previousState -> $state for device: ${device?.address}" - ) + Log.d( + "MediaController", + "A2DP state changed: $previousState -> $state for device: ${device?.address}" + ) - if (state == BluetoothProfile.STATE_CONNECTED && previousState != BluetoothProfile.STATE_CONNECTED && device?.address == this@AirPodsService.device?.address) { + if (state == BluetoothProfile.STATE_CONNECTED && previousState != BluetoothProfile.STATE_CONNECTED && + device?.address == expectedAddress && expectedAddress == this@AirPodsService.device?.address) { - Log.d("MediaController", "A2DP connected, sending play command") - MediaController.sendPlay() - MediaController.iPausedTheMedia = false + clearA2dpConnectionReceiver() + Log.d("MediaController", "A2DP connected, sending play command") + MediaController.sendPlay() + MediaController.iPausedTheMedia = false - context.unregisterReceiver(this) + } } } } - } - val a2dpIntentFilter = - IntentFilter("android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED") - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver(a2dpConnectionStateReceiver, a2dpIntentFilter, RECEIVER_EXPORTED) - } else { - registerReceiver(a2dpConnectionStateReceiver, a2dpIntentFilter) + val a2dpIntentFilter = + IntentFilter("android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED") + a2dpConnectionStateReceiver = receiver + try { + registerReceiver(receiver, a2dpIntentFilter, RECEIVER_EXPORTED) + } catch (e: Exception) { + a2dpConnectionStateReceiver = null + Log.w(TAG, "Could not observe the A2DP connection", e) + } } } @@ -1465,7 +1505,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList if (preferences == null || key == null) return when (key) { - "name" -> config.deviceName = preferences.getString(key, "AirPods") ?: "AirPods" + "name" -> { + config.deviceName = preferences.getString(key, "AirPods") ?: "AirPods" + sendBatteryNotification() + } "mac_address" -> macAddress = preferences.getString(key, "") ?: "" "automatic_ear_detection" -> config.earDetectionEnabled = preferences.getBoolean(key, true) @@ -1476,6 +1519,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList "show_phone_battery_in_widget" -> { config.showPhoneBatteryInWidget = preferences.getBoolean(key, true) widgetMobileBatteryEnabled = config.showPhoneBatteryInWidget + updatePhoneBatteryReceiver() updateBattery() } @@ -1598,31 +1642,13 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } private fun logPacket(packet: ByteArray, @Suppress("SameParameterValue") source: String) { - val packetHex = packet.joinToString(" ") { "%02X".format(it) } + val packetHex = packet.toHexString(packetHexFormat) + Log.d("AirPodsData", "Data received: $packetHex") val logEntry = "$source: $packetHex" synchronized(inMemoryLogs) { - inMemoryLogs.add(logEntry) - if (inMemoryLogs.size > maxLogEntries) { - inMemoryLogs.iterator().next().let { - inMemoryLogs.remove(it) - } - } - - _packetLogsFlow.value = inMemoryLogs.toSet() - } - - CoroutineScope(Dispatchers.IO).launch { - val logs = - sharedPreferencesLogs.getStringSet(packetLogKey, mutableSetOf())?.toMutableSet() - ?: mutableSetOf() - logs.add(logEntry) - - if (logs.size > maxLogEntries) { - val toKeep = logs.toList().takeLast(maxLogEntries).toSet() - sharedPreferencesLogs.edit { putStringSet(packetLogKey, toKeep) } - } else { - sharedPreferencesLogs.edit { putStringSet(packetLogKey, logs) } + if (inMemoryLogs.add(logEntry)) { + packetLogWrites.trySend(Unit) } } } @@ -1631,8 +1657,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList synchronized(inMemoryLogs) { inMemoryLogs.clear() _packetLogsFlow.value = emptySet() + sharedPreferencesLogs.edit { remove(packetLogKey) } } - sharedPreferencesLogs.edit { remove(packetLogKey) } } fun clearLogs() { @@ -1656,7 +1682,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList var popupShown = false + private var popupWindow: PopupWindow? = null fun showPopup(service: Service, name: String) { + if (serviceResources.isClosed) return if (!sharedPreferences.getBoolean("show_bottom_sheet_popup", true)) { return } @@ -1664,13 +1692,15 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList Log.d(TAG, "No permission for SYSTEM_ALERT_WINDOW") return } - if (popupShown) { - return + serviceScope.launch(Dispatchers.Main.immediate) { + if (popupShown || serviceResources.isClosed) return@launch + popupWindow?.close() + val window = PopupWindow(service.applicationContext) + popupWindow = window + val model = airpodsInstance?.model ?: AirPodsModels.getModelByModelNumber(config.airpodsModelNumber) + window.open(name, batteryNotification, model?.connectionArtworkRes) + popupShown = true } - val popupWindow = PopupWindow(service.applicationContext) - val model = airpodsInstance?.model ?: AirPodsModels.getModelByModelNumber(config.airpodsModelNumber) - popupWindow.open(name, batteryNotification, model?.connectionArtworkRes) - popupShown = true } var islandOpen = false @@ -1692,7 +1722,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList Log.d(TAG, "No permission for SYSTEM_ALERT_WINDOW") return } - CoroutineScope(Dispatchers.Main).launch { + serviceScope.launch(Dispatchers.Main.immediate) { + if (serviceResources.isClosed) return@launch + islandWindow?.close() islandWindow = IslandWindow(service.applicationContext) islandWindow!!.show( sharedPreferences.getString("name", "AirPods Pro").toString(), @@ -1717,23 +1749,27 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList // var isConnectedLocally = false var device: BluetoothDevice? = null - private lateinit var earReceiver: BroadcastReceiver var widgetMobileBatteryEnabled = false - object BatteryChangedIntentReceiver : BroadcastReceiver() { + private val phoneBatteryReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent) { - if (intent.action == Intent.ACTION_BATTERY_CHANGED) { - ServiceManager.getService()?.updateBattery() - } else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) { - try { - context?.unregisterReceiver(this) - } catch (e: Exception) { - e.printStackTrace() - } + if (intent.action == Intent.ACTION_BATTERY_CHANGED && !serviceResources.isClosed) { + // A phone battery event does not change earbud metadata or notifications. + updateBatteryWidget() } } } + private fun updatePhoneBatteryReceiver() { + if (widgetMobileBatteryEnabled && !phoneBatteryReceiverRegistered && !serviceResources.isClosed) { + registerReceiver(phoneBatteryReceiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED), RECEIVER_NOT_EXPORTED) + phoneBatteryReceiverRegistered = true + } else if (!widgetMobileBatteryEnabled && phoneBatteryReceiverRegistered) { + unregisterReceiver(phoneBatteryReceiver) + phoneBatteryReceiverRegistered = false + } + } + @OptIn(ExperimentalMaterial3Api::class) fun startForegroundNotification() { val disconnectedNotificationChannel = NotificationChannel( @@ -1837,11 +1873,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList }) } - @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) fun sendBatteryNotification() { updateNotificationContent( true, - getSharedPreferences("settings", MODE_PRIVATE).getString("name", device?.name), + config.deviceName, batteryNotification.getBattery() ) } @@ -1901,6 +1936,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList val componentName = ComponentName(this, BatteryWidget::class.java) val widgetIds = appWidgetManager.getAppWidgetIds(componentName) + if (widgetIds.isEmpty()) return + val remoteViews = RemoteViews(packageName, R.layout.battery_widget).also { it -> val openActivityIntent = PendingIntent.getActivity( this, @@ -1976,17 +2013,22 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList @SuppressLint("MissingPermission") @OptIn(ExperimentalMaterial3Api::class) - fun updateBattery() { + @Synchronized + fun updateBattery(force: Boolean = true) { + val snapshot = device?.address to batteryNotification.getBattery().toList() + if (!force && snapshot == lastBatterySnapshot) return setBatteryMetadata() updateBatteryWidget() sendBatteryBroadcast() sendBatteryNotification() + lastBatterySnapshot = snapshot } fun updateNoiseControlWidget() { val appWidgetManager = AppWidgetManager.getInstance(this) val componentName = ComponentName(this, NoiseControlWidget::class.java) val widgetIds = appWidgetManager.getAppWidgetIds(componentName) + if (widgetIds.isEmpty()) return val remoteViews = RemoteViews(packageName, R.layout.noise_control_widget).also { it -> val ancStatus = ancNotification.status val allowOffModeValue = @@ -2038,11 +2080,26 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } @OptIn(ExperimentalMaterial3Api::class) + @Synchronized fun updateNotificationContent( connected: Boolean, airpodsName: String? = null, batteryList: List? = null ) { val notificationManager = getSystemService(NotificationManager::class.java) + if (!connected) { + lastNotificationState = null + lastBatterySnapshot = null + notificationManager.cancel(2) + return + } + if (BluetoothConnectionManager.aacpSocket == null) return + val state = ConnectionNotificationState( + airpodsName ?: config.deviceName, + batteryList?.toList().orEmpty(), + disconnectedBecauseReversed + ) + if (BluetoothConnectionManager.aacpSocket?.isConnected == true && state == lastNotificationState) return + val notificationIntent = Intent(this, MainActivity::class.java) val pendingIntent = PendingIntent.getActivity( this, @@ -2100,6 +2157,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList notificationManager.notify(2, updatedNotification) notificationManager.cancel(1) + lastNotificationState = state } else if (!connected) { notificationManager.cancel(2) } else if (!config.bleOnlyMode && BluetoothConnectionManager.aacpSocket?.isConnected != true) { @@ -2449,6 +2507,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList if (intent?.action == "me.kavishdevar.librepods.RECONNECT_AFTER_REVERSE") { Log.d(TAG, "reconnect after reversed received, taking over") disconnectedBecauseReversed = false + sendBatteryNotification() otherDeviceTookOver = false takeOver("music", manualTakeOverAfterReversed = true) } @@ -2501,6 +2560,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList if (manualTakeOverAfterReversed) { Log.d(TAG, "forcefully taking over despite reverse as user requested") disconnectedBecauseReversed = false + sendBatteryNotification() } else { Log.d( TAG, @@ -2535,16 +2595,17 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList IslandType.CONNECTED ) - CoroutineScope(Dispatchers.IO).launch { + serviceScope.launch { delay(500) // a2dp takes time, and so does taking control + AirPods pause it for no reason after connecting if (takingOverFor == "music") { Log.d(TAG, "Resuming music after taking control") MediaController.sendPlay(replayWhenPaused = true) } else if (startHeadTrackingAgain) { Log.d(TAG, "Starting head tracking again after taking control") - Handler(Looper.getMainLooper()).postDelayed({ + launch(Dispatchers.Main) { + delay(500) startHeadTracking() - }, 500) + } } delay(1000) // should ideally have a callback when it's taken over because for some reason android doesn't dispatch when it's paused if (takingOverFor == "music") { @@ -2654,8 +2715,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList fun connectToSocket( adapter: BluetoothAdapter, device: BluetoothDevice, manual: Boolean = false ) { - if (!connectionInProgress.compareAndSet(false, true)) { - Log.d(TAG, "Connection already in progress; ignoring duplicate request") + if (serviceResources.isClosed || !connectionInProgress.compareAndSet(false, true)) { + Log.d(TAG, "Connection already in progress or service stopped; ignoring request") + return + } + if (Looper.myLooper() == Looper.getMainLooper()) { + serviceScope.launch { + connectToSocketOnce(adapter, device, manual) + }.invokeOnCompletion { connectionInProgress.set(false) } return } try { @@ -2669,234 +2736,208 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList private fun connectToSocketOnce( adapter: BluetoothAdapter, device: BluetoothDevice, manual: Boolean ) { - if (BluetoothConnectionManager.aacpSocket != null && BluetoothConnectionManager.aacpSocket?.isConnected == true) return + if (serviceResources.isClosed || BluetoothConnectionManager.aacpSocket?.isConnected == true) return + // This method only runs off the main thread. Preserve MAC-before-handshake ordering, + // while a denied root prompt cannot delay foreground-service startup or block forever. + localMacLookupFinished.await(5500, TimeUnit.MILLISECONDS) + if (serviceResources.isClosed) return Log.d(TAG, " Connecting to socket") - val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") -// if (!isConnectedLocally) { - val socket = try { - createBluetoothSocket(adapter, device, uuid, 4097) - } catch (e: Exception) { - Log.e(TAG, "Failed to create BluetoothSocket: ${e.message}") - showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.localizedMessage}") - return - } + val attempt = CloseableResourceScope() + var socket: BluetoothSocket? = null var pendingAttSocket: BluetoothSocket? = null - fun closeFailedAttempt() { - runCatching { pendingAttSocket?.close() } - runCatching { socket.close() } - if (BluetoothConnectionManager.aacpSocket === socket) { - BluetoothConnectionManager.aacpSocket = null - } - if (BluetoothConnectionManager.attSocket === pendingAttSocket) { - BluetoothConnectionManager.attSocket = null + var handedToReader = false + val connectionPhaseFinished = AtomicBoolean(false) + var timeoutJob: kotlinx.coroutines.Job? = null + + fun closeAttempt(notifyDisconnect: Boolean = false) { + // Potentially blocking close stays outside the publication lock. + attempt.close() + serviceResources.release(attempt) + synchronized(BluetoothConnectionManager) { + if (socket != null && BluetoothConnectionManager.aacpSocket === socket) { + BluetoothConnectionManager.aacpSocket = null + if (notifyDisconnect && !serviceResources.isClosed) { + // Check ownership and clear shared state atomically with publication. + // A late old reader must never clear a replacement connection's state. + aacpManager.disconnected() + updateNotificationContent(false) + sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { + setPackage(packageName) + }) + } + } + if (pendingAttSocket != null && BluetoothConnectionManager.attSocket === pendingAttSocket) { + BluetoothConnectionManager.attSocket = null + } } } - try { - runBlocking { - withTimeout(5000.milliseconds) { - try { - socket.connect() - this@AirPodsService.device = device - val xposedRemotePref = XposedRemotePrefProvider.create() - val knownModel = airpodsInstance?.model - ?: AirPodsModels.getModelByModelNumber(config.airpodsModelNumber) - val useAtt = shouldConnectAtt( - knownModel, xposedRemotePref.getBoolean("vendor_id_hook", false) - ) - Log.d(TAG, "ATT connection required: $useAtt (model=${knownModel?.name ?: "unknown"})") - val attSocket = if (useAtt) { - createBluetoothSocket( - adapter, - device, - ParcelUuid.fromString("00000000-0000-0000-0000-000000000000"), - 31 - ) - } else null - pendingAttSocket = attSocket - attSocket?.connect() - - // The reader and characteristic requests obtain this socket - // from the manager, so publish it before starting either. - BluetoothConnectionManager.aacpSocket = socket - BluetoothConnectionManager.attSocket = attSocket - - if (attSocket != null) { - attManager.startReader() - attManager.readCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION) - attManager.readCharacteristic(ATTHandles.TRANSPARENCY) - attManager.readCharacteristic(ATTHandles.HEARING_AID) - } - - // Create AirPodsInstance from stored config if available - if (airpodsInstance == null && config.airpodsModelNumber.isNotEmpty()) { - val model = - AirPodsModels.getModelByModelNumber(config.airpodsModelNumber) - if (model != null) { - airpodsInstance = AirPodsInstance( - name = config.airpodsName, - model = model, - actualModelNumber = config.airpodsModelNumber, - serialNumber = config.airpodsSerialNumber, - leftSerialNumber = config.airpodsLeftSerialNumber, - rightSerialNumber = config.airpodsRightSerialNumber, - version1 = config.airpodsVersion1, - version2 = config.airpodsVersion2, - version3 = config.airpodsVersion3, - ) - setMetadatas(device) - } - } + fun isCurrentConnection(): Boolean = + !serviceResources.isClosed && !attempt.isClosed && + BluetoothConnectionManager.aacpSocket === socket - updateNotificationContent( - true, config.deviceName, batteryNotification.getBattery() - ) - Log.d(TAG, " Socket connected") - sharedPreferences.edit { putBoolean("connection_successful", true) } - if (!sharedPreferences.contains("first_connection_successful_time")) { - sharedPreferences.edit { - putLong( - "first_connection_successful_time", - System.currentTimeMillis() - ) - } - } - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_L2CAP_CONNECTED).apply { - setPackage(packageName) - }) - } catch (e: Exception) { - closeFailedAttempt() -// sharedPreferences.edit { putBoolean("connection_successful", false) } - Log.d( - TAG, " Socket not connected, ${e.message}" - ) - if (manual) { - sendToast( - "Couldn't connect to socket: ${e.localizedMessage}" - ) - } else { - showSocketConnectionFailureNotification("Couldn't connect to socket: ${e.localizedMessage}") - } - return@withTimeout -// throw e // lol how did i not catch this before... gonna comment this line instead of removing to preserve history - } + try { + serviceResources.track(attempt) + timeoutJob = serviceScope.launch { + delay(5000) + if (connectionPhaseFinished.compareAndSet(false, true)) { + // BluetoothSocket.connect() is blocking. Closing it from another worker + // aborts the call; a coroutine timeout alone cannot interrupt it. + attempt.close() + } + } + val connectedSocket = attempt.track(createBluetoothSocket( + adapter, device, ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a"), 4097 + )) + socket = connectedSocket + connectedSocket.connect() + attempt.ensureOpen() + + val knownModel = airpodsInstance?.model + ?: AirPodsModels.getModelByModelNumber(config.airpodsModelNumber) + val useAtt = shouldConnectAtt( + knownModel, XposedRemotePrefProvider.create().getBoolean("vendor_id_hook", false) + ) + Log.d(TAG, "ATT connection required: $useAtt (model=${knownModel?.name ?: "unknown"})") + val attSocket = if (useAtt) { + attempt.track(createBluetoothSocket( + adapter, device, ParcelUuid.fromString("00000000-0000-0000-0000-000000000000"), 31 + )).also { + pendingAttSocket = it + it.connect() } + } else null + if (!connectionPhaseFinished.compareAndSet(false, true)) { + throw IOException("Bluetooth socket connection timed out") + } + timeoutJob.cancel() + attempt.ensureOpen() + + // End the deadline before the three optional ATT reads, each of which has its + // own timeout. A slow characteristic must not tear down connected sockets. + serviceResources.whileOpen { + synchronized(BluetoothConnectionManager) { + attempt.ensureOpen() + BluetoothConnectionManager.aacpSocket = connectedSocket + BluetoothConnectionManager.attSocket = attSocket + this@AirPodsService.device = device + } + } + if (attSocket != null) { + attManager.startReader() + attManager.readCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION) + attManager.readCharacteristic(ATTHandles.TRANSPARENCY) + attManager.readCharacteristic(ATTHandles.HEARING_AID) } - if (!socket.isConnected) { - closeFailedAttempt() - Log.d(TAG, " socket not connected") - if (manual) { - sendToast( - "Couldn't connect to socket: timeout." + attempt.ensureOpen() + serviceResources.ensureOpen() + + if (airpodsInstance == null && config.airpodsModelNumber.isNotEmpty()) { + AirPodsModels.getModelByModelNumber(config.airpodsModelNumber)?.let { model -> + airpodsInstance = AirPodsInstance( + name = config.airpodsName, + model = model, + actualModelNumber = config.airpodsModelNumber, + serialNumber = config.airpodsSerialNumber, + leftSerialNumber = config.airpodsLeftSerialNumber, + rightSerialNumber = config.airpodsRightSerialNumber, + version1 = config.airpodsVersion1, + version2 = config.airpodsVersion2, + version3 = config.airpodsVersion3, ) - } else { - showSocketConnectionFailureNotification("Couldn't connect to socket: Timeout") + setMetadatas(device) + } + } + if (!isCurrentConnection()) return + updateNotificationContent(true, config.deviceName, batteryNotification.getBattery()) + Log.d(TAG, " Socket connected") + sharedPreferences.edit { + putBoolean("connection_successful", true) + if (!sharedPreferences.contains("first_connection_successful_time")) { + putLong("first_connection_successful_time", System.currentTimeMillis()) } - return } - this@AirPodsService.device = device - BluetoothConnectionManager.aacpSocket?.let { - aacpManager.sendPacket(aacpManager.createHandshakePacket()) - aacpManager.sendSetFeatureFlagsPacket() - aacpManager.sendNotificationRequest() - Log.d(TAG, "Requesting proximity keys") - aacpManager.sendRequestProximityKeys((AACPManager.Companion.ProximityKeyType.IRK.value + AACPManager.Companion.ProximityKeyType.ENC_KEY.value).toByte()) - CoroutineScope(Dispatchers.IO).launch { + sendBroadcast(Intent(AirPodsNotifications.AIRPODS_L2CAP_CONNECTED).apply { + setPackage(packageName) + }) + aacpManager.sendPacket(aacpManager.createHandshakePacket()) + aacpManager.sendSetFeatureFlagsPacket() + aacpManager.sendNotificationRequest() + val bothKeys = (AACPManager.Companion.ProximityKeyType.IRK.value + + AACPManager.Companion.ProximityKeyType.ENC_KEY.value).toByte() + aacpManager.sendRequestProximityKeys(bothKeys) + + val readerJob = serviceScope.launch { + var delayedHandshake: kotlinx.coroutines.Job? = null + try { delay(200) + if (!isCurrentConnection()) return@launch aacpManager.sendPacket(aacpManager.createHandshakePacket()) delay(200) + if (!isCurrentConnection()) return@launch aacpManager.sendSetFeatureFlagsPacket() delay(200) + if (!isCurrentConnection()) return@launch aacpManager.sendNotificationRequest() delay(200) + if (!isCurrentConnection()) return@launch aacpManager.sendSomePacketIDontKnowWhatItIs() delay(200) - aacpManager.sendRequestProximityKeys((AACPManager.Companion.ProximityKeyType.IRK.value + AACPManager.Companion.ProximityKeyType.ENC_KEY.value).toByte()) + if (!isCurrentConnection()) return@launch + aacpManager.sendRequestProximityKeys(bothKeys) if (!handleIncomingCallOnceConnected) startHeadTracking() else handleIncomingCall() - Handler(Looper.getMainLooper()).postDelayed({ + delayedHandshake = launch handshake@ { + delay(5000) + if (!isCurrentConnection()) return@handshake aacpManager.sendPacket(aacpManager.createHandshakePacket()) aacpManager.sendSetFeatureFlagsPacket() aacpManager.sendNotificationRequest() aacpManager.sendRequestProximityKeys(AACPManager.Companion.ProximityKeyType.IRK.value) if (!handleIncomingCallOnceConnected) stopHeadTracking() - }, 5000) - - sendBroadcast( - Intent(AirPodsNotifications.AIRPODS_CONNECTED).putExtra("device", device) - .apply { - setPackage(packageName) - }) - + } + sendBroadcast(Intent(AirPodsNotifications.AIRPODS_CONNECTED).putExtra("device", device).apply { + setPackage(packageName) + }) setupStemActions() - while (socket.isConnected) { - try { - val buffer = ByteArray(1024) - val bytesRead = it.inputStream.read(buffer) - var data: ByteArray - if (bytesRead > 0) { - data = buffer.copyOfRange(0, bytesRead) - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DATA).apply { - putExtra("data", buffer.copyOfRange(0, bytesRead)) - setPackage(packageName) - }) - val bytes = buffer.copyOfRange(0, bytesRead) - val formattedHex = bytes.joinToString(" ") { "%02X".format(it) } -// CrossDevice.sendReceivedPacket(bytes) - updateNotificationContent( - true, - sharedPreferences.getString("name", device.name), - batteryNotification.getBattery() - ) - - aacpManager.receivePacket(data) - - if (!isHeadTrackingData(data)) { - Log.d("AirPodsData", "Data received: $formattedHex") - logPacket(data, "AirPods") - } - - } else if (bytesRead == -1) { - Log.d("AirPodsService", "socket closed (bytesRead = -1)") - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { - setPackage(packageName) - }) - aacpManager.disconnected() - return@launch - } - } catch (e: Exception) { - Log.w(TAG, "Error reading data, we have probably disconnected.") - e.printStackTrace() - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { - setPackage(packageName) - }) - aacpManager.disconnected() - return@launch + val buffer = ByteArray(1024) + while (isActive && isCurrentConnection() && connectedSocket.isConnected) { + val bytesRead = connectedSocket.inputStream.read(buffer) + if (bytesRead == -1) break + if (bytesRead > 0 && isCurrentConnection()) { + val data = buffer.copyOfRange(0, bytesRead) + // AACP dispatches typed state callbacks; the former package-only + // raw-data broadcast had no consumers and caused IPC per sample. + // Notifications change on connection/name/battery events, not sensor samples. + aacpManager.receivePacket(data) + if (!isHeadTrackingData(data)) logPacket(data, "AirPods") } - } - Log.d("AirPods Service", "socket closed") -// isConnectedLocally = false - aacpManager.disconnected() - updateNotificationContent(false) - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { - setPackage(packageName) - }) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (isCurrentConnection()) Log.w(TAG, "Error reading Bluetooth data", e) + } finally { + delayedHandshake?.cancel() + closeAttempt(notifyDisconnect = true) } } + // Also covers cancellation before the coroutine body ever starts. + readerJob.invokeOnCompletion { closeAttempt() } + handedToReader = true } catch (e: Exception) { - e.printStackTrace() - Log.d(TAG, "Failed to connect to BluetoothConnectionManager.aacpSocket?: ${e.message}") - closeFailedAttempt() - showSocketConnectionFailureNotification("Failed to establish connection: ${e.localizedMessage}") -// isConnectedLocally = false - this@AirPodsService.device = device - updateNotificationContent(false) - } -// } else { -// Log.d(TAG, "Already connected locally, skipping BluetoothConnectionManager.aacpSocket? connection (isConnectedLocally = $isConnectedLocally, BluetoothConnectionManager.aacpSocket?.isConnected = ${this::BluetoothConnectionManager.aacpSocket?.isInitialized && BluetoothConnectionManager.aacpSocket?.isConnected})") -// } + if (!serviceResources.isClosed) { + Log.d(TAG, " Socket not connected, ${e.message}") + val reason = if (attempt.isClosed) "Bluetooth socket connection timed out" else e.localizedMessage + if (manual) sendToast("Couldn't connect to socket: $reason") + else showSocketConnectionFailureNotification("Couldn't connect to socket: $reason") + } + } finally { + connectionPhaseFinished.set(true) + timeoutJob?.cancel() + if (!handedToReader) closeAttempt() + } } fun disconnectForCD() { @@ -2912,20 +2953,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList ), IslandType.MOVED_TO_REMOTE ) - val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter - bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener { - override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { - if (profile == BluetoothProfile.A2DP) { - val connectedDevices = proxy.connectedDevices - if (connectedDevices.isNotEmpty()) { - MediaController.sendPause() - } - } - bluetoothAdapter.closeProfileProxy(profile, proxy) - } - - override fun onServiceDisconnected(profile: Int) {} - }, BluetoothProfile.A2DP) + withAudioProfile(this, BluetoothProfile.A2DP) { proxy -> + if (proxy.connectedDevices.isNotEmpty()) MediaController.sendPause() + } // isConnectedLocally = false // CrossDevice.isAvailable = true } @@ -2948,21 +2978,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList setPackage(packageName) }) - val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter if (checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED){ - bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener { - override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { - if (profile == BluetoothProfile.A2DP) { - val connectedDevices = proxy.connectedDevices - if (connectedDevices.isNotEmpty()) { - MediaController.sendPause() - } - } - bluetoothAdapter.closeProfileProxy(profile, proxy) - } - - override fun onServiceDisconnected(profile: Int) {} - }, BluetoothProfile.A2DP) + withAudioProfile(this, BluetoothProfile.A2DP) { proxy -> + if (proxy.connectedDevices.isNotEmpty()) MediaController.sendPause() + } try { val currentDevice = device if (currentDevice != null) { @@ -2979,19 +2998,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } } if (checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED){ - bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener { - override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { - if (profile == BluetoothProfile.HEADSET) { - val connectedDevices = proxy.connectedDevices - if (connectedDevices.isNotEmpty()) { - MediaController.sendPause() - } - } - bluetoothAdapter.closeProfileProxy(profile, proxy) - } - - override fun onServiceDisconnected(profile: Int) {} - }, BluetoothProfile.HEADSET) + withAudioProfile(this, BluetoothProfile.HEADSET) { proxy -> + if (proxy.connectedDevices.isNotEmpty()) MediaController.sendPause() + } } Log.d(TAG, "Disconnected AirPods upon user request") } @@ -3024,138 +3033,78 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList return ancNotification.status } - fun disconnectAudio(context: Context, device: BluetoothDevice?) { - val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter - if (checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) { - bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { + private fun withAudioProfile( + context: Context, + requestedProfile: Int, + action: (BluetoothProfile) -> Unit + ) { + if (serviceResources.isClosed) return + val adapter = context.getSystemService(BluetoothManager::class.java).adapter ?: return + try { + adapter.getProfileProxy(context.applicationContext, object : BluetoothProfile.ServiceListener { override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { - if (profile == BluetoothProfile.A2DP) { - try { - if (proxy.getConnectionState(device) == BluetoothProfile.STATE_DISCONNECTED) { - Log.d(TAG, "Already disconnected from A2DP") - return - } - val method = proxy.javaClass.getMethod( - "setConnectionPolicy", BluetoothDevice::class.java, Int::class.java - ) - Log.d(TAG, "calling A2DP.setConnectionPolicy for ${device?.address} to 0") - method.invoke(proxy, device, 0) - } catch (e: Exception) { - e.printStackTrace() - } finally { - bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy) - } + try { + if (profile == requestedProfile && !serviceResources.isClosed) action(proxy) + } catch (e: Exception) { + Log.w(TAG, "Audio profile operation failed", e) + } finally { + // This includes permission-denied, reflection failure and late callbacks. + adapter.closeProfileProxy(profile, proxy) } } override fun onServiceDisconnected(profile: Int) {} - }, BluetoothProfile.A2DP) - } else { - Log.d(TAG, "not disconnecting A2DP, no BLUETOOTH_PRIVILEGED permission") + }, requestedProfile) + } catch (e: Exception) { + Log.w(TAG, "Could not obtain audio profile", e) } - if (checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) { - bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { - override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { - if (profile == BluetoothProfile.HEADSET) { - try { - val method = - proxy.javaClass.getMethod( - "setConnectionPolicy", - BluetoothDevice::class.java, - Int::class.java - ) - Log.d(TAG, "calling HEADSET.setConnectionPolicy for ${device?.address} to 0") - method.invoke(proxy, device, 0) - } catch (e: Exception) { - e.printStackTrace() - } finally { - bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy) - } - } - } + } - override fun onServiceDisconnected(profile: Int) {} - }, BluetoothProfile.HEADSET) - } else { - Log.d(TAG, "not disconnecting HEADSET, no MODIFIY_PHONE_STATE permission") + fun disconnectAudio(context: Context, device: BluetoothDevice?) { + if (device == null) return + if (checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) { + withAudioProfile(context, BluetoothProfile.A2DP) { proxy -> + if (proxy.getConnectionState(device) == BluetoothProfile.STATE_DISCONNECTED) return@withAudioProfile + proxy.javaClass.getMethod( + "setConnectionPolicy", BluetoothDevice::class.java, Int::class.java + ).invoke(proxy, device, 0) + } + } + if (checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) { + withAudioProfile(context, BluetoothProfile.HEADSET) { proxy -> + proxy.javaClass.getMethod( + "setConnectionPolicy", BluetoothDevice::class.java, Int::class.java + ).invoke(proxy, device, 0) + } } } fun connectAudio(context: Context, device: BluetoothDevice?) { - val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter - - bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { - override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { - if (profile == BluetoothProfile.A2DP) { - if (context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) { - try { - val policyMethod = proxy.javaClass.getMethod( - "setConnectionPolicy", - BluetoothDevice::class.java, - Int::class.java - ) - Log.d(TAG, "calling A2DP.setConnectionPolicy for ${device?.address} to 100") - policyMethod.invoke(proxy, device, 100) - - val connectMethod = - proxy.javaClass.getMethod("connect", BluetoothDevice::class.java) - connectMethod.invoke( - proxy, device - ) - } catch (e: Exception) { - e.printStackTrace() - } finally { - bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy) - if (MediaController.pausedWhileTakingOver) { - MediaController.sendPlay() - } - } - } - else { - val connectMethod = - proxy.javaClass.getMethod("connect", BluetoothDevice::class.java) - connectMethod.invoke( - proxy, device - ) - Log.d(TAG, "not setting connection policy for A2DP, no BLUETOOTH_PRIVILEGED permission. just called connect") - } + if (device == null) return + withAudioProfile(context, BluetoothProfile.A2DP) { proxy -> + val canSetPolicy = context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED + try { + if (canSetPolicy) { + proxy.javaClass.getMethod( + "setConnectionPolicy", BluetoothDevice::class.java, Int::class.java + ).invoke(proxy, device, 100) } - } - - override fun onServiceDisconnected(profile: Int) {} - }, BluetoothProfile.A2DP) - - bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { - override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { - if (profile == BluetoothProfile.HEADSET) { - if (checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) { - try { - val policyMethod = proxy.javaClass.getMethod( - "setConnectionPolicy", - BluetoothDevice::class.java, - Int::class.java - ) - Log.d( - TAG, - "calling HEADSET.setConnectionPolicy for ${device?.address} to 100" - ) - policyMethod.invoke(proxy, device, 100) - val connectMethod = - proxy.javaClass.getMethod("connect", BluetoothDevice::class.java) - connectMethod.invoke(proxy, device) - } catch (e: Exception) { - e.printStackTrace() - } finally { - bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy) - } - } else { - Log.d(TAG, "not setting connection policy for HEADSET, no MODIFIY_PHONE_STATE permission") - } + proxy.javaClass.getMethod("connect", BluetoothDevice::class.java).invoke(proxy, device) + } finally { + if (canSetPolicy && MediaController.pausedWhileTakingOver && !serviceResources.isClosed) { + MediaController.sendPlay() } } - - override fun onServiceDisconnected(profile: Int) {} - }, BluetoothProfile.HEADSET) + } + // Without this permission the old HEADSET callback did nothing, but retained its proxy. + if (checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) { + withAudioProfile(context, BluetoothProfile.HEADSET) { proxy -> + proxy.javaClass.getMethod( + "setConnectionPolicy", BluetoothDevice::class.java, Int::class.java + ).invoke(proxy, device, 100) + proxy.javaClass.getMethod("connect", BluetoothDevice::class.java).invoke(proxy, device) + } + } } fun setName(name: String) { @@ -3173,7 +3122,25 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList @SuppressLint("MissingPermission") override fun onDestroy() { + serviceScope.cancel() + serviceResources.close() + localMacLookupFinished.countDown() + gestureDetector?.release() + MediaController.release(this) + popupWindow?.close() + popupWindow = null + islandWindow?.close() + islandWindow = null + if (ServiceManager.getService() === this) ServiceManager.setService(null) + packetLogWrites.close() clearPacketLogs() + clearA2dpConnectionReceiver() + showIslandReceiver?.let { runCatching { unregisterReceiver(it) } } + showIslandReceiver = null + if (phoneBatteryReceiverRegistered) { + runCatching { unregisterReceiver(phoneBatteryReceiver) } + phoneBatteryReceiverRegistered = false + } Log.d(TAG, "Service stopped is being destroyed for some reason!") sharedPreferences.unregisterOnSharedPreferenceChangeListener(this) @@ -3193,11 +3160,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } catch (e: Exception) { e.printStackTrace() } - try { - unregisterReceiver(earReceiver) - } catch (e: Exception) { - e.printStackTrace() - } try { bleManager.stopScanning() } catch (e: Exception) { @@ -3252,12 +3214,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList device = bluetoothAdapter.bondedDevices.find { it.address == macAddress } - if (device != null) { - CoroutineScope(Dispatchers.IO).launch { - Log.d(TAG, "connecting to $macAddress") - connectToSocket(bluetoothAdapter, device!!, manual = true) - connectAudio(this@AirPodsService, device!!) - } + val targetDevice = device ?: return + serviceScope.launch { + Log.d(TAG, "connecting to $macAddress") + connectToSocket(bluetoothAdapter, targetDevice, manual = true) + if (isActive && !serviceResources.isClosed) connectAudio(this@AirPodsService, targetDevice) } } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AppListenerService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AppListenerService.kt index 7eeea7c50..5d5b73e7d 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AppListenerService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AppListenerService.kt @@ -65,8 +65,12 @@ class AppListenerService: AccessibilityService() { } override fun onDestroy() { - super.onDestroy() prefs.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) + if (cameraOpen) { + cameraOpen = false + ServiceManager.getService()?.cameraClosed() + } + super.onDestroy() } override fun onAccessibilityEvent(ev: AccessibilityEvent?) { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/CloseableResourceScope.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/CloseableResourceScope.kt new file mode 100644 index 000000000..56de9b312 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/CloseableResourceScope.kt @@ -0,0 +1,52 @@ +package me.kavishdevar.librepods.services + +import java.io.Closeable +import java.util.concurrent.CancellationException + +/** Owns blocking resources so cancellation can close them without waiting for their worker. */ +internal class CloseableResourceScope : Closeable { + @Volatile private var closed = false + private val resources = linkedSetOf() + + // Status checks may happen under a connection-publication lock; do not acquire + // this scope's lock there and invert the ordering used by whileOpen(). + val isClosed: Boolean get() = closed + + fun track(resource: T): T { + synchronized(this) { + if (!closed) { + resources.add(resource) + return resource + } + } + runCatching { resource.close() } + throw CancellationException("Resource scope is closed") + } + + @Synchronized + fun release(resource: Closeable) { + resources.remove(resource) + } + + @Synchronized + fun ensureOpen() { + if (closed) throw CancellationException("Resource scope is closed") + } + + /** For short publication steps only; never perform blocking I/O inside this block. */ + @Synchronized + fun whileOpen(action: () -> T): T { + ensureOpen() + return action() + } + + override fun close() { + val toClose = synchronized(this) { + if (closed) return + closed = true + resources.toList().also { resources.clear() } + } + // One broken resource must not prevent cancellation of the others. + toClose.forEach { runCatching { it.close() } } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/PacketLogBuffer.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/PacketLogBuffer.kt new file mode 100644 index 000000000..dce87db1f --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/PacketLogBuffer.kt @@ -0,0 +1,22 @@ +package me.kavishdevar.librepods.services + +/** Insertion-ordered, bounded diagnostic history. Callers serialize access. */ +internal class PacketLogBuffer(private val capacity: Int = 1000) { + private val entries = LinkedHashSet() + + init { + require(capacity > 0) + } + + fun add(entry: String): Boolean { + if (!entries.add(entry)) return false + if (entries.size > capacity) { + entries.iterator().apply { next(); remove() } + } + return true + } + + fun snapshot(): Set = entries.toSet() + + fun clear() = entries.clear() +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/ConflatedCallbackSession.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/ConflatedCallbackSession.kt new file mode 100644 index 000000000..2a29b2794 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/ConflatedCallbackSession.kt @@ -0,0 +1,31 @@ +package me.kavishdevar.librepods.utils + +import java.io.Closeable +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch + +/** Keeps only the latest pending sample and cancels both callbacks and child work on close. */ +internal class ConflatedCallbackSession( + dispatcher: CoroutineDispatcher, + consume: (T) -> Unit +) : Closeable { + val scope = CoroutineScope(SupervisorJob() + dispatcher) + private val pending = Channel(Channel.CONFLATED) + + init { + scope.launch { + for (sample in pending) consume(sample) + } + } + + fun offer(sample: T) = pending.trySend(sample).isSuccess + + override fun close() { + pending.cancel() + scope.cancel() + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/DiagnosticLogStream.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/DiagnosticLogStream.kt new file mode 100644 index 000000000..eab91df62 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/DiagnosticLogStream.kt @@ -0,0 +1,33 @@ +package me.kavishdevar.librepods.utils + +import java.io.BufferedReader +import java.io.Writer + +/** Streams complete diagnostics without retaining the session in memory. */ +internal fun streamDiagnosticLog( + reader: BufferedReader, + output: Writer, + shouldContinue: () -> Boolean, + connectionDetected: () -> Unit +) { + var notified = false + while (shouldContinue()) { + val line = reader.readLine() ?: break + val marker = line.contains("") || + line.contains("") || + (line.contains("AirPodsService") && ( + line.contains("Connected to device") || line.contains("Connection failed") + )) || + (line.contains("BluetoothService") && line.contains("CONNECTION_STATE_CONNECTED")) + )) { + notified = true + connectionDetected() + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureDetector.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureDetector.kt index 9892096bc..c69c659e1 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureDetector.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureDetector.kt @@ -23,16 +23,13 @@ package me.kavishdevar.librepods.utils import android.os.Build import android.util.Log import androidx.annotation.RequiresApi -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import me.kavishdevar.librepods.services.AirPodsService -import me.kavishdevar.librepods.services.ServiceManager import java.util.Collections -import java.util.concurrent.CopyOnWriteArrayList import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.abs import kotlin.math.max @@ -55,7 +52,12 @@ class GestureDetector( private const val MAX_VALID_ORIENTATION_VALUE = 6000 } - val audio = GestureFeedback(ServiceManager.getService()?.baseContext!!) + private val audioResource = lazy { GestureFeedback(airPodsService.applicationContext) } + private val audio get() = audioResource.value + private data class DirectionalFeedback(val vertical: Boolean, val value: Double) + private var feedbackSession: ConflatedCallbackSession? = null + private var activeSessionToken: Any? = null + private var released = false private val horizontalBuffer = Collections.synchronizedList(ArrayList()) private val verticalBuffer = Collections.synchronizedList(ArrayList()) @@ -66,10 +68,10 @@ class GestureDetector( private var prevHorizontal: Double = 0.0 private var prevVertical: Double = 0.0 - private val horizontalPeaks = CopyOnWriteArrayList>() - private val horizontalTroughs = CopyOnWriteArrayList>() - private val verticalPeaks = CopyOnWriteArrayList>() - private val verticalTroughs = CopyOnWriteArrayList>() + // Scoring only uses the latest three or four extrema. Keep them in arrival order, + // independent of the sample buffer's index, which stops increasing after 100 samples. + private val horizontalExtremes = RecentGestureExtremes(MAX_REQUIRED_EXTREMES) + private val verticalExtremes = RecentGestureExtremes(MAX_REQUIRED_EXTREMES) private var lastPeakTime: Long = 0 private val peakIntervals = Collections.synchronizedList(ArrayList()) @@ -85,7 +87,7 @@ class GestureDetector( private val minConfidenceThreshold = 0.7 - private var isRunning = false + @Volatile private var isRunning = false private var detectionJob: Job? = null private var gestureDetectedCallback: ((Boolean) -> Unit)? = null @@ -97,12 +99,24 @@ class GestureDetector( while (verticalAvgBuffer.size < 3) verticalAvgBuffer.add(0.0) } -fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> Unit) { - if (isRunning) return + @Synchronized + fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> Unit) { + if (isRunning || released) return Log.d(TAG, "Starting gesture detection...") isRunning = true gestureDetectedCallback = onGestureDetected + audioResource.value // Load sounds only when head gestures are actually used. + val sessionToken = Any() + activeSessionToken = sessionToken + val session = ConflatedCallbackSession(Dispatchers.Main) { feedback -> + synchronized(this) { + if (activeSessionToken === sessionToken && isRunning) { + audio.playDirectional(feedback.vertical, feedback.value) + } + } + } + feedbackSession = session Log.d(TAG, "started: ${airPodsService.startHeadTracking()}") @@ -111,28 +125,36 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U prevHorizontal = 0.0 prevVertical = 0.0 - detectionJob = CoroutineScope(Dispatchers.Default).launch { + detectionJob = session.scope.launch(Dispatchers.Default) { while (isRunning) { delay(50) val gesture = detectGestures() if (gesture != null) { withContext(Dispatchers.Main) { - audio.playConfirmation(gesture) - - gestureDetectedCallback?.invoke(gesture) - stopDetection(doNotStop) + synchronized(this@GestureDetector) { + if (activeSessionToken === sessionToken && isRunning) { + audio.playConfirmation(gesture) + gestureDetectedCallback?.invoke(gesture) + stopDetection(doNotStop) + } + } } break } } } } + @Synchronized fun stopDetection(doNotStop: Boolean = false) { if (!isRunning) return Log.d(TAG, "Stopping gesture detection") isRunning = false + activeSessionToken = null + feedbackSession?.close() + feedbackSession = null + if (audioResource.isInitialized()) audio.stopDirectional() if (!doNotStop) airPodsService.stopHeadTracking() @@ -141,7 +163,16 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U gestureDetectedCallback = null } + @Synchronized + fun release() { + if (released) return + released = true + stopDetection(doNotStop = true) + if (audioResource.isInitialized()) audio.release() + } + @RequiresApi(Build.VERSION_CODES.R) + @Synchronized fun processHeadOrientation(horizontal: Int, vertical: Int) { if (!isRunning) return @@ -157,17 +188,13 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U val significantVertical = abs(verticalDelta) > IMMEDIATE_FEEDBACK_THRESHOLD if (significantHorizontal && (!significantVertical || abs(horizontalDelta) > abs(verticalDelta))) { - CoroutineScope(Dispatchers.Main).launch { - audio.playDirectional(isVertical = false, value = horizontalDelta) - } + feedbackSession?.offer(DirectionalFeedback(vertical = false, value = horizontalDelta)) significantMotion = true lastSignificantMotionTime = System.currentTimeMillis() Log.d(TAG, "Significant HORIZONTAL movement: $horizontalDelta") } else if (significantVertical) { - CoroutineScope(Dispatchers.Main).launch { - audio.playDirectional(isVertical = true, value = verticalDelta) - } + feedbackSession?.offer(DirectionalFeedback(vertical = true, value = verticalDelta)) significantMotion = true lastSignificantMotionTime = System.currentTimeMillis() Log.d(TAG, "Significant VERTICAL movement: $verticalDelta") @@ -217,16 +244,14 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U horizontalBuffer, horizontalIncreasing, hVariance, - horizontalPeaks, - horizontalTroughs + horizontalExtremes )?.let { horizontalIncreasing = it } processDirectionChanges( verticalBuffer, verticalIncreasing, vVariance, - verticalPeaks, - verticalTroughs + verticalExtremes )?.let { verticalIncreasing = it } } @@ -234,8 +259,7 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U buffer: List, isIncreasing: Boolean?, variance: Double, - peaks: MutableList>, - troughs: MutableList> + extremes: RecentGestureExtremes ): Boolean? { if (buffer.size < 2) return isIncreasing @@ -249,7 +273,7 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U if (increasing && current < prev - dynamicThreshold) { if (abs(prev) > peakThreshold) { - peaks.add(Triple(buffer.size - 1, prev, now)) + extremes.add(prev) if (lastPeakTime > 0) { val interval = (now - lastPeakTime) / 1000.0 val timeDiff = now - lastPeakTime @@ -269,7 +293,7 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U increasing = false } else if (!increasing && current > prev + dynamicThreshold) { if (abs(prev) > peakThreshold) { - troughs.add(Triple(buffer.size - 1, prev, now)) + extremes.add(prev) if (lastPeakTime > 0) { val interval = (now - lastPeakTime) / 1000.0 @@ -314,29 +338,23 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U } - private fun calculateConfidenceScore(extremes: List>, isVertical: Boolean): Double { - if (extremes.size < getRequiredExtremes()) return 0.0 - - val sortedExtremes = extremes.sortedBy { it.first } - - val recent = sortedExtremes.takeLast(getRequiredExtremes()) - - val avgAmplitude = recent.map { abs(it.second) }.average() + private fun calculateConfidenceScore(recent: List, isVertical: Boolean): Double { + val avgAmplitude = recent.map { abs(it) }.average() val amplitudeFactor = min(1.0, avgAmplitude / 600) val rhythmFactor = calculateRhythmConsistency() - val signs = recent.map { if (it.second > 0) 1 else -1 } + val signs = recent.map { if (it > 0) 1 else -1 } val alternating = (1 until signs.size).all { signs[it] != signs[it - 1] } val alternationFactor = if (alternating) 1.0 else 0.5 val isolationFactor = if (isVertical) { - val vertAmplitude = recent.map { abs(it.second) }.average() + val vertAmplitude = avgAmplitude val horizVals = horizontalBuffer.takeLast(recent.size * 2) val horizAmplitude = horizVals.map { abs(it) }.average() min(1.0, vertAmplitude / (horizAmplitude + 0.1) * 1.2) } else { - val horizAmplitude = recent.map { abs(it.second) }.average() + val horizAmplitude = avgAmplitude val vertVals = verticalBuffer.takeLast(recent.size * 2) val vertAmplitude = vertVals.map { abs(it) }.average() min(1.0, horizAmplitude / (vertAmplitude + 0.1) * 1.2) @@ -363,12 +381,13 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U } } + @Synchronized private fun detectGestures(): Boolean? { val requiredExtremes = getRequiredExtremes() Log.d(TAG, "Current required extremes: $requiredExtremes") - if (verticalPeaks.size + verticalTroughs.size >= requiredExtremes) { - val allExtremes = (verticalPeaks + verticalTroughs).sortedBy { it.first } + if (verticalExtremes.size >= requiredExtremes) { + val allExtremes = verticalExtremes.recent(requiredExtremes) val confidence = calculateConfidenceScore(allExtremes, isVertical = true) @@ -380,8 +399,8 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U } } - if (horizontalPeaks.size + horizontalTroughs.size >= requiredExtremes) { - val allExtremes = (horizontalPeaks + horizontalTroughs).sortedBy { it.first } + if (horizontalExtremes.size >= requiredExtremes) { + val allExtremes = horizontalExtremes.recent(requiredExtremes) val confidence = calculateConfidenceScore(allExtremes, isVertical = false) @@ -396,13 +415,18 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U return null } + @Synchronized private fun clearData() { horizontalBuffer.clear() verticalBuffer.clear() - horizontalPeaks.clear() - horizontalTroughs.clear() - verticalPeaks.clear() - verticalTroughs.clear() + horizontalAvgBuffer.clear() + verticalAvgBuffer.clear() + repeat(3) { + horizontalAvgBuffer.add(0.0) + verticalAvgBuffer.add(0.0) + } + horizontalExtremes.clear() + verticalExtremes.clear() peakIntervals.clear() movementSpeedIntervals.clear() horizontalIncreasing = null diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureFeedback.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureFeedback.kt index 88ab8cf5f..52376f108 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureFeedback.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureFeedback.kt @@ -23,10 +23,8 @@ package me.kavishdevar.librepods.utils import android.content.Context import android.media.AudioAttributes import android.media.SoundPool -import android.os.Build import android.os.SystemClock import android.util.Log -import androidx.annotation.RequiresApi import me.kavishdevar.librepods.R import java.util.concurrent.atomic.AtomicBoolean @@ -35,6 +33,8 @@ class GestureFeedback(context: Context) { private val TAG = "GestureFeedback" private val soundsLoaded = AtomicBoolean(false) + private var released = false + private val loadedSoundIds = mutableSetOf() private val soundPool = SoundPool.Builder() .setMaxStreams(3) @@ -72,21 +72,25 @@ class GestureFeedback(context: Context) { private val VERTICAL_VOLUME = Pair(1.0f, 1.0f) init { + soundPool.setOnLoadCompleteListener { _, sampleId, status -> + synchronized(this) { + if (!released && status == 0) { + loadedSoundIds.add(sampleId) + if (loadedSoundIds.size == 3 && soundsLoaded.compareAndSet(false, true)) { + Log.d(TAG, "Sounds loaded") + soundPool.play(soundId, 0.0f, 0.0f, 1, 0, 1.0f) + } + } + } + } soundId = soundPool.load(context, R.raw.blip_no, 1) confirmYesId = soundPool.load(context, R.raw.confirm_yes, 1) confirmNoId = soundPool.load(context, R.raw.confirm_no, 1) - - soundPool.setOnLoadCompleteListener { _, _, _ -> - Log.d(TAG, "Sounds loaded") - soundsLoaded.set(true) - - soundPool.play(soundId, 0.0f, 0.0f, 1, 0, 1.0f) - } } - @RequiresApi(Build.VERSION_CODES.R) + @Synchronized fun playDirectional(isVertical: Boolean, value: Double) { - if (!soundsLoaded.get()) { + if (released || !soundsLoaded.get()) { Log.d(TAG, "Sounds not yet loaded, skipping playback") return } @@ -162,13 +166,23 @@ class GestureFeedback(context: Context) { } } - fun playConfirmation(isYes: Boolean) { + @Synchronized + fun stopDirectional() { + if (released) return if (currentHorizontalStreamId > 0) { soundPool.stop(currentHorizontalStreamId) + currentHorizontalStreamId = 0 } if (currentVerticalStreamId > 0) { soundPool.stop(currentVerticalStreamId) + currentVerticalStreamId = 0 } + } + + @Synchronized + fun playConfirmation(isYes: Boolean) { + if (released) return + stopDirectional() val soundId = if (isYes) confirmYesId else confirmNoId if (soundId != 0 && soundsLoaded.get()) { @@ -176,4 +190,16 @@ class GestureFeedback(context: Context) { Log.d(TAG, "Playing ${if (isYes) "YES" else "NO"} confirmation - streamID=$streamId") } } + + @Synchronized + fun release() { + if (released) return + released = true + soundsLoaded.set(false) + soundPool.setOnLoadCompleteListener(null) + soundPool.release() + loadedSoundIds.clear() + currentHorizontalStreamId = 0 + currentVerticalStreamId = 0 + } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/LogCollector.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/LogCollector.kt index 246fd785a..1e9c2b93f 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/LogCollector.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/LogCollector.kt @@ -20,13 +20,19 @@ package me.kavishdevar.librepods.utils import android.content.Context import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.delay import kotlinx.coroutines.withContext -import java.io.BufferedReader import java.io.File -import java.io.InputStreamReader class LogCollector(private val context: Context) { - private var isCollecting = false + private val collectionLock = Any() + @Volatile private var activeCollection: Any? = null private var logProcess: Process? = null suspend fun openXposedSettings(context: Context) { @@ -59,7 +65,7 @@ class LogCollector(private val context: Context) { val uid = executeRootCommand( "dumpsys package $pkg | grep -m 1 \"uid=\" | sed -E 's/.*uid=([0-9]+).*/\\1/'" ).trim() - if (uid.isNotEmpty()) return uid + if (uid.isNotEmpty() && uid.all(Char::isDigit)) return uid } return null } @@ -69,109 +75,95 @@ class LogCollector(private val context: Context) { val btUid = getBluetoothUID() val appUid = executeRootCommand("dumpsys package me.kavishdevar.librepods | grep -m 1 \"uid=\" | sed -E 's/.*uid=([0-9]+).*/\\1/'") .trim() - .takeIf { it.isNotEmpty() } + .takeIf { it.isNotEmpty() && it.all(Char::isDigit) } Pair(btUid, appUid) } } - suspend fun startLogCollection(listener: (String) -> Unit, connectionDetectedCallback: () -> Unit): String { + suspend fun startLogCollection(fileName: String, connectionDetectedCallback: () -> Unit): File { return withContext(Dispatchers.IO) { - isCollecting = true - val (btUid, appUid) = getPackageUIDs() - - val uidFilter = buildString { - if (!btUid.isNullOrEmpty() && !appUid.isNullOrEmpty()) { - append("$btUid,$appUid") - } else if (!btUid.isNullOrEmpty()) { - append(btUid) - } else if (!appUid.isNullOrEmpty()) { - append(appUid) - } - } - - val command = if (uidFilter.isNotEmpty()) { - "su -c logcat --uid=$uidFilter -v threadtime" - } else { - "su -c logcat -v threadtime" + val session = Any() + synchronized(collectionLock) { + check(activeCollection == null) { "Log collection is already running" } + activeCollection = session } - - val logs = StringBuilder() + var process: Process? = null try { - logProcess = Runtime.getRuntime().exec(command) - val reader = BufferedReader(InputStreamReader(logProcess!!.inputStream)) - var line: String? = null - var connectionDetected = false - - while (isCollecting && reader.readLine().also { line = it } != null) { - line?.let { - if (it.contains(" + if (activeCollection === session) { + val startedProcess = ProcessBuilder("su", "-c", command) + .redirectErrorStream(true).start() + process = startedProcess + synchronized(collectionLock) { + if (activeCollection === session) logProcess = startedProcess + else startedProcess.destroy() } - - if (!connectionDetected) { - if (it.contains("")) { - connectionDetected = true - connectionDetectedCallback() - } else if (it.contains("")) { - connectionDetected = true - connectionDetectedCallback() - } else if (it.contains("")) { - } - else if (it.contains("AirPodsService") && it.contains("Connected to device")) { - connectionDetected = true - connectionDetectedCallback() - } else if (it.contains("AirPodsService") && it.contains("Connection failed")) { - connectionDetected = true - connectionDetectedCallback() - } else if (it.contains("AirPodsService") && it.contains("Device disconnected")) { + // Cancellation must close the pipe even while readLine() is blocked. + val cancellationWatcher = launch(Dispatchers.IO, start = CoroutineStart.UNDISPATCHED) { + try { + awaitCancellation() + } finally { + startedProcess.destroy() } - else if (it.contains("BluetoothService") && it.contains("CONNECTION_STATE_CONNECTED")) { - connectionDetected = true - connectionDetectedCallback() - } else if (it.contains("BluetoothService") && it.contains("CONNECTION_STATE_DISCONNECTED")) { + } + try { + startedProcess.inputStream.bufferedReader().use { reader -> + streamDiagnosticLog(reader, output, { activeCollection === session }, connectionDetectedCallback) } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + currentCoroutineContext().ensureActive() + if (activeCollection === session) output.append("Error collecting logs: ${e.message}\n") + } finally { + cancellationWatcher.cancel() + startedProcess.destroy() } } } - } catch (e: Exception) { - logs.append("Error collecting logs: ${e.message}").append("\n") - e.printStackTrace() + file + } finally { + process?.destroy() + synchronized(collectionLock) { + if (activeCollection === session) { + activeCollection = null + logProcess = null + } + } } - - logs.toString() } } fun stopLogCollection() { - isCollecting = false - logProcess?.destroy() - logProcess = null - } - - suspend fun saveLogToInternalStorage(fileName: String, content: String): File? { - return withContext(Dispatchers.IO) { - try { - val logsDir = File(context.filesDir, "logs") - if (!logsDir.exists()) { - logsDir.mkdir() - } - - val file = File(logsDir, fileName) - file.writeText(content) - return@withContext file - } catch (e: Exception) { - e.printStackTrace() - return@withContext null - } + val process = synchronized(collectionLock) { + activeCollection = null + logProcess.also { logProcess = null } } + process?.destroy() } suspend fun addLogMarker(markerType: LogMarkerType, details: String = "") { @@ -200,21 +192,25 @@ class LogCollector(private val context: Context) { private suspend fun executeRootCommand(command: String): String { return withContext(Dispatchers.IO) { + var process: Process? = null try { - val process = Runtime.getRuntime().exec("su -c $command") - val reader = BufferedReader(InputStreamReader(process.inputStream)) - val output = StringBuilder() - var line: String? - - while (reader.readLine().also { line = it } != null) { - output.append(line).append("\n") + val startedProcess = ProcessBuilder("su", "-c", command).redirectErrorStream(true).start() + process = startedProcess + val deadline = launch(Dispatchers.IO, start = CoroutineStart.UNDISPATCHED) { + try { delay(15_000) } finally { startedProcess.destroy() } } - - process.waitFor() - output.toString() + try { + startedProcess.inputStream.bufferedReader().use { it.readText() } + } finally { + deadline.cancel() + } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { e.printStackTrace() "" + } finally { + process?.destroy() } } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt index 400e0ff72..12a721786 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt @@ -35,12 +35,16 @@ import kotlin.io.encoding.ExperimentalEncodingApi object MediaController { private var initialVolume: Int? = null - private lateinit var audioManager: AudioManager + private var audioManager: AudioManager? = null var iPausedTheMedia = false var userPlayedTheMedia = false - private lateinit var sharedPreferences: SharedPreferences private val handler = Handler(Looper.getMainLooper()) - private lateinit var preferenceChangeListener: SharedPreferences.OnSharedPreferenceChangeListener + private val volumeTransition = VolumeTransition( + setVolume = { audioManager?.setStreamVolume(AudioManager.STREAM_MUSIC, it, 0) }, + schedule = { action, delayMs -> handler.postDelayed(action, delayMs); Unit }, + unschedule = { handler.removeCallbacks(it) } + ) + private val callbackLifecycle = OwnedCallbackLifecycle() var pausedWhileTakingOver = false var pausedForOtherDevice = false @@ -52,10 +56,8 @@ object MediaController { private var lastKnownIsMusicActive: Boolean? = null private const val PAUSED_FOR_OTHER_DEVICE_CLEAR_MS = 500L - private val clearPausedForOtherDeviceRunnable = Runnable { - pausedForOtherDevice = false - Log.d("MediaController", "Cleared pausedForOtherDevice after timeout, resuming normal playback monitoring") - } + private var clearPausedForOtherDeviceRunnable: Runnable? = null + private var playbackRefreshRunnable: Runnable? = null private var relativeVolume: Boolean = false private var conversationalAwarenessVolume: Int = 2 @@ -66,40 +68,99 @@ object MediaController { private var lastPlayWithReplay: Boolean = false private var lastPlayTime: Long = 0L - fun initialize(audioManager: AudioManager, sharedPreferences: SharedPreferences) { - if (this::audioManager.isInitialized) { - return - } - this.audioManager = audioManager - this.sharedPreferences = sharedPreferences - Log.d("MediaController", "Initializing MediaController") - relativeVolume = sharedPreferences.getBoolean("relative_conversational_awareness_volume", false) - conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", (audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) / 0.4).toInt()) - conversationalAwarenessPauseMusic = sharedPreferences.getBoolean("conversational_awareness_pause_music", false) - - preferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> - when (key) { - "relative_conversational_awareness_volume" -> { - relativeVolume = sharedPreferences.getBoolean("relative_conversational_awareness_volume", false) + @Synchronized + fun initialize(audioManager: AudioManager, sharedPreferences: SharedPreferences, owner: Any) { + callbackLifecycle.start(owner) { session -> + this.audioManager = audioManager + resetPlaybackState() + relativeVolume = sharedPreferences.getBoolean("relative_conversational_awareness_volume", false) + conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", (audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) / 0.4).toInt()) + conversationalAwarenessPauseMusic = sharedPreferences.getBoolean("conversational_awareness_pause_music", false) + + val preferenceListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + synchronized(this@MediaController) { + if (session.isActive) { + when (key) { + "relative_conversational_awareness_volume" -> + relativeVolume = sharedPreferences.getBoolean(key, false) + "conversational_awareness_volume" -> + conversationalAwarenessVolume = sharedPreferences.getInt(key, (audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * 0.4).toInt()) + "conversational_awareness_pause_music" -> + conversationalAwarenessPauseMusic = sharedPreferences.getBoolean(key, false) + } + } + } + } + clearPausedForOtherDeviceRunnable = Runnable { + synchronized(this@MediaController) { + if (session.isActive) pausedForOtherDevice = false } - "conversational_awareness_volume" -> { - conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", (audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * 0.4).toInt()) + } + playbackRefreshRunnable = Runnable { + synchronized(this@MediaController) { + if (session.isActive) { + val isActive = audioManager.isMusicActive + userPlayedTheMedia = isActive + if (isActive) pausedForOtherDevice = false + } + } + } + val playbackCallback = object : AudioManager.AudioPlaybackCallback() { + @RequiresApi(Build.VERSION_CODES.R) + override fun onPlaybackConfigChanged(configs: MutableList?) { + synchronized(this@MediaController) { + if (session.isActive) handlePlaybackConfigChanged(configs, audioManager) + } } - "conversational_awareness_pause_music" -> { - conversationalAwarenessPauseMusic = sharedPreferences.getBoolean("conversational_awareness_pause_music", false) + } + val cleanup: () -> Unit = { + runCatching { audioManager.unregisterAudioPlaybackCallback(playbackCallback) } + runCatching { sharedPreferences.unregisterOnSharedPreferenceChangeListener(preferenceListener) } + volumeTransition.cancel() + handler.removeCallbacksAndMessages(null) + // Do not leave music attenuated when the service stops during conversation awareness. + initialVolume?.let { volume -> + runCatching { audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, volume, 0) } } + clearPausedForOtherDeviceRunnable = null + playbackRefreshRunnable = null + this.audioManager = null + resetPlaybackState() } + try { + sharedPreferences.registerOnSharedPreferenceChangeListener(preferenceListener) + audioManager.registerAudioPlaybackCallback(playbackCallback, handler) + } catch (error: Throwable) { + cleanup() + throw error + } + cleanup } + } - sharedPreferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener) + @Synchronized + fun release(owner: Any) { + callbackLifecycle.stop(owner) + } - audioManager.registerAudioPlaybackCallback(cb, null) + private fun resetPlaybackState() { + initialVolume = null + iPausedTheMedia = false + userPlayedTheMedia = false + pausedWhileTakingOver = false + pausedForOtherDevice = false + recentlyLostOwnership = false + lastSelfActionAt = 0L + lastPlaybackCallbackAt = 0L + lastKnownIsMusicActive = null + lastPlayWithReplay = false + lastPlayTime = 0L } - val cb = object : AudioManager.AudioPlaybackCallback() { - @RequiresApi(Build.VERSION_CODES.R) - override fun onPlaybackConfigChanged(configs: MutableList?) { - super.onPlaybackConfigChanged(configs) + @RequiresApi(Build.VERSION_CODES.R) + private fun handlePlaybackConfigChanged( + configs: MutableList?, audioManager: AudioManager + ) { val now = SystemClock.uptimeMillis() val isActive = audioManager.isMusicActive Log.d("MediaController", "Playback config changed, iPausedTheMedia: $iPausedTheMedia, isActive: $isActive, pausedForOtherDevice: $pausedForOtherDevice, lastKnownIsMusicActive: $lastKnownIsMusicActive") @@ -125,31 +186,19 @@ object MediaController { return } - Log.d("MediaController", "Configs received: ${configs?.size ?: 0} configurations") - val currentActiveContentTypes = configs?.flatMap { config -> - Log.d("MediaController", "Processing config: ${config}, audioAttributes: ${config.audioAttributes}") - config.audioAttributes?.let { attrs -> - val contentType = attrs.contentType - Log.d("MediaController", "Config content type: $contentType") - listOf(contentType) - } ?: run { - Log.d("MediaController", "Config has no audioAttributes") - emptyList() - } - }?.toSet() ?: emptySet() - - Log.d("MediaController", "Current active content types: $currentActiveContentTypes") - - val hasNewMusicOrMovie = currentActiveContentTypes.any { contentType -> - contentType == android.media.AudioAttributes.CONTENT_TYPE_MUSIC || - contentType == android.media.AudioAttributes.CONTENT_TYPE_MOVIE - } + val hasNewMusicOrMovie = configs?.any { config -> + val type = config.audioAttributes?.contentType + type == android.media.AudioAttributes.CONTENT_TYPE_MUSIC || + type == android.media.AudioAttributes.CONTENT_TYPE_MOVIE + } == true Log.d("MediaController", "Has new music or movie: $hasNewMusicOrMovie") if (pausedForOtherDevice) { - handler.removeCallbacks(clearPausedForOtherDeviceRunnable) - handler.postDelayed(clearPausedForOtherDeviceRunnable, PAUSED_FOR_OTHER_DEVICE_CLEAR_MS) + clearPausedForOtherDeviceRunnable?.let { + handler.removeCallbacks(it) + handler.postDelayed(it, PAUSED_FOR_OTHER_DEVICE_CLEAR_MS) + } if (isActive) { Log.d("MediaController", "Detected play while pausedForOtherDevice; attempting to take over") @@ -178,12 +227,10 @@ object MediaController { isActive ) Log.d("MediaController", "User changed media state themselves; will wait for ear detection pause before auto-play") - handler.postDelayed({ - userPlayedTheMedia = audioManager.isMusicActive - if (audioManager.isMusicActive) { - pausedForOtherDevice = false - } - }, 7) + playbackRefreshRunnable?.let { + handler.removeCallbacks(it) + handler.postDelayed(it, 7) + } } Log.d("MediaController", "pausedWhileTakingOver: $pausedWhileTakingOver") @@ -199,16 +246,14 @@ object MediaController { } lastKnownIsMusicActive = hasNewMusicOrMovie && isActive - } } @Synchronized - fun getMusicActive(): Boolean { - return audioManager.isMusicActive - } + fun getMusicActive(): Boolean = audioManager?.isMusicActive == true @Synchronized fun sendPlayPause() { + val audioManager = audioManager ?: return if (audioManager.isMusicActive) { Log.d("MediaController", "Sending pause because music is active") sendPause() @@ -220,6 +265,7 @@ object MediaController { @Synchronized fun sendPreviousTrack() { + val audioManager = audioManager ?: return Log.d("MediaController", "Sending previous track") audioManager.dispatchMediaKeyEvent( KeyEvent( @@ -238,6 +284,7 @@ object MediaController { @Synchronized fun sendNextTrack() { + val audioManager = audioManager ?: return Log.d("MediaController", "Sending next track") audioManager.dispatchMediaKeyEvent( KeyEvent( @@ -256,6 +303,7 @@ object MediaController { @Synchronized fun sendPause(force: Boolean = false) { + val audioManager = audioManager ?: return Log.d("MediaController", "Sending pause with iPausedTheMedia: $iPausedTheMedia, userPlayedTheMedia: $userPlayedTheMedia, isMusicActive: ${audioManager.isMusicActive}, force: $force") if ((audioManager.isMusicActive) && (!userPlayedTheMedia || force)) { iPausedTheMedia = if (force) audioManager.isMusicActive else true @@ -278,6 +326,7 @@ object MediaController { @Synchronized fun sendPlay(replayWhenPaused: Boolean = false, force: Boolean = false) { + val audioManager = audioManager ?: return Log.d("MediaController", "Sending play with iPausedTheMedia: $iPausedTheMedia, replayWhenPaused: $replayWhenPaused, force: $force") if (replayWhenPaused) { lastPlayWithReplay = true @@ -312,6 +361,7 @@ object MediaController { @Synchronized fun startSpeaking() { + val audioManager = audioManager ?: return Log.d("MediaController", "Starting speaking max vol: ${audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)}, current vol: ${audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)}, conversationalAwarenessVolume: $conversationalAwarenessVolume, relativeVolume: $relativeVolume") if (initialVolume == null) { @@ -334,6 +384,7 @@ object MediaController { @Synchronized fun stopSpeaking() { + val audioManager = audioManager ?: return Log.d("MediaController", "Stopping speaking, initialVolume: $initialVolume") if (initialVolume != null) { smoothVolumeTransition(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC), initialVolume!!) @@ -345,19 +396,12 @@ object MediaController { } private fun smoothVolumeTransition(fromVolume: Int, toVolume: Int) { + val audioManager = audioManager ?: return Log.d("MediaController", "Smooth volume transition from $fromVolume to $toVolume") - val step = if (fromVolume < toVolume) 1 else -1 - val delay = 50L - var currentVolume = fromVolume - - handler.post(object : Runnable { - override fun run() { - if (currentVolume != toVolume) { - currentVolume += step - audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, currentVolume, 0) - handler.postDelayed(this, delay) - } - } - }) + val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + volumeTransition.start( + fromVolume.coerceIn(0, maxVolume), + toVolume.coerceIn(0, maxVolume) + ) } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/OwnedCallbackLifecycle.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/OwnedCallbackLifecycle.kt new file mode 100644 index 000000000..ebc4a5414 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/OwnedCallbackLifecycle.kt @@ -0,0 +1,39 @@ +package me.kavishdevar.librepods.utils + +/** One registration owner at a time, with tokens that invalidate callbacks already in a queue. */ +internal class OwnedCallbackLifecycle { + class Session internal constructor(internal val owner: Any) { + @Volatile var isActive: Boolean = true + internal set + internal var cleanup: (() -> Unit)? = null + } + + private var current: Session? = null + + @Synchronized + fun start(owner: Any, register: (Session) -> (() -> Unit)) { + if (current?.owner === owner) return + closeCurrent() + val session = Session(owner) + current = session + try { + session.cleanup = register(session) + } catch (error: Throwable) { + session.isActive = false + current = null + throw error + } + } + + @Synchronized + fun stop(owner: Any) { + if (current?.owner === owner) closeCurrent() + } + + private fun closeCurrent() { + val session = current ?: return + current = null + session.isActive = false + session.cleanup?.invoke() + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/RecentGestureExtremes.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/RecentGestureExtremes.kt new file mode 100644 index 000000000..890d98f12 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/RecentGestureExtremes.kt @@ -0,0 +1,21 @@ +package me.kavishdevar.librepods.utils + +/** Chronological tail used by gesture scoring. Access is guarded by GestureDetector's lock. */ +internal class RecentGestureExtremes(private val capacity: Int) { + init { + require(capacity > 0) + } + + private val values = ArrayDeque(capacity) + + val size: Int get() = values.size + + fun add(value: Double) { + if (values.size == capacity) values.removeFirst() + values.addLast(value) + } + + fun recent(count: Int): List = values.takeLast(count) + + fun clear() = values.clear() +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/VolumeTransition.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/VolumeTransition.kt new file mode 100644 index 000000000..3e5b6642d --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/VolumeTransition.kt @@ -0,0 +1,42 @@ +package me.kavishdevar.librepods.utils + +/** One volume ramp at a time, including when a cancelled callback has already been dispatched. */ +internal class VolumeTransition( + private val setVolume: (Int) -> Unit, + private val schedule: (Runnable, Long) -> Unit, + private val unschedule: (Runnable) -> Unit, + private val stepDelayMs: Long = 50L +) { + private var activeTransition: Runnable? = null + + @Synchronized + fun cancel() { + activeTransition?.let(unschedule) + activeTransition = null + } + + @Synchronized + fun start(fromVolume: Int, toVolume: Int) { + cancel() + if (fromVolume == toVolume) return + + var currentVolume = fromVolume + val step = if (fromVolume < toVolume) 1 else -1 + val transition = object : Runnable { + override fun run() { + synchronized(this@VolumeTransition) { + if (activeTransition !== this) return + currentVolume += step + setVolume(currentVolume) + if (currentVolume == toVolume) { + activeTransition = null + } else { + schedule(this, stepDelayMs) + } + } + } + } + activeTransition = transition + schedule(transition, 0L) + } +} diff --git a/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/AacpStateTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/AacpStateTest.kt new file mode 100644 index 000000000..77c440513 --- /dev/null +++ b/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/AacpStateTest.kt @@ -0,0 +1,189 @@ +package me.kavishdevar.librepods.bluetooth + +import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class AacpStateTest { + @Test fun changingCommandsKeepOnlyTheLatestValueForEachIdentifier() { + val manager = AACPManager() + val identifiers = ControlCommandIdentifiers.entries + repeat(10_000) { index -> + val identifier = identifiers[index % identifiers.size] + val value = byteArrayOf(index.toByte()) + manager.setControlCommandStatusValue(identifier, value) + assertArrayEquals(value, manager.getControlCommandStatus(identifier)?.value) + assertTrue(manager.controlCommandStatusList.size <= identifiers.size) + } + assertEquals(identifiers.size, manager.controlCommandStatusList.size) + assertEquals(identifiers.toSet(), manager.controlCommandStatusList.map { it.identifier }.toSet()) + } + + @Test fun replacingStatePreservesNotificationsIncludingRepeatedValues() { + val manager = AACPManager() + val identifier = ControlCommandIdentifiers.OWNS_CONNECTION + val received = mutableListOf() + manager.registerControlCommandListener(identifier, object : AACPManager.ControlCommandListener { + override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) { + received.add(controlCommand.value[0]) + assertArrayEquals(controlCommand.value, manager.getControlCommandStatus(identifier)?.value) + } + }) + + manager.setControlCommandStatusValue(identifier, byteArrayOf(1)) + assertTrue(manager.owns) + manager.setControlCommandStatusValue(identifier, byteArrayOf(1)) + manager.setControlCommandStatusValue(identifier, byteArrayOf(2)) + + assertFalse(manager.owns) + assertEquals(listOf(1, 1, 2), received) + assertEquals(1, manager.controlCommandStatusList.size) + assertArrayEquals(byteArrayOf(2), manager.getControlCommandStatus(identifier)?.value) + } + + @Test fun eachParsedControlPacketNotifiesOnceWithItsStoredNormalizedValue() { + val manager = AACPManager() + val identifier = ControlCommandIdentifiers.LISTENING_MODE + val received = mutableListOf() + manager.registerControlCommandListener(identifier, object : AACPManager.ControlCommandListener { + override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) { + received.add(controlCommand) + assertArrayEquals(controlCommand.value, manager.getControlCommandStatus(identifier)?.value) + } + }) + + listOf(byteArrayOf(1), byteArrayOf(2), byteArrayOf(2), byteArrayOf(0)).forEachIndexed { index, value -> + val packet = manager.createDataPacket(manager.createControlCommandPacket(identifier.value, value)) + val parsed = manager.parseAndStoreControlCommand(packet) + assertArrayEquals(value, parsed?.value) + assertEquals(index + 1, received.size) + assertEquals(parsed, received.last()) + assertEquals(1, manager.controlCommandStatusList.size) + } + + val unknownPacket = manager.createDataPacket(manager.createControlCommandPacket(0x7F, byteArrayOf(1))) + assertNull(manager.parseAndStoreControlCommand(unknownPacket)) + assertEquals(4, received.size) + } + + @Test fun aListenerCanUpdateACommandWithoutLeavingDuplicateState() { + val manager = AACPManager() + val identifier = ControlCommandIdentifiers.LISTENING_MODE + val received = mutableListOf() + manager.registerControlCommandListener(identifier, object : AACPManager.ControlCommandListener { + override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) { + received.add(controlCommand.value[0]) + if (controlCommand.value[0] == 1.toByte()) { + manager.setControlCommandStatusValue(identifier, byteArrayOf(2)) + } + } + }) + manager.setControlCommandStatusValue(identifier, byteArrayOf(1)) + assertEquals(listOf(1, 2), received) + assertEquals(1, manager.controlCommandStatusList.size) + assertArrayEquals(byteArrayOf(2), manager.getControlCommandStatus(identifier)?.value) + } + + @Test fun simultaneousSendAndReceiveUpdatesCannotAppendDuplicateStates() { + val manager = AACPManager() + val identifier = ControlCommandIdentifiers.LISTENING_MODE + val received = AtomicInteger() + manager.registerControlCommandListener(identifier, object : AACPManager.ControlCommandListener { + override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) { + received.incrementAndGet() + } + }) + val executor = Executors.newFixedThreadPool(4) + try { + val work = List(4) { worker -> + executor.submit { + repeat(2_500) { index -> + manager.setControlCommandStatusValue(identifier, byteArrayOf((index + worker).toByte())) + } + } + } + work.forEach { it.get(5, TimeUnit.SECONDS) } + manager.setControlCommandStatusValue(identifier, byteArrayOf(42)) + assertEquals(10_001, received.get()) + assertEquals(1, manager.controlCommandStatusList.size) + assertArrayEquals(byteArrayOf(42), manager.getControlCommandStatus(identifier)?.value) + } finally { + executor.shutdownNow() + } + } + + @Test fun reconnectKeepsAnExistingObserverWithoutAddingDuplicateCallbacks() { + val manager = AACPManager() + val identifier = ControlCommandIdentifiers.OWNS_CONNECTION + val received = mutableListOf() + val observer = object : AACPManager.ControlCommandListener { + override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) { + received.add(controlCommand.value[0]) + } + } + manager.registerControlCommandListener(identifier, observer) + fun receive(value: Byte) { + manager.parseAndStoreControlCommand( + manager.createDataPacket(manager.createControlCommandPacket(identifier.value, byteArrayOf(value))) + ) + } + + receive(1) + assertTrue(manager.owns) + manager.resetDeviceState() + assertFalse(manager.owns) + assertTrue(manager.controlCommandStatusList.isEmpty()) + receive(2) + assertEquals(listOf(1, 2), received) + assertArrayEquals(byteArrayOf(2), manager.getControlCommandStatus(identifier)?.value) + + manager.unregisterControlCommandListener(identifier, observer) + receive(1) + assertEquals(listOf(1, 2), received) + } + + @Test fun observerCanUnregisterDuringNotificationWithoutSkippingOtherObservers() { + val manager = AACPManager() + val identifier = ControlCommandIdentifiers.LISTENING_MODE + var firstCount = 0 + var secondCount = 0 + val first = object : AACPManager.ControlCommandListener { + override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) { + firstCount++ + manager.unregisterControlCommandListener(identifier, this) + } + } + val second = object : AACPManager.ControlCommandListener { + override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) { + secondCount++ + } + } + manager.registerControlCommandListener(identifier, first) + manager.registerControlCommandListener(identifier, second) + manager.setControlCommandStatusValue(identifier, byteArrayOf(1)) + manager.setControlCommandStatusValue(identifier, byteArrayOf(2)) + assertEquals(1, firstCount) + assertEquals(2, secondCount) + } + + @Test fun headerValidationAcceptsPayloadWithoutRequiringStringConversion() { + val header = byteArrayOf(4, 0, 4, 0) + assertTrue(AACPManager.hasExpectedHeader(header)) + assertTrue(AACPManager.hasExpectedHeader(header + ByteArray(10_000) { 0xFF.toByte() })) + for (length in 0 until header.size) { + assertFalse(AACPManager.hasExpectedHeader(header.copyOf(length))) + } + for (index in header.indices) { + val corrupted = header.copyOf() + corrupted[index] = 0x7F + assertFalse(AACPManager.hasExpectedHeader(corrupted)) + } + } +} diff --git a/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/AirPodsStatusTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/AirPodsStatusTest.kt new file mode 100644 index 000000000..f659a0a76 --- /dev/null +++ b/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/AirPodsStatusTest.kt @@ -0,0 +1,38 @@ +package me.kavishdevar.librepods.bluetooth + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AirPodsStatusTest { + private val status = BLEManager.AirPodsStatus(address = "AA:BB:CC:DD:EE:FF", lastSeen = 1L) + + @Test fun repeatedAdvertisementsRefreshLivenessWithoutReportingAStateChange() { + repeat(10_000) { index -> + assertTrue(status.hasSameStateAs(status.copy(lastSeen = index.toLong() + 2))) + } + } + + @Test fun everyObservableStateChangeIsReported() { + val changes = listOf( + status.copy(address = "11:22:33:44:55:66"), + status.copy(paired = true), + status.copy(model = "AirPods Pro"), + status.copy(leftBattery = 50), + status.copy(rightBattery = 50), + status.copy(caseBattery = 50), + status.copy(isLeftInEar = true), + status.copy(isRightInEar = true), + status.copy(isLeftCharging = true), + status.copy(isRightCharging = true), + status.copy(isCaseCharging = true), + status.copy(lidOpen = true), + status.copy(color = "White"), + status.copy(connectionState = "Music") + ) + changes.forEach { changed -> + assertFalse("State change was ignored: $changed", status.hasSameStateAs(changed)) + assertFalse(changed.hasSameStateAs(status)) + } + } +} diff --git a/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/AttResponseMailboxTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/AttResponseMailboxTest.kt new file mode 100644 index 000000000..c2c13716e --- /dev/null +++ b/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/AttResponseMailboxTest.kt @@ -0,0 +1,79 @@ +package me.kavishdevar.librepods.bluetooth + +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class AttResponseMailboxTest { + @Test fun unsolicitedNotificationsAndRepliesAreNotSavedForFutureRequests() { + val mailbox = AttResponseMailbox() + repeat(100_000) { + mailbox.offer(byteArrayOf(0x1B, 0x18, 0, 1)) + mailbox.offer(byteArrayOf(0x0B, 1)) + } + val pending = mailbox.expect(0x0B) + assertNull(pending.await(0)) + mailbox.finish(pending) + } + + @Test fun responseArrivingBeforeAwaitIsRetainedAndNotificationsDoNotConsumeIt() { + val mailbox = AttResponseMailbox() + val pending = mailbox.expect(0x0B) + mailbox.offer(byteArrayOf(0x1B, 0x18, 0, 1)) + mailbox.offer(byteArrayOf(0x13)) + mailbox.offer(byteArrayOf(0x0B, 42)) + assertArrayEquals(byteArrayOf(0x0B, 42), pending.await(0)) + assertNull(pending.await(0)) + mailbox.finish(pending) + } + + @Test fun duplicateRepliesCannotBuildAnUnboundedBacklog() { + val mailbox = AttResponseMailbox() + val pending = mailbox.expect(0x0B) + mailbox.offer(byteArrayOf(0x0B, 42)) + repeat(100_000) { mailbox.offer(byteArrayOf(0x0B, 43)) } + assertArrayEquals(byteArrayOf(0x0B, 42), pending.await(0)) + assertNull(pending.await(0)) + mailbox.finish(pending) + } + + @Test fun finishedRequestDoesNotLeakAReplyToTheNextRequest() { + val mailbox = AttResponseMailbox() + val first = mailbox.expect(0x0B) + mailbox.offer(byteArrayOf(0x0B, 1)) + mailbox.finish(first) + mailbox.offer(byteArrayOf(0x0B, 2)) + val second = mailbox.expect(0x0B) + assertNull(second.await(0)) + mailbox.offer(byteArrayOf(0x0B, 3)) + assertArrayEquals(byteArrayOf(0x0B, 3), second.await(0)) + mailbox.finish(second) + } + + @Test fun disconnectReleasesWaitingRequestAndAllowsANewSession() { + val mailbox = AttResponseMailbox() + val pending = mailbox.expect(0x13) + val executor = Executors.newSingleThreadExecutor() + try { + val waiting = executor.submit { pending.await(30_000) } + mailbox.clear() + assertNull(waiting.get(1, TimeUnit.SECONDS)) + val next = mailbox.expect(0x13) + mailbox.finish(pending) + mailbox.offer(byteArrayOf(0x13)) + assertArrayEquals(byteArrayOf(0x13), next.await(0)) + mailbox.finish(next) + } finally { + executor.shutdownNow() + } + } + + @Test(expected = IllegalStateException::class) + fun overlappingRequestsMustBeSerializedByTheCaller() { + val mailbox = AttResponseMailbox() + mailbox.expect(0x0B) + mailbox.expect(0x13) + } +} diff --git a/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/BleScanLifecycleTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/BleScanLifecycleTest.kt new file mode 100644 index 000000000..ef88b4025 --- /dev/null +++ b/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/BleScanLifecycleTest.kt @@ -0,0 +1,110 @@ +package me.kavishdevar.librepods.bluetooth + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class BleScanLifecycleTest { + @Test fun duplicateAddressesAreIgnoredOnlyWithinOneBatch() { + val deduplicator = BleAdvertisementDeduplicator() + repeat(100) { + deduplicator.inBatch { + assertFalse(deduplicator.wasProcessed("AirPods")) + deduplicator.markProcessed("AirPods") + assertTrue(deduplicator.wasProcessed("AirPods")) + assertFalse(deduplicator.wasProcessed("Other AirPods")) + } + assertFalse(deduplicator.wasProcessed("AirPods")) + } + } + + @Test fun individualAdvertisementsNeverLeaveAnAddressBlocked() { + val deduplicator = BleAdvertisementDeduplicator() + repeat(10_000) { + assertFalse(deduplicator.wasProcessed("AirPods")) + deduplicator.markProcessed("AirPods") + } + } + + @Test fun aFailedBatchReleasesItsAddresses() { + val deduplicator = BleAdvertisementDeduplicator() + try { + deduplicator.inBatch { + deduplicator.markProcessed("AirPods") + throw IllegalStateException("Scan processing failed") + } + } catch (_: IllegalStateException) { + // Simulate the scanner's failure boundary. + } + assertFalse(deduplicator.wasProcessed("AirPods")) + } + + @Test fun restartingScanningDoesNotMultiplyMaintenanceLoops() { + val scheduled = mutableListOf() + var runs = 0 + val task = BleMaintenanceTask( + schedule = { scheduled.add(it) }, + cancel = { callback -> scheduled.removeAll { it === callback } }, + maintain = { runs++ } + ) + repeat(100) { task.start() } + assertEquals(1, scheduled.size) + repeat(100) { + scheduled.removeAt(0).run() + assertEquals(1, scheduled.size) + } + assertEquals(100, runs) + val dispatchedBeforeStop = scheduled.first() + task.stop() + assertTrue(scheduled.isEmpty()) + dispatchedBeforeStop.run() + assertTrue(scheduled.isEmpty()) + assertEquals(100, runs) + } + + @Test fun callbackDispatchedBeforeRestartCannotCreateASecondLoop() { + val scheduled = mutableListOf() + var runs = 0 + val task = BleMaintenanceTask( + schedule = { scheduled.add(it) }, + cancel = { callback -> scheduled.removeAll { it === callback } }, + maintain = { runs++ } + ) + task.start() + val oldDispatchedCallback = scheduled.removeAt(0) + task.start() + oldDispatchedCallback.run() + assertEquals(0, runs) + assertEquals(1, scheduled.size) + scheduled.removeAt(0).run() + assertEquals(1, runs) + assertEquals(1, scheduled.size) + } + + @Test fun aListenerRestartDuringMaintenanceStillLeavesOneCallback() { + val scheduled = mutableListOf() + lateinit var task: BleMaintenanceTask + task = BleMaintenanceTask( + schedule = { scheduled.add(it) }, + cancel = { callback -> scheduled.removeAll { it === callback } }, + maintain = { task.start() } + ) + task.start() + scheduled.removeAt(0).run() + assertEquals(1, scheduled.size) + } + + @Test fun aListenerStopDuringMaintenancePreventsRescheduling() { + val scheduled = mutableListOf() + lateinit var task: BleMaintenanceTask + task = BleMaintenanceTask( + schedule = { scheduled.add(it) }, + cancel = { callback -> scheduled.removeAll { it === callback } }, + maintain = { task.stop() } + ) + task.start() + scheduled.removeAt(0).run() + assertTrue(scheduled.isEmpty()) + } +} diff --git a/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/CachedProximityDecryptorTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/CachedProximityDecryptorTest.kt new file mode 100644 index 000000000..38c557768 --- /dev/null +++ b/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/CachedProximityDecryptorTest.kt @@ -0,0 +1,128 @@ +package me.kavishdevar.librepods.bluetooth + +import java.util.Base64 +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import javax.crypto.Cipher +import javax.crypto.spec.SecretKeySpec +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CachedProximityDecryptorTest { + private val key = "000102030405060708090a0b0c0d0e0f".hexToByteArray() + private val encodedKey = Base64.getEncoder().encodeToString(key) + private val plaintext = "00112233445566778899aabbccddeeff".hexToByteArray() + private val ciphertext = "69c4e0d86a7b0430d8cdb78070b4c55a".hexToByteArray() + + private fun cipher(mode: Int, key: ByteArray): Cipher = + Cipher.getInstance("AES/ECB/NoPadding").apply { init(mode, SecretKeySpec(key, "AES")) } + + @Test fun repeatedAdvertisementsDecryptTheRealAesVectorWithOneCipherInitialization() { + var initializations = 0 + val decryptor = CachedProximityDecryptor(createCipher = { + initializations++ + cipher(Cipher.DECRYPT_MODE, it) + }) + val advertisement = ByteArray(11) { 0x07 } + ciphertext + val original = advertisement.copyOf() + repeat(10_000) { assertArrayEquals(plaintext, decryptor.decryptLastBlock(advertisement, encodedKey)) } + assertEquals(1, initializations) + assertArrayEquals(original, advertisement) + } + + @Test fun keyRotationAndRemovalDoNotReuseThePreviousCipher() { + var initializations = 0 + val decryptor = CachedProximityDecryptor(createCipher = { + initializations++ + cipher(Cipher.DECRYPT_MODE, it) + }) + val nextKey = ByteArray(16) { (it + 16).toByte() } + val nextEncodedKey = Base64.getEncoder().encodeToString(nextKey) + val nextCiphertext = cipher(Cipher.ENCRYPT_MODE, nextKey).doFinal(plaintext) + assertArrayEquals(plaintext, decryptor.decryptLastBlock(ciphertext, encodedKey)) + assertArrayEquals(plaintext, decryptor.decryptLastBlock(nextCiphertext, nextEncodedKey)) + assertNull(decryptor.decryptLastBlock(nextCiphertext, null)) + assertArrayEquals(plaintext, decryptor.decryptLastBlock(ciphertext, encodedKey)) + assertEquals(3, initializations) + } + + @Test fun invalidKeysFallBackWithoutRepeatedInitializationOrRepeatedErrors() { + var initializations = 0 + var errors = 0 + val decryptor = CachedProximityDecryptor( + createCipher = { initializations++; cipher(Cipher.DECRYPT_MODE, it) }, + onError = { errors++ } + ) + assertArrayEquals(plaintext, decryptor.decryptLastBlock(ciphertext, encodedKey)) + repeat(1_000) { assertNull(decryptor.decryptLastBlock(ciphertext, "!not-base64!")) } + assertEquals(1, errors) + assertEquals(1, initializations) + val shortKey = Base64.getEncoder().encodeToString(byteArrayOf(1)) + repeat(1_000) { assertNull(decryptor.decryptLastBlock(ciphertext, shortKey)) } + assertEquals(2, errors) + assertEquals(2, initializations) + assertArrayEquals(plaintext, decryptor.decryptLastBlock(ciphertext, encodedKey)) + assertEquals(3, initializations) + } + + @Test fun incompletePacketsAndMissingKeysDoNotInitializeCrypto() { + var initializations = 0 + val decryptor = CachedProximityDecryptor(createCipher = { + initializations++ + cipher(Cipher.DECRYPT_MODE, it) + }) + for (length in 0 until 16) { + assertNull(decryptor.decryptLastBlock(ByteArray(length), encodedKey)) + } + assertNull(decryptor.decryptLastBlock(ciphertext, null)) + assertEquals(0, initializations) + } + + @Test fun concurrentDifferentPacketsReuseTheCipherWithoutMixingTheirPlaintext() { + val initializations = AtomicInteger() + val decryptor = CachedProximityDecryptor(createCipher = { + initializations.incrementAndGet() + cipher(Cipher.DECRYPT_MODE, it) + }) + val messages = List(4) { worker -> ByteArray(16) { (it + worker).toByte() } } + val packets = messages.map { cipher(Cipher.ENCRYPT_MODE, key).doFinal(it) } + val executor = Executors.newFixedThreadPool(4) + try { + val work = List(4) { worker -> + executor.submit { + repeat(1_000) { + assertArrayEquals(messages[worker], decryptor.decryptLastBlock(packets[worker], encodedKey)) + } + } + } + work.forEach { it.get(5, TimeUnit.SECONDS) } + assertEquals(1, initializations.get()) + } finally { + executor.shutdownNow() + } + } + + @Test fun concurrentKeyChangesCannotDecryptUsingAnotherCallsKey() { + val decryptor = CachedProximityDecryptor() + val nextKey = ByteArray(16) { (it + 16).toByte() } + val keys = listOf(encodedKey, Base64.getEncoder().encodeToString(nextKey)) + val packets = listOf(ciphertext, cipher(Cipher.ENCRYPT_MODE, nextKey).doFinal(plaintext)) + val executor = Executors.newFixedThreadPool(4) + try { + val work = List(4) { worker -> + executor.submit { + repeat(250) { + val index = worker % 2 + assertArrayEquals(plaintext, decryptor.decryptLastBlock(packets[index], keys[index])) + } + } + } + work.forEach { it.get(5, TimeUnit.SECONDS) } + } finally { + executor.shutdownNow() + } + } +} diff --git a/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/CachedRpaVerifierTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/CachedRpaVerifierTest.kt new file mode 100644 index 000000000..942e8e077 --- /dev/null +++ b/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/CachedRpaVerifierTest.kt @@ -0,0 +1,129 @@ +package me.kavishdevar.librepods.bluetooth + +import java.util.Base64 +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import me.kavishdevar.librepods.utils.BluetoothCryptography +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CachedRpaVerifierTest { + // Bluetooth ah vector, with the IRK in the existing verifier's little-endian order. + private val key = "9b7d390aa610103405adc857a33402ec".hexToByteArray() + private val encodedKey = Base64.getEncoder().encodeToString(key) + private val address = "70:81:94:0D:FB:AA" + + @Test fun aRealRpaVectorRetainsItsValidationResult() { + var validations = 0 + val verifier = CachedRpaVerifier(verifyRpa = { value, irk -> + validations++ + BluetoothCryptography.verifyRPA(value, irk) + }) + repeat(1_000) { assertTrue(verifier.verify(address, encodedKey)) } + assertFalse(verifier.verify("70:81:94:0D:FB:AB", encodedKey)) + assertEquals(2, validations) + } + + @Test fun aHundredThousandUnrelatedAdvertisementsNeedOneVerification() { + var validations = 0 + val verifier = CachedRpaVerifier(verifyRpa = { value, irk -> + validations++ + BluetoothCryptography.verifyRPA(value, irk) + }) + repeat(100_000) { assertFalse(verifier.verify("70:81:94:0D:FB:AB", encodedKey)) } + assertEquals(1, validations) + } + + @Test fun rotatingOrRemovingIrkInvalidatesBothPositiveAndNegativeResults() { + var validations = 0 + val verifier = CachedRpaVerifier(verifyRpa = { value, irk -> + validations++ + BluetoothCryptography.verifyRPA(value, irk) + }) + val otherKey = Base64.getEncoder().encodeToString(ByteArray(16)) + assertTrue(verifier.verify(address, encodedKey)) + assertFalse(verifier.verify(address, otherKey)) + assertTrue(verifier.verify(address, encodedKey)) + assertFalse(verifier.verify(address, null)) + assertTrue(verifier.verify(address, encodedKey)) + assertEquals(4, validations) + } + + @Test fun addressRetentionIsBoundedAndRecentEntriesRemainCached() { + var validations = 0 + val verifier = CachedRpaVerifier(capacity = 3, verifyRpa = { _, _ -> + validations++ + false + }) + repeat(10_000) { verifier.verify("address-$it", encodedKey) } + assertEquals(10_000, validations) + (9_997..9_999).forEach { verifier.verify("address-$it", encodedKey) } + assertEquals(10_000, validations) + verifier.verify("address-0", encodedKey) + assertEquals(10_001, validations) + // The touch above keeps 9999 recent when address-0 evicts 9997. + verifier.verify("address-9999", encodedKey) + assertEquals(10_001, validations) + verifier.verify("address-9997", encodedKey) + assertEquals(10_002, validations) + } + + @Test fun anInvalidKeyIsReportedOnceAndNeverReusesPreviouslyTrustedAddresses() { + var errors = 0 + var validations = 0 + val verifier = CachedRpaVerifier( + verifyRpa = { _, _ -> validations++; true }, + onInvalidKey = { errors++ } + ) + assertTrue(verifier.verify(address, encodedKey)) + repeat(1_000) { assertFalse(verifier.verify(address, "!not-base64!")) } + assertEquals(1, errors) + assertEquals(1, validations) + repeat(1_000) { assertFalse(verifier.verify(address, Base64.getEncoder().encodeToString(byteArrayOf(1)))) } + assertEquals(2, errors) + assertTrue(verifier.verify(address, encodedKey)) + assertEquals(2, validations) + } + + @Test fun concurrentCallbacksShareOneValidationForTheSameAddressAndKey() { + val validations = AtomicInteger() + val verifier = CachedRpaVerifier(verifyRpa = { value, irk -> + validations.incrementAndGet() + BluetoothCryptography.verifyRPA(value, irk) + }) + val executor = Executors.newFixedThreadPool(4) + try { + val work = List(4) { + executor.submit { + repeat(2_500) { assertTrue(verifier.verify(address, encodedKey)) } + } + } + work.forEach { it.get(5, TimeUnit.SECONDS) } + assertEquals(1, validations.get()) + } finally { + executor.shutdownNow() + } + } + + @Test fun concurrentKeyChangesCannotReuseAnotherKeysTrustDecision() { + val verifier = CachedRpaVerifier() + val otherKey = Base64.getEncoder().encodeToString(ByteArray(16)) + val executor = Executors.newFixedThreadPool(4) + try { + val work = List(4) { worker -> + executor.submit { + repeat(250) { + if (worker % 2 == 0) assertTrue(verifier.verify(address, encodedKey)) + else assertFalse(verifier.verify(address, otherKey)) + } + } + } + work.forEach { it.get(5, TimeUnit.SECONDS) } + } finally { + executor.shutdownNow() + } + } +} diff --git a/android/app/src/test/java/me/kavishdevar/librepods/data/HearingSettingsEncodingTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/data/HearingSettingsEncodingTest.kt new file mode 100644 index 000000000..38a5702dc --- /dev/null +++ b/android/app/src/test/java/me/kavishdevar/librepods/data/HearingSettingsEncodingTest.kt @@ -0,0 +1,65 @@ +package me.kavishdevar.librepods.data + +import org.junit.Assert.* +import org.junit.Test + +class HearingSettingsEncodingTest { + private val left = FloatArray(8) { it + 1f } + private val right = FloatArray(8) { -it - 1f } + private val hearing = HearingAidSettings( + left, right, -0.5f, 0.5f, 0.25f, -0.25f, true, false, + 0.75f, 0.25f, 0f, 1f, 0.125f + ) + private val transparency = TransparencySettings( + true, left, right, -0.5f, 0.5f, 0.25f, -0.25f, true, false, + 0.75f, 0.25f, 0f, 1f + ) + + // Fixed little-endian vectors from the original encoder's field order. + private val leftPayload = + "0000803f0000004000004040000080400000a0400000c0400000e04000000041" + + "000000bf0000803e0000803f0000403f" + private val rightPayload = + "000080bf000000c0000040c0000080c00000a0c00000c0c00000e0c0000000c1" + + "0000003f000080be000000000000803e" + + private fun hex(value: String) = value.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + @Test fun hearingAidMatchesLegacyVectorAndPreservesHeaderTailAndSource() { + val source = ByteArray(106) + hex("02026000").copyInto(source) + hex("aa55").copyInto(source, 104) + val original = source.copyOf() + + val encoded = encodeHearingAidSettings(source, hearing) + + assertArrayEquals(hex("02026400" + leftPayload + rightPayload + "0000003eaa55"), encoded) + assertArrayEquals(original, source) + } + + @Test fun shortHearingAidPacketIsNotWritten() { + assertNull(encodeHearingAidSettings(ByteArray(103), hearing)) + } + + @Test fun transparencyMatchesLegacyVectorWithoutOptionalField() { + val encoded = encodeTransparencySettings(transparency) + assertEquals(100, encoded.size) + assertArrayEquals(hex("0000803f" + leftPayload + rightPayload), encoded) + } + + @Test fun transparencyPreservesOptionalOwnVoiceFieldAndDisabledFlag() { + val encoded = encodeTransparencySettings(transparency.copy(enabled = false, ownVoiceAmplification = 0.125f)) + assertEquals(104, encoded.size) + assertArrayEquals(hex("00000000" + leftPayload + rightPayload + "0000003e"), encoded) + } + + @Test fun roundTripRetainsIndependentLeftAndRightEqualizers() { + val encoded = encodeHearingAidSettings(ByteArray(104), hearing)!! + val parsed = parseHearingAidSettingsResponse(encoded)!! + assertArrayEquals(left, parsed.leftEQ, 0f) + assertArrayEquals(right, parsed.rightEQ, 0f) + assertEquals(-0.5f, parsed.leftAmplification, 0f) + assertEquals(0.5f, parsed.rightAmplification, 0f) + assertEquals(0.125f, parsed.ownVoiceAmplification, 0f) + } +} diff --git a/android/app/src/test/java/me/kavishdevar/librepods/presentation/screens/DeviceSettingsEditorTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/presentation/screens/DeviceSettingsEditorTest.kt new file mode 100644 index 000000000..d8892cb0e --- /dev/null +++ b/android/app/src/test/java/me/kavishdevar/librepods/presentation/screens/DeviceSettingsEditorTest.kt @@ -0,0 +1,120 @@ +package me.kavishdevar.librepods.presentation.screens + +import me.kavishdevar.librepods.data.HearingAidSettings +import me.kavishdevar.librepods.data.encodeHearingAidSettings +import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse +import org.junit.Assert.* +import org.junit.Test + +class DeviceSettingsEditorTest { + private class Fixture { + var now = 0L + val writes = mutableListOf() + val scheduled = mutableListOf<() -> Unit>() + val delays = mutableListOf() + val editor = DeviceSettingsEditor( + schedule = { delay, action -> + delays += delay + scheduled += action + // Keep canceled callbacks to also simulate a callback already queued. + val cancel: () -> Unit = {} + cancel + }, + write = { writes += it }, + nowMillis = { now } + ) + } + + @Test fun repeatedDeviceNotificationsAndUneditedPageExitNeverWrite() { + val f = Fixture() + var displayed = 0 + repeat(100) { value -> + assertTrue(f.editor.applyDeviceUpdate { displayed = value }) + } + f.editor.flush() + assertEquals(99, displayed) + assertTrue(f.writes.isEmpty()) + assertTrue(f.scheduled.isEmpty()) + } + + @Test fun continuousDraggingOnlySendsLatestValueAfterOriginalDebounce() { + val f = Fixture() + repeat(100) { f.editor.userEdited(it) } + assertTrue(f.writes.isEmpty()) + assertTrue(f.delays.all { it == 100L }) + f.scheduled.forEach { it() } + assertEquals(listOf(99), f.writes) + } + + @Test fun leavingPageFlushesFinalEditExactlyOnce() { + val f = Fixture() + f.editor.userEdited(1) + f.editor.userEdited(2) + f.editor.flush() + f.scheduled.forEach { it() } + f.editor.flush() + assertEquals(listOf(2), f.writes) + } + + @Test fun delayedOrNormalizedEchoCannotReplaceNewerUserDraft() { + val f = Fixture() + var displayed = 2 + f.editor.userEdited(1) + f.now = 2000 + f.editor.userEdited(2) + f.now = 3000 + assertFalse(f.editor.applyDeviceUpdate { displayed = 1 }) + assertFalse(f.editor.applyDeviceUpdate { displayed = 3 }) + assertEquals(2, displayed) + f.editor.flush() + assertEquals(listOf(2), f.writes) + } + + @Test fun notificationsDoNotExtendSettlingWindowAfterFailedWrite() { + val f = Fixture() + f.editor.userEdited(2) + f.editor.flush() + f.now = 2499 + assertFalse(f.editor.applyDeviceUpdate {}) + f.now = 2500 + assertTrue(f.editor.applyDeviceUpdate {}) + assertEquals(listOf(2), f.writes) + } + + @Test fun normalizedGainAndQuantizedAsymmetricEqEchoCannotChangePendingPacket() { + val callbacks = mutableListOf<() -> Unit>() + val packets = mutableListOf() + val source = ByteArray(104) + val original = HearingAidSettings( + FloatArray(8) { 0.25f }, FloatArray(8) { -0.5f }, + 0.4f, 0.8f, 0f, 0f, false, false, 0f, 0f, 0.4f, 0.4f, 0f + ) + val latest = original.copy(leftEQ = FloatArray(8) { 0.33333f }) + var displayed = latest + val editor = DeviceSettingsEditor( + schedule = { _, action -> + callbacks += action + val cancel: () -> Unit = {} + cancel + }, + write = { packets += encodeHearingAidSettings(source, it)!! }, + nowMillis = { 0L } + ) + editor.userEdited(original) + editor.userEdited(latest) + val echo = parseHearingAidSettingsResponse( + encodeHearingAidSettings(source, original.copy(leftEQ = FloatArray(8) { 0.33f }))!! + )!! + // The protocol derives average gain from the two sides, changing 0.4 to 0.6. + assertEquals(0.6f, echo.netAmplification, 0.00001f) + assertFalse(editor.applyDeviceUpdate { displayed = echo }) + callbacks.forEach { it() } + assertEquals(latest, displayed) + assertEquals(1, packets.size) + val sent = parseHearingAidSettingsResponse(packets.single())!! + assertArrayEquals(latest.leftEQ, sent.leftEQ, 0f) + assertArrayEquals(latest.rightEQ, sent.rightEQ, 0f) + assertEquals(latest.leftAmplification, sent.leftAmplification, 0f) + assertEquals(latest.rightAmplification, sent.rightAmplification, 0f) + } +} diff --git a/android/app/src/test/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingPlotHistoryTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingPlotHistoryTest.kt new file mode 100644 index 000000000..359278a5e --- /dev/null +++ b/android/app/src/test/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingPlotHistoryTest.kt @@ -0,0 +1,64 @@ +package me.kavishdevar.librepods.presentation.screens + +import org.junit.Assert.assertEquals +import org.junit.Test +import kotlin.math.abs +import kotlin.random.Random + +class HeadTrackingPlotHistoryTest { + @Test fun emptyHistoryHasNoMagnitude() { + val history = HeadTrackingPlotHistory(100) + + assertEquals(0, history.size) + assertEquals(0f, history.maxMagnitude(), 0f) + } + + @Test fun wrappedHistoryRetainsChronologicalOrderAndFloatPrecision() { + val history = HeadTrackingPlotHistory(3) + for (value in 1..5) history.add(value + 0.125f, -value - 0.75f) + + assertEquals(3, history.size) + for (index in 0..2) { + assertEquals(index + 3.125f, history.horizontalAt(index), 0f) + assertEquals(-index - 3.75f, history.verticalAt(index), 0f) + } + } + + @Test fun evictingLargestSampleUpdatesAutomaticScale() { + val history = HeadTrackingPlotHistory(2) + history.add(-20_000f, 0f) + history.add(100f, 200f) + assertEquals(20_000f, history.maxMagnitude(), 0f) + + history.add(-300f, 100f) + + assertEquals(300f, history.maxMagnitude(), 0f) + } + + @Test fun longStreamMatchesPreviousLimitedListBehavior() { + val history = HeadTrackingPlotHistory(100) + val reference = ArrayDeque>() + val random = Random(42) + + repeat(2000) { + val horizontal = random.nextInt(-32_768, 32_768).toFloat() + val vertical = random.nextInt(-32_768, 32_768).toFloat() + reference.addLast(horizontal to vertical) + if (reference.size > 100) reference.removeFirst() + history.add(horizontal, vertical) + + assertEquals(reference.size, history.size) + reference.forEachIndexed { index, point -> + assertEquals(point.first, history.horizontalAt(index), 0f) + assertEquals(point.second, history.verticalAt(index), 0f) + } + val expectedMaximum = reference.maxOf { maxOf(abs(it.first), abs(it.second)) } + assertEquals(expectedMaximum, history.maxMagnitude(), 0f) + } + } + + @Test(expected = IllegalArgumentException::class) + fun zeroCapacityIsRejected() { + HeadTrackingPlotHistory(0) + } +} diff --git a/android/app/src/test/java/me/kavishdevar/librepods/presentation/screens/LogPreviewTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/presentation/screens/LogPreviewTest.kt new file mode 100644 index 000000000..20e27e2ee --- /dev/null +++ b/android/app/src/test/java/me/kavishdevar/librepods/presentation/screens/LogPreviewTest.kt @@ -0,0 +1,67 @@ +package me.kavishdevar.librepods.presentation.screens + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class LogPreviewTest { + @get:Rule val temporaryFolder = TemporaryFolder() + + @Test fun smallUtf8LogPreservesLines() { + val file = temporaryFolder.newFile() + file.writeText("连接成功\r\nBattery: 80%\r\n") + + val preview = readLogPreview(file) + + assertEquals(listOf("连接成功", "Battery: 80%", ""), preview.lines) + assertFalse(preview.isTruncated) + } + + @Test fun largeLogKeepsRecentCompleteLinesWithoutChangingFullFile() { + val file = temporaryFolder.newFile() + val original = (1..20_000).joinToString("\n") { "事件 $it: connected" } + file.writeText(original) + + val preview = readLogPreview(file) + + assertTrue(preview.isTruncated) + assertEquals(2000, preview.lines.size) + assertEquals("事件 20000: connected", preview.lines.last()) + assertTrue(preview.lines.all { it.startsWith("事件 ") }) + assertEquals(original, file.readText()) + } + + @Test fun excessiveLineCountIsBoundedEvenForSmallFiles() { + val file = temporaryFolder.newFile() + file.writeText((1..3000).joinToString("\n")) + + val preview = readLogPreview(file) + + assertEquals(2000, preview.lines.size) + assertEquals("1001", preview.lines.first()) + assertEquals("3000", preview.lines.last()) + assertTrue(preview.isTruncated) + } + + @Test fun singleHugeLineCannotCreateUnboundedTextLayout() { + val file = temporaryFolder.newFile() + file.writeText("x".repeat(1024 * 1024)) + + val preview = readLogPreview(file) + + assertEquals(1, preview.lines.size) + assertEquals(4097, preview.lines.single().length) + assertTrue(preview.isTruncated) + assertEquals(1024 * 1024L, file.length()) + } + + @Test fun emptyLogCanBePreviewed() { + val preview = readLogPreview(temporaryFolder.newFile()) + + assertEquals(listOf(""), preview.lines) + assertFalse(preview.isTruncated) + } +} diff --git a/android/app/src/test/java/me/kavishdevar/librepods/services/CloseableResourceScopeTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/services/CloseableResourceScopeTest.kt new file mode 100644 index 000000000..22e8d79b2 --- /dev/null +++ b/android/app/src/test/java/me/kavishdevar/librepods/services/CloseableResourceScopeTest.kt @@ -0,0 +1,104 @@ +package me.kavishdevar.librepods.services + +import java.io.Closeable +import java.util.concurrent.CancellationException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +class CloseableResourceScopeTest { + @Test + fun closingScopeUnblocksAnInFlightBlockingOperation() { + val scope = CloseableResourceScope() + val entered = CountDownLatch(1) + val connectionClosed = CountDownLatch(1) + val finished = CountDownLatch(1) + scope.track(Closeable { connectionClosed.countDown() }) + val worker = Thread { + entered.countDown() + connectionClosed.await() + finished.countDown() + }.apply { isDaemon = true; start() } + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)) + scope.close() + assertTrue("Closing the socket/process must release its blocked worker", finished.await(5, TimeUnit.SECONDS)) + } finally { + scope.close() + worker.join(5000) + } + } + + @Test + fun resourceCreatedAfterCancellationIsImmediatelyClosed() { + val scope = CloseableResourceScope() + var closes = 0 + scope.close() + try { + scope.track(Closeable { closes++ }) + fail("A stopped service cannot accept a new resource") + } catch (_: CancellationException) { + assertEquals(1, closes) + } + } + + @Test + fun completingOldAttemptDoesNotCloseANewerConnection() { + val service = CloseableResourceScope() + val oldAttempt = service.track(CloseableResourceScope()) + val newAttempt = service.track(CloseableResourceScope()) + var oldCloses = 0 + var newCloses = 0 + oldAttempt.track(Closeable { oldCloses++ }) + newAttempt.track(Closeable { newCloses++ }) + + oldAttempt.close() + service.release(oldAttempt) + oldAttempt.close() // A late worker completion repeats cleanup. + assertEquals(1, oldCloses) + assertEquals(0, newCloses) + assertFalse(newAttempt.isClosed) + + service.close() + assertEquals(1, oldCloses) + assertEquals(1, newCloses) + } + + @Test + fun oneFailingCloseDoesNotLeakOtherPendingResources() { + val scope = CloseableResourceScope() + var closed = false + scope.track(Closeable { error("Broken socket close") }) + scope.track(Closeable { closed = true }) + scope.close() + assertTrue(closed) + } + + @Test + fun cancelledAttemptCannotPublishItsConnection() { + val scope = CloseableResourceScope() + var published = false + scope.close() + try { + scope.whileOpen { published = true } + fail("Publishing after cancellation must be rejected") + } catch (_: CancellationException) { + assertFalse(published) + } + } + + @Test + fun finishedProcessesCanBeReleasedBeforeServiceDestruction() { + val scope = CloseableResourceScope() + var closes = 0 + val process = scope.track(Closeable { closes++ }) + process.close() + scope.release(process) + scope.close() + assertEquals(1, closes) + } +} diff --git a/android/app/src/test/java/me/kavishdevar/librepods/services/PacketLogBufferTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/services/PacketLogBufferTest.kt new file mode 100644 index 000000000..085bf97a9 --- /dev/null +++ b/android/app/src/test/java/me/kavishdevar/librepods/services/PacketLogBufferTest.kt @@ -0,0 +1,34 @@ +package me.kavishdevar.librepods.services + +import org.junit.Assert.* +import org.junit.Test + +class PacketLogBufferTest { + @Test fun longSessionRetainsOnlyTheLatestDistinctPackets() { + val history = PacketLogBuffer(1000) + repeat(100_000) { history.add("packet $it") } + assertEquals((99_000 until 100_000).map { "packet $it" }.toSet(), history.snapshot()) + } + + @Test fun repeatedPacketsDoNotEvictOtherDiagnosticsOrRequestAnotherWrite() { + val history = PacketLogBuffer(2) + history.add("first") + history.add("second") + repeat(10_000) { assertFalse(history.add("first")) } + assertEquals(linkedSetOf("first", "second"), history.snapshot()) + history.add("third") + assertEquals(linkedSetOf("second", "third"), history.snapshot()) + } + + @Test fun publishedSnapshotIsUnaffectedByNewPacketsAndClear() { + val history = PacketLogBuffer(2) + history.add("first") + history.add("second") + val snapshot = history.snapshot() + history.add("third") + history.clear() + assertTrue(history.snapshot().isEmpty()) + assertEquals(linkedSetOf("first", "second"), snapshot) + assertTrue(history.add("first")) + } +} diff --git a/android/app/src/test/java/me/kavishdevar/librepods/utils/ConflatedCallbackSessionTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/utils/ConflatedCallbackSessionTest.kt new file mode 100644 index 000000000..a370da2f7 --- /dev/null +++ b/android/app/src/test/java/me/kavishdevar/librepods/utils/ConflatedCallbackSessionTest.kt @@ -0,0 +1,72 @@ +package me.kavishdevar.librepods.utils + +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.launch +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConflatedCallbackSessionTest { + private class QueuedDispatcher : CoroutineDispatcher() { + private val pending = ArrayDeque() + val queuedTasks: Int get() = pending.size + + override fun dispatch(context: CoroutineContext, block: Runnable) { + pending.addLast(block) + } + + fun drain() { + var limit = 100 + while (pending.isNotEmpty()) { + check(limit-- > 0) { "Callback queue did not quiesce" } + pending.removeFirst().run() + } + } + } + + @Test + fun sensorBurstProducesOneQueuedTaskAndKeepsTheLatestDirection() { + val dispatcher = QueuedDispatcher() + val heard = mutableListOf() + val session = ConflatedCallbackSession(dispatcher, heard::add) + repeat(10_000) { assertTrue(session.offer(it)) } + assertEquals(1, dispatcher.queuedTasks) + dispatcher.drain() + assertEquals(listOf(9_999), heard) + session.close() + dispatcher.drain() + } + + @Test + fun stoppingSessionSuppressesAlreadyDispatchedSoundAndDetectionTasks() { + val dispatcher = QueuedDispatcher() + val heard = mutableListOf() + var detectionRan = false + val session = ConflatedCallbackSession(dispatcher, heard::add) + session.offer(1) + session.scope.launch { detectionRan = true } + session.close() + dispatcher.drain() + assertTrue(heard.isEmpty()) + assertFalse(detectionRan) + assertFalse(session.offer(2)) + } + + @Test + fun restartingDoesNotPlayOldFeedbackOrCancelNewSession() { + val dispatcher = QueuedDispatcher() + val heard = mutableListOf() + val oldSession = ConflatedCallbackSession(dispatcher, heard::add) + oldSession.offer("old") + oldSession.close() + val newSession = ConflatedCallbackSession(dispatcher, heard::add) + newSession.offer("new") + oldSession.close() + dispatcher.drain() + assertEquals(listOf("new"), heard) + newSession.close() + dispatcher.drain() + } +} diff --git a/android/app/src/test/java/me/kavishdevar/librepods/utils/DiagnosticLogStreamTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/utils/DiagnosticLogStreamTest.kt new file mode 100644 index 000000000..813e3a4e6 --- /dev/null +++ b/android/app/src/test/java/me/kavishdevar/librepods/utils/DiagnosticLogStreamTest.kt @@ -0,0 +1,68 @@ +package me.kavishdevar.librepods.utils + +import java.io.Reader +import java.io.StringReader +import java.io.StringWriter +import java.io.Writer +import org.junit.Assert.* +import org.junit.Test + +class DiagnosticLogStreamTest { + @Test fun preservesUnicodeAndMarkersAndReportsConnectionOnlyOnce() { + val input = "中文日志\n\n\n\nlast\n" + val output = StringWriter() + var callbacks = 0 + streamDiagnosticLog(StringReader(input).buffered(), output, { true }) { callbacks++ } + assertEquals(1, callbacks) + assertEquals( + "中文日志\n\n=============\n\n=============\n\n" + + "\n=============\n\n=============\n\n" + + "\n=============\n\n=============\n\nlast\n", + output.toString() + ) + } + + @Test fun disconnectDoesNotFinishCollectionAndStopPreventsMoreWrites() { + val output = StringWriter() + var collecting = true + var callbacks = 0 + val input = "BluetoothService CONNECTION_STATE_DISCONNECTED\nAirPodsService Connected to device\nignored\n" + streamDiagnosticLog(StringReader(input).buffered(), output, { collecting }) { + callbacks++ + collecting = false + } + assertEquals(1, callbacks) + assertEquals("BluetoothService CONNECTION_STATE_DISCONNECTED\nAirPodsService Connected to device\n", output.toString()) + } + + @Test fun veryLongSessionsAreWrittenIncrementallyWithoutDroppingLines() { + val line = "AirPods packet 1234\n" + val totalCharacters = line.length * 100_000 + var readCharacters = 0 + val input = object : Reader() { + override fun read(buffer: CharArray, offset: Int, length: Int): Int { + if (readCharacters == totalCharacters) return -1 + val count = minOf(length, totalCharacters - readCharacters) + repeat(count) { buffer[offset + it] = line[(readCharacters + it) % line.length] } + readCharacters += count + return count + } + override fun close() = Unit + } + var writtenCharacters = 0 + var largestWrite = 0 + val output = object : Writer() { + override fun write(buffer: CharArray, offset: Int, length: Int) { + largestWrite = maxOf(largestWrite, length) + writtenCharacters += length + // Only the reader's buffer may be ahead, never the whole collection. + assertTrue(readCharacters - writtenCharacters <= 8192) + } + override fun flush() = Unit + override fun close() = Unit + } + streamDiagnosticLog(input.buffered(), output, { true }) { fail("Unexpected callback") } + assertEquals(totalCharacters, writtenCharacters) + assertTrue(largestWrite <= line.length) + } +} diff --git a/android/app/src/test/java/me/kavishdevar/librepods/utils/OwnedCallbackLifecycleTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/utils/OwnedCallbackLifecycleTest.kt new file mode 100644 index 000000000..696ba505f --- /dev/null +++ b/android/app/src/test/java/me/kavishdevar/librepods/utils/OwnedCallbackLifecycleTest.kt @@ -0,0 +1,90 @@ +package me.kavishdevar.librepods.utils + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class OwnedCallbackLifecycleTest { + @Test + fun repeatedInitializationDoesNotRegisterDuplicateSystemCallbacks() { + val lifecycle = OwnedCallbackLifecycle() + val owner = Any() + var registrations = 0 + var unregistrations = 0 + repeat(100) { + lifecycle.start(owner) { + registrations++ + val cleanup: () -> Unit = { unregistrations++ } + cleanup + } + } + lifecycle.stop(owner) + lifecycle.stop(owner) + assertEquals(1, registrations) + assertEquals(1, unregistrations) + } + + @Test + fun callbacksQueuedBeforeStopCannotActOnTheNextService() { + val lifecycle = OwnedCallbackLifecycle() + val oldOwner = Any() + val newOwner = Any() + val queued = mutableListOf<() -> Unit>() + val received = mutableListOf() + lifecycle.start(oldOwner) { session -> + queued.add { if (session.isActive) received.add("old") } + val cleanup: () -> Unit = {} + cleanup + } + lifecycle.start(newOwner) { session -> + queued.add { if (session.isActive) received.add("new") } + val cleanup: () -> Unit = {} + cleanup + } + lifecycle.stop(oldOwner) // A delayed old service onDestroy must not unregister the new one. + queued.forEach { it() } + assertEquals(listOf("new"), received) + } + + @Test + fun replacingOwnerInvalidatesOldTokenBeforeUnregistering() { + val lifecycle = OwnedCallbackLifecycle() + val first = Any() + var oldUnregistered = false + lifecycle.start(first) { session -> + val cleanup: () -> Unit = { + assertFalse(session.isActive) + oldUnregistered = true + } + cleanup + } + lifecycle.start(Any()) { session -> + assertTrue(oldUnregistered) + assertTrue(session.isActive) + val cleanup: () -> Unit = {} + cleanup + } + } + + @Test + fun failedRegistrationInvalidatesCallbacksAndAllowsRetry() { + val lifecycle = OwnedCallbackLifecycle() + val owner = Any() + var failedToken: OwnedCallbackLifecycle.Session? = null + try { + lifecycle.start(owner) { session -> + failedToken = session + error("System registration failed") + } + } catch (_: IllegalStateException) { } + assertFalse(failedToken!!.isActive) + var retried = false + lifecycle.start(owner) { + retried = true + val cleanup: () -> Unit = {} + cleanup + } + assertTrue(retried) + } +} diff --git a/android/app/src/test/java/me/kavishdevar/librepods/utils/RecentGestureExtremesTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/utils/RecentGestureExtremesTest.kt new file mode 100644 index 000000000..2392da682 --- /dev/null +++ b/android/app/src/test/java/me/kavishdevar/librepods/utils/RecentGestureExtremesTest.kt @@ -0,0 +1,44 @@ +package me.kavishdevar.librepods.utils + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RecentGestureExtremesTest { + @Test + fun longSessionRetainsOnlyRecentExtremesInMovementOrder() { + val history = RecentGestureExtremes(4) + repeat(100_000) { index -> + history.add(if (index % 2 == 0) 700.0 else -800.0) + assertTrue("History must stay bounded throughout a session", history.size <= 4) + } + + // Peaks and troughs must stay interleaved even after the 100-sample sensor buffer fills. + assertEquals(listOf(700.0, -800.0, 700.0, -800.0), history.recent(4)) + assertEquals(listOf(-800.0, 700.0, -800.0), history.recent(3)) + } + + @Test + fun switchingBetweenFastAndSlowScoringUsesTheSameChronologicalTail() { + val history = RecentGestureExtremes(4) + val reference = mutableListOf() + repeat(240) { index -> + val value = (index + 500.0) * if (index % 2 == 0) 1 else -1 + reference.add(value) + history.add(value) + assertEquals(reference.takeLast(3), history.recent(3)) + assertEquals(reference.takeLast(4), history.recent(4)) + } + } + + @Test + fun newSessionDoesNotScorePreviousMovements() { + val history = RecentGestureExtremes(4) + listOf(700.0, -800.0, 900.0, -1000.0).forEach(history::add) + history.clear() + history.add(600.0) + + assertEquals(1, history.size) + assertEquals(listOf(600.0), history.recent(4)) + } +} diff --git a/android/app/src/test/java/me/kavishdevar/librepods/utils/VolumeTransitionTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/utils/VolumeTransitionTest.kt new file mode 100644 index 000000000..6952737d5 --- /dev/null +++ b/android/app/src/test/java/me/kavishdevar/librepods/utils/VolumeTransitionTest.kt @@ -0,0 +1,110 @@ +package me.kavishdevar.librepods.utils + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class VolumeTransitionTest { + private class TestScheduler { + val pending = ArrayDeque>() + val executedDelays = mutableListOf() + + fun schedule(action: Runnable, delayMs: Long) { + pending.addLast(action to delayMs) + } + + fun unschedule(action: Runnable) { + pending.removeAll { it.first === action } + } + + fun runNext() { + val (action, delayMs) = pending.removeFirst() + executedDelays.add(delayMs) + action.run() + } + + fun drain() { + var remaining = 100 + while (pending.isNotEmpty()) { + check(remaining-- > 0) { "Volume transition did not stop" } + runNext() + } + } + } + + @Test + fun rampsToTargetWithoutAnExtraWakeupAtTheEnd() { + val scheduler = TestScheduler() + val volumes = mutableListOf() + val transition = VolumeTransition(volumes::add, scheduler::schedule, scheduler::unschedule) + + transition.start(0, 3) + scheduler.drain() + assertEquals(listOf(1, 2, 3), volumes) + assertEquals(listOf(0L, 50L, 50L), scheduler.executedDelays) + + transition.start(3, 0) + scheduler.drain() + assertEquals(listOf(1, 2, 3, 2, 1, 0), volumes) + } + + @Test + fun rapidSpeakingEndCancelsOldRampEvenIfItsCallbackWasAlreadyDispatched() { + val scheduler = TestScheduler() + val volumes = mutableListOf() + val transition = VolumeTransition(volumes::add, scheduler::schedule, scheduler::unschedule) + + transition.start(10, 2) + scheduler.runNext() + val oldCallback = scheduler.pending.first().first + transition.start(9, 10) + assertEquals(1, scheduler.pending.size) + oldCallback.run() + scheduler.drain() + + assertEquals(listOf(9, 10), volumes) + assertTrue(scheduler.pending.isEmpty()) + } + + @Test + fun unchangedVolumeCancelsPreviouslyQueuedRamp() { + val scheduler = TestScheduler() + val volumes = mutableListOf() + val transition = VolumeTransition(volumes::add, scheduler::schedule, scheduler::unschedule) + + transition.start(10, 2) + val oldCallback = scheduler.pending.first().first + transition.start(10, 10) + oldCallback.run() + + assertTrue(volumes.isEmpty()) + assertTrue(scheduler.pending.isEmpty()) + } + + @Test + fun releaseCancelsPendingWritesWithoutSchedulingAnotherRamp() { + val scheduler = TestScheduler() + val volumes = mutableListOf() + val transition = VolumeTransition(volumes::add, scheduler::schedule, scheduler::unschedule) + transition.start(10, 2) + val dispatched = scheduler.pending.first().first + transition.cancel() + dispatched.run() + assertTrue(scheduler.pending.isEmpty()) + assertTrue(volumes.isEmpty()) + } + + @Test + fun repeatedChangesKeepOnlyOnePendingVolumeWrite() { + val scheduler = TestScheduler() + val volumes = mutableListOf() + val transition = VolumeTransition(volumes::add, scheduler::schedule, scheduler::unschedule) + + repeat(1_000) { index -> + transition.start(5, if (index % 2 == 0) 0 else 10) + assertEquals(1, scheduler.pending.size) + } + scheduler.drain() + assertEquals(listOf(6, 7, 8, 9, 10), volumes) + } +} diff --git a/android/gradle/gradle-daemon-jvm.properties b/android/gradle/gradle-daemon-jvm.properties new file mode 100644 index 000000000..fa4ed510e --- /dev/null +++ b/android/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,12 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/c2dd35c9d0aaf0ba6ad0791320f99dfc/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/e5810bd7fd1f8a586644409d395a7e55/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7b3c4877c0749019e6805bb61e421497/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/d76df094a9cbbabd3b08251f9e61444a/redirect +toolchainVersion=25