diff --git a/.github/workflows/ci-android.yml b/.github/workflows/ci-android.yml index 50922efa1..20d7503b8 100644 --- a/.github/workflows/ci-android.yml +++ b/.github/workflows/ci-android.yml @@ -1,212 +1,210 @@ -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' + 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 + id: sdk + # Keep these versions in sync with android/app/build.gradle.kts. + shell: bash 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 }} + 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: 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 - - 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 + echo "available=true" >> "$GITHUB_OUTPUT" - - 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 + - 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 assembleFossDebug \ + 2>&1 | tee build/ci-apk.log - - uses: actions/upload-artifact@v4 - if: github.event_name != 'pull_request' - with: - name: apk-debug - path: release/*debug.apk + - 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 - - uses: actions/upload-artifact@v4 - if: github.event_name != 'pull_request' - with: - name: root-module-release - path: release/*release.zip + - 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 - - uses: actions/upload-artifact@v4 - if: github.event_name != 'pull_request' - with: - name: root-module-debug - path: release/*debug.zip + - 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 - - uses: actions/upload-artifact@v4 - if: github.event_name != 'pull_request' - with: - name: release-bundle - path: release/*.aab + - 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 - release: - if: github.event_name == 'push' - runs-on: ubuntu-latest - needs: build - permissions: - contents: write + - name: Checksum release APK + if: ${{ !cancelled() && steps.release.outcome == 'success' }} + shell: bash + run: | + cd android/app/build/outputs/apk/foss/release + sha256sum *.apk > SHA256SUMS - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - fetch-tags: true - - uses: actions/download-artifact@v4 + - name: Upload debug APK + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 with: - name: apk-release - path: artifacts/apk-release + 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 release APK + if: ${{ !cancelled() && steps.release.outcome == 'success' }} + uses: actions/upload-artifact@v4 with: - name: apk-debug - path: artifacts/apk-debug + 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 - - 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-*.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 + SDK_OUTCOME: ${{ steps.sdk.outcome }} + APK_OUTCOME: ${{ steps.apk.outcome }} + TEST_OUTCOME: ${{ steps.tests.outcome }} + LINT_OUTCOME: ${{ steps.lint.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 "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)" + 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/.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/build.gradle.kts b/android/app/build.gradle.kts index 9b20a00ba..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-rc2" +val appVersionName = "1.0.0-airpods5.2" plugins { alias(libs.plugins.android.application) @@ -44,18 +44,26 @@ 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" targetSdk = 37 - versionCode = 63 + 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" ) @@ -123,6 +131,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/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/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 @@ + + 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/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/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/AttConnectionPolicy.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AttConnectionPolicy.kt new file mode 100644 index 000000000..5b7833441 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AttConnectionPolicy.kt @@ -0,0 +1,16 @@ +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. */ +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). + // 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/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 52fa05512..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,25 +89,16 @@ 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() - 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", @@ -111,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 @@ -125,6 +128,7 @@ class BLEManager(private val context: Context) { @SuppressLint("MissingPermission") fun startScanning() { + cleanupTask.stop() try { Log.d(TAG, "Starting BLE scanner") @@ -175,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") } } @@ -189,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) } @@ -197,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") @@ -204,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 @@ -254,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 (manufacturerData.size <= 20) 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 { @@ -295,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) } @@ -334,8 +301,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 @@ -415,25 +381,9 @@ 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 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/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/AirPods.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/AirPods.kt index 9d83f05f5..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, @@ -141,6 +138,55 @@ class AirPods4ANC: AirPodsBase( ) ) +// Share only artwork with AirPods 4; capabilities stay model-specific. +// The same artwork is used on the main screen and connection overlays. +private val airPods4Artwork = AirPods4() + +// 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, + Capability.ADAPTIVE_AUDIO, + Capability.ADAPTIVE_VOLUME, + Capability.STEM_CONFIG + ) +) + +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, + Capability.ADAPTIVE_AUDIO, + Capability.ADAPTIVE_VOLUME, + Capability.STEM_CONFIG, + Capability.SWIPE_FOR_VOLUME + ) +) + class AirPodsPro1: AirPodsBase( modelNumber = listOf("A2084", "A2083"), name = "AirPods Pro 1", @@ -156,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 ) ) @@ -175,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, @@ -203,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, @@ -231,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, @@ -265,6 +318,8 @@ object AirPodsModels { AirPods3(), AirPods4(), AirPods4ANC(), + AirPods5(), + AirPods5Wireless(), AirPodsPro1(), AirPodsPro2Lightning(), AirPodsPro2USBC(), 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/SleepTimerSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/SleepTimerSettings.kt new file mode 100644 index 000000000..748fd97f1 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/SleepTimerSettings.kt @@ -0,0 +1,370 @@ +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.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 +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +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.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 +fun SleepTimerSettings( + endAt: Long, + currentMode: Int, + 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 showSheet by remember(initiallyOpen) { mutableStateOf(initiallyOpen) } + 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 isActive = timerEndAt > now + val description = if (isActive) { + val minutes = SleepTimerManager.remainingMinutes(timerEndAt, now) + if (minutes >= 60) { + stringResource( + R.string.sleep_timer_active_hours, + modeLabel(activeTargetMode), + minutes / 60, + minutes % 60 + ) + } else { + stringResource( + R.string.sleep_timer_active_minutes, + modeLabel(activeTargetMode), + minutes + ) + } + } else { + stringResource(R.string.sleep_timer_not_set) + } + + 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( + text = stringResource(R.string.sleep_timer_duration), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) + 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 = { + durationText = ((duration ?: 90) - 5) + .coerceAtLeast(1) + .toString() + }, + enabled = (duration ?: 1) > 1 + ) { + Text( + text = "−", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + 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 = { + 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( + text = modeLabel(mode), + style = MaterialTheme.typography.labelMedium, + color = if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + textAlign = TextAlign.Center, + maxLines = 1 + ) + } + } + } + + 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 = { + if (!canStart) return@Button + val minutes = duration + activeTargetMode = selectedMode + timerEndAt = onStartTimer(minutes, selectedMode) + showSheet = false + }, + enabled = canStart, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(28.dp) + ) { + Text(stringResource(R.string.start_timer)) + } + } + } + } +} + +@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) +} + +@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/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/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..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 @@ -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 @@ -114,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 @@ -123,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 @@ -148,6 +149,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 @@ -236,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( @@ -436,15 +442,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 +465,7 @@ fun AirPodsSettingsScreen( conversationalAwarenessCapability = conversationalAwarenessCapability, loudSoundReductionCapability = loudSoundReductionCapability, adaptiveAudioCapability = adaptiveAudioCapability, - customEqCapability = true, + customEqCapability = capabilities.contains(Capability.CUSTOM_EQ), adaptiveVolumeChecked = adaptiveVolumeChecked, onAdaptiveVolumeCheckedChange = { checked -> setControlCommandBoolean( @@ -561,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/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/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 0b43370e0..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] @@ -460,13 +509,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 +643,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 ?: "" ) } } @@ -663,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()) { @@ -705,6 +800,7 @@ class AirPodsViewModel( } } } + enableATTNotifications() } fun setAutomaticEarDetectionEnabled(enabled: Boolean) { 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 0cf08c11d..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 @@ -109,6 +113,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 @@ -130,9 +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" @@ -157,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 @@ -228,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 @@ -274,7 +300,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList caseLevel = caseLevel, caseCharging = caseCharging == true ) - updateBattery() + updateBattery(force = false) } override fun onBroadcastFromNewAddress(device: BLEManager.AirPodsStatus) { @@ -341,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) + } } } @@ -371,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() @@ -386,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 { @@ -537,10 +541,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 @@ -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,12 +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) - popupWindow.open(name, batteryNotification) - popupShown = true } var islandOpen = false @@ -1691,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(), @@ -1699,7 +1732,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList this@AirPodsService, type, reversed, - otherDeviceName + otherDeviceName, + artworkRes = (airpodsInstance?.model + ?: AirPodsModels.getModelByModelNumber(config.airpodsModelNumber))?.connectionArtworkRes ) } } @@ -1714,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( @@ -1834,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() ) } @@ -1898,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, @@ -1973,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 = @@ -2035,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, @@ -2097,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) { @@ -2439,9 +2500,14 @@ 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 + sendBatteryNotification() otherDeviceTookOver = false takeOver("music", manualTakeOverAfterReversed = true) } @@ -2449,6 +2515,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( @@ -2485,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, @@ -2519,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") { @@ -2633,212 +2710,234 @@ 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 (BluetoothConnectionManager.aacpSocket != null && BluetoothConnectionManager.aacpSocket?.isConnected == true) 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}") + 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 { - runBlocking { - withTimeout(5000.milliseconds) { - try { - socket.connect() - this@AirPodsService.device = device - val xposedRemotePref = XposedRemotePrefProvider.create() - val attSocket = if (xposedRemotePref.getBoolean("vendor_id_hook", false)) { - createBluetoothSocket( - adapter, - device, - ParcelUuid.fromString("00000000-0000-0000-0000-000000000000"), - 31 - ) - } else null - attSocket?.connect() - - if (attSocket != null) { - attManager.startReader() - attManager.readCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION) - attManager.readCharacteristic(ATTHandles.TRANSPARENCY) - attManager.readCharacteristic(ATTHandles.HEARING_AID) - } + connectToSocketOnce(adapter, device, manual) + } finally { + connectionInProgress.set(false) + } + } - BluetoothConnectionManager.aacpSocket = socket - BluetoothConnectionManager.attSocket = attSocket - - // 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) - } - } + @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag") + private fun connectToSocketOnce( + adapter: BluetoothAdapter, device: BluetoothDevice, manual: Boolean + ) { + 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") - 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)) - } catch (e: Exception) { -// 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 + val attempt = CloseableResourceScope() + var socket: BluetoothSocket? = null + var pendingAttSocket: BluetoothSocket? = 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 + } + } + } + + fun isCurrentConnection(): Boolean = + !serviceResources.isClosed && !attempt.isClosed && + BluetoothConnectionManager.aacpSocket === socket + + 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 (!socket.isConnected) { - Log.d(TAG, " socket not connected") - if (manual) { - sendToast( - "Couldn't connect to socket: timeout." + if (attSocket != null) { + attManager.startReader() + attManager.readCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION) + attManager.readCharacteristic(ATTHandles.TRANSPARENCY) + attManager.readCharacteristic(ATTHandles.HEARING_AID) + } + 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}") - 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() { @@ -2854,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 } @@ -2890,41 +2978,29 @@ 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() - } + withAudioProfile(this, BluetoothProfile.A2DP) { proxy -> + if (proxy.connectedDevices.isNotEmpty()) MediaController.sendPause() + } + try { + 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) } - bluetoothAdapter.closeProfileProxy(profile, proxy) } - - override fun onServiceDisconnected(profile: Int) {} - }, BluetoothProfile.A2DP) - try { - device?.disconnect() } catch (e: Exception) { Log.w(TAG, "device.disconnect() failed, $e") } } 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") } @@ -2957,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) { @@ -3106,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) @@ -3126,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) { @@ -3185,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/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/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/main/res-apple/drawable/airpods_4.png b/android/app/src/main/res-apple/drawable/airpods_4.png new file mode 100644 index 000000000..ba699ec54 Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_4.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_4_buds.png b/android/app/src/main/res-apple/drawable/airpods_4_buds.png new file mode 100644 index 000000000..ff3a60016 Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_4_buds.png differ 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 000000000..97e8e24ea Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_4_case.png differ diff --git a/android/app/src/main/res-apple/drawable/airpods_4_left.png b/android/app/src/main/res-apple/drawable/airpods_4_left.png new file mode 100644 index 000000000..8ab22f1d4 Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_4_left.png differ 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 000000000..f2fe728c2 Binary files /dev/null and b/android/app/src/main/res-apple/drawable/airpods_4_right.png differ 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"> + + 故障排除步骤 请输入 dbHL 中的损失值 关于 + 睡眠定时器 + 设置时长和结束时切换的模式。 + 未设置 + 当前模式:%1$s(保持不变) + 结束时切换到 + 时长 + 分钟 + 开始定时 + %2$d 分钟后切换到%1$s + %2$d 小时 %3$d 分钟后切换到%1$s + 取消定时 型号名称 型号编号 序列号 @@ -211,4 +222,5 @@ 允许外部声音进入 动态调整外部噪音 阻隔外部声音 + 将手机的蓝牙身份模拟为苹果设备,以使用兼容的连接功能。可用设置取决于耳机型号,不会为 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..d253411ee 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -222,6 +222,17 @@ Battery View accurate battery status in the app and notification. Switch between listening modes directly from the app or Quick Settings. + Sleep Timer + Set a duration and the mode to switch to. + Not set + 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 @@ -291,4 +302,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/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/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) + } + } + } +} 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/AttConnectionPolicyTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/AttConnectionPolicyTest.kt new file mode 100644 index 000000000..562826e28 --- /dev/null +++ b/android/app/src/test/java/me/kavishdevar/librepods/bluetooth/AttConnectionPolicyTest.kt @@ -0,0 +1,35 @@ +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 +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(AirPodsPro1(), true)) + 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/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/AirPodsModelsTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/data/AirPodsModelsTest.kt new file mode 100644 index 000000000..822ca1b45 --- /dev/null +++ b/android/app/src/test/java/me/kavishdevar/librepods/data/AirPodsModelsTest.kt @@ -0,0 +1,113 @@ +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.SLEEP_DETECTION, + Capability.CUSTOM_EQ, + 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, + Capability.LOUD_SOUND_REDUCTION, Capability.CUSTOM_TRANSPARENCY) + 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.connectionArtworkRes, actual.connectionArtworkRes) + 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) + } +} 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 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) + 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. diff --git a/root-module-manual/module.prop b/root-module-manual/module.prop index aeaa4129c..e95aad4e7 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.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. -updateJson=https://raw.githubusercontent.com/kavishdevar/librepods/main/extras/update_nonpatch.json