Compare commits

..

22 Commits

Author SHA1 Message Date
Kavish Devar
0f50eab788 android: move ATT code to viewmodel from screens and enable notifications 2026-05-31 00:48:20 +05:30
Kavish Devar
1381022b2e android: fix name field being empty on rename screen launch 2026-05-31 00:48:20 +05:30
Kavish Devar
af4261485a android: fix rework ATT connection 2026-05-31 00:48:20 +05:30
Kavish Devar
571db0ebde android: listen to UUID broadcasts 2026-05-31 00:48:20 +05:30
Kavish Devar
3c3c0edffd android: add message for Play users who unlocked FOSS upgrade 2026-05-31 00:48:20 +05:30
Kavish Devar
f86d7b9aca android: fix PLAY_BUILD flag 2026-05-31 00:48:20 +05:30
Kavish Devar
29a914c2ff docs: update 2-way audio description for Android in project README 2026-05-19 02:05:07 +05:30
Kavish Devar
3f2a7df749 docs: remove installation instructions from project README 2026-05-16 10:10:06 +05:30
Kavish Devar
f9367f4445 docs: fix root requirement link in repo README 2026-05-16 10:01:57 +05:30
Kavish Devar
0101428108 docs: add screenshots header to android README 2026-05-16 00:11:49 +05:30
Kavish Devar
37c1837d0b docs: fix badge label encoding in README 2026-05-15 23:42:41 +05:30
Kavish Devar
154ed17c05 docs: update README 2026-05-15 23:36:11 +05:30
Kavish Devar
362e34202a ci: rename linux ci 2026-05-15 22:25:31 +05:30
Kavish Devar
5bc5079e13 update android bug report template 2026-05-11 22:12:39 +05:30
Kavish Devar
64d233d427 android: use issue template 2026-05-11 22:10:14 +05:30
Kavish Devar
ea2c2b811b update app source options 2026-05-11 21:55:25 +05:30
Kavish Devar
6f28df734e ci: build only unsigned debug apks on PR 2026-05-11 21:48:27 +05:30
Kavish Devar
c15e15a6b7 update app source options in android bug report template 2026-05-11 21:44:35 +05:30
thisisAcidic
75a52cdfd7 add GitHub issue templates (#565)
* chore: add GitHub issue templates for Android, Linux, and feature requests

* remove btl2capfix module option and add instructions for logs

Updated bug report template for Android to clarify logging instructions and options for rooted devices.

* Refine Linux bug report template details

Updated labels and descriptions for clarity in the bug report template.

---------

Co-authored-by: Kavish Devar <mail@kavishdevar.me>
2026-05-11 21:35:04 +05:30
Kavish Devar
d1b32d5a00 android: add permissions file to root module 2026-05-10 15:41:35 +05:30
Kavish Devar
044aff731f android: keep only xposed flavor
also changed Build.ID check to startsWith("CP1A")
2026-05-07 21:12:10 +05:30
Kavish Devar
216c97f9ca android: add CP1A.260505.005 to comptible build ids on Pixel 2026-05-06 17:29:23 +05:30
56 changed files with 1382 additions and 1060 deletions

View File

@@ -0,0 +1,110 @@
name: Bug report (Android)
description: Report a bug in the Android app
labels: ["bug", "android"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug. Please fill in as much as you can.
- type: input
id: app-version
attributes:
label: App version
description: "Find this in `Settings → About → Version` in the app, or in your phone's app info."
placeholder: "v0.2.5 (build 46)"
validations:
required: true
- type: dropdown
id: app-source
attributes:
label: App source
options:
- GitHub
- Play
- Built from source
validations:
required: true
- type: input
id: device
attributes:
label: Device
description: Manufacturer and model.
placeholder: "Google Pixel 8 Pro"
validations:
required: true
- type: input
id: android-version
attributes:
label: Android / OS version
description: Include the OEM skin if relevant.
placeholder: "Android 16, OxygenOS 16, ColorOS 16, ..."
validations:
required: true
- type: dropdown
id: root-method
attributes:
label: Root / hook method
options:
- No root (native L2CAP support)
- Magisk + Xposed
- KernelSU + Xposed
- Other (describe in additional context)
validations:
required: true
- type: dropdown
id: airpods-model
attributes:
label: AirPods model
options:
- AirPods (1st gen)
- AirPods (2nd gen)
- AirPods (3rd gen)
- AirPods (4th gen)
- AirPods (4th gen) with ANC
- AirPods Pro (1st gen)
- AirPods Pro 2 (Lightning)
- AirPods Pro 2 (USB-C)
- AirPods Pro 3
- Other / not sure
validations:
required: true
- type: input
id: firmware
attributes:
label: AirPods firmware
description: Find this under `About` in the app once connected.
placeholder: "8454768"
- type: textarea
id: description
attributes:
label: What happened
description: Describe what you observed and what you expected. Include steps to reproduce if applicable.
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs
description: |
If you are rooted, give the app root access, open the app, go to `Settings → Troubleshooting → Collect Logs`, and attach the resulting file here.
Without logs most bugs are very hard to diagnose. If you are not, follow these instructions:
(Needs access to a computer, and USB/Wireless Debugging under developer options enabled)
Commands:
- Get the uid: Linux/Mac: `adb shell dumpsys package me.kavishdevar.librepods | grep uid`
- Start logs: `adb logcat --uid=<uid>,1002 >> librepods-logs.txt` (1002 is for bluetooth)
Steps for proper logs
- force close the app
- turn off bluetooth
- start logs
- open the app
- turn on bluetooth and connect
placeholder: Paste log content or attach the file
- type: textarea
id: extra
attributes:
label: Additional context
description: Anything else that might help (screenshots, video, related issues, what you've already tried).

View File

@@ -0,0 +1,83 @@
name: Bug report (Linux)
description: Report a bug in the Linux program
labels: ["bug", "linux"]
title: "[Linux] "
body:
- type: markdown
attributes:
value: |
Thanks for the report. Please fill in as much as you can.
- type: input
id: app-version
attributes:
label: App version
placeholder: "linux-v0.1.0, or linux-rust commit abc1234"
validations:
required: true
- type: dropdown
id: variant
attributes:
label: Variant
options:
- Rust rewrite (`linux-rust` branch)
- QT version (NOT MAINTAINED! issues will be closed)
validations:
required: true
- type: input
id: distro
attributes:
label: Distro and version
placeholder: "Arch Linux, Fedora 41, Ubuntu 24.04, NixOS 25.05"
validations:
required: true
- type: input
id: desktop
attributes:
label: Desktop environment / compositor
placeholder: "GNOME 47 (Wayland), KDE 6 (X11), Hyprland, ..."
validations:
required: true
- type: dropdown
id: install-method
attributes:
label: Install method (only official sources)
options:
- Built from source (`nix` or otherwise)
- Pre-built binary
- AppImage
- Other
validations:
required: true
- type: dropdown
id: airpods-model
attributes:
label: AirPods model
options:
- AirPods (1st gen)
- AirPods (2nd gen)
- AirPods (3rd gen)
- AirPods (4th gen)
- AirPods (4th gen) with ANC
- AirPods Pro (1st gen)
- AirPods Pro 2 (Lightning)
- AirPods Pro 2 (USB-C)
- AirPods Pro 3
- Other / not sure
validations:
required: true
- type: textarea
id: description
attributes:
label: What happened
description: Describe what you observed and what you expected. Include steps to reproduce if applicable.
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs and stderr
description: Run the app from a terminal with `--debug` and paste the output.
- type: textarea
id: extra
attributes:
label: Additional context

View File

@@ -0,0 +1,31 @@
name: Feature request
description: Suggest a new feature or improvement
labels: ["enhancement"]
body:
- type: dropdown
id: scope
attributes:
label: Scope
options:
- Android
- Linux
- Both
- Other
validations:
required: true
- type: textarea
id: problem
attributes:
label: Problem or use case
description: What are you trying to do? What is missing or hard today?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
description: How might it work? UI sketches, behavior, edge cases.
- type: textarea
id: alternatives
attributes:
label: Alternatives considered

View File

@@ -6,6 +6,7 @@ on:
- '*'
paths:
- 'android/**'
- 'root-module-manual/**'
pull_request:
paths:
- 'android/**'
@@ -34,6 +35,7 @@ jobs:
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
@@ -42,6 +44,7 @@ jobs:
- name: Install NDK
run: sdkmanager "ndk;30.0.14904198"
- name: Create local.properties
if: github.event_name != 'pull_request'
run: |
cat <<EOF > android/local.properties
RELEASE_STORE_FILE=../release.keystore
@@ -49,7 +52,12 @@ jobs:
RELEASE_KEY_ALIAS=${{ secrets.RELEASE_KEY_ALIAS }}
RELEASE_KEY_PASSWORD=${{ secrets.RELEASE_KEY_PASSWORD }}
EOF
- name: Build
- name: Build debug APK for PRs
if: github.event_name == 'pull_request'
run: ./gradlew assembleFossDebug
working-directory: android
- name: Build release artifacts
if: github.event_name != 'pull_request'
run: ./gradlew packageReleaseArtifacts
working-directory: android
- name: Get app version
@@ -58,26 +66,37 @@ jobs:
- id: vars
run: echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- uses: actions/upload-artifact@v4
if: github.event_name != 'pull_request'
with:
name: apk-release
path: release/*release.apk
- uses: actions/upload-artifact@v4
if: github.event_name == 'pull_request'
with:
name: apk-debug
path: android/app/build/outputs/apk/foss/debug/app-foss-debug.apk
- uses: actions/upload-artifact@v4
if: github.event_name != 'pull_request'
with:
name: apk-debug
path: release/*debug.apk
- uses: actions/upload-artifact@v4
if: github.event_name != 'pull_request'
with:
name: root-module-release
path: release/*release.zip
- uses: actions/upload-artifact@v4
if: github.event_name != 'pull_request'
with:
name: root-module-debug
path: release/*debug.zip
- uses: actions/upload-artifact@v4
if: github.event_name != 'pull_request'
with:
name: release-bundle
path: release/*.aab

View File

@@ -1,4 +1,4 @@
name: Linux Build & Release
name: Linux CI (Rust)
on:
push:

287
README.md
View File

@@ -1,110 +1,99 @@
<p align="center">
<img src="https://img.shields.io/github/stars/kavishdevar/librepods?style=for-the-badge&logoColor=white" />
<img src="https://img.shields.io/github/license/kavishdevar/librepods?style=for-the-badge" />
<img src="https://img.shields.io/github/v/release/kavishdevar/librepods?style=for-the-badge&logoColor=white&label=Release" />
<img src="https://img.shields.io/github/downloads/kavishdevar/librepods/total?style=for-the-badge&label=Downloads" />
<img src="https://img.shields.io/github/issues/kavishdevar/librepods?style=for-the-badge" />
<a href="https://discord.gg/HhG4ycVum4">
<img src="https://img.shields.io/discord/1441416992027574375?style=for-the-badge&logoColor=white&color=5865F2&label=Discord" />
</a>
</p>
>[!IMPORTANT]
Development paused due to lack of time until 17th May 2026 (JEE Advanced). PRs and issues might not be responded to until then.
Development paused due to lack of time until June 2026 (JEE Advanced). PRs and issues might not be responded to until then.
![LibrePods Banner](./imgs/banner.png)
---
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./imgs/banner-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./imgs/banner.png" />
<img alt="LibrePods" src="./imgs/banner.png" />
</picture>
<div align="center" style="margin: 20px 0px;">
<a href="https://github.com/kavishdevar/librepods/releases/latest">
<img src="https://img.shields.io/github/downloads/kavishdevar/librepods/total?label=GitHub%20Downloads" />
</a>
<a href="https://github.com/kavishdevar/librepods/actions/workflows/ci-android.yml">
<img src="https://github.com/kavishdevar/librepods/actions/workflows/ci-android.yml/badge.svg" />
</a>
<a href="https://github.com/kavishdevar/librepods/actions/workflows/ci-linux-rust.yml">
<img src="https://github.com/kavishdevar/librepods/actions/workflows/ci-linux-rust.yml/badge.svg" />
</a>
<a href="https://github.com/kavishdevar/librepods/issues">
<img src="https://img.shields.io/github/issues/kavishdevar/librepods" />
</a>
<a href="https://discord.gg/HhG4ycVum4">
<img src="https://img.shields.io/discord/1441416992027574375?logoColor=white&color=5865F2&label=Discord" />
</a>
</div>
# What is LibrePods?
LibrePods unlocks Apple's exclusive AirPods features on non-Apple devices. Get access to noise control modes, adaptive transparency, ear detection, hearing aid, customized transparency mode, battery status, and more - all the premium features you paid for but Apple locked to their ecosystem.
LibrePods allows you to use AirPods features that are exclusive to Apple devices. It implements the proprietary protocol used to exchange data between AirPods and Apple devices, enabling features like changing noise control modes, fast ear detection, accurate battery status, head gestures, conversational awareness, and more on non-Apple platforms.
# Device Compatibility
# Feature availability
| Status | Device | Features |
| ------ | --------------------- | ---------------------------------------------------------- |
| ✅ | AirPods Pro (2nd Gen) | Fully supported and tested |
| ✅ | AirPods Pro (3rd Gen) | Fully supported (except heartrate monitoring) |
| ✅ | AirPods Max | Fully supported (client shows unsupported features) |
| ⚠️ | Other AirPods models | Basic features (battery status, ear detection) should work |
| Feature | Linux | Android |
| ----------------------------------------------------------- | ----- | ------- |
| Changing Listening Mode | ✅ | ✅ |
| Ear detection | ✅ | ✅ |
| Battery status | ✅ | ✅ |
| Renaming AirPods <details><summary>Note for Android</summary>On Android, you need to re-pair your AirPods after renaming them because Android might not use the latest name.</details> | ✅ | ✅ |
| Loud Sound Reduction | 🔴 | ⚪ |
| Head Gestures | ⛔ | ✅ |
| Conversational Awareness | ✅ | ✅ |
| Automatically connect to AirPods | ✅ | ✅ |
| Hearing Aid | 🔴 | ⚪ |
| Transparency Mode customization | 🔴 | ⚪ |
| Multi-device connectivity (Bluetooth Multipoint; 2 devices only) | ⚪ | ⚪ |
| <details><summary>Other accessibility configs (click to expand)</summary><ul><li>Press speed</li><li>Press and Hold duration</li><li>Noise Cancellation with single AirPod</li><li>Volume control on swipe</li><li>Volume swipe speed</li></ul></details> | 🔴 | ✅ |
| <details><summary>Other general configs</summary><ul><li>Press and Hold to cycle between listening modes/invoke digital assistant (invoking digital assistant needs a recent firmware)</li><li>Configure call controls</li><li>Personalized volume</li><li>Loud Sound Reduction (needs <a href="#vendorid-spoofing">VendorID spoofing</a>)</li><li>Microphone side</li><li>Pause media when falling asleep (needs a recent firmware)</li><li>Enable <code>Off listening mode</code> to switch to <code>Off</code></li></ul></details> | 🔴 | ✅ |
| [Head-tracked Spatial Audio](#spatial-audio) | ❓ | ❓ |
| [Heart Rate Monitoring](#heart-rate-monitoring) | ⛔ | 🔴 |
| [Find My](#find-my) | ❓ | ❓ |
| [High quality two-way audio](#high-quality-two-way-audio) | 🔴 | 🔴 |
Most features should work with any AirPods. Currently, I've only got AirPods Pro 2 to test with. But, I believe the protocol remains the same for all other AirPods (based on analysis of the bluetooth stack on macOS).
| Symbol | Meaning |
| ------ | ------------------------------------------------------------------- |
| ✅ | Implemented and works well |
| ⚪ | Needs [VendorID spoofing](#vendorid-spoofing); use at your own risk |
| 🔴 | Not implemented yet; planned |
| ⛔ | Will not be implemented |
| ❓ | Unknown |
# Key Features
## Find My
- **Noise Control Modes**: Easily switch between noise control modes without having to reach out to your AirPods to long press
- **Ear Detection**: Controls your music automatically when you put your AirPods in or take them out, and switch to phone speaker when you take them out
- **Battery Status**: Accurate battery levels
- **Head Gestures**: Answer calls just by nodding your head
- **Conversational Awareness**: Volume automatically lowers when you speak
- **Hearing Aid\***
- **Customize Transparency Mode\***
- **Multi-device connectivity\*** (upto 2 devices)
- **Other customizations**:
- Rename your AirPods
- Customize long-press actions
- All accessibility settings
- And more!
The following features related to Find My are planned, but require further RE and might need root on Android:
&ast; Features marked with an asterisk require the VendorID to be change to that of Apple.
- Add your AirPods to the Find My network
- Play sound through charging case to find it
- Notify when leaving behind
- Toggle case charging sounds
# Platform Support
## Spatial Audio
## Linux
for the old version see the [Linux README](./linux/README.md). (doesn't have many features, maintainer didn't have time to work on it)
The app does not currently provide head tracking information to Android for the OS to perform HRTF. This has not been explored completely, and it might need root.
new version in development ([#241](https://github.com/kavishdevar/librepods/pull/241))
Spatializing stereo sound is beyond this project's scope and will never be available. Many OEMs have an implementation of their own for this.
![new version](https://github.com/user-attachments/assets/86b3c871-89a8-4e49-861a-5119de1e1d28)
## Heart Rate Monitoring (AirPods Pro 3 and later)
This is being worked upon, check the #reverse-engineering channel on the LibrePods Discord server for more information. If it is ever implemented, it will most likely need root on Android.
## Android
## High quality two-way audio
On iOS/iPadOS, you can continue using A2DP while AirPods send the audio stream from its microphone over AACP.
### Screenshots
Since this needs deeper integration with audio on Android, it will most likely need root.
| | | |
| --------------------------------------------------------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------- |
| ![Settings 1](./android/imgs/settings-1.png) | ![Settings 2](./android/imgs/settings-2.png) | ![Debug Screen](./android/imgs/debug.png) |
| ![Battery Notification and QS Tile for NC Mode](./android/imgs/notification-and-qs.png) | ![Popup](./android/imgs/popup.png) | ![Head Tracking and Gestures](./android/imgs/head-tracking-and-gestures.png) |
| ![Long Press Configuration](./android/imgs/long-press.png) | ![Widget](./android/imgs/widget.png) | ![Customizations 1](./android/imgs/customizations-1.png) |
| ![Customizations 2](./android/imgs/customizations-2.png) | ![accessibility](./android/imgs/accessibility.png) | ![transparency](./android/imgs/transparency.png) |
| ![hearing-aid](./android/imgs/hearing-aid.png) | ![hearing-test](./android/imgs/hearing-test.png) | ![hearing-aid-adjustments](./android/imgs/hearing-aid-adjustments.png) |
# Installation
- [**Android**](/android/README.md)
- [**Linux**](/linux/README.md)
here's a very unprofessional demo video
https://github.com/user-attachments/assets/43911243-0576-4093-8c55-89c1db5ea533
### Root Requirement
LibrePods **may** require root depending on your device/OS and what features you want access to:
- Features requiring the VendorID hook ([the features marked with an asterisk here](https://github.com/kavishdevar/librepods#key-features)) will always require root regardless of your device/OS.
- On **ColorOS/OxygenOS 16** and **Pixel devices on Android 16 QPR3** (with the latest Google Play system update), LibrePods does not need root for most features (except those requiring the VendorID hook mentioned above).
- On other devices, LibrePods needs root because of a bug in the Android Bluetooth stack Fluoride/non-compliance of Apple with Bluetooth standards. You must have Xposed installed for the app to workaround this bug and connect to AirPods. [This issue is being tracked here](https://issuetracker.google.com/issues/371713238). Please do not comment on the issue thread. The issue has already been resolved and should be available in **Android 17** for all devices.
> [!IMPORTANT]
> This workaround with Xposed is not guaranteed to work on all devices.
### Troubleshooting steps for common errors
- Ensure the correct scope is set in LSPosed/Vector.
- Ensure there is no root-hiding module preventing the hook from loading on the Bluetooth app.
- Restart your phone after confirming the scope.
### A few notes
- Due to recent AirPods' firmware upgrades, you must enable `Off listening mode` to switch to `Off`. This is because in this mode, loud sounds are not reduced.
- If you have take both AirPods out, the app will automatically switch to the phone speaker. But, Android might keep on trying to connect to the AirPods because the phone is still connected to them, just the A2DP profile is not connected. The app tries to disconnect the A2DP profile as soon as it detects that Android has connected again if they're not in the ear.
- When renaming your AirPods through the app, you'll need to re-pair them with your phone for the name change to take effect. This is a limitation of how Bluetooth device naming works on Android.
- If you want the AirPods icon and battery status to show in Android Settings app, install the app as a system app by using the root module.
# Changing VendorID in the DID profile to that of Apple
# VendorID Spoofing
Turns out, if you change the VendorID in DID Profile to that of Apple, you get access to several special features!
You can do this on Linux by editing the DeviceID in `/etc/bluetooth/main.conf`. Add this line to the config file `DeviceID = bluetooth:004C:0000:0000`. For android you can enable the `act as Apple device` setting in the app's settings.
You can do this on Linux by editing the DeviceID in `/etc/bluetooth/main.conf`. Add this line to the config file `DeviceID = bluetooth:004C:0000:0000`. For android you can enable the `act as Apple device` setting in the app's settings (shown only when Xposed is available and LibrePods module is enabled).
## Multi-device Connectivity
@@ -116,49 +105,123 @@ Accessibility settings like customizing transparency mode (amplification, balanc
All hearing aid customizations can be done from Android (linux soon), including setting the audiogram result. The app doesn't provide a way to take a hearing test because it requires much more precision. It is much better to use an already available audiogram result.
# Protocol and Reverse Engineering
Please refer to the Wireshark dissector plugin by Nojus ([@pabloaul](https://github.com/pabloaul)) for more information on the protocols used: [pabloaul/apple-wireshark](https://github.com/pabloaul/apple-wireshark)
The dissector had not been used in LibrePods for most of the implementation; I had reverse engineered the protocol myself before this dissector was made. But many (future) features including two-way high quality audio and spatial audio would not have been possible without their RE efforts!
# Use of AI
## Android app
These parts of the app were completely AI-generated:
- Head Gestures - all of it, including logic and the UI
- The offset setup with r2+the xposed module (both versions)
- Troubleshooter and LogCollector
Rest everything- the background service, the Bluetooth manager classes (AACP and ATT), the entire UI, even the smallest components were written manually.
Some parts of the UI components were borrowed from [Kyant0's demo app](https://github.com/Kyant0/AndroidLiquidGlass/tree/master/catalog), which is licensed under [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0).
## Linux (rewrite)
The `aacp.rs` and the `att.rs` files were translated from Kotlin to Rust with AI. Some parts of the `media_controller.rs` file, mainly the pulse integration, was also AI-generated.
# Supporters
A huge thank you to everyone supporting the project!
- @davdroman
- @tedsalmon
- @wiless
- @SmartMsg
- @lunaroyster
- @ressiwage
# Special thanks
<table>
<tr>
<td align="center">
<a href="https://github.com/davdroman">
<img src="https://github.com/davdroman.png?size=48" width="48" height="48"alt="davdroman"/><br />
@davdroman
</a>
</td>
<td align="center">
<a href="https://github.com/tedsalmon">
<img src="https://github.com/tedsalmon.png?size=48" width="48" height="48"alt="tedsalmon"/><br />
@tedsalmon
</a>
</td>
<td align="center">
<a href="https://github.com/wiless">
<img src="https://github.com/wiless.png?size=48" width="48" height="48"alt="wiless"/><br />
@wiless
</a>
</td>
</tr>
<tr>
<td align="center">
<a href="https://github.com/SmartMsg">
<img src="https://github.com/SmartMsg.png?size=48" width="48" height="48"alt="SmartMsg"/><br />
@SmartMsg
</a>
</td>
<td align="center">
<a href="https://github.com/lunaroyster">
<img src="https://github.com/lunaroyster.png?size=48" width="48" height="48"alt="lunaroyster"/><br />
@lunaroyster
</a>
</td>
<td align="center">
<a href="https://github.com/ressiwage">
<img src="https://github.com/ressiwage.png?size=48" width="48" height="48"alt="ressiwage"/><br />
@ressiwage
</a>
</td>
</tr>
<tr>
<td align="center">
<a href="https://github.com/kkjdroid">
<img src="https://github.com/kkjdroid.png?size=48" width="48" height="48"alt="kkjdroid"/><br />
@kkjdroid
</a>
</td>
<td align="center">
<a href="https://github.com/CitrusJoules">
<img src="https://github.com/CitrusJoules.png?size=48" width="48" height="48"alt="CitrusJoules"/><br />
@CitrusJoules
</a>
</td>
<td align="center">
<a href="https://github.com/DanielReyesDev">
<img src="https://github.com/DanielReyesDev.png?size=48" width="48" height="48"alt="DanielReyesDev"/><br />
@DanielReyesDev
</a>
</td>
</tr>
<tr>
<td align="center">
<a href="https://github.com/sumitduster">
<img src="https://github.com/sumitduster.png?size=48" width="48" height="48"alt="sumitduster"/><br />
@sumitduster
</a>
</td>
<td align="center">
<a href="https://github.com/GrifTheDev">
<img src="https://github.com/GrifTheDev.png?size=48" width="48" height="48"alt="GrifTheDev"/><br />
@GrifTheDev
</a>
</td>
</tr>
</table>
# Special Thanks
- @tyalie for making the first documentation on the protocol! ([tyalie/AAP-Protocol-Definition](https://github.com/tyalie/AAP-Protocol-Defintion))
- @rithvikvibhu and folks over at lagrangepoint for helping with the hearing aid feature ([gist](https://gist.github.com/rithvikvibhu/45e24bbe5ade30125f152383daf07016))
- @devnoname120 for helping with the first root patch
- @timgromeyer for making the first version of the linux app
- @hackclub for hosting [High Seas](https://highseas.hackclub.com) and [Low Skies](https://low-skies.hackclub.com)!
- Of course, everyone who has contributed to the project in any way, including by testing, sharing feedback, or just showing interest!
# Alternates for other platforms:
- CAPod - A companion app for AirPods on Android. ([play store](https://play.google.com/store/apps/details?id=eu.darken.capod) | [source code](https://github.com/d4rken-org/capod)). Use this if you're using Android version 16 QPR3 or below and are not rooted.
- MagicPods for Steam Deck ([website](https://magicpods.app/steamdeck/))
- MagicPods - if you're looking for "LibrePods for Windows" ([ms store](https://apps.microsoft.com/store/detail/9P6SKKFKSHKM) [installer](https://magicpods.app/installer/MagicPods.appinstaller) | [website](https://magicpods.app/))
# Nightly/Development Builds
Want to try the latest features before they're officially released? You can grab nightly builds from GitHub Actions:
### Android
1. Go to the [Actions tab](https://github.com/kavishdevar/librepods/actions/workflows/ci-android.yml)
2. Click on the most recent successful workflow run
3. Scroll down to **Artifacts** and download the **Debug APK** zip file
4. Extract the zip and install the `.apk` on your device
> [!NOTE]
> You need to be signed in to GitHub to download artifacts. Nightly builds are debug-signed and may not auto-update. You may need to uninstall the stable version first.
### Linux (Rust)
1. Go to the [Actions tab](https://github.com/kavishdevar/librepods/actions/workflows/ci-linux-rust.yml)
2. Click on the most recent successful workflow run
3. Download the **librepods-x86_64.AppImage** or **librepods** binary from **Artifacts**
> [!WARNING]
> Nightly builds are unstable and may contain bugs. Use at your own risk.
# Star History
<a href="https://www.star-history.com/#kavishdevar/librepods&type=date&legend=top-left">

67
android/README.md Normal file
View File

@@ -0,0 +1,67 @@
## Root Requirement
LibrePods *may* require root depending on your device/OS and what features you want access to:
- Features requiring the VendorID hook ([the features marked with an asterisk here](https://github.com/kavishdevar/librepods#key-features)) will always require root regardless of your device/OS.
- On **ColorOS/OxygenOS 16 and realme UI 7.0** and **Pixel devices on Android 16 QPR3** (with the latest Google Play system update), LibrePods does not need root for most features.
- On other devices, LibrePods needs root because of a bug in the Android Bluetooth stack Fluoride/non-compliance of Apple with Bluetooth standards. You must have Xposed installed for the app to workaround this bug and connect to AirPods. [This issue is being tracked here](https://issuetracker.google.com/issues/371713238). **Please do not comment on the issue thread.** The issue has already been resolved and should be available in **Android 17** for all devices.
> [!IMPORTANT]
> This workaround with Xposed is not guaranteed to work on all devices.
## Installation
### Google Play Store
If you are using a supported device/OS combination, you can install LibrePods from the Google Play Store. You can use the VendorID hook features with root even from the Play Store version.
<a href="https://play.google.com/store/apps/details?id=me.kavishdevar.librepods"><img width="170" alt="GetItOnGooglePlay_Badge_Web_color_English" src="https://github.com/user-attachments/assets/2948308f-af92-443f-94d9-ee381c3a6ccc"/></a>
### GitHub Releases
If you need xposed because of the [root requirement](#root-requirement), you will have to use the apk/zip from the [GitHub releases](https://github.com/kavishdevar/librepods/releases/latest).
### As a system app (root module)
If you want LibrePods to have privileged Bluetooth permissions to
- show battery status in the system settings and widgets
- show AirPods icon in the system settings (xposed is also currently required for this)
- switch audio to phone speakers when you are not wearing your AirPods
you can install the root module. This is optional and only provides extra features, but it is not required for the app to work.
> [!IMPORTANT]
> When using the root module, do not install the Play Store version. There might be issues because of the signature mismatch between the Play Store version and the root module.
## Nightly/Development Builds
Want to try the latest features before they're officially released? You can grab nightly builds from the [latest nightly release](https://github.com/kavishdevar/librepods/releases?q=nightly).
> [!WARNING]
> These builds are automatically generated from the latest code and may contain new features and bug fixes that haven't been included in a stable release yet. However, please note that they may also be less stable than official releases, so use them at your own risk.
## Screenshots
| | | |
| ------------------------------------------------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------- |
| ![Settings 1](./imgs/settings-1.png) | ![Settings 2](./imgs/settings-2.png) | ![Head Tracking and Gestures](./imgs/head-tracking-and-gestures.png) |
| ![Long Press Configuration](./imgs/long-press.png) | ![Customizations 1](./imgs/customizations-1.png) | ![accessibility](./imgs/accessibility.png) |
| ![transparency](./imgs/transparency.png) | ![hearing-aid](./imgs/hearing-aid.png) | ![hearing-test](./imgs/hearing-test.png) |
| ![hearing-aid-adjustments](./imgs/hearing-aid-adjustments.png) | ![Battery Notification and QS Tile for NC Mode](./imgs/notification-and-qs.png) | ![Widget](./imgs/widget.png) |
here's a very unprofessional demo video
https://github.com/user-attachments/assets/43911243-0576-4093-8c55-89c1db5ea533
### Troubleshooting steps for common errors
- Ensure the correct scope is set in LSPosed/Vector.
- Ensure there is no root-hiding module preventing the hook from loading on the Bluetooth app.
- Restart your phone after confirming the scope.
### A few notes
- Due to recent AirPods' firmware upgrades, you must enable `Off listening mode` to switch to `Off`. This is because in this mode, loud sounds are not reduced.
- When renaming your AirPods through the app, you'll need to re-pair them with your phone for the name change to take effect. This is a limitation of how Bluetooth device naming works on Android.

View File

@@ -10,12 +10,23 @@ plugins {
id("kotlin-parcelize")
}
val localPropsFile = rootProject.file("local.properties")
val props = Properties().apply {
load(rootProject.file("local.properties").inputStream())
if (localPropsFile.exists()) {
load(localPropsFile.inputStream())
}
}
val releaseSigningAvailable = listOf(
"RELEASE_STORE_FILE",
"RELEASE_STORE_PASSWORD",
"RELEASE_KEY_ALIAS",
"RELEASE_KEY_PASSWORD"
).all { props[it]?.toString()?.isNotBlank() == true }
android {
signingConfigs {
if (releaseSigningAvailable) {
create("release") {
storeFile = file(props["RELEASE_STORE_FILE"] as String)
storePassword = props["RELEASE_STORE_PASSWORD"] as String
@@ -23,14 +34,14 @@ android {
keyPassword = props["RELEASE_KEY_PASSWORD"] as String
}
}
}
namespace = "me.kavishdevar.librepods"
compileSdk = 37
defaultConfig {
applicationId = "me.kavishdevar.librepods"
minSdk = 33
targetSdk = 37
versionCode = 50
versionCode = 55
versionName = appVersionName
}
buildTypes {
@@ -45,23 +56,33 @@ android {
arguments += "-DCMAKE_BUILD_TYPE=Release"
}
}
buildConfigField("Boolean", "PLAY_BUILD", "false")
if (releaseSigningAvailable) {
signingConfig = signingConfigs.getByName("release")
}
defaultConfig {
minSdk = 33
}
}
debug {
buildConfigField("Boolean", "PLAY_BUILD", "false")
if (releaseSigningAvailable) {
signingConfig = signingConfigs.getByName("release")
versionNameSuffix = "-debug"
}
create("playRelease") {
initWith(getByName("release"))
versionNameSuffix = "-debug"
defaultConfig {
minSdk = 33
}
}
}
productFlavors {
create("foss") {
dimension = "env"
buildConfigField("Boolean", "PLAY_BUILD", "false")
}
create("play") {
dimension = "env"
buildConfigField("Boolean", "PLAY_BUILD", "true")
versionNameSuffix = "-play"
}
create("playDebug") {
initWith(getByName("debug"))
buildConfigField("Boolean", "PLAY_BUILD", "true")
versionNameSuffix = "-youshouldnothavethis"
minSdk = 36
}
}
compileOptions {
@@ -91,25 +112,6 @@ android {
ndkVersion = "30.0.14904198"
flavorDimensions += "env"
productFlavors {
create("normal") {
dimension = "env"
externalNativeBuild {
cmake {
arguments += "-DIS_XPOSED=OFF"
}
}
}
create("xposed") {
dimension = "env"
externalNativeBuild {
cmake {
arguments += "-DIS_XPOSED=ON"
}
}
}
}
}
dependencies {
@@ -139,9 +141,10 @@ dependencies {
implementation(libs.backdrop)
// implementation(libs.hilt)
// implementation(libs.hilt.compiler)
add("xposedCompileOnly", libs.libxposed.api)
add("xposedImplementation", libs.libxposed.service)
add("playReleaseImplementation", libs.billing)
compileOnly(libs.libxposed.api)
implementation(libs.libxposed.service)
implementation(libs.play.review)
implementation(libs.play.review.ktx)
}
aboutLibraries {
@@ -184,14 +187,14 @@ fun registerRootModuleZipTask(
}
val zipRelease = registerRootModuleZipTask(
"zipXposedReleaseModule",
"xposed",
"zipReleaseModule",
"foss",
"release"
)
val zipDebug = registerRootModuleZipTask(
"zipXposedDebugModule",
"xposed",
"zipDebugModule",
"foss",
"debug"
)
@@ -200,22 +203,22 @@ val collect = tasks.register<Copy>("collectReleaseArtifacts") {
dependsOn(
zipRelease,
zipDebug,
"bundleXposedPlayRelease"
"bundlePlayRelease"
)
into(releaseDir)
from(layout.buildDirectory.dir("outputs/apk/xposed/release")) {
from(layout.buildDirectory.dir("outputs/apk/foss/release")) {
include("*.apk")
rename(".*", "LibrePods-FOSS-v$appVersionName-release.apk")
}
from(layout.buildDirectory.dir("outputs/apk/xposed/debug")) {
from(layout.buildDirectory.dir("outputs/apk/foss/debug")) {
include("*.apk")
rename(".*", "LibrePods-FOSS-v$appVersionName-debug.apk")
}
from(layout.buildDirectory.dir("outputs/bundle/xposedPlayRelease")) {
from(layout.buildDirectory.dir("outputs/bundle/playRelease")) {
include("*.aab")
}

View File

@@ -3,8 +3,6 @@ cmake_minimum_required(VERSION 3.22.1)
project("l2c_fcr_hook")
set(CMAKE_CXX_STANDARD 23)
option(IS_XPOSED "Build Xposed components" OFF)
add_library(bluetooth_socket SHARED
bluetooth_socket.cpp
)
@@ -24,24 +22,22 @@ target_link_libraries(bluetooth_socket
log
)
if(IS_XPOSED)
set(XPOSED_SRC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../xposed/cpp)
add_library(l2c_fcr_hook SHARED
${XPOSED_SRC_DIR}/l2c_fcr_hook.cpp
l2c_fcr_hook.cpp
${XPOSED_SRC_DIR}/xz/xz_crc32.c
${XPOSED_SRC_DIR}/xz/xz_crc64.c
${XPOSED_SRC_DIR}/xz/xz_sha256.c
${XPOSED_SRC_DIR}/xz/xz_dec_stream.c
${XPOSED_SRC_DIR}/xz/xz_dec_lzma2.c
${XPOSED_SRC_DIR}/xz/xz_dec_bcj.c
xz/xz_crc32.c
xz/xz_crc64.c
xz/xz_sha256.c
xz/xz_dec_stream.c
xz/xz_dec_lzma2.c
xz/xz_dec_bcj.c
)
target_include_directories(l2c_fcr_hook PRIVATE
${XPOSED_SRC_DIR}
${XPOSED_SRC_DIR}/xz
xz
)
target_compile_definitions(l2c_fcr_hook PRIVATE
@@ -59,5 +55,3 @@ if(IS_XPOSED)
android
log
)
endif()

View File

@@ -12,6 +12,7 @@ import me.kavishdevar.librepods.utils.XposedServiceHolder
import me.kavishdevar.librepods.utils.XposedState
class LibrePodsApplication: Application(), XposedServiceHelper.OnServiceListener, DefaultLifecycleObserver {
override fun onCreate() {
XposedServiceHelper.registerListener(this)
BillingManager.provider = BillingProviderFactory.create(this)

View File

@@ -24,6 +24,7 @@ package me.kavishdevar.librepods
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
//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
@@ -65,7 +66,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Notifications
@@ -87,13 +87,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
@@ -114,6 +112,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.MultiplePermissionsState
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.google.android.play.core.review.ReviewManagerFactory
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource
@@ -122,14 +121,8 @@ import dev.chrisbanes.haze.rememberHazeState
import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.data.ControlCommandRepository
import me.kavishdevar.librepods.presentation.components.AppInfoCard
import me.kavishdevar.librepods.presentation.components.ConfirmationDialog
import me.kavishdevar.librepods.presentation.components.DeviceInfoCard
import me.kavishdevar.librepods.presentation.components.SelectItem
import me.kavishdevar.librepods.presentation.components.StyledBottomSheet
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.presentation.components.StyledInputField
import me.kavishdevar.librepods.presentation.components.StyledSelectList
import me.kavishdevar.librepods.presentation.screens.AccessibilitySettingsScreen
import me.kavishdevar.librepods.presentation.screens.AdaptiveStrengthScreen
import me.kavishdevar.librepods.presentation.screens.AirPodsSettingsScreen
@@ -159,6 +152,7 @@ import kotlin.io.encoding.ExperimentalEncodingApi
lateinit var serviceConnection: ServiceConnection
lateinit var connectionStatusReceiver: BroadcastReceiver
lateinit var testReviewReceiver: BroadcastReceiver
//@AndroidEntryPoint
@ExperimentalMaterial3Api
@@ -225,8 +219,6 @@ fun Main() {
val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
if (!isSupported(sharedPreferences) && !XposedState.bluetoothScopeEnabled) {
val showDialog = remember { mutableStateOf(false) }
val showPlayBypassVisible = remember { mutableStateOf(false) }
val hazeState = rememberHazeState()
val backdrop = rememberLayerBackdrop()
val isDarkTheme = isSystemInDarkTheme()
@@ -288,173 +280,25 @@ fun Main() {
.padding(horizontal = 12.dp, vertical = 16.dp)
)
}
StyledButton(
onClick = { showDialog.value = true },
backdrop = rememberLayerBackdrop(),
modifier = Modifier
.fillMaxWidth(),
isInteractive = false,
surfaceColor = if (isDarkTheme) Color(0xFF862424) else Color(0xFFC94646)
) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.bypass_compatibility_check),
text = stringResource(R.string.enable_app_in_xposed_or_update_device),
style = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium,
color = Color.White,
fontSize = 16.sp
fontWeight = FontWeight.Light,
color = if (isDarkTheme) Color.White else Color.Black,
fontSize = 14.sp
),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp)
)
}
Spacer(modifier = Modifier.height(24.dp))
DeviceInfoCard()
AppInfoCard()
}
Spacer(modifier = Modifier.height(48.dp))
}
}
ConfirmationDialog(
showDialog = showDialog,
title = stringResource(R.string.bypass_compatibility_check),
message = stringResource(R.string.bypass_compatiblity_check_confirmation),
confirmText = stringResource(R.string.yes),
dismissText = stringResource(R.string.no),
onConfirm = {
showDialog.value = false
if (BuildConfig.PLAY_BUILD) {
showPlayBypassVisible.value = true
} else {
sharedPreferences.edit {
putBoolean("bypass_device_check.v2", true)
}
val intent = Intent(context, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
context.startActivity(intent)
}
},
onDismiss = {
showDialog.value = false
},
backdrop = backdrop
// hazeState = hazeState
)
if (BuildConfig.PLAY_BUILD) {
StyledBottomSheet(
visible = showPlayBypassVisible.value,
onDismiss = {
showPlayBypassVisible.value = false
showDialog.value = true
},
backdrop = backdrop
) { innerBackdrop, _ ->
val contentColor = if (isDarkTheme) Color.White else Color.Black
var acknowledged by remember { mutableStateOf(false) }
val inputState = rememberTextFieldState("")
val isValid = acknowledged && inputState.text.trim() == "OK"
val sfPro = FontFamily(Font(R.font.sf_pro))
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = stringResource(R.string.bypass_compatibility_check),
style = TextStyle(
fontFamily = sfPro,
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp,
color = contentColor
),
modifier = Modifier.padding(horizontal = 12.dp)
)
Text(
text = stringResource(R.string.compatibility_play_dialog_confirmation),
style = TextStyle(
fontFamily = sfPro,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
color = contentColor
),
modifier = Modifier.padding(horizontal = 12.dp)
)
StyledSelectList(
items = listOf(
SelectItem(
name = stringResource(R.string.read_compatibility_requirements),
selected = acknowledged,
onClick = { acknowledged = !acknowledged }
)
)
)
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) {
focusRequester.requestFocus()
keyboardController?.show()
}
StyledInputField(
inputState = inputState,
focusRequester = focusRequester,
placeholder = stringResource(R.string.type_ok_to_continue, "OK")
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(24.dp)
) {
StyledButton(
onClick = { showPlayBypassVisible.value = false },
backdrop = innerBackdrop,
modifier = Modifier.weight(1f),
) {
Text(
text = stringResource(R.string.no),
style = TextStyle(
fontFamily = sfPro,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
color = contentColor
)
)
}
StyledButton(
onClick = {
showPlayBypassVisible.value = false
sharedPreferences.edit {
putBoolean("bypass_device_check.v2", true)
val intent = Intent(context, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
context.startActivity(intent)
}
},
backdrop = innerBackdrop,
isInteractive = isValid,
modifier = Modifier.weight(1f),
enabled = isValid,
surfaceColor = if (isDarkTheme) Color(0xFF0091FF) else Color(0xFF0088FF)
) {
Text(
text = stringResource(R.string.proceed),
style = TextStyle(
fontFamily = sfPro,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
color = if (isValid) contentColor else contentColor.copy(alpha = 0.4f)
)
)
}
}
}
}
}
return
}
@@ -515,6 +359,31 @@ fun Main() {
val navController = rememberNavController()
LaunchedEffect(Unit) {
if (BuildConfig.PLAY_BUILD) {
val now = System.currentTimeMillis()
val firstConn =
sharedPreferences.getLong("first_connection_successful_time", 0L)
val alreadyPrompted =
sharedPreferences.getBoolean("review_prompted", false)
val oneDay = 24 * 60 * 60 * 1000L
if (
firstConn != 0L &&
!alreadyPrompted &&
(now - firstConn) > oneDay
) {
triggerReviewFlow(context as? Activity ?: return@LaunchedEffect)
sharedPreferences.edit {
putBoolean("review_prompted", true)
}
}
}
}
Box(
modifier = Modifier.fillMaxSize()
) {
@@ -597,7 +466,7 @@ fun Main() {
OpenSourceLicensesScreen(navController)
}
composable("update_hearing_test") {
if (airPodsViewModel != null) UpdateHearingTestScreen()
if (airPodsViewModel != null) UpdateHearingTestScreen(airPodsViewModel)
}
composable("version_info") {
if (airPodsViewModel != null) VersionScreen(airPodsViewModel)
@@ -652,6 +521,12 @@ fun Main() {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val binder = service as AirPodsService.LocalBinder
airPodsService.value = binder.getService()
if (!sharedPreferences.contains("first_connection_successful_time")) {
sharedPreferences.edit {
putLong("first_connection_successful_time", System.currentTimeMillis())
}
}
}
override fun onServiceDisconnected(name: ComponentName?) {
@@ -677,6 +552,17 @@ fun Main() {
}
}
private fun triggerReviewFlow(activity: Activity) {
val manager = ReviewManagerFactory.create(activity)
val request = manager.requestReviewFlow()
request.addOnCompleteListener { task ->
if (task.isSuccessful) {
val reviewInfo = task.result
manager.launchReviewFlow(activity, reviewInfo)
}
}
}
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
@Composable
fun PermissionsScreen(

View File

@@ -1143,7 +1143,7 @@ class AACPManager {
)
}
val socket = BluetoothConnectionManager.getCurrentSocket() ?: return false
val socket = BluetoothConnectionManager.getAACPSocket() ?: return false
if (socket.isConnected) {
socket.outputStream?.write(packet)

View File

@@ -16,234 +16,196 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/* This is a very basic ATT (Attribute Protocol) implementation. I have only implemented
* what is necessary for LibrePods to function, i.e. reading and writing characteristics,
* and receiving notifications. It is not a complete implementation of the ATT protocol.
*/
package me.kavishdevar.librepods.bluetooth
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import android.os.ParcelUuid
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.io.InputStream
import java.io.OutputStream
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"
enum class ATTHandles(val value: Int) {
TRANSPARENCY(0x18),
LOUD_SOUND_REDUCTION(0x1B),
HEARING_AID(0x2A),
HEARING_AID(0x2A)
}
enum class ATTCCCDHandles(val value: Int) {
TRANSPARENCY(ATTHandles.TRANSPARENCY.value + 1),
LOUD_SOUND_REDUCTION(ATTHandles.LOUD_SOUND_REDUCTION.value + 1),
HEARING_AID(ATTHandles.HEARING_AID.value + 1),
// LOUD_SOUND_REDUCTION(ATTHandles.LOUD_SOUND_REDUCTION.value + 1), // doesn't work
HEARING_AID(ATTHandles.HEARING_AID.value + 1)
}
class ATTManager(private val adapter: BluetoothAdapter, private val device: BluetoothDevice) {
companion object {
private const val TAG = "ATTManager"
class ATTManagerv2 {
val characteristicList = mutableMapOf<ATTHandles, ByteArray>()
private const val OPCODE_READ_REQUEST: Byte = 0x0A
private const val OPCODE_WRITE_REQUEST: Byte = 0x12
private const val OPCODE_HANDLE_VALUE_NTF: Byte = 0x1B
}
private val responseQueues = ConcurrentHashMap<Byte, LinkedBlockingQueue<ByteArray>>()
var socket: BluetoothSocket? = null
private var input: InputStream? = null
private var output: OutputStream? = null
private val listeners = mutableMapOf<Int, MutableList<(ByteArray) -> Unit>>()
private var notificationJob: Job? = null
private val readerRunning = AtomicBoolean(false)
private var readerThread: Thread? = null
// queue for non-notification PDUs (responses to requests)
private val responses = LinkedBlockingQueue<ByteArray>()
private var onNotificationReceived: ((handle: Byte, value: ByteArray) -> Unit)? = null
@SuppressLint("MissingPermission")
fun connect() {
val uuid = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000")
fun startReader() {
if (readerRunning.getAndSet(true)) return
readerThread = Thread {
try {
socket = createBluetoothSocket(adapter, device, uuid)
} catch (e: Exception) {
Log.w(TAG, "Failed to create socket")
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")
}
fun stopReader() {
readerRunning.set(false)
readerThread?.interrupt()
readerThread = null
}
fun setOnNotificationReceived(listener: ((handle: Byte, value: ByteArray) -> Unit)?) {
onNotificationReceived = listener
}
fun enableNotification(handle: ATTCCCDHandles) {
writeCharacteristic(handle.value.toByte(), byteArrayOf(0x01))
}
fun getCharacteristic(handle: ATTHandles): ByteArray? {
val storedValue = characteristicList[handle]
return if (storedValue?.isNotEmpty() != true) {
readCharacteristic(handle)
} else storedValue
}
fun readCharacteristic(handle: ATTHandles, timeoutMillis: Long = 2000): ByteArray? {
val socket = BluetoothConnectionManager.getATTSocket() ?: return null
try {
socket!!.connect()
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 {
Log.e(TAG, "Timeout waiting for Read Response (0x0B) for handle ${handle.value}")
return null
}
Log.d(TAG, "read response: ${resp.joinToString(" ") { String.format("%02X", it) }}")
val value = resp.copyOfRange(1, resp.size)
characteristicList[handle] = value
return value
} catch (e: Exception) {
Log.w(TAG, "ATT socket failed to connect")
Log.e(TAG, "error reading characteristic: ${e.message}")
return null
}
}
fun writeCharacteristic(handle: ATTHandles, data: ByteArray, timeoutMillis: Long = 2000) {
characteristicList[handle] = data
writeCharacteristic(handle.value.toByte(), data, timeoutMillis)
}
fun writeCharacteristic(handle: Byte, data: ByteArray, timeoutMillis: Long = 2000) {
val socket = BluetoothConnectionManager.getATTSocket() ?: 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 {
Log.e(TAG, "timeout waiting for response (0x13) for handle ${String.format("%02X", handle)}")
return
}
input = socket!!.inputStream
output = socket!!.outputStream
Log.d(TAG, "Connected to ATT")
notificationJob = CoroutineScope(Dispatchers.IO).launch {
while (socket?.isConnected == true) {
try {
val pdu = readPDU()
if (pdu.isNotEmpty() && pdu[0] == OPCODE_HANDLE_VALUE_NTF) {
// notification -> dispatch to listeners
val handle = (pdu[1].toInt() and 0xFF) or ((pdu[2].toInt() and 0xFF) shl 8)
val value = pdu.copyOfRange(3, pdu.size)
listeners[handle]?.forEach { listener ->
try {
listener(value)
Log.d(TAG, "Dispatched notification for handle $handle to listener, with value ${value.joinToString(" ") { String.format("%02X", it) }}")
Log.d(TAG, "write respose: ${resp.joinToString(" ") { String.format("%02X", it) }}")
} catch (e: Exception) {
Log.w(TAG, "Error in listener for handle $handle: ${e.message}")
}
}
} else {
// not a notification -> treat as a response for pending request(s)
responses.put(pdu)
}
} catch (e: Exception) {
Log.w(TAG, "Error reading notification/response: ${e.message}")
if (socket?.isConnected != true) break
}
}
Log.e(TAG, "error writing characteristic: ${e.message}")
}
}
fun disconnect() {
fun disconnected() {
characteristicList.clear()
stopReader()
val socket = BluetoothConnectionManager.getATTSocket() ?: return
try {
notificationJob?.cancel()
socket?.close()
socket.close()
} catch (e: Exception) {
Log.w(TAG, "Error closing socket: ${e.message}")
Log.w(TAG, "error closing socket: ${e.message}")
}
Log.d(TAG, "ATT disconnected")
}
fun registerListener(handle: ATTHandles, listener: (ByteArray) -> Unit) {
listeners.getOrPut(handle.value) { mutableListOf() }.add(listener)
private fun runReaderLoop() {
val socket = BluetoothConnectionManager.getATTSocket() ?: run {
Log.w(TAG, "ATT socket not available. stopping reader")
readerRunning.set(false)
return
}
fun unregisterListener(handle: ATTHandles, listener: (ByteArray) -> Unit) {
listeners[handle.value]?.remove(listener)
}
fun enableNotifications(handle: ATTHandles) {
write(ATTCCCDHandles.valueOf(handle.name), byteArrayOf(0x01, 0x00))
}
fun read(handle: ATTHandles): ByteArray {
val lsb = (handle.value and 0xFF).toByte()
val msb = ((handle.value shr 8) and 0xFF).toByte()
val pdu = byteArrayOf(OPCODE_READ_REQUEST, lsb, msb)
writeRaw(pdu)
// wait for response placed into responses queue by the reader coroutine
return readResponse()
}
fun write(handle: ATTHandles, value: ByteArray) {
val lsb = (handle.value and 0xFF).toByte()
val msb = ((handle.value shr 8) and 0xFF).toByte()
val pdu = byteArrayOf(OPCODE_WRITE_REQUEST, lsb, msb) + value
writeRaw(pdu)
// usually a Write Response (0x13) will arrive; wait for it (but discard return)
try {
readResponse()
} catch (e: Exception) {
Log.w(TAG, "No write response received: ${e.message}")
}
}
fun write(handle: ATTCCCDHandles, value: ByteArray) {
val lsb = (handle.value and 0xFF).toByte()
val msb = ((handle.value shr 8) and 0xFF).toByte()
val pdu = byteArrayOf(OPCODE_WRITE_REQUEST, lsb, msb) + value
writeRaw(pdu)
// usually a Write Response (0x13) will arrive; wait for it (but discard return)
try {
readResponse()
} catch (e: Exception) {
Log.w(TAG, "No write response received: ${e.message}")
}
}
private fun writeRaw(pdu: ByteArray) {
if (output == null) return
output?.write(pdu)
output?.flush()
Log.d(TAG, "writeRaw: ${pdu.joinToString(" ") { String.format("%02X", it) }}")
}
// rename / specialize: read raw PDU directly from input stream (blocking)
private fun readPDU(): ByteArray {
val inp = input ?: throw IllegalStateException("Not connected")
val input = socket.inputStream
val buffer = ByteArray(512)
val len = inp.read(buffer)
while (readerRunning.get()) {
try {
val len = input.read(buffer)
if (len == -1) {
disconnect()
throw IllegalStateException("End of stream reached")
Log.w(TAG, "ATT input stream ended")
break
}
val data = buffer.copyOfRange(0, len)
Log.d(TAG, "readPDU: ${data.joinToString(" ") { String.format("%02X", it) }}")
return data
}
if (data.isEmpty()) continue
// wait for a response PDU produced by the background reader
private fun readResponse(timeoutMs: Long = 2000): ByteArray {
val opcode = data[0]
Log.d(TAG, "pdu received ${data.joinToString(" ") { String.format("%02X", it) }}")
val queue = responseQueues.computeIfAbsent(opcode) { LinkedBlockingQueue() }
queue.offer(data)
if (opcode == 0x1B.toByte()) {
if (data.size >= 3) {
val handle = data[1]
val value = if (data.size > 3) data.copyOfRange(3, data.size) else ByteArray(0)
Log.d(TAG, "notification/indication handle=0x${String.format("%02X", handle)} value=${value.toHexString()}")
try {
val resp = responses.poll(timeoutMs, TimeUnit.MILLISECONDS)
?: throw IllegalStateException("No response read from ATT socket within $timeoutMs ms")
Log.d(TAG, "readResponse: ${resp.joinToString(" ") { String.format("%02X", it) }}")
return resp.copyOfRange(1, resp.size)
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
throw IllegalStateException("Interrupted while waiting for ATT response", e)
onNotificationReceived?.invoke(handle, value)
} catch (t: Throwable) {
Log.e(TAG, "onNotificationReceived threw: ${t.message}", t)
}
} else {
Log.w(TAG, "notification PDU too short: ${data.joinToString(" ") { String.format("%02X", it) }}")
}
}
private fun createBluetoothSocket(adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid): BluetoothSocket {
val type = 3 // L2CAP
val constructorSpecs = listOf(
arrayOf(adapter, device, type, true, true, 31, uuid),
arrayOf(device, type, true, true, 31, uuid),
arrayOf(device, type, 1, true, true, 31, uuid),
arrayOf(type, 1, true, true, device, 31, uuid),
arrayOf(type, true, true, device, 31, uuid)
)
val constructors = BluetoothSocket::class.java.declaredConstructors
Log.d("ATTManager", "BluetoothSocket has ${constructors.size} constructors:")
constructors.forEachIndexed { index, constructor ->
val params = constructor.parameterTypes.joinToString(", ") { it.simpleName }
Log.d("ATTManager", "Constructor $index: ($params)")
}
var lastException: Exception? = null
var attemptedConstructors = 0
for ((index, params) in constructorSpecs.withIndex()) {
try {
Log.d("ATTManager", "Trying constructor signature #${index + 1}")
attemptedConstructors++
val paramTypes = params.map { it::class.javaPrimitiveType ?: it::class.java }.toTypedArray()
val constructor = BluetoothSocket::class.java.getDeclaredConstructor(*paramTypes)
constructor.isAccessible = true
return constructor.newInstance(*params) as BluetoothSocket
} catch (e: Exception) {
Log.e("ATTManager", "Constructor signature #${index + 1} failed: ${e.message}")
lastException = e
Log.e(TAG, "error in reader loop: ${e.message}", e)
break
}
}
val errorMessage = "Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures"
Log.e("ATTManager", errorMessage)
throw lastException ?: IllegalStateException(errorMessage)
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
}
}
}

View File

@@ -18,23 +18,22 @@
package me.kavishdevar.librepods.bluetooth
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import android.util.Log
object BluetoothConnectionManager {
private const val TAG = "BluetoothConnectionManager"
private var aacpSocket: BluetoothSocket? = null
private var attSocket: BluetoothSocket? = null
private var currentSocket: BluetoothSocket? = null
private var currentDevice: BluetoothDevice? = null
fun setCurrentConnection(socket: BluetoothSocket, device: BluetoothDevice) {
currentSocket = socket
currentDevice = device
Log.d(TAG, "Current connection set to device: ${device.address}")
fun setCurrentConnection(aacpSocket: BluetoothSocket?, attSocket: BluetoothSocket?) {
BluetoothConnectionManager.aacpSocket = aacpSocket
BluetoothConnectionManager.attSocket = attSocket
}
fun getCurrentSocket(): BluetoothSocket? {
return currentSocket
fun getAACPSocket(): BluetoothSocket? {
return aacpSocket
}
fun getATTSocket(): BluetoothSocket? {
return attSocket
}
}

View File

@@ -26,7 +26,6 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.bluetooth.ATTManager
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
@@ -138,15 +137,15 @@ fun parseHearingAidSettingsResponse(data: ByteArray): HearingAidSettings? {
}
fun sendHearingAidSettings(
attManager: ATTManager,
currentData: ByteArray,
hearingAidSettings: HearingAidSettings,
debounceJob: MutableState<Job?>
debounceJob: MutableState<Job?>,
sender: (ATTHandles, ByteArray) -> Unit
) {
debounceJob.value?.cancel()
debounceJob.value = CoroutineScope(Dispatchers.IO).launch {
delay(100)
try {
val currentData = attManager.read(ATTHandles.HEARING_AID)
Log.d(TAG, "Current data before update: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
if (currentData.size < 104) {
Log.w(TAG, "Current data size ${currentData.size} too small, cannot send settings")
@@ -184,7 +183,7 @@ fun sendHearingAidSettings(
Log.d(TAG, "Sending updated settings: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
attManager.write(ATTHandles.HEARING_AID, currentData)
sender(ATTHandles.HEARING_AID, currentData)
} catch (e: IOException) {
e.printStackTrace()
}

View File

@@ -83,7 +83,8 @@ data class TransparencySettings(
}
}
fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings {
fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings? {
if (data.size < 50) return null // 50 is arbitrary, too lazy to count
val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
val enabled = buffer.float

View File

@@ -23,10 +23,14 @@ package me.kavishdevar.librepods.presentation.screens
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
import android.annotation.SuppressLint
import android.content.Context.MODE_PRIVATE
import android.content.Intent
import android.content.SharedPreferences
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -51,6 +55,7 @@ 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.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
@@ -64,6 +69,7 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
@@ -93,6 +99,7 @@ import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import java.util.concurrent.TimeUnit
import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@@ -131,7 +138,6 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
viewModel.refreshInitialData()
}
isSystemInDarkTheme()
val hazeStateS = remember { mutableStateOf(HazeState()) }
StyledScaffold(
@@ -171,6 +177,44 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
}
} else Modifier)) {
item(key = "spacer_top") { Spacer(modifier = Modifier.height(topPadding)) }
item(key = "play_update_banner") {
if (state.timeUntilFOSSPremiumExpiry > 0L) {
val context = LocalContext.current
Box(
modifier = Modifier
.background(Color(0xFF32829B), RoundedCornerShape(28.dp))
.clip(RoundedCornerShape(28.dp))
.clickable {
val emailIntent = Intent(Intent.ACTION_SENDTO).apply {
data = "mailto:".toUri()
putExtra(Intent.EXTRA_EMAIL, arrayOf("billing@kavish.xyz"))
putExtra(Intent.EXTRA_SUBJECT, "LibrePods Play billing error")
putExtra(
Intent.EXTRA_TEXT,
"Please enter your GitHub username to restore your premium access:\n\nGitHub username: "
)
}
context.startActivity(emailIntent)
}
) {
Text(
text = stringResource(
R.string.play_foss_premium_banner, maxOf(1, TimeUnit.MILLISECONDS.toDays(state.timeUntilFOSSPremiumExpiry).toInt())
),
modifier = Modifier
.padding(16.dp),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
item(key = "battery") {
BatteryView(
batteryList = state.battery,

View File

@@ -20,9 +20,11 @@ package me.kavishdevar.librepods.presentation.screens
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -90,6 +92,7 @@ import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
import me.kavishdevar.librepods.utils.XposedState
import java.util.concurrent.TimeUnit
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -146,7 +149,39 @@ fun AppSettingsScreen(
)
}
}
if (state.timeUntilFOSSPremiumExpiry > 0L) {
Box(
modifier = Modifier
.background(Color(0xFF32829B), RoundedCornerShape(28.dp))
.clip(RoundedCornerShape(28.dp))
.clickable {
val emailIntent = Intent(Intent.ACTION_SENDTO).apply {
data = "mailto:".toUri()
putExtra(Intent.EXTRA_EMAIL, arrayOf("billing@kavish.xyz"))
putExtra(Intent.EXTRA_SUBJECT, "LibrePods Play billing error")
putExtra(
Intent.EXTRA_TEXT,
"Please enter your GitHub username to restore your premium access:\n\nGitHub username: "
)
}
context.startActivity(emailIntent)
}
) {
Text(
text = stringResource(
R.string.play_foss_premium_banner, maxOf(1, TimeUnit.MILLISECONDS.toDays(state.timeUntilFOSSPremiumExpiry).toInt())
),
modifier = Modifier
.padding(16.dp),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
if (state.connectionSuccessful) {
StyledToggle(
title = stringResource(R.string.widget),
@@ -542,10 +577,23 @@ fun AppSettingsScreen(
name = stringResource(R.string.github_issues),
navController = navController,
onClick = {
val intent = Intent(
Intent.ACTION_VIEW,
"https://github.com/kavishdevar/librepods/issues".toUri()
val appVersion = Uri.encode("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
val device = Uri.encode("${Build.MANUFACTURER} ${Build.MODEL}")
val androidVersion = Uri.encode("${Build.ID} (${Build.DISPLAY})")
val appSource = Uri.encode(
when {
BuildConfig.PLAY_BUILD -> "Play"
else -> "GitHub"
}
)
val url = "https://github.com/kavishdevar/librepods/issues/new" +
"?template=01-bug-report-android.yml" +
"&app-source=$appSource" +
"&app-version=$appVersion" +
"&device=$device" +
"&android-version=$androidVersion"
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
context.startActivity(intent)
},
independent = false

View File

@@ -32,13 +32,12 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -48,22 +47,17 @@ import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager
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.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import java.io.IOException
import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
private const val TAG = "HearingAidAdjustments"
@SuppressLint("DefaultLocale")
@@ -74,64 +68,42 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
isSystemInDarkTheme()
val verticalScrollState = rememberScrollState()
val hazeState = remember { HazeState() }
val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available")
val state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.adjustments)
) { spacerHeight ->
Column(
modifier = Modifier
.hazeSource(hazeState)
.fillMaxSize()
.layerBackdrop(backdrop)
.verticalScroll(verticalScrollState)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(spacerHeight))
val amplificationSliderValue = remember { mutableFloatStateOf(0.5f) }
val balanceSliderValue = remember { mutableFloatStateOf(0.5f) }
val toneSliderValue = remember { mutableFloatStateOf(0.5f) }
val ambientNoiseReductionSliderValue = remember { mutableFloatStateOf(0.0f) }
val conversationBoostEnabled = remember { mutableStateOf(false) }
val leftEQ = remember { mutableStateOf(FloatArray(8)) }
val rightEQ = remember { mutableStateOf(FloatArray(8)) }
val ownVoiceAmplification = remember { mutableFloatStateOf(0.5f) }
val debounceJob = remember { mutableStateOf<Job?>(null) }
val initialLoadComplete = remember { mutableStateOf(false) }
val amplificationSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
val balanceSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
val toneSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
val ambientNoiseReductionSliderValue = rememberSaveable { mutableFloatStateOf(0.0f) }
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
val leftEQ = rememberSaveable { mutableStateOf(FloatArray(8)) }
val rightEQ = rememberSaveable { mutableStateOf(FloatArray(8)) }
val ownVoiceAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
val initialReadSucceeded = remember { mutableStateOf(false) }
val initialReadAttempts = remember { mutableIntStateOf(0) }
val initialized = rememberSaveable { mutableStateOf(false) }
val hearingAidSettings = remember {
mutableStateOf(
val hearingAidSettings = remember { mutableStateOf(
HearingAidSettings(
leftEQ = leftEQ.value,
rightEQ = rightEQ.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,
ownVoiceAmplification = ownVoiceAmplification.floatValue
leftAmplification = 0f,
rightAmplification = 0f,
leftTone = 0f,
rightTone = 0f,
leftConversationBoost = false,
rightConversationBoost = false,
leftAmbientNoiseReduction = 0f,
rightAmbientNoiseReduction = 0f,
netAmplification = 0f,
balance = 0f,
ownVoiceAmplification = 0f
)
)
}
) }
val hearingAidATTListener = remember {
object : (ByteArray) -> Unit {
override fun invoke(value: ByteArray) {
val parsed = parseHearingAidSettingsResponse(value)
if (parsed != null) {
LaunchedEffect(state.hearingAidData) {
parseHearingAidSettingsResponse(state.hearingAidData)?.let { parsed ->
amplificationSliderValue.floatValue = parsed.netAmplification
balanceSliderValue.floatValue = parsed.balance
toneSliderValue.floatValue = parsed.leftTone
@@ -140,25 +112,19 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
leftEQ.value = parsed.leftEQ.copyOf()
rightEQ.value = parsed.rightEQ.copyOf()
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
Log.d(TAG, "Updated hearing aid settings from notification")
} else {
Log.w(TAG, "Failed to parse hearing aid settings from notification")
}
}
initialized.value = true
}
}
LaunchedEffect(amplificationSliderValue.floatValue, balanceSliderValue.floatValue, toneSliderValue.floatValue, conversationBoostEnabled.value, ambientNoiseReductionSliderValue.floatValue, ownVoiceAmplification.floatValue, initialLoadComplete.value, initialReadSucceeded.value) {
if (!initialLoadComplete.value) {
Log.d(TAG, "Initial device load not complete - skipping send")
return@LaunchedEffect
}
if (!initialReadSucceeded.value) {
Log.d(TAG, "Initial device read not successful yet - skipping send until read succeeds")
return@LaunchedEffect
}
LaunchedEffect(
amplificationSliderValue.floatValue,
balanceSliderValue.floatValue,
toneSliderValue.floatValue,
conversationBoostEnabled.value,
ambientNoiseReductionSliderValue.floatValue,
ownVoiceAmplification.floatValue
) {
if (!initialized.value) return@LaunchedEffect
hearingAidSettings.value = HearingAidSettings(
leftEQ = leftEQ.value,
rightEQ = rightEQ.value,
@@ -175,53 +141,20 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
ownVoiceAmplification = ownVoiceAmplification.floatValue
)
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, viewModel::setATTCharacteristicValue)
}
LaunchedEffect(Unit) {
Log.d(TAG, "Connecting to ATT...")
try {
attManager.enableNotifications(ATTHandles.HEARING_AID)
attManager.registerListener(ATTHandles.HEARING_AID, hearingAidATTListener)
var parsedSettings: HearingAidSettings? = null
for (attempt in 1..3) {
initialReadAttempts.intValue = attempt
try {
val data = attManager.read(ATTHandles.HEARING_AID)
parsedSettings = parseHearingAidSettingsResponse(data = data)
if (parsedSettings != null) {
Log.d(TAG, "Parsed settings on attempt $attempt")
break
} else {
Log.d(TAG, "Parsing returned null on attempt $attempt")
}
} catch (e: Exception) {
Log.w(TAG, "Read attempt $attempt failed: ${e.message}")
}
delay(200)
}
if (parsedSettings != null) {
Log.d(TAG, "Initial hearing aid settings: $parsedSettings")
amplificationSliderValue.floatValue = parsedSettings.netAmplification
balanceSliderValue.floatValue = parsedSettings.balance
toneSliderValue.floatValue = parsedSettings.leftTone
ambientNoiseReductionSliderValue.floatValue = parsedSettings.leftAmbientNoiseReduction
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
leftEQ.value = parsedSettings.leftEQ.copyOf()
rightEQ.value = parsedSettings.rightEQ.copyOf()
ownVoiceAmplification.floatValue = parsedSettings.ownVoiceAmplification
initialReadSucceeded.value = true
} else {
Log.d(TAG, "Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts")
}
} catch (e: IOException) {
e.printStackTrace()
} finally {
initialLoadComplete.value = true
}
}
StyledScaffold(title = stringResource(R.string.adjustments)) { spacerHeight ->
Column(
modifier = Modifier
.hazeSource(hazeState)
.fillMaxSize()
.layerBackdrop(backdrop)
.verticalScroll(verticalScrollState)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(spacerHeight))
StyledSlider(
label = stringResource(R.string.amplification),
@@ -235,7 +168,6 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
independent = true,
)
StyledToggle(
label = stringResource(R.string.swipe_to_control_amplification),
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(),

View File

@@ -259,6 +259,10 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
return@launch
}
val parsed = parseTransparencySettingsResponse(state.hearingAidData)
if (parsed == null) {
Log.w(TAG, "transparency parse failed")
return@launch
}
val disabledSettings = parsed.copy(enabled = false)
sendTransparencySettings(viewModel::setATTCharacteristicValue, disabledSettings)
} catch (e: Exception) {

View File

@@ -53,11 +53,11 @@ import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
import me.kavishdevar.librepods.utils.XposedState
@Composable
fun PurchaseScreen(
@@ -199,7 +199,7 @@ fun PurchaseScreen(
)
)
}
if (BuildConfig.FLAVOR == "xposed") {
if (XposedState.isAvailable) {
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),

View File

@@ -68,8 +68,9 @@ fun RenameScreen(viewModel: AirPodsViewModel) {
) {
Spacer(modifier = Modifier.height(spacerHeight))
val textFieldState = rememberTextFieldState()
textFieldState.edit { sharedPreferences.getString("name", "") ?: "" }
val name = sharedPreferences.getString("name", "")?: ""
val textFieldState = rememberTextFieldState(initialText = name)
LaunchedEffect(textFieldState.text) {
sharedPreferences.edit {putString("name", textFieldState.text as String?)}
viewModel.setName(textFieldState.text.toString())

View File

@@ -46,9 +46,10 @@ 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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
@@ -64,17 +65,14 @@ import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.delay
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.data.TransparencySettings
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
import me.kavishdevar.librepods.data.sendTransparencySettings
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import java.io.IOException
import kotlin.io.encoding.ExperimentalEncodingApi
private const val TAG = "TransparencySettings"
@@ -112,19 +110,26 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
Spacer(modifier = Modifier.height(topPadding))
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val enabled = remember { mutableStateOf(false) }
val amplificationSliderValue = remember { mutableFloatStateOf(0.5f) }
val balanceSliderValue = remember { mutableFloatStateOf(0.5f) }
val toneSliderValue = remember { mutableFloatStateOf(0.5f) }
val ambientNoiseReductionSliderValue = remember { mutableFloatStateOf(0.0f) }
val conversationBoostEnabled = remember { mutableStateOf(false) }
val eq = remember { mutableStateOf(FloatArray(8)) }
val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
val enabled = rememberSaveable { mutableStateOf(false) }
val amplificationSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
val balanceSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
val toneSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
val ambientNoiseReductionSliderValue = rememberSaveable { mutableFloatStateOf(0.0f) }
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
val eq = rememberSaveable(
saver = Saver(
save = { it.value.toList() },
restore = { mutableStateOf(it.toFloatArray()) }
)
) { mutableStateOf(FloatArray(8)) }
val phoneMediaEQ = rememberSaveable(
saver = Saver(
save = { it.value.toList() },
restore = { mutableStateOf(it.toFloatArray()) }
)
) { mutableStateOf(FloatArray(8) { 0.5f }) }
val initialLoadComplete = remember { mutableStateOf(false) }
val initialReadSucceeded = remember { mutableStateOf(false) }
val initialReadAttempts = remember { mutableIntStateOf(0) }
val initialized = rememberSaveable { mutableStateOf(false) }
val transparencySettings = remember {
mutableStateOf(
@@ -153,23 +158,9 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
toneSliderValue.floatValue,
conversationBoostEnabled.value,
ambientNoiseReductionSliderValue.floatValue,
eq.value,
initialLoadComplete.value,
initialReadSucceeded.value
eq.value
) {
if (!initialLoadComplete.value) {
Log.d(TAG, "Initial device load not complete - skipping send")
return@LaunchedEffect
}
if (!initialReadSucceeded.value) {
Log.d(
TAG,
"Initial device read not successful yet - skipping send until read succeeds"
)
return@LaunchedEffect
}
if (!initialized.value) return@LaunchedEffect
transparencySettings.value = TransparencySettings(
enabled = enabled.value,
leftEQ = eq.value,
@@ -189,38 +180,8 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value)
}
LaunchedEffect(Unit) {
Log.d(TAG, "Connecting to ATT...")
try {
// If we have an AACP manager, prefer its EQ data to populate EQ controls first
try {
Log.d(TAG, "Found AACPManager, reading cached EQ data")
val aacpEQ = state.eqData
if (aacpEQ.isNotEmpty()) {
eq.value = aacpEQ.copyOf()
phoneMediaEQ.value = aacpEQ.copyOf()
Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}")
} else {
Log.d(TAG, "AACPManager EQ data empty")
}
} catch (e: Exception) {
Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}")
}
var parsedSettings: TransparencySettings? = null
for (attempt in 1..3) {
initialReadAttempts.intValue = attempt
try {
val data = state.transparencyData
parsedSettings = parseTransparencySettingsResponse(data = data)
Log.d(TAG, "Parsed settings on attempt $attempt")
} catch (e: Exception) {
Log.w(TAG, "Read attempt $attempt failed: ${e.message}")
}
delay(200)
}
if (parsedSettings != null) {
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
@@ -229,19 +190,10 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
ambientNoiseReductionSliderValue.floatValue =
parsedSettings.leftAmbientNoiseReduction
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
if (!eq.value.contentEquals(parsedSettings.leftEQ)) {
eq.value = parsedSettings.leftEQ.copyOf()
initialReadSucceeded.value = true
} else {
Log.d(
TAG,
"Failed to read/parse initial transparency settings after ${initialReadAttempts.intValue} attempts"
)
}
} catch (e: IOException) {
e.printStackTrace()
} finally {
initialLoadComplete.value = true
}
initialized.value = true
}
if (state.vendorIdHook) {

View File

@@ -35,13 +35,14 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -57,34 +58,19 @@ import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.services.ServiceManager
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 java.io.IOException
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
private const val TAG = "HearingAidAdjustments"
@Composable
fun UpdateHearingTestScreen() {
fun UpdateHearingTestScreen(viewModel: AirPodsViewModel) {
val verticalScrollState = rememberScrollState()
val attManager = ServiceManager.getService()?.attManager
if (attManager == null) {
Text(
text = stringResource(R.string.att_manager_is_null_try_reconnecting),
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
textAlign = TextAlign.Center
)
return
}
val state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.hearing_test)
@@ -112,18 +98,31 @@ fun UpdateHearingTestScreen() {
),
textAlign = TextAlign.Center,
)
val tone = remember { mutableFloatStateOf(0.5f) }
val ambientNoiseReduction = remember { mutableFloatStateOf(0.0f) }
val ownVoiceAmplification = remember { mutableFloatStateOf(0.5f) }
val leftAmplification = remember { mutableFloatStateOf(0.5f) }
val rightAmplification = remember { mutableFloatStateOf(0.5f) }
val conversationBoostEnabled = remember { mutableStateOf(false) }
val leftEQ = remember { mutableStateOf(FloatArray(8)) }
val rightEQ = remember { mutableStateOf(FloatArray(8)) }
val tone = rememberSaveable { mutableFloatStateOf(0.5f) }
val ambientNoiseReduction = rememberSaveable { mutableFloatStateOf(0.0f) }
val ownVoiceAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
val leftAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
val rightAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
val leftEQ = rememberSaveable(
saver = Saver(
save = { it.value.toList() },
restore = { mutableStateOf(it.toFloatArray()) }
)
) {
mutableStateOf(FloatArray(8))
}
val rightEQ = rememberSaveable(
saver = Saver(
save = { it.value.toList() },
restore = { mutableStateOf(it.toFloatArray()) }
)
) {
mutableStateOf(FloatArray(8))
}
val initialLoadComplete = remember { mutableStateOf(false) }
val initialReadSucceeded = remember { mutableStateOf(false) }
val initialReadAttempts = remember { mutableIntStateOf(0) }
val debounceJob = remember { mutableStateOf<Job?>(null) }
val initialized = rememberSaveable { mutableStateOf(false) }
val hearingAidSettings = remember {
mutableStateOf(
@@ -145,10 +144,8 @@ fun UpdateHearingTestScreen() {
)
}
val hearingAidATTListener = remember {
object : (ByteArray) -> Unit {
override fun invoke(value: ByteArray) {
val parsed = parseHearingAidSettingsResponse(value)
LaunchedEffect(state.hearingAidData) {
val parsed = parseHearingAidSettingsResponse(state.hearingAidData)
if (parsed != null) {
leftEQ.value = parsed.leftEQ.copyOf()
rightEQ.value = parsed.rightEQ.copyOf()
@@ -158,46 +155,24 @@ fun UpdateHearingTestScreen() {
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
leftAmplification.floatValue = parsed.leftAmplification
rightAmplification.floatValue = parsed.rightAmplification
initialized.value = true
Log.d(TAG, "Updated hearing aid settings from notification")
} else {
Log.w(TAG, "Failed to parse hearing aid settings from notification")
}
}
}
}
DisposableEffect(Unit) {
onDispose {
attManager.unregisterListener(ATTHandles.HEARING_AID, hearingAidATTListener)
}
}
LaunchedEffect(
leftEQ.value,
rightEQ.value,
conversationBoostEnabled.value,
initialLoadComplete.value,
initialReadSucceeded.value,
leftAmplification.floatValue,
rightAmplification.floatValue,
tone.floatValue,
ambientNoiseReduction.floatValue,
ownVoiceAmplification.floatValue
) {
if (!initialLoadComplete.value) {
Log.d(TAG, "Initial device load not complete - skipping send")
return@LaunchedEffect
}
if (!initialReadSucceeded.value) {
Log.d(
TAG,
"Initial device read not successful yet - skipping send until read succeeds"
)
return@LaunchedEffect
}
if (!initialized.value) return@LaunchedEffect
hearingAidSettings.value = HearingAidSettings(
leftEQ = leftEQ.value,
rightEQ = rightEQ.value,
@@ -214,55 +189,7 @@ fun UpdateHearingTestScreen() {
ownVoiceAmplification = ownVoiceAmplification.floatValue
)
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
}
LaunchedEffect(Unit) {
Log.d(TAG, "Connecting to ATT...")
try {
attManager.enableNotifications(ATTHandles.HEARING_AID)
attManager.registerListener(ATTHandles.HEARING_AID, hearingAidATTListener)
var parsedSettings: HearingAidSettings? = null
for (attempt in 1..3) {
initialReadAttempts.intValue = attempt
try {
val data = attManager.read(ATTHandles.HEARING_AID)
parsedSettings = parseHearingAidSettingsResponse(data = data)
if (parsedSettings != null) {
Log.d(TAG, "Parsed settings on attempt $attempt")
break
} else {
Log.d(TAG, "Parsing returned null on attempt $attempt")
}
} catch (e: Exception) {
Log.w(TAG, "Read attempt $attempt failed: ${e.message}")
}
delay(200)
}
if (parsedSettings != null) {
Log.d(TAG, "Initial hearing aid settings: $parsedSettings")
leftEQ.value = parsedSettings.leftEQ.copyOf()
rightEQ.value = parsedSettings.rightEQ.copyOf()
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
tone.floatValue = parsedSettings.leftTone
ambientNoiseReduction.floatValue = parsedSettings.leftAmbientNoiseReduction
ownVoiceAmplification.floatValue = parsedSettings.ownVoiceAmplification
leftAmplification.floatValue = parsedSettings.leftAmplification
rightAmplification.floatValue = parsedSettings.rightAmplification
initialReadSucceeded.value = true
} else {
Log.d(
TAG,
"Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts"
)
}
} catch (e: IOException) {
e.printStackTrace()
} finally {
initialLoadComplete.value = true
}
sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, viewModel::setATTCharacteristicValue)
}
val frequencies =

View File

@@ -29,15 +29,16 @@ import androidx.core.content.edit
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
import me.kavishdevar.librepods.bluetooth.ATTCCCDHandles
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.data.AirPodsInstance
import me.kavishdevar.librepods.data.AirPodsModels
@@ -93,7 +94,8 @@ data class AirPodsUiState(
val dynamicEndOfCharge: Boolean = false,
val connectionSuccessful: Boolean = false
val connectionSuccessful: Boolean = false,
val timeUntilFOSSPremiumExpiry: Long = 0L
)
class AirPodsViewModel(
@@ -142,9 +144,11 @@ class AirPodsViewModel(
loadInstance()
loadSharedPreferences()
setupControlObservers()
observeBilling()
loadControlList()
loadATT()
observeATT()
observeSharedPreferences()
observeBilling()
if (isDemoMode) activateDemoMode()
}
@@ -172,17 +176,37 @@ class AirPodsViewModel(
// billingFirstCollectDone = true
// return@collect
// }
if (!premium) {
if (premium) {
sharedPreferences.edit {
remove("premium_expiry_time")
remove("foss_upgraded")
}
_uiState.update { it.copy(isPremium = true, timeUntilFOSSPremiumExpiry = 0L) }
} else {
if (_uiState.value.timeUntilFOSSPremiumExpiry <= 0L) {
setControlCommandBoolean(
ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
false
)
setHeadGesturesEnabled(false)
}
_uiState.update { it.copy(isPremium = premium) }
_uiState.update { it.copy(isPremium = false) }
}
}
}
}
}
private fun observeSharedPreferences() {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
when (key) {
"name" -> loadName()
"off_listening_mode", "automatic_ear_detection", "automatic_connection_ctrl_cmd",
"head_gestures", "left_long_press_action", "right_long_press_action",
"dynamic_end_of_charge", "foss_upgraded", "premium_expiry_time" -> loadSharedPreferences()
}
}
sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
}
private fun observeBroadcasts() {
broadcastReceiver = object : BroadcastReceiver() {
@@ -358,6 +382,7 @@ class AirPodsViewModel(
val connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false)
val fossUpgraded = sharedPreferences.getBoolean("foss_upgraded", false)
_uiState.update {
it.copy(
offListeningMode = offListeningModeEnabled,
@@ -368,9 +393,56 @@ class AirPodsViewModel(
rightAction = rightAction,
vendorIdHook = vendorIdHook,
dynamicEndOfCharge = dynamicEndOfCharge,
connectionSuccessful = connectionSuccessful
connectionSuccessful = connectionSuccessful,
)
}
// faulty update on Play caused PLAY_BUILD to be false and resulted in use of FOSS billing in Play. since FOSS is not verified, we need to give 2 weeks to verify the purchase
val expiryTime = sharedPreferences.getLong("premium_expiry_time", 0L)
val now = System.currentTimeMillis()
when {
// existing temporary premium
expiryTime > 0L -> {
if (expiryTime <= now) {
sharedPreferences.edit {
remove("premium_expiry_time")
remove("foss_upgraded")
}
_uiState.update {
it.copy(
timeUntilFOSSPremiumExpiry = 0L,
isPremium = false
)
}
} else {
_uiState.update {
it.copy(
timeUntilFOSSPremiumExpiry = expiryTime - now,
isPremium = true
)
}
}
}
// First migration from accidental FOSS Play build
fossUpgraded && !_uiState.value.isPremium && BuildConfig.PLAY_BUILD -> {
val newExpiry = now + 28L * 24 * 60 * 60 * 1000
sharedPreferences.edit {
putLong("premium_expiry_time", newExpiry)
}
_uiState.update {
it.copy(
timeUntilFOSSPremiumExpiry = newExpiry - now,
isPremium = true
)
}
}
}
}
fun setOffListeningMode(enabled: Boolean) {
@@ -454,51 +526,69 @@ class AirPodsViewModel(
}
fun setATTCharacteristicValue(handle: ATTHandles, value: ByteArray) {
if (handle == ATTHandles.LOUD_SOUND_REDUCTION) {
_uiState.update { it.copy(loudSoundReductionEnabled = value[0].toInt() == 0x01) }
when (handle) {
// ideally should be using a different viewmodel for ATT based things because there are a lot of values, and I am not going to add all to this state, but there's loudsoundreduction.
ATTHandles.LOUD_SOUND_REDUCTION -> {
_uiState.value = _uiState.value.copy(loudSoundReductionEnabled = value[0].toInt() == 0x01)
}
ATTHandles.HEARING_AID -> {
_uiState.value = _uiState.value.copy(hearingAidData = value)
}
ATTHandles.TRANSPARENCY -> {
_uiState.value = _uiState.value.copy(transparencyData = value)
}
}
viewModelScope.launch(Dispatchers.IO) {
try {
service.attManager?.connect()
while (service.attManager?.socket?.isConnected != true) {
delay(250)
}
service.attManager?.write(handle, value)
service.attManager.writeCharacteristic(handle, value)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
fun refreshATT() {
viewModelScope.launch(Dispatchers.IO) {
val loudSoundReduction =
runCatching { service.attManager?.read(ATTHandles.LOUD_SOUND_REDUCTION) }.getOrNull()
val transparencyData =
runCatching { service.attManager?.read(ATTHandles.TRANSPARENCY) }.getOrNull()?: byteArrayOf()
val hearingAid =
runCatching { service.attManager?.read(ATTHandles.HEARING_AID) }.getOrNull()?: byteArrayOf()
_uiState.value = _uiState.value.copy(
loudSoundReductionEnabled = loudSoundReduction?.get(0)?.toInt() == 0x01,
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 = hearingAid
hearingAidData = hearingAidData
)
}
}
fun observeATT() {
viewModelScope.launch(Dispatchers.IO) {
service.attManager?.connect()
while (service.attManager?.socket?.isConnected != true) {
delay(1000)
service.attManager.enableNotification(ATTCCCDHandles.HEARING_AID)
service.attManager.enableNotification(ATTCCCDHandles.TRANSPARENCY)
// service.attManager.enableNotification(ATTCCCDHandles.LOUD_SOUND_REDUCTION)
}
service.attManager.setOnNotificationReceived { handle, value ->
when (handle) {
ATTHandles.LOUD_SOUND_REDUCTION.value.toByte() -> {
val loudSoundReductionEnabled = if (value.isNotEmpty()) {
value[0].toInt() == 1
} else false
_uiState.update {
it.copy(loudSoundReductionEnabled = loudSoundReductionEnabled)
}
}
ATTHandles.HEARING_AID.value.toByte() -> {
_uiState.update {
it.copy(hearingAidData = value)
}
}
ATTHandles.TRANSPARENCY.value.toByte() -> {
_uiState.update {
it.copy(transparencyData = value)
}
}
service.attManager?.enableNotifications(ATTHandles.LOUD_SOUND_REDUCTION)
service.attManager?.enableNotifications(ATTHandles.TRANSPARENCY)
service.attManager?.enableNotifications(ATTHandles.HEARING_AID)
while (true) {
refreshATT()
delay(15000)
}
}
}

View File

@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
import kotlin.math.roundToInt
@@ -34,7 +35,8 @@ data class AppSettingsUiState(
val isPremium: Boolean = false,
val connectionSuccessful: Boolean = false,
val showBottomSheetPopup: Boolean = true,
val showIslandPopup: Boolean = true
val showIslandPopup: Boolean = true,
val timeUntilFOSSPremiumExpiry: Long = 0L
)
class AppSettingsViewModel(application: Application) : AndroidViewModel(application) {
@@ -66,12 +68,71 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
private fun observeBilling() {
viewModelScope.launch {
BillingManager.provider.isPremium.collect { premium ->
_uiState.update { it.copy(isPremium = premium) }
if (premium) {
sharedPreferences.edit {
remove("premium_expiry_time")
remove("foss_upgraded")
}
_uiState.update { it.copy(isPremium = true, timeUntilFOSSPremiumExpiry = 0L) }
} else {
// No billing premium, only update if no temporary premium is active
if (_uiState.value.timeUntilFOSSPremiumExpiry <= 0L) {
_uiState.update { it.copy(isPremium = false) }
}
}
}
}
}
private fun loadSettings() {
// faulty update on Play caused PLAY_BUILD to be false and resulted in use of FOSS billing in Play. since FOSS is not verified, we need to give 2 weeks to verify the purchase
val fossUpgraded = sharedPreferences.getBoolean("foss_upgraded", false)
val expiryTime = sharedPreferences.getLong("premium_expiry_time", 0L)
val now = System.currentTimeMillis()
when {
// existing temporary premium
expiryTime > 0L -> {
if (expiryTime <= now) {
sharedPreferences.edit {
remove("premium_expiry_time")
remove("foss_upgraded")
}
_uiState.update {
it.copy(
timeUntilFOSSPremiumExpiry = 0L,
isPremium = false
)
}
} else {
_uiState.update {
it.copy(
timeUntilFOSSPremiumExpiry = expiryTime - now,
isPremium = true
)
}
}
}
// First migration from accidental FOSS Play build
fossUpgraded && !_uiState.value.isPremium && BuildConfig.PLAY_BUILD -> {
val newExpiry = now + 28L * 24 * 60 * 60 * 1000
sharedPreferences.edit {
putLong("premium_expiry_time", newExpiry)
}
_uiState.update {
it.copy(
timeUntilFOSSPremiumExpiry = newExpiry - now,
isPremium = true
)
}
}
}
_uiState.update { currentState ->
currentState.copy(
showPhoneBatteryInWidget = sharedPreferences.getBoolean("show_phone_battery_in_widget", false),

View File

@@ -85,7 +85,9 @@ import me.kavishdevar.librepods.MainActivity
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.StemPressType
import me.kavishdevar.librepods.bluetooth.ATTManager
import me.kavishdevar.librepods.bluetooth.ATTCCCDHandles
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.bluetooth.ATTManagerv2
import me.kavishdevar.librepods.bluetooth.BLEManager
import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager
import me.kavishdevar.librepods.data.AirPodsInstance
@@ -128,6 +130,7 @@ import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.time.Duration.Companion.milliseconds
private const val TAG = "AirPodsService"
@@ -150,7 +153,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
var macAddress = ""
var localMac = ""
lateinit var aacpManager: AACPManager
var attManager: ATTManager? = null
lateinit var attManager: ATTManagerv2
var airpodsInstance: AirPodsInstance? = null
var cameraActive = false
private var disconnectedBecauseReversed = false
@@ -379,6 +382,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
aacpManager = AACPManager()
initializeAACPManagerCallback()
attManager = ATTManagerv2()
sharedPreferences.registerOnSharedPreferenceChangeListener(this)
localMac = config.selfMacAddress
@@ -653,6 +658,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
addAction("android.bluetooth.headset.action.VENDOR_SPECIFIC_HEADSET_EVENT")
addAction("android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED")
addAction("android.bluetooth.a2dp.profile.action.PLAYING_STATE_CHANGED")
addAction("android.bluetooth.device.action.UUID")
}
connectionReceiver = object : BroadcastReceiver() {
@@ -690,8 +696,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
// isConnectedLocally = false
popupShown = false
updateNotificationContent(false)
attManager?.disconnect()
attManager = null
aacpManager.disconnected()
attManager.disconnected()
BluetoothConnectionManager.setCurrentConnection(null, null)
}
}
}
@@ -1018,7 +1025,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
)
// Store in SharedPreferences
sharedPreferences.edit {
putString("airpods_name", deviceInformation.name)
putString("name", deviceInformation.name)
putString("airpods_model_number", deviceInformation.modelNumber)
putString("airpods_manufacturer", deviceInformation.manufacturer)
putString("airpods_serial_number", deviceInformation.serialNumber)
@@ -1093,9 +1100,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
"Stem press received: $stemPressType on $bud, cameraActive: $cameraActive, cameraAction: ${config.cameraAction}"
)
if (cameraActive && config.cameraAction != null && stemPressType == config.cameraAction) {
if (BuildConfig.FLAVOR == "xposed") {
Runtime.getRuntime().exec(arrayOf("su", "-c", "input keyevent 27"))
}
} else {
val action = getActionFor(bud, stemPressType)
Log.d("AirPodsParser", "$bud $stemPressType action: $action")
@@ -2389,16 +2394,27 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
?.getString("name", bluetoothDevice?.name)
if (bluetoothDevice != null && !action.isNullOrEmpty()) {
Log.d(TAG, "Received bluetooth connection broadcast: action=$action")
if (BluetoothDevice.ACTION_ACL_CONNECTED == action) {
val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
bluetoothDevice.fetchUuidsWithSdp()
if (bluetoothDevice.uuids != null) {
if (bluetoothDevice.uuids.contains(uuid)) {
if (BluetoothDevice.ACTION_ACL_CONNECTED == action) {
if (bluetoothDevice.uuids?.contains(uuid) == true) {
val intent = Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
intent.putExtra("name", name)
intent.putExtra("device", bluetoothDevice)
context?.sendBroadcast(intent)
} else {
bluetoothDevice.fetchUuidsWithSdp()
}
} else if ("android.bluetooth.device.action.UUID" == action) {
val savedMac = context?.getSharedPreferences("settings", MODE_PRIVATE)
?.getString("mac_address", "") ?: ""
val matchedByMac = savedMac.isNotEmpty() && bluetoothDevice.address == savedMac
val matchedByUuid = bluetoothDevice.uuids?.contains(uuid) == true
if (matchedByUuid || matchedByMac) {
val intent = Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
intent.putExtra("name", name)
intent.putExtra("device", bluetoothDevice)
context?.sendBroadcast(intent)
}
}
}
@@ -2611,15 +2627,15 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
private fun createBluetoothSocket(
adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid
adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid, psm: Int
): BluetoothSocket {
val type = 3 // L2CAP
val constructorSpecs = listOf(
arrayOf(adapter, device, type, true, true, 0x1001, uuid), // A16QPR3
arrayOf(device, type, true, true, 0x1001, uuid),
arrayOf(device, type, 1, true, true, 0x1001, uuid),
arrayOf(type, 1, true, true, device, 0x1001, uuid),
arrayOf(type, true, true, device, 0x1001, uuid)
arrayOf(adapter, device, type, true, true, psm, uuid), // A16QPR3
arrayOf(device, type, true, true, psm, uuid),
arrayOf(device, type, 1, true, true, psm, uuid),
arrayOf(type, 1, true, true, device, psm, uuid),
arrayOf(type, true, true, device, psm, uuid)
)
val constructors = BluetoothSocket::class.java.declaredConstructors
@@ -2665,7 +2681,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
// if (!isConnectedLocally) {
socket = try {
createBluetoothSocket(adapter, device, uuid)
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}")
@@ -2674,17 +2690,30 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
try {
runBlocking {
withTimeout(5000L) {
withTimeout(5000.milliseconds) {
try {
socket.connect()
// isConnectedLocally = true
this@AirPodsService.device = device
BluetoothConnectionManager.setCurrentConnection(socket, device)
val xposedRemotePref = XposedRemotePrefProvider.create()
if (xposedRemotePref.getBoolean("vendor_id_hook", false)) {
attManager = ATTManager(adapter, device)
attManager!!.connect()
val attSocket = if (xposedRemotePref.getBoolean("vendor_id_hook", false)) {
createBluetoothSocket(
adapter,
device,
ParcelUuid.fromString("00000000-0000-0000-0000-000000000000"),
31
)
} else null
attSocket?.connect()
BluetoothConnectionManager.setCurrentConnection(socket, attSocket)
if (attSocket != null) {
attManager.startReader()
attManager.readCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION)
attManager.readCharacteristic(ATTHandles.TRANSPARENCY)
attManager.readCharacteristic(ATTHandles.HEARING_AID)
attManager.enableNotification(ATTCCCDHandles.HEARING_AID)
// attManager.enableNotification(ATTCCCDHandles.LOUD_SOUND_REDUCTION)
attManager.enableNotification(ATTCCCDHandles.TRANSPARENCY)
}
// Create AirPodsInstance from stored config if available
@@ -2712,6 +2741,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
)
Log.d(TAG, "<LogCollector:Complete:Success> 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) }
@@ -2882,7 +2919,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
socket.close()
// isConnectedLocally = false
aacpManager.disconnected()
attManager?.disconnect()
attManager.disconnected()
BluetoothConnectionManager.setCurrentConnection(null, null)
updateNotificationContent(false)
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply {
setPackage(packageName)

View File

@@ -23,7 +23,7 @@ import android.os.Build
fun isSupported(sharedPreferences: SharedPreferences): Boolean {
val isPixel = Build.MANUFACTURER.lowercase() == "google"
val isOppoOrOnePlus = Build.MANUFACTURER.lowercase() in listOf("oneplus", "oppo")
val isOppoFamily = Build.MANUFACTURER.lowercase() in listOf("oneplus", "oppo", "realme")
val isBypassFlagActive = sharedPreferences.getBoolean("bypass_device_check.v2", false)
if (isBypassFlagActive) return true
@@ -31,14 +31,14 @@ fun isSupported(sharedPreferences: SharedPreferences): Boolean {
if (isPixel) {
when (Build.VERSION.SDK_INT) {
36 -> {
return Build.ID == "CP1A.260305.018" || Build.ID == "CP1A.260405.005"
return Build.ID.startsWith("CP1A")
}
37 -> {
return true
}
}
} else if (isOppoOrOnePlus) {
} else if (isOppoFamily) {
return Build.VERSION.SDK_INT >= 36
}
return false

View File

@@ -252,7 +252,8 @@
\n• Google Pixel® running 17 Beta 3 and above
\n• OnePlus devices running OxygenOS 16 or later
\n• Oppo devices running ColorOS 16 or later
\n\nFor details, see the project documentation.</string>
\n\nFor details, see the project documentation.
</string>
<string name="name_your_own_price">(Name your own price)</string>
<string name="compatibility_play_dialog_confirmation">
This device may not be supported due to platform limitations and requires an Xposed framework. Tick the checkbox below and type OK to continue.
@@ -273,5 +274,7 @@
<string name="subject">Subject</string>
<string name="describe_your_issue">Describe your issue</string>
<string name="optimized_charging">Optimized Charge Limit</string>
<string name="optimized_charging_description">AirPods can learn from your daily usage and determine when to charge to an optmized limit and when to allow or full charge. This limit adapts to your daily usage and preserves your battery lifespan over time.\\nThis setting may not affect unsupported AirPods, or AirPods on an older firmware version.</string>
<string name="optimized_charging_description">AirPods can learn from your daily usage and determine when to charge to an optmized limit and when to allow or full charge. This limit adapts to your daily usage and preserves your battery lifespan over time.\nThis setting may not affect unsupported AirPods, or AirPods on an older firmware version.</string>
<string name="enable_app_in_xposed_or_update_device">Enable LibrePods in Xposed or update your device to proceed.</string>
<string name="play_foss_premium_banner">Due to an error in billing, premium access will expire in %1$d days. If you already upgraded the app, please click on this message to email billing@kavish.xyz to restore or verify access. Apologies for the inconvenience.</string>
</resources>

View File

@@ -1,21 +0,0 @@
package me.kavishdevar.librepods
import android.app.Application
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.billing.BillingProviderFactory
class LibrePodsApplication: Application(), DefaultLifecycleObserver {
override fun onCreate() {
BillingManager.provider = BillingProviderFactory.create(this)
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
super<Application>.onCreate()
}
override fun onResume(owner: LifecycleOwner) {
BillingManager.provider.queryPurchases()
}
}

View File

@@ -1,11 +0,0 @@
package me.kavishdevar.librepods.data
class XposedRemotePrefImpl: XposedRemotePref {
override fun isAvailable(): Boolean { return false }
override fun getBoolean(key: String, def: Boolean): Boolean {
return false
}
override fun putBoolean(key: String, value: Boolean) { }
}

View File

@@ -1,24 +1,25 @@
[versions]
accompanistPermissions = "0.37.3"
agp = "9.1.0"
kotlin = "2.3.20"
agp = "9.1.1"
kotlin = "2.3.21"
coreKtx = "1.18.0"
lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.13.0"
composeBom = "2026.03.01"
composeBom = "2026.05.00"
annotations = "26.1.0"
navigationCompose = "2.9.7"
navigationCompose = "2.9.8"
constraintlayout = "2.2.1"
haze = "1.7.2"
hazeMaterials = "1.7.2"
dynamicanimation = "1.1.0"
aboutLibraries = "14.0.1"
aboutLibraries = "14.2.0"
materialIconsCore = "1.7.8"
backdrop = "2.0.0-alpha03"
billing = "8.3.0"
hilt = "2.59.2"
xposed = "101.0.0"
lifecycleProcess = "2.10.0"
play = "2.0.2"
[libraries]
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" }
@@ -49,6 +50,8 @@ hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.r
libxposed-api = { group = "io.github.libxposed", name = "api", version.ref = "xposed" }
libxposed-service = { group = "io.github.libxposed", name = "service", version.ref = "xposed" }
androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleProcess" }
play-review = { group = "com.google.android.play", name="review", version.ref = "play" }
play-review-ktx = { group = "com.google.android.play", name="review-ktx", version.ref = "play" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }

BIN
imgs/banner-dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

View File

@@ -1,3 +1,26 @@
# LibrePods on Linux
A new rewrite is being worked upon. Please look at the list of features in the root README to see what's supported in the new version.
The rewrite can be found in the `linux/rust` branch [here](https://github.com/kavishdevar/librepods/tree/linux/rust/linux-rust). Follow the development in [PR #241](https://github.com/kavishdevar/librepods/pull/241).
## Installation
### GitHub Releases
The app is ready to download as an AppImage or an executable. You can download the latest pre-release from the [GitHub releases](https://github.com/kavishdevar/librepods/releases?q="linux-v0").
### Nightly Builds (recommended)
You can also try the latest build of the new version from the [GitHub Actions artifacts](https://github.com/kavishdevar/librepods/actions/workflows/ci-linux-rust.yml). On the latest successful workflow run, download the **librepods-x86_64.AppImage** or **librepods** binary from **Artifacts**.
![new version screenshot](https://github.com/user-attachments/assets/86b3c871-89a8-4e49-861a-5119de1e1d28)
<details>
<summary>README for the old version</summary>
# LibrePods Linux
![screenshot](imgs/main-app.png)
@@ -189,3 +212,5 @@ It is possible that the AirPods disconnect after a short period of time and play
### Why a separate script?
Because I discovered that QBluetooth doesn't support connecting to a socket with its PSM, only a UUID can be used. I could add a dependency on BlueZ, but then having two bluetooth interfaces seems unnecessary. So, I decided to use a separate script for hearing aid features. In the future, QBluetooth will be replaced with BlueZ native calls, and then everything will be in one application.
</details>

View File

@@ -1 +0,0 @@
system/

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<permissions>
<privapp-permissions package="me.kavishdevar.librepods">
<permission name="android.permission.BLUETOOTH_PRIVILEGED"/>
<permission name="android.permission.MODIFY_PHONE_STATE"/>
<permission name="android.permission.INTERACT_ACROSS_USERS"/>
<permission name="android.permission.LOCAL_MAC_ADDRESS"/>
</privapp-permissions>
</permissions>