Compare commits

..

63 Commits

Author SHA1 Message Date
Kavish Devar 76206e5588 add bluez support
merging windows and linux scripts into one
2025-12-08 14:33:35 +05:30
Kavish Devar 52b4d07c0e windows: remove unnecessary sys path modification 2025-11-28 14:04:03 +05:30
Kavish Devar f99732649e windows: add hearing aid script
uses bumble
2025-11-28 13:47:42 +05:30
rudolphtran25 8088594df5 android(i18n): add Vietnamese language (#322) 2025-11-25 13:55:58 +05:30
Kavish Devar 345b7b9051 android(fix): set HSP/HFP policy to allow when connecting to audio (#318)
* android: use setConnectionProfile instead of (dis)connect on BluetoothProfile

this prevents android from trying to reconnect to audio itself after we disconnect. this is essentially the same as toggling the 'Media Audio' and 'Phone Calls' settings in the android settings

* android(fix): set HSP/HFP policy to allow when connecting to audio

forgot to change it
2025-11-24 14:59:01 +05:30
Ozan Durgut fa30d3c09a linux: fix single-instance logic (#314)
Replaced the previous QSharedMemory-based single-instance logic with a
clean QLocalServer approach. The old method left stale shared memory and
socket files after crashes, causing false "already running" states and
occasional segmentation faults.
2025-11-24 09:49:10 +05:30
Kavish Devar 826e395379 android: use setConnectionProfile instead of (dis)connect on BluetoothProfile (#313)
this prevents android from trying to reconnect to audio itself after we disconnect. this is essentially the same as toggling the 'Media Audio' and 'Phone Calls' settings in the android settings
2025-11-23 12:13:14 +05:30
Ozan Durgut 574c193a6d linux: improve conversation detect logging (#302)
media: correct AirPods conversational awareness state handling

Fix incorrect detection of conversational awareness events. The previous
implementation treated all non-0x01 packets as "disabled", which caused
wrong behavior when the user manually enabled/disabled the feature or when
voice-end events were received.

Adds full decoding for packet types:
 - 0x01 → voice detected
 - 0x06/others → voice ended
 - 0x08 → feature disabled
 - 0x09 → feature enabled

Signed-off-by: ozan956 <ozandurgut.2001@hotmail.com>
2025-11-23 00:42:00 +05:30
Sergio LM 10bf2fe68c android(i18n): add Spanish translations (#310) 2025-11-22 21:47:08 +05:30
albanobattistella e192dc114b android(i18n): add Italian translation (#307)
Added Italian localization strings for the app.
2025-11-22 21:46:34 +05:30
Kavish Devar de82cdd8c2 android(fix): parse and send hearing aid data properly (#286) 2025-11-22 10:57:58 +05:30
Gabriel Oliveira a06c673400 android(i18n): add pt translation (#297) 2025-11-21 23:48:06 +05:30
Subhrajyoti Sen a80680ff73 docs: fix broken link to hearing aid gist (#304)
Without `https://`, URLs are treated as relative paths.
2025-11-21 23:37:54 +05:30
İbrahim Erdoğan 36c55169f1 android(i18n): add tr-TR (#303) 2025-11-21 23:36:58 +05:30
Brad 93ac06b8e3 linux(docs): add missing dependencies (#300) 2025-11-21 22:59:24 +05:30
Kavish Devar aa0898a65a android: remove sponsor dialog
hiding it, rather than removing it entirely
2025-11-21 10:23:25 +05:30
Kavish Devar ecfbcd1c02 android: set min version to 13
I haven't been able to figure out a workaround for pre-A13
2025-11-21 02:55:51 +05:30
Kavish Devar 5231b12c71 docs: add supporters and acknowledgments to README 2025-11-20 17:03:15 +05:30
Kavish Devar 0123449d80 docs: it's vendorid not manufacturerid
no idea where i got manufacturerid from
2025-11-20 16:43:48 +05:30
Tyrone 8a5d6087c9 linux: AirPods Max battery status support (#272) 2025-11-20 13:14:40 +05:30
Kavish Devar f12fe90134 docs: clarify root requirement for oxygen/coloros 2025-11-20 12:31:27 +05:30
Iscle 8fbdfd879c android(refactor): remove unused textColor property and related preferences (#266) 2025-11-20 12:20:14 +05:30
Miko 0a608afbe6 refactor: Add Python type annotations wherever appropriate (#269)
* Add Python type annotations wherever appropriate

* Might as well annotate this too
2025-11-20 00:29:32 +05:30
ternera be362c5079 docs: fixed grammar in AAP definitions (#287)
minor typo corrections
2025-11-20 00:28:57 +05:30
Kavish Devar 141f1e7604 android(fix): do not require phone's MAC for service start (#253)
This makes the app run without issues on OxygenOS/ColorOS16 without root.

* android(fix): add missing HEAD_GESTURES capability on app2

* android(fix): catch att initial read exceptions in toggle

* android(refactor): remove navcontroller from head gestures screen

* android(fix): do not crash when connected devices list is sent empty

had never seen this before, this was the first time airpods saying zero connected devices

* android(fix): do not crash if phone's MAC not available

also removed crossdevice code

* android: skip sdp hook check if setup skipped
2025-11-19 23:20:24 +05:30
unpleased 1dbb36a2aa docs: fix grammar in README (#277) 2025-11-19 20:56:22 +05:30
callie 938f0d5448 docs: Add Airpods Max to README (#260)
docs: update README
2025-11-18 02:04:24 +05:30
Kavish Devar e3eab3e31e docs: update root requirement
yay! finally some OEM fixed it
2025-11-17 12:27:24 +05:30
Kavish Devar 55d1a69b21 docs: fix linux app pr link 2025-11-17 03:07:06 +05:30
Kavish Devar f4fbcc9e88 remove CoC and contributing
dk why i added it in the first place
2025-11-17 02:56:15 +05:30
Kavish Devar 944195b193 docs: add linux screenshots to readme 2025-11-17 02:55:10 +05:30
Kavish Devar e8e1650145 docs: add dark themed star history 2025-11-16 20:12:40 +05:30
Kavish Devar 4a4494121d update license to GPLv3 2025-11-16 20:09:32 +05:30
Victor Adossi ("vados") 3696a4e729 docs(linux): use GitHub warning text for warning note (#248) 2025-11-16 09:20:24 +05:30
Mathias S. 7356e57878 linux: Use white text for tray icon battery percentage (#243)
* docs: add troubleshooting section for media controls not working

* always use white text for the battery percentage in the system tray icon which matches what other applications like Bluetooth battery indicators do in Plasma

* init librepods asset

* remove update script for librepods
2025-11-13 14:46:18 +05:30
Hen acf2b9bea7 android(i18n): added ukrainian translation (#245)
feat(i18n): added ukrainian translation with extracting of some string to resources. Auto generation of locale config
2025-11-13 07:51:18 +05:30
Kavish Devar 34f60699b8 ci: use just to build tarball 2025-11-10 13:32:40 +05:30
Kavish Devar a38ebd213b ci: fix indendation in linux ci 2025-11-10 12:02:24 +05:30
Kavish Devar 142e0c5e37 ci: upload appimage and binary separately 2025-11-10 12:00:24 +05:30
Kavish Devar a8963ecef5 ci: add libfuse in ci for appimage 2025-11-10 11:50:49 +05:30
Kavish Devar 53f5626914 ci: build appimage and vendored tarball for flatpak 2025-11-10 11:37:37 +05:30
imgbot[bot] 89782d9b7c [ImgBot] Optimize images (#237)
*Total -- 2,162.84kb -> 1,856.20kb (14.18%)

/android/imgs/hearing-test.png -- 72.02kb -> 32.69kb (54.62%)
/android/imgs/customizations-1.png -- 187.73kb -> 147.89kb (21.22%)
/android/imgs/accessibility.png -- 175.15kb -> 139.03kb (20.62%)
/android/imgs/transparency.png -- 142.45kb -> 114.67kb (19.5%)
/android/imgs/hearing-aid-adjustments.png -- 118.17kb -> 95.98kb (18.78%)
/android/imgs/long-press.png -- 89.79kb -> 74.73kb (16.77%)
/linux/imgs/main-app.png -- 32.58kb -> 27.15kb (16.67%)
/android/imgs/settings-2.png -- 265.03kb -> 221.76kb (16.33%)
/android/imgs/hearing-aid.png -- 73.45kb -> 61.93kb (15.68%)
/android/imgs/customizations-2.png -- 300.41kb -> 264.08kb (12.09%)
/android/imgs/settings-1.png -- 199.67kb -> 186.42kb (6.64%)
/android/imgs/head-tracking-and-gestures.png -- 142.86kb -> 134.84kb (5.61%)
/android/app/src/main/res-apple/drawable/airpods_pro_3_case.png -- 51.93kb -> 50.72kb (2.34%)
/android/app/src/main/res-apple/drawable/airpods_4_case.png -- 51.93kb -> 50.72kb (2.34%)
/android/app/src/main/res-apple/drawable/airpods_2_case.png -- 51.93kb -> 50.72kb (2.34%)
/android/app/src/main/res-apple/drawable/airpods_pro_2_case.png -- 51.93kb -> 50.72kb (2.34%)
/android/app/src/main/res-apple/drawable/airpods_3_case.png -- 51.93kb -> 50.72kb (2.34%)
/android/app/src/main/res-apple/drawable/airpods_pro_1_case.png -- 51.93kb -> 50.72kb (2.34%)
/android/app/src/main/res-apple/drawable/airpods_1_case.png -- 51.93kb -> 50.72kb (2.34%)

Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>
Co-authored-by: ImgBotApp <ImgBotHelp@gmail.com>
2025-11-10 10:08:44 +05:30
汞齐 283f841855 android(i18n): Add complete Chinese translation (#240)
This pull request is purely a localization effort and **does not add any new features or UI elements** to the Android application. Its sole purpose is to enable a complete Chinese (zh-CN) display in a Chinese Android environment.

Changes:
- All translatable strings from  now have corresponding translated entries in .
- This significantly improves the user experience for Chinese-speaking users.
2025-11-05 19:19:09 +05:30
Marek Veselý 4c8ebe27bc android: Add missing HEAD_GESTURES capability to the Airpods Pro 3 (#239)
fix: Add missing HEAD_GESTURES capability to the Airpods Pro 3
2025-10-27 20:00:40 +05:30
Kavish Devar bc5bcd81b3 ci: run android CI only when android src is changed 2025-10-27 17:51:28 +05:30
Kavish Devar b72680a1c4 ci: build rust rewrite 2025-10-27 17:47:45 +05:30
Kavish Devar 816048f1f8 linux: parse enroled/enabled false as hearing aid disabled 2025-10-27 15:29:31 +05:30
Kavish Devar d6b98b70e8 linux: fix hearing aid control cmd value
closes #236
2025-10-27 15:26:41 +05:30
Kavish Devar f62a57c888 android: fix find replace mess-up 2025-10-27 15:19:10 +05:30
Kavish Devar f7bb1ce6fc docs: update readme 2025-10-27 15:19:10 +05:30
Kavish Devar 9bf3e64b20 docs: update readme 2025-10-26 21:34:41 +05:30
Kavish Devar 217455fecb android: bump version 2025-10-26 21:10:44 +05:30
Kavish Devar 84d5fa466e stop linux ci
development for the old version is stopped. rewrite wip
2025-10-26 21:10:35 +05:30
Kavish Devar 8eb6eba049 android: multidevice capabilites and accessiblity features (and "liquid glass") (#202)
many thanks to @rithvikvibhu for help with the hearing aids feature

adds:
hearing aid
two-device connection
new UI
transparency mode customization

commits:
* android: add accessibility stuff

adds option for customizing transparency mode, amplification, tone, etc.

* docs: update transparency mode format

* android: don't 'start' service every time MainActivity is launched

* android: add basic multidevice capabilities

use at your own risk, may or may not work

* android: clean up a bit of AI gen'd code

* android: clean up main service and remove minimum API on head gestures

* android: clean up a lot of stuff

* android: implement the accessiblity settings page

* android: add EQ settings for phone and media

* android: add toggle for DID hook

* docs: add 'has ownership' control cmd

* android: fix balance NaN error when amplification L/R is both zero

* android: bring back some accessiblity settings and add listeners for all config

* android: add header to ATTManager

* android: use device name sent by the connected device in island

* android: fix track color in tone volume

* android: remove unused composable

* android: update eq sliders style

* android: fix text color in selectors

* android: add delay before starting head tracking again

* android: add a few options

ik not the right branch/pr but, eh, i am not merging this hook until i test further, and if i don't merge, conflicts, a lot of 'em

* android: a small ui fix

* docs: a few more control cmds

* android: add microphone setting

also, un-hardcoded strings, and updated text sizes

* android: improve dropdowns

ai generated

* android: move attmanager to service to avoid trying to connect multiple times

* android: add ui for hearing stuff

mostly copied from the transparency settings, which are now updated to match ios <26 ui

* android: add media assist options in hearing aid

ui only

* android: add hearing aid adjustments

* android: liquidglass sliders

* android: improve liquid glass sliders

* android: little more liquid glass

* android: fix hearing aid parsing

* android: remove customdeviceactivity from manifest

* android: remove unused strings

* android: small ui tweaks

* android: a very big commit

refactoring ui, mostly

* android: move padding to StyledScaffold's content

because haze needs it

* android: revert accidental capitalization on toggle label

* android: update usages for toggle

* android: liquidglass, maybe?

the switch and icon button took quite a while. i forgot the order of modifiers matters!

* remove bleonly mode, use CAPod instead

* remove bleonly mode, use CAPod instead

* android: fix switch styling

* android: remove fade from transition

* android: add A16's new bluetooth identifier for log collection

just why...

* android: fix crash in head gestures screen

* android: show head gestures status in the navigation button

* android: don't crash if att not available

* android: use lazycolumn in airpods settings for better performance and navigation transitions

* android: fix text color in troubshooting button and pressandhold settings

* android: bring back original confirmation dialog

too lazy to fix/implement properly the glassy one

* android: prevent hearing aid turning off itself

* android: hide media assist, not implemented

* docs: update README with new features

* docs: add demo video

* docs: add new screenshots for android

* docs: update demo video position

* docs: app3 compatibility

* docs: new control cmds '25 (again)

* docs: change section title in control cmd doc

Updated section title from 'Control Commands' to 'Identifiers and details'.

* android: ui tweaks

* android: update styled slider thumb

* android: add accessiblity service for camera control

* android: add camera control, finally

i got too lazy to find out how to listen to app openings earlier, wasn't too hard

* android: add option to change camera app id

* android: not use relative paths for executing commands

i hope it's the same across all skins

* android: fix transparency and noise cancellation flags

huh... was it always like this?

* android: revert to using relative paths for su

compatibility issues with magisk

* android: bump version

* android: don't crash if self MAC is not available

* android: remove unused LOCAL_ADDRESS permission

* android: add opensource licenses

should've done this a long time ago!

* android: move navigation button to activity level

* android: update animation time on switch tap

* android: implement setting hearing test results

* android: update title in hearing test screen

* docs: add screenshot for hearing test

* android: fix haze for dialog when enabling hearing aid

* android: parse device info

* android: add support for various models

still need to update images or find a way to fetch from apple's cdn

* android: fix a2dp connection

* android: remove stray eq config in accessibility settings

* android: improve connection handling

* android: add a (very important) support dialog

to not be invasive, this only shows up once, and never again.

* docs: add note for DID hook on android
2025-10-26 21:03:49 +05:30
Kavish Devar e437572355 linux: remove specific qt version requirement 2025-10-20 14:50:23 +05:30
Kavish Devar f062eb43b3 linux: hearing aid support (#230)
* linux: add hearing aid

it's just a simple python script, with a toggle in the main app. i dont want to mess with the main app because it uses ATT instead of the AACP protocol which is implemented in the app.

* linux: implement adding hearing aid test results

* docs: add linux screenshot

* docs: add linux hearing aid script

* linux: add reset button for hearing aid adjustments

* linux: remove MAC address logging for security
2025-10-16 12:13:57 +05:30
Mathias S. 28ffd217d6 docs: add troubleshooting section for media controls not working (#224) 2025-10-02 20:37:52 +05:30
Kavish Devar b5d1ad0dd5 linux: add support for multiple media players (#222)
also add wait for setting card profiles to complete
2025-10-02 12:06:44 +05:30
Kavish Devar a6d6e0e13c linux: fix name for preferred a2dp profile in mediacontroller 2025-10-02 11:33:23 +05:30
Kavish Devar fe774d565d linux: replace pactl calls with libpulse (#221) 2025-10-02 11:25:41 +05:30
Kavish Devar 120681541f docs: remove instructions for phone mac address
Removed instructions for setting PHONE_MAC_ADDRESS, handled in UI.
2025-10-02 00:47:40 +05:30
Mathias S. 784b7a2cfa [Linux] Auto-restart WirePlumber when A2DP profile is unavailable (#213)
Fix: Auto-restart WirePlumber when A2DP profile is unavailable
2025-10-01 20:23:19 +05:30
Mathias S. d7a96c9fc5 Fix: Activate A2DP audio profile for AirPods after system reboot on Linx (#212)
Fix: Activate A2DP audio profile for AirPods after system reboot

- Add A2DP profile activation on system wake-up
- Add A2DP profile activation when BlueZ detects connection
- Add A2DP profile activation for already connected devices on startup

Fixes issue where AirPods microphone works but audio output is unavailable after reboot
2025-09-30 14:17:16 +05:30
169 changed files with 13087 additions and 3315 deletions
+1 -1
View File
@@ -16,5 +16,5 @@ indent_size = 4
trim_trailing_whitespace = false trim_trailing_whitespace = false
max_line_length = off max_line_length = off
[*.{py,java,r,R,kt,xml,kts}] [*.{py,java,r,R,kt,xml,kts,h,hpp,cpp,qml}]
indent_size = 4 indent_size = 4
+2
View File
@@ -4,6 +4,8 @@ on:
push: push:
branches: branches:
- '*' - '*'
paths:
- 'android/**'
workflow_dispatch: workflow_dispatch:
inputs: inputs:
release: release:
+87
View File
@@ -0,0 +1,87 @@
name: Linux Build & Release
on:
push:
branches:
- linux/rust
tags:
- 'linux-v*'
paths:
- 'linux-rust/**'
- '.github/workflows/linux-build.yml'
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y pkg-config libdbus-1-dev libpulse-dev appstream just libfuse2
- name: Install AppImage tools
run: |
wget -q https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage -O /usr/local/bin/appimagetool
wget -q https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage -O /usr/local/bin/linuxdeploy
chmod +x /usr/local/bin/{appimagetool,linuxdeploy}
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache Cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
linux-rust/target
key: ${{ runner.os }}-cargo-${{ hashFiles('linux-rust/Cargo.lock') }}
- name: Build AppImage and Binary
working-directory: linux-rust
run: |
cargo build --release --verbose
just
mkdir -p dist
cp target/release/librepods dist/librepods
mv dist/LibrePods-x86_64.AppImage dist/librepods-x86_64.AppImage
- name: Upload AppImage artifact
if: "!startsWith(github.ref, 'refs/tags/linux-v')"
uses: actions/upload-artifact@v4
with:
name: librepods-x86_64.AppImage
path: linux-rust/dist/librepods-x86_64.AppImage
- name: Upload binary artifact
if: "!startsWith(github.ref, 'refs/tags/linux-v')"
uses: actions/upload-artifact@v4
with:
name: librepods
path: linux-rust/dist/librepods
- name: Create tarball for Flatpak
if: startsWith(github.ref, 'refs/tags/linux-v')
working-directory: linux-rust
run: |
VERSION="${GITHUB_REF_NAME#linux-v}"
just tarball "${VERSION}"
echo "VERSION=${VERSION}" >> $GITHUB_ENV
echo "TAR_PATH=linux-rust/dist/librepods-v${VERSION}-source.tar.gz" >> $GITHUB_ENV
- name: Create GitHub Release (AppImage + binary + source)
if: startsWith(github.ref, 'refs/tags/linux-v')
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
files: |
linux-rust/dist/librepods-v${{ env.VERSION }}-source.tar.gz
linux-rust/dist/librepods-x86_64.AppImage
linux-rust/dist/librepods
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+5 -4
View File
@@ -1,9 +1,10 @@
name: Build LibrePods Linux name: Build LibrePods Linux
on: on:
push: workflow_dispatch:
branches: # push:
- '*' # branches:
# - '*'
jobs: jobs:
build-linux: build-linux:
@@ -33,4 +34,4 @@ jobs:
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: librepods-linux name: librepods-linux
path: linux/build/librepods path: linux/build/librepods
+2 -19
View File
@@ -122,7 +122,7 @@ If primary is removed, mic will be changed and the secondary will be the new pri
## Conversational Awareness ## Conversational Awareness
AirPods send conversational awareness packets when the person wearing them start speaking. The packet format is as follows: AirPods send conversational awareness packets when the person wearing them starts speaking. The packet format is as follows:
```plaintext ```plaintext
04 00 04 00 4B 00 02 00 01 [level] 04 00 04 00 4B 00 02 00 01 [level]
@@ -307,7 +307,7 @@ All values are formatted as IEEE 754 floats in little endian order.
## Configure Stem Long Press ## Configure Stem Long Press
I have noted all the packets sent to configure what the press and hold of the steam should do. The packets sent are specific to the current state. And are probably overwritten everytime the AirPods are connected to a new (apple) device that is not synced with icloud (i think)... So, for non-Apple device too, the configuration needs to be stored and overwritten everytime the AirPods are connected to the device. That is the only way to keep the configuration. I have noted all the packets sent to configure what the press and hold of the steam should do. The packets sent are specific to the current state. And are probably overwritten everytime the AirPods are connected to a new (apple) device that is not synced with icloud (i think)... So, for non-Apple devices too, the configuration needs to be stored and overwritten everytime the AirPods are connected to the device. That is the only way to keep the configuration.
This is also the only way to control the configuration as the previous state needs to be known, and then the new state can be set. This is also the only way to control the configuration as the previous state needs to be known, and then the new state can be set.
@@ -403,20 +403,3 @@ Once tracking is active, the AirPods stream sensor packets with the following co
| orientation 3 | 47 | 2 | | orientation 3 | 47 | 2 |
| Horizontal Acceleration | 51 | 2 | | Horizontal Acceleration | 51 | 2 |
| Vertical Acceleration | 53 | 2 | | Vertical Acceleration | 53 | 2 |
# LICENSE
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-128
View File
@@ -1,128 +0,0 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
report@kavishdevar.me.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
-70
View File
@@ -1,70 +0,0 @@
# Welcome to LibrePods contributing guide <!-- omit in toc -->
Thank you for considering a contribution to LibrePods! Your support helps bring Apple-exclusive AirPods features to Linux and Android.
Read our [Code of Conduct](./CODE_OF_CONDUCT.md) to keep our community approachable and respectful.
This guide provides an overview of the contribution workflow, from opening an issue to creating and reviewing a pull request (PR).
## New contributor guide
To get an overview of the project, read the [README](./README.md). Here are some resources to help you get started with open-source contributions:
- [Finding ways to contribute to open source on GitHub](https://docs.github.com/en/get-started/exploring-projects-on-github/finding-ways-to-contribute-to-open-source-on-github)
- [Set up Git](https://docs.github.com/en/get-started/getting-started-with-git/set-up-git)
- [GitHub flow](https://docs.github.com/en/get-started/using-github/github-flow)
- [Collaborating with pull requests](https://docs.github.com/en/github/collaborating-with-pull-requests)
## Getting started
To navigate our codebase with confidence, see the [README](./README.md) for setup instructions and usage details. We accept various types of contributions, which dont always require writing code (like translations).
To develop for the Android App, Android Studio is the preferred IDE. And you can use any IDE for the linux program, it is just python!
### Issues
#### Create a new issue
If you find a bug or want to suggest a feature, check if an issue already exists by searching through our [existing issues](https://github.com/kavishdevar/librepods/issues). If no relevant issue exists, open a new one and fill in the details.
#### Solve an issue
Browse our [issues list](https://github.com/kavishdevar/librepods/issues) to find an interesting issue to work on. Use labels to filter issues and pick one that matches your expertise. If youd like to work on an issue, open a PR with your solution.
### Make Changes
#### Make changes locally
1. Fork the repository and clone it to your local environment.
```
git clone https://github.com/kavishdevar/librepods.git
cd AirPods-Like-Normal
```
2. Create a working branch to start your changes.
```
git checkout -b your-feature-branch
```
3. Make your changes, following the existing style and structure.
4. Test your changes to ensure they work as expected and do not introduce new issues.
### Commit your changes
Commit your changes with a descriptive message.
### Pull Request
When your changes are ready, create a pull request (PR):
- Fill out the PR template to help reviewers understand your changes.
- If your PR is related to an issue, dont forget to [link your PR to it](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
- Enable the checkbox to allow maintainers to edit your PR, so any required changes can be merged easily.
Once your PR is open, a team member will review it. They may ask questions or request additional information.
- If changes are requested, apply them in your fork and commit them to the PR branch.
- Mark conversations as resolved as you apply feedback.
- For merge conflicts, follow this [git tutorial](https://github.com/skills/resolve-merge-conflicts) to resolve them.
### Your PR is merged!
Congratulations! :tada: Once merged, your contributions will be publicly available in LibrePods.
+80 -67
View File
@@ -1,5 +1,5 @@
GNU AFFERO GENERAL PUBLIC LICENSE GNU GENERAL PUBLIC LICENSE
Version 3, 19 November 2007 Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies Everyone is permitted to copy and distribute verbatim copies
@@ -7,15 +7,17 @@
Preamble Preamble
The GNU Affero General Public License is a free, copyleft license for The GNU General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure software and other kinds of works.
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast, to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free share and change all versions of a program--to make sure it remains free
software for all its users. software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you price. Our General Public Licenses are designed to make sure that you
@@ -24,34 +26,44 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things. free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights To protect your rights, we need to prevent others from denying you
with two steps: (1) assert copyright on the software, and (2) offer these rights or asking you to surrender the rights. Therefore, you have
you this License which gives you legal permission to copy, distribute certain responsibilities if you distribute copies of the software, or if
and/or modify the software. you modify it: responsibilities to respect the freedom of others.
A secondary benefit of defending all users' freedom is that For example, if you distribute copies of such a program, whether
improvements made in alternate versions of the program, if they gratis or for a fee, you must pass on to the recipients the same
receive widespread use, become available for other developers to freedoms that you received. You must make sure that they, too, receive
incorporate. Many developers of free software are heartened and or can get the source code. And you must show them these terms so they
encouraged by the resulting cooperation. However, in the case of know their rights.
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to Developers that use the GNU GPL protect your rights with two steps:
ensure that, in such cases, the modified source code becomes available (1) assert copyright on the software, and (2) offer you this License
to the community. It requires the operator of a network server to giving you legal permission to copy, distribute and/or modify it.
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and For the developers' and authors' protection, the GPL clearly explains
published by Affero, was designed to accomplish similar goals. This is that there is no warranty for this free software. For both users' and
a different license, not a version of the Affero GPL, but Affero has authors' sake, the GPL requires that modified versions be marked as
released a new version of the Affero GPL which permits relicensing under changed, so that their problems will not be attributed erroneously to
this license. authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and The precise terms and conditions for copying, distribution and
modification follow. modification follow.
@@ -60,7 +72,7 @@ modification follow.
0. Definitions. 0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License. "This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of "Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks. works, such as semiconductor masks.
@@ -537,45 +549,35 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program. License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License. 13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work, License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version but the special requirements of the GNU Affero General Public License,
3 of the GNU General Public License. section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License. 14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions the GNU General Public License from time to time. Such new versions will
will be similar in spirit to the present version, but may differ in detail to be similar in spirit to the present version, but may differ in detail to
address new problems or concerns. address new problems or concerns.
Each version is given a distinguishing version number. If the Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published GNU General Public License, you may choose any version ever published
by the Free Software Foundation. by the Free Software Foundation.
If the Program specifies that a proxy can decide which future If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you public statement of acceptance of a version permanently authorizes you
to choose that version for the Program. to choose that version for the Program.
@@ -633,29 +635,40 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C) <year> <name of author> Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published it under the terms of the GNU General Public License as published by
by the Free Software Foundation, either version 3 of the License, or the Free Software Foundation, either version 3 of the License, or
(at your option) any later version. any later version.
This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details. GNU General Public License for more details.
You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail. Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer If the program does terminal interaction, make it output a short
network, you should also make sure that it provides a way for users to notice like this when it starts in an interactive mode:
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive <program> Copyright (C) <year> <name of author>
of the code. There are many ways you could offer source, and different This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
solutions will be better for different programs; see section 13 for the This is free software, and you are welcome to redistribute it
specific requirements. under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school, You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary. if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>. <https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+63 -81
View File
@@ -1,27 +1,19 @@
![LibrePods Banner](/imgs/banner.png) ![LibrePods Banner](/imgs/banner.png)
[![XDA Thread](https://img.shields.io/badge/XDA_Forums-Thread-orange)](https://xdaforums.com/t/app-root-for-now-airpodslikenormal-unlock-apple-exclusive-airpods-features-on-android.4707585/)
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/kavishdevar/librepods)](https://github.com/kavishdevar/librepods/releases/latest)
[![GitHub all releases](https://img.shields.io/github/downloads/kavishdevar/librepods/total)](https://github.com/kavishdevar/librepods/releases)
[![GitHub stars](https://img.shields.io/github/stars/kavishdevar/librepods)](https://github.com/kavishdevar/librepods/stargazers)
[![GitHub issues](https://img.shields.io/github/issues/kavishdevar/librepods)](https://github.com/kavishdevar/librepods/issues)
[![GitHub license](https://img.shields.io/github/license/kavishdevar/librepods)](https://github.com/kavishdevar/librepods/blob/main/LICENSE)
[![GitHub contributors](https://img.shields.io/github/contributors/kavishdevar/librepods)](https://github.com/kavishdevar/librepods/graphs/contributors)
## What is LibrePods? ## 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 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.
## Device Compatibility ## Device Compatibility
| Status | Device | Features | | Status | Device | Features |
|--------|--------|----------| | ------ | --------------------- | ---------------------------------------------------------- |
| ✅ | AirPods Pro (2nd Gen) | Fully supported and tested | | ✅ | AirPods Pro (2nd Gen) | Fully supported and tested |
| ✅ | AirPods Pro (3rd Gen) | Fully supported (except heartrate monitoring) | | ✅ | AirPods Pro (3rd Gen) | Fully supported (except heartrate monitoring) |
| ⚠️ | Other AirPods models | Basic features (battery status, ear detection) should work | | ✅ | AirPods Max | Fully supported (client shows unsupported features) |
| ⚠️ | Other AirPods models | Basic features (battery status, ear detection) should work |
Most features should work with any AirPods. Currently, I've only got AirPods Pro 2 to test with. 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).
## Key Features ## Key Features
@@ -36,39 +28,31 @@ Most features should work with any AirPods. Currently, I've only got AirPods Pro
- **Other customizations**: - **Other customizations**:
- Rename your AirPods - Rename your AirPods
- Customize long-press actions - Customize long-press actions
- Few accessibility features - All accessibility settings
- And more! - And more!
See our [pinned issue](https://github.com/kavishdevar/librepods/issues/20) for a complete feature list and roadmap. &ast; Features marked with an asterisk require the VendorID to be change to that of Apple.
## Platform Support ## Platform Support
### Linux ### 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 Linux version runs as a system tray app. Connect your AirPods and enjoy: new version in development ([#241](https://github.com/kavishdevar/librepods/pull/241))
- Battery monitoring ![new version](https://github.com/user-attachments/assets/86b3c871-89a8-4e49-861a-5119de1e1d28)
- Automatic Ear detection
- Conversational Awareness
- Switching Noise Control modes
- Device renaming
> [!NOTE]
> Work in progress, but core functionality is stable and usable.
For installation and detailed info, see the [Linux README](/linux/README.md).
### Android ### Android
#### Screenshots #### Screenshots
| | | | | | | |
|-------------------|-------------------|-------------------| | -------------------------------------------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------- |
| ![Settings 1](/android/imgs/settings-1.png) | ![Settings 2](/android/imgs/settings-2.png) | ![Debug Screen](/android/imgs/debug.png) | | ![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) | | ![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) | | ![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) | | ![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-aid-adjustments](/android/imgs/hearing-aid-adjustments.png) | | | ![hearing-aid](/android/imgs/hearing-aid.png) | ![hearing-test](/android/imgs/hearing-test.png) | ![hearing-aid-adjustments](/android/imgs/hearing-aid-adjustments.png) |
here's a very unprofessional demo video here's a very unprofessional demo video
@@ -77,14 +61,20 @@ https://github.com/user-attachments/assets/43911243-0576-4093-8c55-89c1db5ea533
#### Root Requirement #### Root Requirement
If you are using ColorOS/OxygenOS 16, you don't need root except for customizing transparency mode, setting up hearing aid, and use Bluetooth Multipoint. Changing ANC, conversational awareness, ear detection, and other customizations will work without root. For everyone else:
> [!CAUTION] > [!CAUTION]
> **You must have a rooted device to use LibrePods on Android.** This is due to a [bug in the Android Bluetooth stack](https://issuetracker.google.com/issues/371713238). Please upvote the issue by clicking the '+1' icon on the IssueTracker page. > **You must have a rooted device with Xposed to use LibrePods on Android.** This is due to a [bug in the Android Bluetooth stack](https://issuetracker.google.com/issues/371713238). Please upvote the issue by clicking the '+1' icon on the IssueTracker page. I don't know a fix for Android versions <13 either. So, this needs a phone running A13+.
> >
> There are **no exceptions** to the root requirement until Google merges the fix. > There are **no exceptions** to the root requirement until Google/your OEM figures out a fix.
## Bluetooth DID (Device Identification) Hook Until then, you must xposed. I used to provide a non-xposed method too, where the module used overlayfs to replace the bluetooth library with a locally patched one, but that was broken due to how various devices handled overlayfs and a patched library. With xposed, you can also enable the DID hook enabling a few extra features.
Turns out, if you change the manufacturerid to that of Apple, you get access to several special features! ## Changing VendorID in the DID profile to that of Apple
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.
### Multi-device Connectivity ### Multi-device Connectivity
@@ -94,53 +84,44 @@ Upto two devices can be simultaneously connected to AirPods, for audio and contr
Accessibility settings like customizing transparency mode (amplification, balance, tone, conversation boost, and ambient noise reduction), and loud sound reduction can be configured. Accessibility settings like customizing transparency mode (amplification, balance, tone, conversation boost, and ambient noise reduction), and loud sound reduction can be configured.
The hearing aid feature can now also be used. Currently it can only be used to adjust the settings, not actually take a hearing test because it requires much more precision. It is much better to use an already available audiogram result. 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.
#### Installation Methods
##### Method 1: Xposed Module (Recommended)
This method is less intrusive and should be tried first:
1. Install LSPosed, or another Xposed provider on your rooted device
2. Download the LibrePods app from the releases section, and install it.
3. Enable the Xposed module for the bluetooth app in your Xposed manager.
4. Disable unmount modules for the Bluetooth app if enabled.
5. Follow the instructions in the app to set up the module.
6. Open the app and connect your AirPods
##### Method 2: Root Module (Backup Option)
If the Xposed method doesn't work for you:
1. Download the `btl2capfix.zip` module from the releases section
2. Install it using your preferred root manager (KernelSU, Apatch, or Magisk).
3. Disable Unmount modules for the Bluetooth aop if enabled.
4. Reboot your device
5. Connect your AirPods
##### Method 3: Patching it yourself
If you prefer to patch the Bluetooth stack yourself, follow these steps:
1. Look for the library in use by running `lsof | grep libbluetooth`
2. Find the library path (e.g., `/system/lib64/libbluetooth_jni.so`)
3. Find the `l2c_fcr_chk_chan_modes` function in the library
4. Patch the function to always return `1` (true)
5. Repack the library and push it back to the device. You can do this by creating a root module yourself.
6. Reboot your device
If you're unfamiliar with these steps, search for tutorials online or ask in Android rooting communities.
#### A few notes #### 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, louds sounds are not reduced! - 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. - 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. - 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.
## Supporters
A huge thank you to everyone supporting the project!
- @davdroman
- @tedsalmon
- @wiless
- @SmartMsg
- @lunaroyster
- @ressiwage
## 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](low-skies.hackclub.com)!
## Star History ## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=kavishdevar/librepods&type=Date)](https://star-history.com/#kavishdevar/librepods&Date) <a href="https://www.star-history.com/#kavishdevar/librepods&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=kavishdevar/librepods&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=kavishdevar/librepods&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=kavishdevar/librepods&type=date&legend=top-left" />
</picture>
</a>
# License # License
@@ -148,15 +129,16 @@ LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published it under the terms of the GNU General Public License as published by
by the Free Software Foundation, either version 3 of the License. the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details. GNU General Public License for more details.
You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
along with this program over [here](/LICENSE). If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
All trademarks, logos, and brand names are the property of their respective owners. Use of them does not imply any affiliation with or endorsement by them. All AirPods images, symbols, and the SF Pro font are the property of Apple Inc. All trademarks, logos, and brand names are the property of their respective owners. Use of them does not imply any affiliation with or endorsement by them. All AirPods images, symbols, and the SF Pro font are the property of Apple Inc.
+24 -5
View File
@@ -2,19 +2,20 @@ plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.aboutLibraries)
id("kotlin-parcelize") id("kotlin-parcelize")
} }
android { android {
namespace = "me.kavishdevar.librepods" namespace = "me.kavishdevar.librepods"
compileSdk = 35 compileSdk = 36
defaultConfig { defaultConfig {
applicationId = "me.kavishdevar.librepods" applicationId = "me.kavishdevar.librepods"
minSdk = 28 minSdk = 33
targetSdk = 35 targetSdk = 36
versionCode = 8 versionCode = 9
versionName = "0.2.0-alpha" versionName = "0.2.0"
} }
buildTypes { buildTypes {
@@ -37,12 +38,20 @@ android {
compose = true compose = true
viewBinding = true viewBinding = true
} }
androidResources {
generateLocaleConfig = true
}
externalNativeBuild { externalNativeBuild {
cmake { cmake {
path = file("src/main/cpp/CMakeLists.txt") path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1" version = "3.22.1"
} }
} }
sourceSets {
getByName("main") {
res.srcDirs("src/main/res", "src/main/res-apple")
}
}
} }
dependencies { dependencies {
@@ -65,9 +74,19 @@ dependencies {
implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.compose.foundation.layout) implementation(libs.androidx.compose.foundation.layout)
implementation(libs.aboutlibraries)
implementation(libs.aboutlibraries.compose.m3)
// compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar")))) // compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
// implementation(fileTree(mapOf("dir" to "lib", "include" to listOf("*.aar")))) // implementation(fileTree(mapOf("dir" to "lib", "include" to listOf("*.aar"))))
compileOnly(files("libs/libxposed-api-100.aar")) compileOnly(files("libs/libxposed-api-100.aar"))
debugImplementation(files("libs/backdrop-debug.aar")) debugImplementation(files("libs/backdrop-debug.aar"))
releaseImplementation(files("libs/backdrop-release.aar")) releaseImplementation(files("libs/backdrop-release.aar"))
} }
aboutLibraries {
export{
prettyPrint = true
excludeFields = listOf("generated")
outputFile = file("src/main/res/raw/aboutlibraries.json")
}
}
+1 -2
View File
@@ -35,8 +35,6 @@
android:maxSdkVersion="30" /> android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"
android:maxSdkVersion="30" /> android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.LOCAL_MAC_ADDRESS"
tools:ignore="ProtectedPermissions" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -62,6 +60,7 @@
android:name="android.appwidget.provider" android:name="android.appwidget.provider"
android:resource="@xml/noise_control_widget_info" /> android:resource="@xml/noise_control_widget_info" />
</receiver> </receiver>
<receiver <receiver
android:name=".widgets.BatteryWidget" android:name=".widgets.BatteryWidget"
android:exported="false"> android:exported="false">
+16 -16
View File
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
#include <cstdint> #include <cstdint>
#include <cstring> #include <cstring>
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -27,7 +27,6 @@ import android.content.Context
import android.content.Context.MODE_PRIVATE import android.content.Context.MODE_PRIVATE
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection import android.content.ServiceConnection
import android.content.SharedPreferences
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@@ -38,11 +37,16 @@ import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
@@ -87,6 +91,8 @@ import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
@@ -104,7 +110,10 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.MultiplePermissionsState import com.google.accompanist.permissions.MultiplePermissionsState
import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.constants.AirPodsNotifications import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen
import me.kavishdevar.librepods.screens.AdaptiveStrengthScreen import me.kavishdevar.librepods.screens.AdaptiveStrengthScreen
@@ -115,14 +124,17 @@ import me.kavishdevar.librepods.screens.DebugScreen
import me.kavishdevar.librepods.screens.HeadTrackingScreen import me.kavishdevar.librepods.screens.HeadTrackingScreen
import me.kavishdevar.librepods.screens.HearingAidAdjustmentsScreen import me.kavishdevar.librepods.screens.HearingAidAdjustmentsScreen
import me.kavishdevar.librepods.screens.HearingAidScreen import me.kavishdevar.librepods.screens.HearingAidScreen
import me.kavishdevar.librepods.screens.HearingProtectionScreen
import me.kavishdevar.librepods.screens.LongPress import me.kavishdevar.librepods.screens.LongPress
import me.kavishdevar.librepods.screens.Onboarding import me.kavishdevar.librepods.screens.Onboarding
import me.kavishdevar.librepods.screens.OpenSourceLicensesScreen
import me.kavishdevar.librepods.screens.RenameScreen import me.kavishdevar.librepods.screens.RenameScreen
import me.kavishdevar.librepods.screens.TransparencySettingsScreen import me.kavishdevar.librepods.screens.TransparencySettingsScreen
import me.kavishdevar.librepods.screens.TroubleshootingScreen import me.kavishdevar.librepods.screens.TroubleshootingScreen
import me.kavishdevar.librepods.screens.UpdateHearingTestScreen
import me.kavishdevar.librepods.screens.VersionScreen
import me.kavishdevar.librepods.services.AirPodsService import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
import me.kavishdevar.librepods.utils.CrossDevice
import me.kavishdevar.librepods.utils.RadareOffsetFinder import me.kavishdevar.librepods.utils.RadareOffsetFinder
import kotlin.io.encoding.Base64 import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -144,10 +156,6 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
LibrePodsTheme { LibrePodsTheme {
getSharedPreferences("settings", MODE_PRIVATE).edit {
putLong(
"textColor",
MaterialTheme.colorScheme.onSurface.toArgb().toLong())}
Main() Main()
} }
} }
@@ -300,104 +308,139 @@ fun Main() {
val navController = rememberNavController() val navController = rememberNavController()
val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
val isAvailableChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == "CrossDeviceIsAvailable") {
Log.d("MainActivity", "CrossDeviceIsAvailable changed")
isRemotelyConnected.value = sharedPreferences.getBoolean("CrossDeviceIsAvailable", false)
}
}
sharedPreferences.registerOnSharedPreferenceChangeListener(isAvailableChangeListener)
Log.d("MainActivity", "CrossDeviceIsAvailable: ${sharedPreferences.getBoolean("CrossDeviceIsAvailable", false)} | isAvailable: ${CrossDevice.isAvailable}")
isRemotelyConnected.value = sharedPreferences.getBoolean("CrossDeviceIsAvailable", false) || CrossDevice.isAvailable
Log.d("MainActivity", "isRemotelyConnected: ${isRemotelyConnected.value}")
Box ( Box (
modifier = Modifier modifier = Modifier
.padding(0.dp)
.fillMaxSize() .fillMaxSize()
.background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)) ){
) { val backButtonBackdrop = rememberLayerBackdrop()
NavHost( Box (
navController = navController, modifier = Modifier
startDestination = if (hookAvailable) "settings" else "onboarding", .fillMaxSize()
enterTransition = { .background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7))
slideInHorizontally( .layerBackdrop(backButtonBackdrop)
initialOffsetX = { it },
animationSpec = tween(durationMillis = 300)
) // + fadeIn(animationSpec = tween(durationMillis = 300))
},
exitTransition = {
slideOutHorizontally(
targetOffsetX = { -it/4 },
animationSpec = tween(durationMillis = 300)
) // + fadeOut(animationSpec = tween(durationMillis = 150))
},
popEnterTransition = {
slideInHorizontally(
initialOffsetX = { -it/4 },
animationSpec = tween(durationMillis = 300)
) // + fadeIn(animationSpec = tween(durationMillis = 300))
},
popExitTransition = {
slideOutHorizontally(
targetOffsetX = { it },
animationSpec = tween(durationMillis = 300)
) // + fadeOut(animationSpec = tween(durationMillis = 150))
}
) { ) {
composable("settings") { NavHost(
if (airPodsService.value != null) { navController = navController,
AirPodsSettingsScreen( startDestination = if (hookAvailable) "settings" else "onboarding",
dev = airPodsService.value?.device, enterTransition = {
service = airPodsService.value!!, slideInHorizontally(
initialOffsetX = { it },
animationSpec = tween(durationMillis = 300)
) // + fadeIn(animationSpec = tween(durationMillis = 300))
},
exitTransition = {
slideOutHorizontally(
targetOffsetX = { -it/4 },
animationSpec = tween(durationMillis = 300)
) // + fadeOut(animationSpec = tween(durationMillis = 150))
},
popEnterTransition = {
slideInHorizontally(
initialOffsetX = { -it/4 },
animationSpec = tween(durationMillis = 300)
) // + fadeIn(animationSpec = tween(durationMillis = 300))
},
popExitTransition = {
slideOutHorizontally(
targetOffsetX = { it },
animationSpec = tween(durationMillis = 300)
) // + fadeOut(animationSpec = tween(durationMillis = 150))
}
) {
composable("settings") {
if (airPodsService.value != null) {
AirPodsSettingsScreen(
dev = airPodsService.value?.device,
service = airPodsService.value!!,
navController = navController,
isConnected = isConnected.value,
isRemotelyConnected = isRemotelyConnected.value
)
}
}
composable("debug") {
DebugScreen(navController = navController)
}
composable("long_press/{bud}") { navBackStackEntry ->
LongPress(
navController = navController, navController = navController,
isConnected = isConnected.value, name = navBackStackEntry.arguments?.getString("bud")!!
isRemotelyConnected = isRemotelyConnected.value
) )
} }
composable("rename") {
RenameScreen(navController)
}
composable("app_settings") {
AppSettingsScreen(navController)
}
composable("troubleshooting") {
TroubleshootingScreen(navController)
}
composable("head_tracking") {
HeadTrackingScreen()
}
composable("onboarding") {
Onboarding(navController, context)
}
composable("accessibility") {
AccessibilitySettingsScreen(navController)
}
composable("transparency_customization") {
TransparencySettingsScreen(navController)
}
composable("hearing_aid") {
HearingAidScreen(navController)
}
composable("hearing_aid_adjustments") {
HearingAidAdjustmentsScreen(navController)
}
composable("adaptive_strength") {
AdaptiveStrengthScreen(navController)
}
composable("camera_control") {
CameraControlScreen(navController)
}
composable("open_source_licenses") {
OpenSourceLicensesScreen(navController)
}
composable("update_hearing_test") {
UpdateHearingTestScreen(navController)
}
composable("version_info") {
VersionScreen(navController)
}
composable("hearing_protection") {
HearingProtectionScreen(navController)
}
} }
composable("debug") { }
DebugScreen(navController = navController)
val showBackButton = remember{ mutableStateOf(false) }
LaunchedEffect(navController) {
navController.addOnDestinationChangedListener { _, destination, _ ->
showBackButton.value = destination.route != "settings" && destination.route != "onboarding"
Log.d("MainActivity", "Navigated to ${destination.route}, showBackButton: ${showBackButton.value}")
} }
composable("long_press/{bud}") { navBackStackEntry -> }
LongPress(
navController = navController, AnimatedVisibility(
name = navBackStackEntry.arguments?.getString("bud")!! visible = showBackButton.value,
enter = fadeIn(animationSpec = tween()) + scaleIn(initialScale = 0f, animationSpec = tween()),
exit = fadeOut(animationSpec = tween()) + scaleOut(targetScale = 0.5f, animationSpec = tween(100)),
modifier = Modifier
.align(Alignment.TopStart)
.padding(
start = 8.dp,
top = (LocalWindowInfo.current.containerSize.width * 0.05f).dp
)
) {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isSystemInDarkTheme(),
backdrop = backButtonBackdrop
) )
}
composable("rename") {
RenameScreen(navController)
}
composable("app_settings") {
AppSettingsScreen(navController)
}
composable("troubleshooting") {
TroubleshootingScreen(navController)
}
composable("head_tracking") {
HeadTrackingScreen(navController)
}
composable("onboarding") {
Onboarding(navController, context)
}
composable("accessibility") {
AccessibilitySettingsScreen(navController)
}
composable("transparency_customization") {
TransparencySettingsScreen(navController)
}
composable("hearing_aid") {
HearingAidScreen(navController)
}
composable("hearing_aid_adjustments") {
HearingAidAdjustmentsScreen(navController)
}
composable("adaptive_strength") {
AdaptiveStrengthScreen(navController)
}
composable("camera_control") {
CameraControlScreen(navController)
}
} }
} }
@@ -522,7 +565,7 @@ fun PermissionsScreen(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "The following permissions are required to use the app. Please grant them to continue.", text = stringResource(R.string.permissions_required),
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
@@ -699,7 +742,11 @@ fun PermissionCard(
modifier = Modifier modifier = Modifier
.size(40.dp) .size(40.dp)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(if (isGranted) accentColor.copy(alpha = 0.15f) else Color.Gray.copy(alpha = 0.15f)), .background(
if (isGranted) accentColor.copy(alpha = 0.15f) else Color.Gray.copy(
alpha = 0.15f
)
),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -0,0 +1,205 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun AboutCard(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val service = ServiceManager.getService()
if (service == null) return
val airpodsInstance = service.airpodsInstance
if (airpodsInstance == null) return
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Box(
modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
.padding(horizontal = 16.dp, vertical = 4.dp)
){
Text(
text = stringResource(R.string.about),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
)
)
}
val rowHeight = remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
Column(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.onGloballyPositioned { coordinates ->
rowHeight.value = with(density) { coordinates.size.height.toDp() }
},
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.model_name),
style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = airpodsInstance.model.displayName,
style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.model_name),
style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = airpodsInstance.actualModelNumber,
style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
val serialNumbers = listOf(
airpodsInstance.serialNumber?: "",
"􀀛 ${airpodsInstance.leftSerialNumber}",
"􀀧 ${airpodsInstance.rightSerialNumber}"
)
val serialNumber = remember { mutableStateOf(0) }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.serial_number),
style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
)
Text(
text = serialNumbers[serialNumber.value],
style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
serialNumber.value = (serialNumber.value + 1) % serialNumbers.size
}
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
NavigationButton(
to = "version_info",
navController = navController,
name = stringResource(R.string.version),
currentState = airpodsInstance.version3,
independent = false,
height = rowHeight.value + 32.dp
)
}
}
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -42,15 +42,27 @@ import androidx.compose.ui.unit.sp
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.Capability
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@Composable @Composable
fun AudioSettings(navController: NavController) { fun AudioSettings(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val service = ServiceManager.getService()
if (service == null) return
val airpodsInstance = service.airpodsInstance
if (airpodsInstance == null) return
if (!airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_VOLUME) &&
!airpodsInstance.model.capabilities.contains(Capability.CONVERSATION_AWARENESS) &&
!airpodsInstance.model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) &&
!airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_AUDIO)
) {
return
}
Box( Box(
modifier = Modifier modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)) .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
@@ -76,52 +88,60 @@ fun AudioSettings(navController: NavController) {
.padding(top = 2.dp) .padding(top = 2.dp)
) { ) {
StyledToggle( if (airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_VOLUME)) {
label = stringResource(R.string.personalized_volume), StyledToggle(
description = stringResource(R.string.personalized_volume_description), label = stringResource(R.string.personalized_volume),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG, description = stringResource(R.string.personalized_volume_description),
independent = false controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG,
) independent = false
)
HorizontalDivider( HorizontalDivider(
thickness = 1.dp, thickness = 1.dp,
color = Color(0x40888888), color = Color(0x40888888),
modifier = Modifier modifier = Modifier
.padding(horizontal= 12.dp) .padding(horizontal = 12.dp)
) )
}
StyledToggle( if (airpodsInstance.model.capabilities.contains(Capability.CONVERSATION_AWARENESS)) {
label = stringResource(R.string.conversational_awareness), StyledToggle(
description = stringResource(R.string.conversational_awareness_description), label = stringResource(R.string.conversational_awareness),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG, description = stringResource(R.string.conversational_awareness_description),
independent = false controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
) independent = false
HorizontalDivider( )
thickness = 1.dp, HorizontalDivider(
color = Color(0x40888888), thickness = 1.dp,
modifier = Modifier color = Color(0x40888888),
.padding(horizontal= 12.dp) modifier = Modifier
) .padding(horizontal = 12.dp)
)
}
StyledToggle( if (airpodsInstance.model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION)){
label = stringResource(R.string.loud_sound_reduction), StyledToggle(
description = stringResource(R.string.loud_sound_reduction_description), label = stringResource(R.string.loud_sound_reduction),
attHandle = ATTHandles.LOUD_SOUND_REDUCTION, description = stringResource(R.string.loud_sound_reduction_description),
independent = false attHandle = ATTHandles.LOUD_SOUND_REDUCTION,
) independent = false
HorizontalDivider( )
thickness = 1.dp, HorizontalDivider(
color = Color(0x40888888), thickness = 1.dp,
modifier = Modifier color = Color(0x40888888),
.padding(horizontal= 12.dp) modifier = Modifier
) .padding(horizontal = 12.dp)
)
}
NavigationButton( if (airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_AUDIO)) {
to = "adaptive_strength", NavigationButton(
name = stringResource(R.string.adaptive_audio), to = "adaptive_strength",
navController = navController, name = stringResource(R.string.adaptive_audio),
independent = false navController = navController,
) independent = false
)
}
} }
} }
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -135,6 +135,13 @@ fun BatteryView(service: AirPodsService, preview: Boolean = false) {
val singleDisplayed = remember { mutableStateOf(false) } val singleDisplayed = remember { mutableStateOf(false) }
val airpodsInstance = service.airpodsInstance
if (airpodsInstance == null) {
return
}
val budsRes = airpodsInstance.model.budsRes
val caseRes = airpodsInstance.model.caseRes
Row { Row {
Column ( Column (
modifier = Modifier modifier = Modifier
@@ -142,7 +149,7 @@ fun BatteryView(service: AirPodsService, preview: Boolean = false) {
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Image ( Image (
bitmap = ImageBitmap.imageResource(R.drawable.pro_2_buds), bitmap = ImageBitmap.imageResource(budsRes),
contentDescription = stringResource(R.string.buds), contentDescription = stringResource(R.string.buds),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -198,7 +205,7 @@ fun BatteryView(service: AirPodsService, preview: Boolean = false) {
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Image( Image(
bitmap = ImageBitmap.imageResource(R.drawable.pro_2_case), bitmap = ImageBitmap.imageResource(caseRes),
contentDescription = stringResource(R.string.case_alt), contentDescription = stringResource(R.string.case_alt),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
@@ -180,7 +180,13 @@ fun ConfirmationDialog(
.background(if (leftPressed) pressedColor else Color.Transparent), .background(if (leftPressed) pressedColor else Color.Transparent),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text(dismissText, color = accentColor) Text(
text = dismissText,
style = TextStyle(
color = accentColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
} }
Box( Box(
modifier = Modifier modifier = Modifier
@@ -195,11 +201,17 @@ fun ConfirmationDialog(
.background(if (rightPressed) pressedColor else Color.Transparent), .background(if (rightPressed) pressedColor else Color.Transparent),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text(confirmText, color = accentColor) Text(
text = confirmText,
style = TextStyle(
color = accentColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
} }
} }
} }
} }
} }
} }
} }
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -61,7 +61,7 @@ fun ConnectionSettings() {
thickness = 1.dp, thickness = 1.dp,
color = Color(0x40888888), color = Color(0x40888888),
modifier = Modifier modifier = Modifier
.padding(horizontal= 12.dp) .padding(horizontal = 12.dp)
) )
StyledToggle( StyledToggle(
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods Contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:Suppress("unused") @file:Suppress("unused")
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods Contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
@@ -0,0 +1,109 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.Capability
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun HearingHealthSettings(navController: NavController) {
val service = ServiceManager.getService()
if (service == null) return
val airpodsInstance = service.airpodsInstance
if (airpodsInstance == null) return
if (airpodsInstance.model.capabilities.contains(Capability.HEARING_AID)) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
if (airpodsInstance.model.capabilities.contains(Capability.PPE)) {
Box(
modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
.padding(horizontal = 16.dp, vertical = 4.dp)
){
Text(
text = stringResource(R.string.hearing_health),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
)
)
}
Column(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp)
) {
NavigationButton(
to = "hearing_protection",
name = stringResource(R.string.hearing_protection),
navController = navController,
independent = false
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
NavigationButton(
to = "hearing_aid",
name = stringResource(R.string.hearing_aid),
navController = navController,
independent = false
)
}
} else {
NavigationButton(
to = "hearing_aid",
name = stringResource(R.string.hearing_aid),
navController = navController
)
}
}
}
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
@@ -47,6 +47,7 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController import androidx.navigation.NavController
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
@@ -59,7 +60,8 @@ fun NavigationButton(
independent: Boolean = true, independent: Boolean = true,
title: String? = null, title: String? = null,
description: String? = null, description: String? = null,
currentState: String? = null currentState: String? = null,
height: Dp = 58.dp,
) { ) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
@@ -84,7 +86,7 @@ fun NavigationButton(
Row( Row(
modifier = Modifier modifier = Modifier
.background(animatedBackgroundColor, RoundedCornerShape(if (independent) 28.dp else 0.dp)) .background(animatedBackgroundColor, RoundedCornerShape(if (independent) 28.dp else 0.dp))
.height(58.dp) .height(height)
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures( detectTapGestures(
onPress = { onPress = {
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
@@ -127,8 +127,8 @@ half4 main(float2 coord) {
highlight = { Highlight.Ambient.copy(alpha = if (isDarkTheme) 1f else 0f) }, highlight = { Highlight.Ambient.copy(alpha = if (isDarkTheme) 1f else 0f) },
shadow = { shadow = {
Shadow( Shadow(
radius = 48f.dp, radius = 12f.dp,
color = Color.Black.copy(if (isDarkTheme) 0.08f else 0.4f) color = Color.Black.copy(if (isDarkTheme) 0.08f else 0.2f)
) )
}, },
layerBlock = { layerBlock = {
@@ -136,8 +136,7 @@ half4 main(float2 coord) {
val height = size.height val height = size.height
val progress = progressAnimation.value val progress = progressAnimation.value
val maxScale = 0.1f val scale = lerp(1f, 1.5f, progress)
val scale = lerp(1f, 1f + maxScale, progress)
val maxOffset = size.minDimension val maxOffset = size.minDimension
val initialDerivative = 0.05f val initialDerivative = 0.05f
@@ -220,7 +219,7 @@ half4 main(float2 coord) {
}, },
effects = { effects = {
refractionWithDispersion(6f.dp.toPx(), size.height / 2f) refractionWithDispersion(6f.dp.toPx(), size.height / 2f)
blur(24f, TileMode.Decal) // blur(24f, TileMode.Decal)
}, },
) )
.pointerInput(animationScope) { .pointerInput(animationScope) {
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
@@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
@@ -35,6 +36,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@@ -46,6 +49,10 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.LayerBackdrop
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeProgressive import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeTint import dev.chrisbanes.haze.HazeTint
@@ -58,8 +65,7 @@ import me.kavishdevar.librepods.R
@Composable @Composable
fun StyledScaffold( fun StyledScaffold(
title: String, title: String,
navigationButton: @Composable () -> Unit = {}, actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
actionButtons: List<@Composable () -> Unit> = emptyList(),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
content: @Composable (spacerValue: Dp, hazeState: HazeState) -> Unit content: @Composable (spacerValue: Dp, hazeState: HazeState) -> Unit
) { ) {
@@ -68,7 +74,10 @@ fun StyledScaffold(
Scaffold( Scaffold(
containerColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7), containerColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7),
snackbarHost = { SnackbarHost(snackbarHostState) } snackbarHost = { SnackbarHost(snackbarHostState) },
modifier = Modifier
.then(if (!isDarkTheme) Modifier.shadow(elevation = 36.dp, shape = RoundedCornerShape(52.dp), ambientColor = Color.Black, spotColor = Color.Black) else Modifier)
.clip(RoundedCornerShape(52.dp))
) { paddingValues -> ) { paddingValues ->
val topPadding = paddingValues.calculateTopPadding() val topPadding = paddingValues.calculateTopPadding()
val bottomPadding = paddingValues.calculateBottomPadding() val bottomPadding = paddingValues.calculateBottomPadding()
@@ -80,23 +89,21 @@ fun StyledScaffold(
.fillMaxSize() .fillMaxSize()
.padding(start = startPadding, end = endPadding, bottom = bottomPadding) .padding(start = startPadding, end = endPadding, bottom = bottomPadding)
) { ) {
val backdrop = rememberLayerBackdrop()
Box( Box(
modifier = Modifier modifier = Modifier
.zIndex(2f) .zIndex(2f)
.height(64.dp + topPadding) .height(64.dp + topPadding)
.fillMaxWidth() .fillMaxWidth()
.layerBackdrop(backdrop)
.hazeEffect(state = hazeState) { .hazeEffect(state = hazeState) {
tints = listOf(HazeTint(color = if (isDarkTheme) Color.Black else Color.White)) tints = listOf(HazeTint(color = if (isDarkTheme) Color.Black else Color.White))
progressive = HazeProgressive.verticalGradient(startIntensity = 1f, endIntensity = 0f) progressive = HazeProgressive.verticalGradient(startIntensity = 1f, endIntensity = 0f)
} }
) { ) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
Spacer(modifier = Modifier.height(topPadding)) Spacer(modifier = Modifier.height(topPadding + 12.dp))
Box( Text(
modifier = Modifier.fillMaxWidth()
) {
navigationButton()
Text(
text = title, text = title,
style = TextStyle( style = TextStyle(
fontSize = 20.sp, fontSize = 20.sp,
@@ -104,15 +111,19 @@ fun StyledScaffold(
color = if (isDarkTheme) Color.White else Color.Black, color = if (isDarkTheme) Color.White else Color.Black,
fontFamily = FontFamily(Font(R.font.sf_pro)) fontFamily = FontFamily(Font(R.font.sf_pro))
), ),
modifier = Modifier.align(Alignment.Center), modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Row( }
modifier = Modifier.align(Alignment.CenterEnd) }
) { Row(
actionButtons.forEach { it() } modifier = Modifier
} .zIndex(3f)
} .padding(top = topPadding, end = 8.dp)
.align(Alignment.TopEnd)
) {
actionButtons.forEach { actionButton ->
actionButton(backdrop)
} }
} }
@@ -126,16 +137,14 @@ fun StyledScaffold(
@Composable @Composable
fun StyledScaffold( fun StyledScaffold(
title: String, title: String,
navigationButton: @Composable () -> Unit = {}, actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
actionButtons: List<@Composable () -> Unit> = emptyList(),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
StyledScaffold( StyledScaffold(
title = title, title = title,
navigationButton = navigationButton,
actionButtons = actionButtons, actionButtons = actionButtons,
snackbarHostState = snackbarHostState snackbarHostState = snackbarHostState,
) { _, _ -> ) { _, _ ->
content() content()
} }
@@ -145,16 +154,14 @@ fun StyledScaffold(
@Composable @Composable
fun StyledScaffold( fun StyledScaffold(
title: String, title: String,
navigationButton: @Composable () -> Unit = {}, actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
actionButtons: List<@Composable () -> Unit> = emptyList(),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
content: @Composable (spacerValue: Dp) -> Unit content: @Composable (spacerValue: Dp) -> Unit
) { ) {
StyledScaffold( StyledScaffold(
title = title, title = title,
navigationButton = navigationButton,
actionButtons = actionButtons, actionButtons = actionButtons,
snackbarHostState = snackbarHostState snackbarHostState = snackbarHostState,
) { spacerValue, _ -> ) { spacerValue, _ ->
content(spacerValue) content(spacerValue)
} }
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
@@ -106,17 +106,28 @@ fun StyledSwitch(
compositingStrategy = CompositingStrategy.Offscreen compositingStrategy = CompositingStrategy.Offscreen
} }
val animatedTrackColor = remember { Animatable(if (checked) onColor else offColor) } val animatedTrackColor = remember { Animatable(if (checked) onColor else offColor) }
val totalDrag = remember { mutableFloatStateOf(0f) }
val tapThreshold = 10f
val isFirstComposition = remember { mutableStateOf(true) }
LaunchedEffect(checked) { LaunchedEffect(checked) {
coroutineScope { if (!isFirstComposition.value) {
launch { coroutineScope {
val targetColor = if (checked) onColor else offColor launch {
animatedTrackColor.animateTo(targetColor, colorAnimationSpec) val targetColor = if (checked) onColor else offColor
} animatedTrackColor.animateTo(targetColor, colorAnimationSpec)
launch { }
val targetFrac = if (checked) 1f else 0f launch {
animatedFraction.animateTo(targetFrac, progressAnimationSpec) val targetFrac = if (checked) 1f else 0f
animatedFraction.animateTo(targetFrac, progressAnimationSpec)
}
if (progressAnimation.value > 0f) return@coroutineScope
launch {
progressAnimation.animateTo(1f, tween(175, easing = FastOutSlowInEasing))
progressAnimation.animateTo(0f, tween(175, easing = FastOutSlowInEasing))
}
} }
} }
isFirstComposition.value = false
} }
Box( Box(
@@ -147,6 +158,7 @@ fun StyledSwitch(
animationScope.launch { animationScope.launch {
animatedFraction.snapTo(newFraction) animatedFraction.snapTo(newFraction)
} }
totalDrag.floatValue += kotlin.math.abs(delta)
val newChecked = newFraction >= 0.5f val newChecked = newFraction >= 0.5f
if (newChecked != checked) { if (newChecked != checked) {
onCheckedChange(newChecked) onCheckedChange(newChecked)
@@ -156,17 +168,28 @@ fun StyledSwitch(
Orientation.Horizontal, Orientation.Horizontal,
startDragImmediately = true, startDragImmediately = true,
onDragStarted = { onDragStarted = {
totalDrag.floatValue = 0f
animationScope.launch { animationScope.launch {
progressAnimation.animateTo(1f, progressAnimationSpec) progressAnimation.animateTo(1f, progressAnimationSpec)
} }
}, },
onDragStopped = { onDragStopped = {
animationScope.launch { animationScope.launch {
val snappedFraction = if (animatedFraction.value >= 0.5f) 1f else 0f if (totalDrag.floatValue < tapThreshold) {
onCheckedChange(snappedFraction >= 0.5f) val newChecked = !checked
coroutineScope { onCheckedChange(newChecked)
launch { progressAnimation.animateTo(0f, progressAnimationSpec) } val snappedFraction = if (newChecked) 1f else 0f
launch { animatedFraction.animateTo(snappedFraction, progressAnimationSpec) } coroutineScope {
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
launch { animatedFraction.animateTo(snappedFraction, progressAnimationSpec) }
}
} else {
val snappedFraction = if (animatedFraction.value >= 0.5f) 1f else 0f
onCheckedChange(snappedFraction >= 0.5f)
coroutineScope {
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
launch { animatedFraction.animateTo(snappedFraction, progressAnimationSpec) }
}
} }
} }
} }
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -59,7 +59,6 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.edit import androidx.core.content.edit
import kotlinx.coroutines.delay
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
@@ -473,30 +472,17 @@ fun StyledToggle(
val attManager = ServiceManager.getService()?.attManager ?: return val attManager = ServiceManager.getService()?.attManager ?: return
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
var checked by remember { mutableStateOf(false) } val checkedValue = try {
attManager.read(attHandle).getOrNull(0)?.toInt()
} catch (e: Exception) {
Log.w("StyledToggle", "Error reading initial value for $label: ${e.message}")
null
} ?: 0
var checked by remember { mutableStateOf(checkedValue !=0) }
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500)) val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
LaunchedEffect(Unit) { attManager.enableNotifications(attHandle)
attManager.enableNotifications(attHandle)
var parsed = false
for (attempt in 1..3) {
try {
val data = attManager.read(attHandle)
checked = data[0].toInt() != 0
Log.d("StyledToggle", "Read attempt $attempt for $label: enabled=$checked")
parsed = true
break
} catch (e: Exception) {
Log.w("StyledToggle", "Read attempt $attempt for $label failed: ${e.message}")
}
delay(200)
}
if (!parsed) {
Log.d("StyledToggle", "Failed to read state for $label after 3 attempts")
}
}
if (sharedPreferenceKey != null && sharedPreferences != null) { if (sharedPreferenceKey != null && sharedPreferences != null) {
checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked) checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods Contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.constants package me.kavishdevar.librepods.constants
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.constants package me.kavishdevar.librepods.constants
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -1,27 +1,25 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.screens
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.util.Log import android.util.Log
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
@@ -35,17 +33,10 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@@ -59,8 +50,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
@@ -88,13 +77,13 @@ import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.NavigationButton import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.composables.StyledDropdown import me.kavishdevar.librepods.composables.StyledDropdown
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.Capability
import me.kavishdevar.librepods.utils.RadareOffsetFinder import me.kavishdevar.librepods.utils.RadareOffsetFinder
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -117,6 +106,8 @@ fun AccessibilitySettingsScreen(navController: NavController) {
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5) val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF) val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
val capabilities = remember { ServiceManager.getService()?.airpodsInstance?.model?.capabilities ?: emptySet<Capability>() }
val hearingAidEnabled = remember { mutableStateOf( val hearingAidEnabled = remember { mutableStateOf(
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }?.value?.getOrNull(1) == 0x01.toByte() && aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }?.value?.getOrNull(1) == 0x01.toByte() &&
aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }?.value?.getOrNull(0) == 0x01.toByte() aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }?.value?.getOrNull(0) == 0x01.toByte()
@@ -150,15 +141,7 @@ fun AccessibilitySettingsScreen(navController: NavController) {
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold(
title = stringResource(R.string.accessibility), title = stringResource(R.string.accessibility)
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
},
) { spacerHeight, hazeState -> ) { spacerHeight, hazeState ->
Column( Column(
modifier = Modifier modifier = Modifier
@@ -177,9 +160,9 @@ fun AccessibilitySettingsScreen(navController: NavController) {
val mediaEQEnabled = remember { mutableStateOf(false) } val mediaEQEnabled = remember { mutableStateOf(false) }
val pressSpeedOptions = mapOf( val pressSpeedOptions = mapOf(
0.toByte() to "Default", 0.toByte() to stringResource(R.string.default_option),
1.toByte() to "Slower", 1.toByte() to stringResource(R.string.slower),
2.toByte() to "Slowest" 2.toByte() to stringResource(R.string.slowest)
) )
val selectedPressSpeedValue = val selectedPressSpeedValue =
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL }?.value?.takeIf { it.isNotEmpty() } aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL }?.value?.takeIf { it.isNotEmpty() }
@@ -213,9 +196,9 @@ fun AccessibilitySettingsScreen(navController: NavController) {
} }
val pressAndHoldDurationOptions = mapOf( val pressAndHoldDurationOptions = mapOf(
0.toByte() to "Default", 0.toByte() to stringResource(R.string.default_option),
1.toByte() to "Slower", 1.toByte() to stringResource(R.string.slower),
2.toByte() to "Slowest" 2.toByte() to stringResource(R.string.slowest)
) )
val selectedPressAndHoldDurationValue = val selectedPressAndHoldDurationValue =
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL }?.value?.takeIf { it.isNotEmpty() } aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL }?.value?.takeIf { it.isNotEmpty() }
@@ -251,9 +234,9 @@ fun AccessibilitySettingsScreen(navController: NavController) {
} }
val volumeSwipeSpeedOptions = mapOf( val volumeSwipeSpeedOptions = mapOf(
1.toByte() to "Default", 1.toByte() to stringResource(R.string.default_option),
2.toByte() to "Longer", 2.toByte() to stringResource(R.string.longer),
3.toByte() to "Longest" 3.toByte() to stringResource(R.string.longest)
) )
val selectedVolumeSwipeSpeedValue = val selectedVolumeSwipeSpeedValue =
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL }?.value?.takeIf { it.isNotEmpty() } aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL }?.value?.takeIf { it.isNotEmpty() }
@@ -339,7 +322,7 @@ fun AccessibilitySettingsScreen(navController: NavController) {
label = stringResource(R.string.press_speed), label = stringResource(R.string.press_speed),
description = stringResource(R.string.press_speed_description), description = stringResource(R.string.press_speed_description),
options = pressSpeedOptions.values.toList(), options = pressSpeedOptions.values.toList(),
selectedOption = selectedPressSpeed?: "Default", selectedOption = selectedPressSpeed?: stringResource(R.string.default_option),
onOptionSelected = { newValue -> onOptionSelected = { newValue ->
selectedPressSpeed = newValue selectedPressSpeed = newValue
aacpManager?.sendControlCommand( aacpManager?.sendControlCommand(
@@ -357,7 +340,7 @@ fun AccessibilitySettingsScreen(navController: NavController) {
label = stringResource(R.string.press_and_hold_duration), label = stringResource(R.string.press_and_hold_duration),
description = stringResource(R.string.press_and_hold_duration_description), description = stringResource(R.string.press_and_hold_duration_description),
options = pressAndHoldDurationOptions.values.toList(), options = pressAndHoldDurationOptions.values.toList(),
selectedOption = selectedPressAndHoldDuration?: "Default", selectedOption = selectedPressAndHoldDuration?: stringResource(R.string.default_option),
onOptionSelected = { newValue -> onOptionSelected = { newValue ->
selectedPressAndHoldDuration = newValue selectedPressAndHoldDuration = newValue
aacpManager?.sendControlCommand( aacpManager?.sendControlCommand(
@@ -379,11 +362,13 @@ fun AccessibilitySettingsScreen(navController: NavController) {
independent = true, independent = true,
) )
StyledToggle( if (capabilities.contains(Capability.LOUD_SOUND_REDUCTION)) {
label = stringResource(R.string.loud_sound_reduction), StyledToggle(
description = stringResource(R.string.loud_sound_reduction_description), label = stringResource(R.string.loud_sound_reduction),
attHandle = ATTHandles.LOUD_SOUND_REDUCTION description = stringResource(R.string.loud_sound_reduction_description),
) attHandle = ATTHandles.LOUD_SOUND_REDUCTION
)
}
if (!hearingAidEnabled.value&& isSdpOffsetAvailable.value) { if (!hearingAidEnabled.value&& isSdpOffsetAvailable.value) {
NavigationButton( NavigationButton(
@@ -407,251 +392,254 @@ fun AccessibilitySettingsScreen(navController: NavController) {
independent = true independent = true
) )
StyledToggle( if (capabilities.contains(Capability.SWIPE_FOR_VOLUME)) {
label = stringResource(R.string.volume_control), StyledToggle(
description = stringResource(R.string.volume_control_description), label = stringResource(R.string.volume_control),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, description = stringResource(R.string.volume_control_description),
) controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE,
)
DropdownMenuComponent( DropdownMenuComponent(
label = stringResource(R.string.volume_swipe_speed), label = stringResource(R.string.volume_swipe_speed),
description = stringResource(R.string.volume_swipe_speed_description), description = stringResource(R.string.volume_swipe_speed_description),
options = volumeSwipeSpeedOptions.values.toList(), options = volumeSwipeSpeedOptions.values.toList(),
selectedOption = selectedVolumeSwipeSpeed?: "Default", selectedOption = selectedVolumeSwipeSpeed?: stringResource(R.string.default_option),
onOptionSelected = { newValue -> onOptionSelected = { newValue ->
selectedVolumeSwipeSpeed = newValue selectedVolumeSwipeSpeed = newValue
aacpManager?.sendControlCommand( aacpManager?.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value, identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value,
value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
?: 1.toByte() ?: 1.toByte()
) )
}, },
textColor = textColor, textColor = textColor,
hazeState = hazeState, hazeState = hazeState,
independent = true independent = true
) )
}
if (!hearingAidEnabled.value&& isSdpOffsetAvailable.value) { if (!hearingAidEnabled.value&& isSdpOffsetAvailable.value) {
Text( // Text(
text = stringResource(R.string.apply_eq_to), // text = stringResource(R.string.apply_eq_to),
style = TextStyle( // style = TextStyle(
fontSize = 14.sp, // fontSize = 14.sp,
fontWeight = FontWeight.Bold, // fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f), // color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)) // fontFamily = FontFamily(Font(R.font.sf_pro))
), // ),
modifier = Modifier.padding(8.dp, bottom = 0.dp) // modifier = Modifier.padding(8.dp, bottom = 0.dp)
) // )
Column( // Column(
modifier = Modifier // modifier = Modifier
.fillMaxWidth() // .fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp)) // .background(backgroundColor, RoundedCornerShape(28.dp))
.padding(vertical = 0.dp) // .padding(vertical = 0.dp)
) { // ) {
val darkModeLocal = isSystemInDarkTheme() // val darkModeLocal = isSystemInDarkTheme()
//
// val phoneShape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
// var phoneBackgroundColor by remember {
// mutableStateOf(
// if (darkModeLocal) Color(
// 0xFF1C1C1E
// ) else Color(0xFFFFFFFF)
// )
// }
// val phoneAnimatedBackgroundColor by animateColorAsState(
// targetValue = phoneBackgroundColor,
// animationSpec = tween(durationMillis = 500)
// )
//
// Row(
// modifier = Modifier
// .height(48.dp)
// .fillMaxWidth()
// .background(phoneAnimatedBackgroundColor, phoneShape)
// .pointerInput(Unit) {
// detectTapGestures(
// onPress = {
// phoneBackgroundColor =
// if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9)
// tryAwaitRelease()
// phoneBackgroundColor =
// if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
// phoneEQEnabled.value = !phoneEQEnabled.value
// }
// )
// }
// .padding(horizontal = 16.dp),
// verticalAlignment = Alignment.CenterVertically
// ) {
// Text(
// stringResource(R.string.phone),
// fontSize = 16.sp,
// color = textColor,
// fontFamily = FontFamily(Font(R.font.sf_pro)),
// modifier = Modifier.weight(1f)
// )
// Checkbox(
// checked = phoneEQEnabled.value,
// onCheckedChange = { phoneEQEnabled.value = it },
// colors = CheckboxDefaults.colors().copy(
// checkedCheckmarkColor = Color(0xFF007AFF),
// uncheckedCheckmarkColor = Color.Transparent,
// checkedBoxColor = Color.Transparent,
// uncheckedBoxColor = Color.Transparent,
// checkedBorderColor = Color.Transparent,
// uncheckedBorderColor = Color.Transparent
// ),
// modifier = Modifier
// .height(24.dp)
// .scale(1.5f)
// )
// }
//
// HorizontalDivider(
// thickness = 1.dp,
// color = Color(0x40888888)
// )
//
// val mediaShape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
// var mediaBackgroundColor by remember {
// mutableStateOf(
// if (darkModeLocal) Color(
// 0xFF1C1C1E
// ) else Color(0xFFFFFFFF)
// )
// }
// val mediaAnimatedBackgroundColor by animateColorAsState(
// targetValue = mediaBackgroundColor,
// animationSpec = tween(durationMillis = 500)
// )
//
// Row(
// modifier = Modifier
// .height(48.dp)
// .fillMaxWidth()
// .background(mediaAnimatedBackgroundColor, mediaShape)
// .pointerInput(Unit) {
// detectTapGestures(
// onPress = {
// mediaBackgroundColor =
// if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9)
// tryAwaitRelease()
// mediaBackgroundColor =
// if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
// mediaEQEnabled.value = !mediaEQEnabled.value
// }
// )
// }
// .padding(horizontal = 16.dp),
// verticalAlignment = Alignment.CenterVertically
// ) {
// Text(
// stringResource(R.string.media),
// fontSize = 16.sp,
// color = textColor,
// fontFamily = FontFamily(Font(R.font.sf_pro)),
// modifier = Modifier.weight(1f)
// )
// Checkbox(
// checked = mediaEQEnabled.value,
// onCheckedChange = { mediaEQEnabled.value = it },
// colors = CheckboxDefaults.colors().copy(
// checkedCheckmarkColor = Color(0xFF007AFF),
// uncheckedCheckmarkColor = Color.Transparent,
// checkedBoxColor = Color.Transparent,
// uncheckedBoxColor = Color.Transparent,
// checkedBorderColor = Color.Transparent,
// uncheckedBorderColor = Color.Transparent
// ),
// modifier = Modifier
// .height(24.dp)
// .scale(1.5f)
// )
// }
// }
val phoneShape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp) // EQ Settings. Don't seem to have an effect?
var phoneBackgroundColor by remember { // Column(
mutableStateOf( // modifier = Modifier
if (darkModeLocal) Color( // .fillMaxWidth()
0xFF1C1C1E // .background(backgroundColor, RoundedCornerShape(28.dp))
) else Color(0xFFFFFFFF) // .padding(12.dp),
) // horizontalAlignment = Alignment.CenterHorizontally
} // ) {
val phoneAnimatedBackgroundColor by animateColorAsState( // for (i in 0 until 8) {
targetValue = phoneBackgroundColor, // val eqPhoneValue =
animationSpec = tween(durationMillis = 500) // remember(phoneMediaEQ.value[i]) { mutableFloatStateOf(phoneMediaEQ.value[i]) }
) // Row(
// horizontalArrangement = Arrangement.SpaceBetween,
// verticalAlignment = Alignment.CenterVertically,
// modifier = Modifier
// .fillMaxWidth()
// .height(38.dp)
// ) {
// Text(
// text = String.format("%.2f", eqPhoneValue.floatValue),
// fontSize = 12.sp,
// color = textColor,
// modifier = Modifier.padding(bottom = 4.dp)
// )
Row( // Slider(
modifier = Modifier // value = eqPhoneValue.floatValue,
.height(48.dp) // onValueChange = { newVal ->
.fillMaxWidth() // eqPhoneValue.floatValue = newVal
.background(phoneAnimatedBackgroundColor, phoneShape) // val newEQ = phoneMediaEQ.value.copyOf()
.pointerInput(Unit) { // newEQ[i] = eqPhoneValue.floatValue
detectTapGestures( // phoneMediaEQ.value = newEQ
onPress = { // },
phoneBackgroundColor = // valueRange = 0f..100f,
if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9) // modifier = Modifier
tryAwaitRelease() // .fillMaxWidth(0.9f)
phoneBackgroundColor = // .height(36.dp),
if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) // colors = SliderDefaults.colors(
phoneEQEnabled.value = !phoneEQEnabled.value // thumbColor = thumbColor,
} // activeTrackColor = activeTrackColor,
) // inactiveTrackColor = trackColor
} // ),
.padding(horizontal = 16.dp), // thumb = {
verticalAlignment = Alignment.CenterVertically // Box(
) { // modifier = Modifier
Text( // .size(24.dp)
stringResource(R.string.phone), // .shadow(4.dp, CircleShape)
fontSize = 16.sp, // .background(thumbColor, CircleShape)
color = textColor, // )
fontFamily = FontFamily(Font(R.font.sf_pro)), // },
modifier = Modifier.weight(1f) // track = {
) // Box(
Checkbox( // modifier = Modifier
checked = phoneEQEnabled.value, // .fillMaxWidth()
onCheckedChange = { phoneEQEnabled.value = it }, // .height(12.dp),
colors = CheckboxDefaults.colors().copy( // contentAlignment = Alignment.CenterStart
checkedCheckmarkColor = Color(0xFF007AFF), // )
uncheckedCheckmarkColor = Color.Transparent, // {
checkedBoxColor = Color.Transparent, // Box(
uncheckedBoxColor = Color.Transparent, // modifier = Modifier
checkedBorderColor = Color.Transparent, // .fillMaxWidth()
uncheckedBorderColor = Color.Transparent // .height(4.dp)
), // .background(trackColor, RoundedCornerShape(4.dp))
modifier = Modifier // )
.height(24.dp) // Box(
.scale(1.5f) // modifier = Modifier
) // .fillMaxWidth(eqPhoneValue.floatValue / 100f)
} // .height(4.dp)
// .background(activeTrackColor, RoundedCornerShape(4.dp))
// )
// }
// }
// )
HorizontalDivider( // Text(
thickness = 1.dp, // text = stringResource(R.string.band_label, i + 1),
color = Color(0x40888888) // fontSize = 12.sp,
) // color = textColor,
// modifier = Modifier.padding(top = 4.dp)
val mediaShape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp) // )
var mediaBackgroundColor by remember { // }
mutableStateOf( // }
if (darkModeLocal) Color( // }
0xFF1C1C1E
) else Color(0xFFFFFFFF)
)
}
val mediaAnimatedBackgroundColor by animateColorAsState(
targetValue = mediaBackgroundColor,
animationSpec = tween(durationMillis = 500)
)
Row(
modifier = Modifier
.height(48.dp)
.fillMaxWidth()
.background(mediaAnimatedBackgroundColor, mediaShape)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
mediaBackgroundColor =
if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
mediaBackgroundColor =
if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
mediaEQEnabled.value = !mediaEQEnabled.value
}
)
}
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
stringResource(R.string.media),
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro)),
modifier = Modifier.weight(1f)
)
Checkbox(
checked = mediaEQEnabled.value,
onCheckedChange = { mediaEQEnabled.value = it },
colors = CheckboxDefaults.colors().copy(
checkedCheckmarkColor = Color(0xFF007AFF),
uncheckedCheckmarkColor = Color.Transparent,
checkedBoxColor = Color.Transparent,
uncheckedBoxColor = Color.Transparent,
checkedBorderColor = Color.Transparent,
uncheckedBorderColor = Color.Transparent
),
modifier = Modifier
.height(24.dp)
.scale(1.5f)
)
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
for (i in 0 until 8) {
val eqPhoneValue =
remember(phoneMediaEQ.value[i]) { mutableFloatStateOf(phoneMediaEQ.value[i]) }
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.height(38.dp)
) {
Text(
text = String.format("%.2f", eqPhoneValue.floatValue),
fontSize = 12.sp,
color = textColor,
modifier = Modifier.padding(bottom = 4.dp)
)
Slider(
value = eqPhoneValue.floatValue,
onValueChange = { newVal ->
eqPhoneValue.floatValue = newVal
val newEQ = phoneMediaEQ.value.copyOf()
newEQ[i] = eqPhoneValue.floatValue
phoneMediaEQ.value = newEQ
},
valueRange = 0f..100f,
modifier = Modifier
.fillMaxWidth(0.9f)
.height(36.dp),
colors = SliderDefaults.colors(
thumbColor = thumbColor,
activeTrackColor = activeTrackColor,
inactiveTrackColor = trackColor
),
thumb = {
Box(
modifier = Modifier
.size(24.dp)
.shadow(4.dp, CircleShape)
.background(thumbColor, CircleShape)
)
},
track = {
Box(
modifier = Modifier
.fillMaxWidth()
.height(12.dp),
contentAlignment = Alignment.CenterStart
)
{
Box(
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.background(trackColor, RoundedCornerShape(4.dp))
)
Box(
modifier = Modifier
.fillMaxWidth(eqPhoneValue.floatValue / 100f)
.height(4.dp)
.background(activeTrackColor, RoundedCornerShape(4.dp))
)
}
}
)
Text(
text = stringResource(R.string.band_label, i + 1),
fontSize = 12.sp,
color = textColor,
modifier = Modifier.padding(top = 4.dp)
)
}
}
}
} }
} }
} }
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.screens
@@ -99,15 +99,7 @@ fun AdaptiveStrengthScreen(navController: NavController) {
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold(
title = stringResource(R.string.customize_adaptive_audio), title = stringResource(R.string.customize_adaptive_audio)
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
}
) { spacerHeight -> ) { spacerHeight ->
Column( Column(
modifier = Modifier modifier = Modifier
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -65,20 +65,25 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.highlight.Highlight import com.kyant.backdrop.highlight.Highlight
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.AboutCard
import me.kavishdevar.librepods.composables.AudioSettings import me.kavishdevar.librepods.composables.AudioSettings
import me.kavishdevar.librepods.composables.BatteryView import me.kavishdevar.librepods.composables.BatteryView
import me.kavishdevar.librepods.composables.CallControlSettings import me.kavishdevar.librepods.composables.CallControlSettings
import me.kavishdevar.librepods.composables.ConfirmationDialog
import me.kavishdevar.librepods.composables.ConnectionSettings import me.kavishdevar.librepods.composables.ConnectionSettings
import me.kavishdevar.librepods.composables.HearingHealthSettings
import me.kavishdevar.librepods.composables.MicrophoneSettings import me.kavishdevar.librepods.composables.MicrophoneSettings
import me.kavishdevar.librepods.composables.NavigationButton import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.composables.NoiseControlSettings import me.kavishdevar.librepods.composables.NoiseControlSettings
@@ -91,6 +96,8 @@ import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.services.AirPodsService import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.Capability
import me.kavishdevar.librepods.utils.RadareOffsetFinder
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@@ -196,46 +203,60 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
} }
} }
} }
LaunchedEffect(service) {
service.let {
it.sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply {
putParcelableArrayListExtra("data", ArrayList(it.getBattery()))
})
it.sendBroadcast(Intent(AirPodsNotifications.ANC_DATA).apply {
putExtra("data", it.getANC())
})
}
}
val darkMode = isSystemInDarkTheme() val darkMode = isSystemInDarkTheme()
val backdrop = rememberLayerBackdrop() val hazeStateS = remember { mutableStateOf(HazeState()) }
// val showDialog = remember { mutableStateOf(!sharedPreferences.getBoolean("donationDialogShown", false)) }
val showDialog = remember { mutableStateOf(false) }
StyledScaffold( StyledScaffold(
title = deviceName.text, title = deviceName.text,
actionButtons = listOf { actionButtons = listOf(
StyledIconButton( {scaffoldBackdrop ->
onClick = { navController.navigate("app_settings") }, StyledIconButton(
icon = "􀍟", onClick = { navController.navigate("app_settings") },
darkMode = darkMode, icon = "􀍟",
backdrop = backdrop darkMode = darkMode,
) backdrop = scaffoldBackdrop
}, )
}
),
snackbarHostState = snackbarHostState snackbarHostState = snackbarHostState
) { spacerHeight, hazeState -> ) { spacerHeight, hazeState ->
hazeStateS.value = hazeState
if (isLocallyConnected || isRemotelyConnected) { if (isLocallyConnected || isRemotelyConnected) {
val instance = service.airpodsInstance
if (instance == null) {
Text("Error: AirPods instance is null")
return@StyledScaffold
}
val capabilities = instance.model.capabilities
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.hazeSource(hazeState) .hazeSource(hazeState)
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.layerBackdrop(backdrop)
) { ) {
item { Spacer(modifier = Modifier.height(spacerHeight)) } item(key = "spacer_top") { Spacer(modifier = Modifier.height(spacerHeight)) }
item { item(key = "battery") {
LaunchedEffect(service) {
service.let {
it.sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply {
putParcelableArrayListExtra("data", ArrayList(it.getBattery()))
})
it.sendBroadcast(Intent(AirPodsNotifications.ANC_DATA).apply {
putExtra("data", it.getANC())
})
}
}
BatteryView(service = service) BatteryView(service = service)
} }
item { Spacer(modifier = Modifier.height(32.dp)) } item(key = "spacer_battery") { Spacer(modifier = Modifier.height(32.dp)) }
item { item(key = "name") {
NavigationButton( NavigationButton(
to = "rename", to = "rename",
name = stringResource(R.string.name), name = stringResource(R.string.name),
@@ -244,61 +265,76 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
independent = true independent = true
) )
} }
val actAsAppleDeviceHookEnabled = RadareOffsetFinder.isSdpOffsetAvailable()
item { Spacer(modifier = Modifier.height(32.dp)) } if (actAsAppleDeviceHookEnabled) {
item { NavigationButton(to = "hearing_aid", name = stringResource(R.string.hearing_aid), navController = navController) } item(key = "spacer_hearing_health") { Spacer(modifier = Modifier.height(32.dp)) }
item(key = "hearing_health") {
item { Spacer(modifier = Modifier.height(16.dp)) } HearingHealthSettings(navController = navController)
item { NoiseControlSettings(service = service) } }
item { Spacer(modifier = Modifier.height(16.dp)) }
item { PressAndHoldSettings(navController = navController) }
item { Spacer(modifier = Modifier.height(16.dp)) }
item { CallControlSettings(hazeState = hazeState) }
item { Spacer(modifier = Modifier.height(16.dp)) }
item { NavigationButton(to = "camera_control", name = stringResource(R.string.camera_remote), description = stringResource(R.string.camera_control_description), title = stringResource(R.string.camera_control), navController = navController) }
item { Spacer(modifier = Modifier.height(16.dp)) }
item { AudioSettings(navController = navController) }
item { Spacer(modifier = Modifier.height(16.dp)) }
item { ConnectionSettings() }
item { Spacer(modifier = Modifier.height(16.dp)) }
item { MicrophoneSettings(hazeState) }
item { Spacer(modifier = Modifier.height(16.dp)) }
item {
StyledToggle(
label = stringResource(R.string.sleep_detection),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG
)
} }
item { Spacer(modifier = Modifier.height(16.dp)) } if (capabilities.contains(Capability.LISTENING_MODE)) {
item { item(key = "spacer_noise") { Spacer(modifier = Modifier.height(16.dp)) }
NavigationButton(to = "head_tracking", name = stringResource(R.string.head_gestures), navController = navController, currentState = if (sharedPreferences.getBoolean("head_gestures", false)) stringResource(R.string.on) else stringResource(R.string.off)) item(key = "noise_control") { NoiseControlSettings(service = service) }
} }
item { Spacer(modifier = Modifier.height(16.dp)) } if (capabilities.contains(Capability.STEM_CONFIG)) {
item { NavigationButton(to = "accessibility", name = stringResource(R.string.accessibility), navController = navController) } item(key = "spacer_press_hold") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "press_hold") { PressAndHoldSettings(navController = navController) }
item { Spacer(modifier = Modifier.height(16.dp)) }
item {
StyledToggle(
label = stringResource(R.string.off_listening_mode),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION,
description = stringResource(R.string.off_listening_mode_description)
)
} }
// an about card- everything but the version number is unknown - will add later if i find out item(key = "spacer_call") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "call_control") { CallControlSettings(hazeState = hazeState) }
item { Spacer(modifier = Modifier.height(16.dp)) } if (capabilities.contains(Capability.STEM_CONFIG)) {
item { NavigationButton("debug", "Debug", navController) } item(key = "spacer_camera") { Spacer(modifier = Modifier.height(16.dp)) }
item { Spacer(Modifier.height(24.dp)) } item(key = "camera_control") { NavigationButton(to = "camera_control", name = stringResource(R.string.camera_remote), description = stringResource(R.string.camera_control_description), title = stringResource(R.string.camera_control), navController = navController) }
}
item(key = "spacer_audio") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "audio") { AudioSettings(navController = navController) }
item(key = "spacer_connection") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "connection") { ConnectionSettings() }
item(key = "spacer_microphone") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "microphone") { MicrophoneSettings(hazeState) }
if (capabilities.contains(Capability.SLEEP_DETECTION)) {
item(key = "spacer_sleep") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "sleep_detection") {
StyledToggle(
label = stringResource(R.string.sleep_detection),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG
)
}
}
if (capabilities.contains(Capability.HEAD_GESTURES)) {
item(key = "spacer_head_tracking") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "head_tracking") { NavigationButton(to = "head_tracking", name = stringResource(R.string.head_gestures), navController = navController, currentState = if (sharedPreferences.getBoolean("head_gestures", false)) stringResource(R.string.on) else stringResource(R.string.off)) }
}
item(key = "spacer_accessibility") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "accessibility") { NavigationButton(to = "accessibility", name = stringResource(R.string.accessibility), navController = navController) }
if (capabilities.contains(Capability.LOUD_SOUND_REDUCTION)){
item(key = "spacer_off_listening") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "off_listening") {
StyledToggle(
label = stringResource(R.string.off_listening_mode),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION,
description = stringResource(R.string.off_listening_mode_description)
)
}
}
item(key = "spacer_about") { Spacer(modifier = Modifier.height(32.dp)) }
item(key = "about") { AboutCard(navController = navController) }
item(key = "spacer_debug") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "debug") { NavigationButton("debug", "Debug", navController) }
item(key = "spacer_bottom") { Spacer(Modifier.height(24.dp)) }
} }
} }
else { else {
@@ -314,6 +350,7 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
Highlight.Ambient.copy(alpha = 0f) Highlight.Ambient.copy(alpha = 0f)
} }
) )
.hazeSource(hazeState)
.padding(horizontal = 8.dp), .padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
@@ -344,21 +381,61 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
StyledButton( StyledButton(
onClick = { navController.navigate("troubleshooting") }, onClick = { navController.navigate("troubleshooting") },
backdrop = backdrop backdrop = backdrop,
modifier = Modifier
.fillMaxWidth(0.9f)
) { ) {
Text( Text(
text = "Troubleshoot Connection", text = stringResource(R.string.troubleshooting),
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro)),
color = if (isSystemInDarkTheme()) Color.White else Color.Black color = if (isSystemInDarkTheme()) Color.White else Color.Black
)
)
}
Spacer(Modifier.height(16.dp))
StyledButton(
onClick = {
service.reconnectFromSavedMac()
},
backdrop = backdrop,
modifier = Modifier
.fillMaxWidth(0.9f)
) {
Text(
text = stringResource(R.string.reconnect_to_last_device),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = if (isSystemInDarkTheme()) Color.White else Color.Black
) )
) )
} }
} }
} }
} }
ConfirmationDialog(
showDialog = showDialog,
title = stringResource(R.string.support_librepods),
message = stringResource(R.string.support_dialog_description),
confirmText = stringResource(R.string.support_me) + " \uDBC0\uDEB5",
dismissText = stringResource(R.string.never_show_again),
onConfirm = {
val browserIntent = Intent(
Intent.ACTION_VIEW,
"https://github.com/sponsors/kavishdevar".toUri()
)
context.startActivity(browserIntent)
sharedPreferences.edit { putBoolean("donationDialogShown", true) }
},
onDismiss = {
sharedPreferences.edit { putBoolean("donationDialogShown", true) }
},
hazeState = hazeStateS.value,
)
} }
@Preview @Preview
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.screens
@@ -80,7 +80,6 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.NavigationButton import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.composables.StyledToggle
@@ -193,15 +192,7 @@ fun AppSettingsScreen(navController: NavController) {
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold(
title = stringResource(R.string.app_settings), title = stringResource(R.string.app_settings)
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
}
) { spacerHeight, hazeState -> ) { spacerHeight, hazeState ->
Column( Column(
modifier = Modifier modifier = Modifier
@@ -300,7 +291,7 @@ fun AppSettingsScreen(navController: NavController) {
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
NavigationButton( NavigationButton(
to = "", to = "",
title = stringResource(R.string.camera_control), title = stringResource(R.string.camera_control),
@@ -648,6 +639,15 @@ fun AppSettingsScreen(navController: NavController) {
} }
} }
Spacer(modifier = Modifier.height(16.dp))
NavigationButton(
to = "open_source_licenses",
name = stringResource(R.string.open_source_licenses),
navController = navController,
independent = true
)
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
if (showResetDialog.value) { if (showResetDialog.value) {
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.screens
@@ -130,15 +130,7 @@ fun CameraControlScreen(navController: NavController) {
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold(
title = stringResource(R.string.camera_control), title = stringResource(R.string.camera_control)
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
}
) { spacerHeight -> ) { spacerHeight ->
Column( Column(
modifier = Modifier modifier = Modifier
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class) @file:OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class)
@@ -327,16 +327,8 @@ fun DebugScreen(navController: NavController) {
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold(
title = "Debug", title = "Debug",
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
},
actionButtons = listOf( actionButtons = listOf(
{ {scaffoldBackdrop ->
StyledIconButton( StyledIconButton(
onClick = { onClick = {
airPodsService?.clearLogs() airPodsService?.clearLogs()
@@ -344,7 +336,7 @@ fun DebugScreen(navController: NavController) {
}, },
icon = "􀈑", icon = "􀈑",
darkMode = isDarkTheme, darkMode = isDarkTheme,
backdrop = backdrop backdrop = scaffoldBackdrop
) )
} }
), ),
@@ -1,20 +1,23 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
// this is absolutely unnecessary, why did I make this. a simple toggle would've sufficed
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -83,7 +86,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
@@ -108,7 +110,7 @@ import kotlin.random.Random
@RequiresApi(Build.VERSION_CODES.Q) @RequiresApi(Build.VERSION_CODES.Q)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
@Composable @Composable
fun HeadTrackingScreen(navController: NavController) { fun HeadTrackingScreen() {
DisposableEffect(Unit) { DisposableEffect(Unit) {
ServiceManager.getService()?.startHeadTracking() ServiceManager.getService()?.startHeadTracking()
onDispose { onDispose {
@@ -121,18 +123,10 @@ fun HeadTrackingScreen(navController: NavController) {
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold ( StyledScaffold(
title = stringResource(R.string.head_tracking), title = stringResource(R.string.head_tracking),
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
},
actionButtons = listOf( actionButtons = listOf(
{ { scaffoldBackdrop ->
var isActive by remember { mutableStateOf(ServiceManager.getService()?.isHeadTrackingActive == true) } var isActive by remember { mutableStateOf(ServiceManager.getService()?.isHeadTrackingActive == true) }
StyledIconButton( StyledIconButton(
onClick = { onClick = {
@@ -146,7 +140,7 @@ fun HeadTrackingScreen(navController: NavController) {
}, },
icon = if (isActive) "􀊅" else "􀊃", icon = if (isActive) "􀊅" else "􀊃",
darkMode = isDarkTheme, darkMode = isDarkTheme,
backdrop = backdrop backdrop = scaffoldBackdrop
) )
} }
), ),
@@ -229,9 +223,10 @@ fun HeadTrackingScreen(navController: NavController) {
} }
} }
} }
val gestureTextValue = stringResource(R.string.shake_your_head_or_nod)
StyledButton( StyledButton(
onClick = { onClick = {
gestureText = "Shake your head or nod!" gestureText = gestureTextValue
coroutineScope.launch { coroutineScope.launch {
val accepted = ServiceManager.getService()?.testHeadGestures() ?: false val accepted = ServiceManager.getService()?.testHeadGestures() ?: false
gestureText = if (accepted) "\"Yes\" gesture detected." else "\"No\" gesture detected." gestureText = if (accepted) "\"Yes\" gesture detected." else "\"No\" gesture detected."
@@ -750,5 +745,5 @@ private fun AccelerationPlot() {
@Preview @Preview
@Composable @Composable
fun HeadTrackingScreenPreview() { fun HeadTrackingScreenPreview() {
HeadTrackingScreen(navController = NavController(LocalContext.current)) HeadTrackingScreen()
} }
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.screens
@@ -33,6 +33,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -46,26 +47,22 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.ATTManager import me.kavishdevar.librepods.utils.HearingAidSettings
import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.utils.sendHearingAidSettings
import java.io.IOException import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: Job? = null private var debounceJob: MutableState<Job?> = mutableStateOf(null)
private const val TAG = "HearingAidAdjustments" private const val TAG = "HearingAidAdjustments"
@SuppressLint("DefaultLocale") @SuppressLint("DefaultLocale")
@@ -73,7 +70,7 @@ private const val TAG = "HearingAidAdjustments"
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController) { fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController) {
val isDarkTheme = isSystemInDarkTheme() isSystemInDarkTheme()
val verticalScrollState = rememberScrollState() val verticalScrollState = rememberScrollState()
val hazeState = remember { HazeState() } val hazeState = remember { HazeState() }
val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available") val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available")
@@ -81,15 +78,7 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
val aacpManager = remember { ServiceManager.getService()?.aacpManager } val aacpManager = remember { ServiceManager.getService()?.aacpManager }
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold(
title = stringResource(R.string.adjustments), title = stringResource(R.string.adjustments)
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
}
) { spacerHeight -> ) { spacerHeight ->
Column( Column(
modifier = Modifier modifier = Modifier
@@ -107,13 +96,10 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
val toneSliderValue = remember { mutableFloatStateOf(0.5f) } val toneSliderValue = remember { mutableFloatStateOf(0.5f) }
val ambientNoiseReductionSliderValue = remember { mutableFloatStateOf(0.0f) } val ambientNoiseReductionSliderValue = remember { mutableFloatStateOf(0.0f) }
val conversationBoostEnabled = remember { mutableStateOf(false) } val conversationBoostEnabled = remember { mutableStateOf(false) }
val eq = remember { mutableStateOf(FloatArray(8)) } val leftEQ = remember { mutableStateOf(FloatArray(8)) }
val rightEQ = remember { mutableStateOf(FloatArray(8)) }
val ownVoiceAmplification = remember { mutableFloatStateOf(0.5f) } val ownVoiceAmplification = remember { mutableFloatStateOf(0.5f) }
val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
val phoneEQEnabled = remember { mutableStateOf(false) }
val mediaEQEnabled = remember { mutableStateOf(false) }
val initialLoadComplete = remember { mutableStateOf(false) } val initialLoadComplete = remember { mutableStateOf(false) }
val initialReadSucceeded = remember { mutableStateOf(false) } val initialReadSucceeded = remember { mutableStateOf(false) }
@@ -122,8 +108,8 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
val hearingAidSettings = remember { val hearingAidSettings = remember {
mutableStateOf( mutableStateOf(
HearingAidSettings( HearingAidSettings(
leftEQ = eq.value, leftEQ = leftEQ.value,
rightEQ = eq.value, rightEQ = rightEQ.value,
leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2, leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2,
rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2, rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2,
leftTone = toneSliderValue.floatValue, leftTone = toneSliderValue.floatValue,
@@ -168,7 +154,8 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
toneSliderValue.floatValue = parsed.leftTone toneSliderValue.floatValue = parsed.leftTone
ambientNoiseReductionSliderValue.floatValue = parsed.leftAmbientNoiseReduction ambientNoiseReductionSliderValue.floatValue = parsed.leftAmbientNoiseReduction
conversationBoostEnabled.value = parsed.leftConversationBoost conversationBoostEnabled.value = parsed.leftConversationBoost
eq.value = parsed.leftEQ.copyOf() leftEQ.value = parsed.leftEQ.copyOf()
rightEQ.value = parsed.rightEQ.copyOf()
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
Log.d(TAG, "Updated hearing aid settings from notification") Log.d(TAG, "Updated hearing aid settings from notification")
} else { } else {
@@ -203,8 +190,8 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
} }
hearingAidSettings.value = HearingAidSettings( hearingAidSettings.value = HearingAidSettings(
leftEQ = eq.value, leftEQ = leftEQ.value,
rightEQ = eq.value, rightEQ = rightEQ.value,
leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f, leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f,
rightAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue > 0) balanceSliderValue.floatValue else 0f, rightAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue > 0) balanceSliderValue.floatValue else 0f,
leftTone = toneSliderValue.floatValue, leftTone = toneSliderValue.floatValue,
@@ -218,7 +205,7 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
ownVoiceAmplification = ownVoiceAmplification.floatValue ownVoiceAmplification = ownVoiceAmplification.floatValue
) )
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}") Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
sendHearingAidSettings(attManager, hearingAidSettings.value) sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -227,26 +214,6 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
attManager.enableNotifications(ATTHandles.HEARING_AID) attManager.enableNotifications(ATTHandles.HEARING_AID)
attManager.registerListener(ATTHandles.HEARING_AID, hearingAidATTListener) attManager.registerListener(ATTHandles.HEARING_AID, hearingAidATTListener)
try {
if (aacpManager != null) {
Log.d(TAG, "Found AACPManager, reading cached EQ data")
val aacpEQ = aacpManager.eqData
if (aacpEQ.isNotEmpty()) {
eq.value = aacpEQ.copyOf()
phoneMediaEQ.value = aacpEQ.copyOf()
phoneEQEnabled.value = aacpManager.eqOnPhone
mediaEQEnabled.value = aacpManager.eqOnMedia
Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}")
} else {
Log.d(TAG, "AACPManager EQ data empty")
}
} else {
Log.d(TAG, "No AACPManager available")
}
} catch (e: Exception) {
Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}")
}
var parsedSettings: HearingAidSettings? = null var parsedSettings: HearingAidSettings? = null
for (attempt in 1..3) { for (attempt in 1..3) {
initialReadAttempts.intValue = attempt initialReadAttempts.intValue = attempt
@@ -272,7 +239,8 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
toneSliderValue.floatValue = parsedSettings.leftTone toneSliderValue.floatValue = parsedSettings.leftTone
ambientNoiseReductionSliderValue.floatValue = parsedSettings.leftAmbientNoiseReduction ambientNoiseReductionSliderValue.floatValue = parsedSettings.leftAmbientNoiseReduction
conversationBoostEnabled.value = parsedSettings.leftConversationBoost conversationBoostEnabled.value = parsedSettings.leftConversationBoost
eq.value = parsedSettings.leftEQ.copyOf() leftEQ.value = parsedSettings.leftEQ.copyOf()
rightEQ.value = parsedSettings.rightEQ.copyOf()
ownVoiceAmplification.floatValue = parsedSettings.ownVoiceAmplification ownVoiceAmplification.floatValue = parsedSettings.ownVoiceAmplification
initialReadSucceeded.value = true initialReadSucceeded.value = true
} else { } else {
@@ -350,150 +318,3 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
} }
} }
} }
private data class HearingAidSettings(
val leftEQ: FloatArray,
val rightEQ: FloatArray,
val leftAmplification: Float,
val rightAmplification: Float,
val leftTone: Float,
val rightTone: Float,
val leftConversationBoost: Boolean,
val rightConversationBoost: Boolean,
val leftAmbientNoiseReduction: Float,
val rightAmbientNoiseReduction: Float,
val netAmplification: Float,
val balance: Float,
val ownVoiceAmplification: Float
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as HearingAidSettings
if (leftAmplification != other.leftAmplification) return false
if (rightAmplification != other.rightAmplification) return false
if (leftTone != other.leftTone) return false
if (rightTone != other.rightTone) return false
if (leftConversationBoost != other.leftConversationBoost) return false
if (rightConversationBoost != other.rightConversationBoost) return false
if (leftAmbientNoiseReduction != other.leftAmbientNoiseReduction) return false
if (rightAmbientNoiseReduction != other.rightAmbientNoiseReduction) return false
if (!leftEQ.contentEquals(other.leftEQ)) return false
if (!rightEQ.contentEquals(other.rightEQ)) return false
if (ownVoiceAmplification != other.ownVoiceAmplification) return false
return true
}
override fun hashCode(): Int {
var result = leftAmplification.hashCode()
result = 31 * result + rightAmplification.hashCode()
result = 31 * result + leftTone.hashCode()
result = 31 * result + rightTone.hashCode()
result = 31 * result + leftConversationBoost.hashCode()
result = 31 * result + rightConversationBoost.hashCode()
result = 31 * result + leftAmbientNoiseReduction.hashCode()
result = 31 * result + rightAmbientNoiseReduction.hashCode()
result = 31 * result + leftEQ.contentHashCode()
result = 31 * result + rightEQ.contentHashCode()
result = 31 * result + ownVoiceAmplification.hashCode()
return result
}
}
private fun parseHearingAidSettingsResponse(data: ByteArray): HearingAidSettings? {
if (data.size < 104) return null
val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
buffer.get() // skip 0x02
buffer.get() // skip 0x02
buffer.getShort() // skip 0x60 0x00
val leftEQ = FloatArray(8)
for (i in 0..7) {
leftEQ[i] = buffer.float
}
val leftAmplification = buffer.float
val leftTone = buffer.float
val leftConvFloat = buffer.float
val leftConversationBoost = leftConvFloat > 0.5f
val leftAmbientNoiseReduction = buffer.float
val rightEQ = FloatArray(8)
for (i in 0..7) {
rightEQ[i] = buffer.float
}
val rightAmplification = buffer.float
val rightTone = buffer.float
val rightConvFloat = buffer.float
val rightConversationBoost = rightConvFloat > 0.5f
val rightAmbientNoiseReduction = buffer.float
val ownVoiceAmplification = buffer.float
val avg = (leftAmplification + rightAmplification) / 2
val amplification = avg.coerceIn(-1f, 1f)
val diff = rightAmplification - leftAmplification
val balance = diff.coerceIn(-1f, 1f)
return HearingAidSettings(
leftEQ = leftEQ,
rightEQ = rightEQ,
leftAmplification = leftAmplification,
rightAmplification = rightAmplification,
leftTone = leftTone,
rightTone = rightTone,
leftConversationBoost = leftConversationBoost,
rightConversationBoost = rightConversationBoost,
leftAmbientNoiseReduction = leftAmbientNoiseReduction,
rightAmbientNoiseReduction = rightAmbientNoiseReduction,
netAmplification = amplification,
balance = balance,
ownVoiceAmplification = ownVoiceAmplification
)
}
private fun sendHearingAidSettings(
attManager: ATTManager,
hearingAidSettings: HearingAidSettings
) {
debounceJob?.cancel()
debounceJob = 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")
return@launch
}
val buffer = ByteBuffer.wrap(currentData).order(ByteOrder.LITTLE_ENDIAN)
// for some reason
buffer.put(2, 0x64)
// Left ear adjustments
buffer.putFloat(36, hearingAidSettings.leftAmplification)
buffer.putFloat(40, hearingAidSettings.leftTone)
buffer.putFloat(44, if (hearingAidSettings.leftConversationBoost) 1.0f else 0.0f)
buffer.putFloat(48, hearingAidSettings.leftAmbientNoiseReduction)
// Right ear adjustments
buffer.putFloat(84, hearingAidSettings.rightAmplification)
buffer.putFloat(88, hearingAidSettings.rightTone)
buffer.putFloat(92, if (hearingAidSettings.rightConversationBoost) 1.0f else 0.0f)
buffer.putFloat(96, hearingAidSettings.rightAmbientNoiseReduction)
// Own voice amplification
buffer.putFloat(100, hearingAidSettings.ownVoiceAmplification)
Log.d(TAG, "Sending updated settings: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
attManager.write(ATTHandles.HEARING_AID, currentData)
} catch (e: IOException) {
e.printStackTrace()
}
}
}
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.screens
@@ -63,7 +63,6 @@ import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.ConfirmationDialog import me.kavishdevar.librepods.composables.ConfirmationDialog
import me.kavishdevar.librepods.composables.NavigationButton import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
@@ -83,7 +82,6 @@ fun HearingAidScreen(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val verticalScrollState = rememberScrollState() val verticalScrollState = rememberScrollState()
val hazeState = remember { HazeState() }
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val attManager = ServiceManager.getService()?.attManager ?: return val attManager = ServiceManager.getService()?.attManager ?: return
@@ -99,19 +97,12 @@ fun HearingAidScreen(navController: NavController) {
mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())) mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte()))
} }
val hazeStateS = remember { mutableStateOf(HazeState()) } // dont question this. i could possibly use something other than initializing it with an empty state and then replacing it with the the one provided by the scaffold
StyledScaffold( StyledScaffold(
title = stringResource(R.string.hearing_aid), title = stringResource(R.string.hearing_aid),
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
},
actionButtons = emptyList(),
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,
) { spacerHeight -> ) { spacerHeight, hazeState ->
Column( Column(
modifier = Modifier modifier = Modifier
.layerBackdrop(backdrop) .layerBackdrop(backdrop)
@@ -121,6 +112,7 @@ fun HearingAidScreen(navController: NavController) {
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
hazeStateS.value = hazeState
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(spacerHeight))
val hearingAidListener = remember { val hearingAidListener = remember {
@@ -136,9 +128,9 @@ fun HearingAidScreen(navController: NavController) {
} }
} }
val mediaAssistEnabled = remember { mutableStateOf(false) } // val mediaAssistEnabled = remember { mutableStateOf(false) }
val adjustMediaEnabled = remember { mutableStateOf(false) } // val adjustMediaEnabled = remember { mutableStateOf(false) }
val adjustPhoneEnabled = remember { mutableStateOf(false) } // val adjustPhoneEnabled = remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener) aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
@@ -163,13 +155,13 @@ fun HearingAidScreen(navController: NavController) {
initialLoad.value = false initialLoad.value = false
} }
fun onAdjustPhoneChange(value: Boolean) { // fun onAdjustPhoneChange(value: Boolean) {
// TODO // // TODO
} // }
fun onAdjustMediaChange(value: Boolean) { // fun onAdjustMediaChange(value: Boolean) {
// TODO // // TODO
} // }
Text( Text(
text = stringResource(R.string.hearing_aid), text = stringResource(R.string.hearing_aid),
@@ -222,6 +214,13 @@ fun HearingAidScreen(navController: NavController) {
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
NavigationButton(
to = "update_hearing_test",
name = stringResource(R.string.update_hearing_test),
navController,
independent = true
)
// not implemented yet // not implemented yet
// StyledToggle( // StyledToggle(
@@ -289,7 +288,7 @@ fun HearingAidScreen(navController: NavController) {
} }
} }
}, },
hazeState = hazeState, hazeState = hazeStateS.value,
// backdrop = backdrop // backdrop = backdrop
) )
} }
@@ -0,0 +1,90 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.screens
import android.annotation.SuppressLint
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Job
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles
import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: Job? = null
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
fun HearingProtectionScreen(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
val service = ServiceManager.getService()
if (service == null) return
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.hearing_protection),
) { spacerHeight ->
Column(
modifier = Modifier
.fillMaxSize()
.layerBackdrop(backdrop)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(spacerHeight))
StyledToggle(
title = stringResource(R.string.environmental_noise),
label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description),
attHandle = ATTHandles.LOUD_SOUND_REDUCTION
)
Spacer(modifier = Modifier.height(12.dp))
StyledToggle(
title = stringResource(R.string.workspace_use),
label = stringResource(R.string.ppe),
description = stringResource(R.string.workspace_use_description),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG
)
}
}
}
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.screens
@@ -63,6 +63,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
@@ -157,14 +158,14 @@ fun Onboarding(navController: NavController, activityContext: Context) {
StyledScaffold( StyledScaffold(
title = "Setting Up", title = "Setting Up",
actionButtons = listOf( actionButtons = listOf(
{ {scaffoldBackdrop ->
StyledIconButton( StyledIconButton(
onClick = { onClick = {
showSkipDialog = true showSkipDialog = true
}, },
icon = "􀊋", icon = "􀊋",
darkMode = isDarkTheme, darkMode = isDarkTheme,
backdrop = backdrop backdrop = scaffoldBackdrop
) )
} }
) )
@@ -201,7 +202,7 @@ fun Onboarding(navController: NavController, activityContext: Context) {
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Text( Text(
text = "Root Access Required", text = stringResource(R.string.root_access_required),
style = TextStyle( style = TextStyle(
fontSize = 22.sp, fontSize = 22.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
@@ -214,7 +215,7 @@ fun Onboarding(navController: NavController, activityContext: Context) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "This app needs root access to hook onto the Bluetooth library", text = stringResource(R.string.this_app_needs_root_access_to_hook_onto_the_bluetooth_library),
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
@@ -227,7 +228,7 @@ fun Onboarding(navController: NavController, activityContext: Context) {
if (rootCheckFailed) { if (rootCheckFailed) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "Root access was denied. Please grant root permissions.", text = stringResource(R.string.root_access_denied),
style = TextStyle( style = TextStyle(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
@@ -0,0 +1,93 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.screens
import android.annotation.SuppressLint
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
import com.mikepenz.aboutlibraries.ui.compose.produceLibraries
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: Job? = null
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
fun OpenSourceLicensesScreen(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.open_source_licenses)
) { spacerHeight ->
Column(
modifier = Modifier
.fillMaxSize()
.layerBackdrop(backdrop)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(spacerHeight))
val context = androidx.compose.ui.platform.LocalContext.current
val libraries by produceLibraries {
context.resources.openRawResource(R.raw.aboutlibraries)
.bufferedReader()
.use { it.readText() }
}
LibrariesContainer(
libraries = libraries,
modifier = Modifier
.padding(0.dp)
.fillMaxSize()
)
}
}
}
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalStdlibApi::class, ExperimentalEncodingApi::class) @file:OptIn(ExperimentalStdlibApi::class, ExperimentalEncodingApi::class)
@@ -122,15 +122,7 @@ fun LongPress(navController: NavController, name: String) {
var longPressAction by remember { mutableStateOf(StemAction.valueOf(longPressActionPref ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) } var longPressAction by remember { mutableStateOf(StemAction.valueOf(longPressActionPref ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) }
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold(
title = name, title = name
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
}
) { spacerHeight -> ) { spacerHeight ->
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Column ( Column (
@@ -194,7 +186,7 @@ fun LongPress(navController: NavController, name: String) {
listeningModeItems.add( listeningModeItems.add(
SelectItem( SelectItem(
name = stringResource(R.string.off), name = stringResource(R.string.off),
description = "Turns off noise management", description = stringResource(R.string.listening_mode_off_description),
iconRes = R.drawable.noise_cancellation, iconRes = R.drawable.noise_cancellation,
selected = (currentByte and 0x01) != 0, selected = (currentByte and 0x01) != 0,
onClick = { onClick = {
@@ -220,7 +212,7 @@ fun LongPress(navController: NavController, name: String) {
listeningModeItems.addAll(listOf( listeningModeItems.addAll(listOf(
SelectItem( SelectItem(
name = stringResource(R.string.transparency), name = stringResource(R.string.transparency),
description = "Lets in external sounds", description = stringResource(R.string.listening_mode_transparency_description),
iconRes = R.drawable.transparency, iconRes = R.drawable.transparency,
selected = (currentByte and 0x04) != 0, selected = (currentByte and 0x04) != 0,
onClick = { onClick = {
@@ -243,7 +235,7 @@ fun LongPress(navController: NavController, name: String) {
), ),
SelectItem( SelectItem(
name = stringResource(R.string.adaptive), name = stringResource(R.string.adaptive),
description = "Dynamically adjust external noise", description = stringResource(R.string.listening_mode_adaptive_description),
iconRes = R.drawable.adaptive, iconRes = R.drawable.adaptive,
selected = (currentByte and 0x08) != 0, selected = (currentByte and 0x08) != 0,
onClick = { onClick = {
@@ -266,7 +258,7 @@ fun LongPress(navController: NavController, name: String) {
), ),
SelectItem( SelectItem(
name = stringResource(R.string.noise_cancellation), name = stringResource(R.string.noise_cancellation),
description = "Blocks out external sounds", description = stringResource(R.string.listening_mode_noise_cancellation_description),
iconRes = R.drawable.noise_cancellation, iconRes = R.drawable.noise_cancellation,
selected = (currentByte and 0x02) != 0, selected = (currentByte and 0x02) != 0,
onClick = { onClick = {
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -87,14 +87,6 @@ fun RenameScreen(navController: NavController) {
StyledScaffold( StyledScaffold(
title = stringResource(R.string.name), title = stringResource(R.string.name),
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
},
) { spacerHeight -> ) { spacerHeight ->
Column( Column(
modifier = Modifier modifier = Modifier
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.screens
@@ -100,15 +100,7 @@ fun TransparencySettingsScreen(navController: NavController) {
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold(
title = stringResource(R.string.customize_transparency_mode), title = stringResource(R.string.customize_transparency_mode)
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
}
){ spacerHeight, hazeState -> ){ spacerHeight, hazeState ->
Column( Column(
modifier = Modifier modifier = Modifier
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.screens
@@ -94,7 +94,6 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.utils.LogCollector import me.kavishdevar.librepods.utils.LogCollector
import java.io.File import java.io.File
@@ -216,15 +215,7 @@ fun TroubleshootingScreen(navController: NavController) {
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
StyledScaffold( StyledScaffold(
title = stringResource(R.string.troubleshooting), title = stringResource(R.string.troubleshooting)
navigationButton = {
StyledIconButton(
onClick = { navController.popBackStack() },
icon = "􀯶",
darkMode = isDarkTheme,
backdrop = backdrop
)
}
){ spacerHeight, hazeState -> ){ spacerHeight, hazeState ->
Column( Column(
modifier = Modifier modifier = Modifier
@@ -378,7 +369,7 @@ fun TroubleshootingScreen(navController: NavController) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
text = "TROUBLESHOOTING STEPS", text = stringResource(R.string.troubleshooting_steps),
style = TextStyle( style = TextStyle(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Light, fontWeight = FontWeight.Light,
@@ -0,0 +1,347 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.screens
import android.annotation.SuppressLint
import android.util.Log
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
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.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
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.Job
import kotlinx.coroutines.delay
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.HearingAidSettings
import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.utils.sendHearingAidSettings
import java.io.IOException
import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
private const val TAG = "HearingAidAdjustments"
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
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 backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.hearing_test)
) { spacerHeight, hazeState ->
Column(
modifier = Modifier
.hazeSource(hazeState)
.fillMaxSize()
.layerBackdrop(backdrop)
.verticalScroll(verticalScrollState)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black
Spacer(modifier = Modifier.height(spacerHeight))
Text(
text = stringResource(R.string.hearing_test_value_instruction),
modifier = Modifier.fillMaxWidth(),
style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
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 initialLoadComplete = remember { mutableStateOf(false) }
val initialReadSucceeded = remember { mutableStateOf(false) }
val initialReadAttempts = remember { mutableIntStateOf(0) }
val hearingAidSettings = remember {
mutableStateOf(
HearingAidSettings(
leftEQ = leftEQ.value,
rightEQ = rightEQ.value,
leftAmplification = leftAmplification.value,
rightAmplification = rightAmplification.value,
leftTone = tone.value,
rightTone = tone.value,
leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = ambientNoiseReduction.value,
rightAmbientNoiseReduction = ambientNoiseReduction.value,
netAmplification = leftAmplification.value + rightAmplification.value / 2,
balance = 0.5f + (rightAmplification.value - leftAmplification.value) / 2,
ownVoiceAmplification = ownVoiceAmplification.value
)
)
}
val hearingAidATTListener = remember {
object : (ByteArray) -> Unit {
override fun invoke(value: ByteArray) {
val parsed = parseHearingAidSettingsResponse(value)
if (parsed != null) {
leftEQ.value = parsed.leftEQ.copyOf()
rightEQ.value = parsed.rightEQ.copyOf()
conversationBoostEnabled.value = parsed.leftConversationBoost
tone.value = parsed.leftTone
ambientNoiseReduction.value = parsed.leftAmbientNoiseReduction
ownVoiceAmplification.value = parsed.ownVoiceAmplification
leftAmplification.value = parsed.leftAmplification
rightAmplification.value = parsed.rightAmplification
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.value, rightAmplification.value, tone.value, ambientNoiseReduction.value, ownVoiceAmplification.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
}
hearingAidSettings.value = HearingAidSettings(
leftEQ = leftEQ.value,
rightEQ = rightEQ.value,
leftAmplification = leftAmplification.value,
rightAmplification = rightAmplification.value,
leftTone = tone.value,
rightTone = tone.value,
leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = ambientNoiseReduction.value,
rightAmbientNoiseReduction = ambientNoiseReduction.value,
netAmplification = leftAmplification.value + rightAmplification.value / 2,
balance = 0.5f + (rightAmplification.value - leftAmplification.value) / 2,
ownVoiceAmplification = ownVoiceAmplification.value
)
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.value = parsedSettings.leftTone
ambientNoiseReduction.value = parsedSettings.leftAmbientNoiseReduction
ownVoiceAmplification.value = parsedSettings.ownVoiceAmplification
leftAmplification.value = parsedSettings.leftAmplification
rightAmplification.value = 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
}
}
val frequencies = listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz")
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Spacer(modifier = Modifier.width(60.dp))
Text(
text = stringResource(R.string.left),
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
style = TextStyle(
fontSize = 18.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.right),
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
style = TextStyle(
fontSize = 18.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
}
frequencies.forEachIndexed { index, freq ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = freq,
modifier = Modifier
.width(60.dp)
.align(Alignment.CenterVertically),
textAlign = TextAlign.End,
style = TextStyle(
color = textColor,
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
)
OutlinedTextField(
value = leftEQ.value[index].toString(),
onValueChange = { newValue ->
val parsed = newValue.toFloatOrNull()
if (parsed != null) {
val newArray = leftEQ.value.copyOf()
newArray[index] = parsed
leftEQ.value = newArray
Log.d(TAG, "Left EQ updated at index $index to $parsed")
}
},
// label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
textStyle = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontSize = 14.sp
),
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = rightEQ.value[index].toString(),
onValueChange = { newValue ->
val parsed = newValue.toFloatOrNull()
if (parsed != null) {
val newArray = rightEQ.value.copyOf()
newArray[index] = parsed
rightEQ.value = newArray
Log.d(TAG, "Right EQ updated at index $index to $parsed")
}
},
// label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
textStyle = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontSize = 14.sp
),
modifier = Modifier.weight(1f)
)
}
}
}
}
}
@@ -0,0 +1,192 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.screens
import androidx.compose.foundation.background
import android.annotation.SuppressLint
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.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Job
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: Job? = null
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
fun VersionScreen(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
val service = ServiceManager.getService()
if (service == null) return
val airpodsInstance = service.airpodsInstance
if (airpodsInstance == null) return
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.customize_adaptive_audio)
) { spacerHeight ->
Column(
modifier = Modifier
.fillMaxSize()
.layerBackdrop(backdrop)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(spacerHeight))
Box(
modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
.padding(horizontal = 16.dp, vertical = 4.dp)
){
Text(
text = stringResource(R.string.version),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
)
)
}
Column(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.version) + " 1",
style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = airpodsInstance.version1 ?: "N/A",
style = TextStyle(
fontSize = 16.sp,
color = textColor.copy(0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.version) + " 2",
style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = airpodsInstance.version2 ?: "N/A",
style = TextStyle(
fontSize = 16.sp,
color = textColor.copy(0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.version) + " 3",
style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = airpodsInstance.version3 ?: "N/A",
style = TextStyle(
fontSize = 16.sp,
color = textColor.copy(0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
}
}
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods Contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods Contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@file:Suppress("DEPRECATION") @file:Suppress("DEPRECATION")
@@ -89,10 +89,12 @@ import me.kavishdevar.librepods.constants.isHeadTrackingData
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType
import me.kavishdevar.librepods.utils.ATTManager import me.kavishdevar.librepods.utils.ATTManager
import me.kavishdevar.librepods.utils.AirPodsInstance
import me.kavishdevar.librepods.utils.AirPodsModels
import me.kavishdevar.librepods.utils.BLEManager import me.kavishdevar.librepods.utils.BLEManager
import me.kavishdevar.librepods.utils.BluetoothConnectionManager import me.kavishdevar.librepods.utils.BluetoothConnectionManager
import me.kavishdevar.librepods.utils.CrossDevice //import me.kavishdevar.librepods.utils.CrossDevice
import me.kavishdevar.librepods.utils.CrossDevicePackets //import me.kavishdevar.librepods.utils.CrossDevicePackets
import me.kavishdevar.librepods.utils.GestureDetector import me.kavishdevar.librepods.utils.GestureDetector
import me.kavishdevar.librepods.utils.HeadTracking import me.kavishdevar.librepods.utils.HeadTracking
import me.kavishdevar.librepods.utils.IslandType import me.kavishdevar.librepods.utils.IslandType
@@ -152,6 +154,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
var localMac = "" var localMac = ""
lateinit var aacpManager: AACPManager lateinit var aacpManager: AACPManager
var attManager: ATTManager? = null var attManager: ATTManager? = null
var airpodsInstance: AirPodsInstance? = null
var cameraActive = false var cameraActive = false
private var disconnectedBecauseReversed = false private var disconnectedBecauseReversed = false
private var otherDeviceTookOver = false private var otherDeviceTookOver = false
@@ -164,7 +167,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
var headGestures: Boolean = true, var headGestures: Boolean = true,
var disconnectWhenNotWearing: Boolean = false, var disconnectWhenNotWearing: Boolean = false,
var conversationalAwarenessVolume: Int = 43, var conversationalAwarenessVolume: Int = 43,
var textColor: Long = -1L,
var qsClickBehavior: String = "cycle", var qsClickBehavior: String = "cycle",
var bleOnlyMode: Boolean = false, var bleOnlyMode: Boolean = false,
@@ -190,7 +192,23 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
var leftLongPressAction: StemAction = StemAction.defaultActions[StemPressType.LONG_PRESS]!!, var leftLongPressAction: StemAction = StemAction.defaultActions[StemPressType.LONG_PRESS]!!,
var rightLongPressAction: StemAction = StemAction.defaultActions[StemPressType.LONG_PRESS]!!, var rightLongPressAction: StemAction = StemAction.defaultActions[StemPressType.LONG_PRESS]!!,
var cameraAction: AACPManager.Companion.StemPressType? = null, var cameraAction: StemPressType? = null,
// AirPods device information
var airpodsName: String = "",
var airpodsModelNumber: String = "",
var airpodsManufacturer: String = "",
var airpodsSerialNumber: String = "",
var airpodsLeftSerialNumber: String = "",
var airpodsRightSerialNumber: String = "",
var airpodsVersion1: String = "",
var airpodsVersion2: String = "",
var airpodsVersion3: String = "",
var airpodsHardwareRevision: String = "",
var airpodsUpdaterIdentifier: String = "",
// phone's mac, needed for tipi
var selfMacAddress: String = ""
) )
private lateinit var config: ServiceConfig private lateinit var config: ServiceConfig
@@ -213,6 +231,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
private var handleIncomingCallOnceConnected = false private var handleIncomingCallOnceConnected = false
lateinit var bleManager: BLEManager lateinit var bleManager: BLEManager
private lateinit var socket: BluetoothSocket
private val bleStatusListener = object : BLEManager.AirPodsStatusListener { private val bleStatusListener = object : BLEManager.AirPodsStatusListener {
@SuppressLint("NewApi") @SuppressLint("NewApi")
override fun onDeviceStatusChanged( override fun onDeviceStatusChanged(
@@ -349,9 +370,29 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
sharedPreferences.registerOnSharedPreferenceChangeListener(this) sharedPreferences.registerOnSharedPreferenceChangeListener(this)
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "settings", "get", "secure", "bluetooth_address")) localMac = config.selfMacAddress
val output = process.inputStream.bufferedReader().use { it.readLine() } if (localMac.isEmpty()) {
localMac = output.trim() localMac = try {
val process = Runtime.getRuntime().exec(
arrayOf("su", "-c", "settings get secure bluetooth_address")
)
val exitCode = process.waitFor()
if (exitCode == 0) {
process.inputStream.bufferedReader().use { it.readLine()?.trim().orEmpty() }
} else {
""
}
} catch (e: Exception) {
Log.e(TAG, "Error retrieving local MAC address: ${e.message}. We probably aren't rooted.")
""
}
config.selfMacAddress = localMac
sharedPreferences.edit {
putString("self_mac_address", localMac)
}
}
ServiceManager.setService(this) ServiceManager.setService(this)
startForegroundNotification() startForegroundNotification()
@@ -434,8 +475,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
43 43
) )
if (!contains("textColor")) putLong("textColor", -1L)
if (!contains("qs_click_behavior")) putString("qs_click_behavior", "cycle") if (!contains("qs_click_behavior")) putString("qs_click_behavior", "cycle")
if (!contains("name")) putString("name", "AirPods") if (!contains("name")) putString("name", "AirPods")
@@ -537,11 +576,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
MODE_PRIVATE MODE_PRIVATE
) )
) )
Log.d(TAG, "Initializing CrossDevice") // Log.d(TAG, "Initializing CrossDevice")
CoroutineScope(Dispatchers.IO).launch { // CoroutineScope(Dispatchers.IO).launch {
CrossDevice.init(this@AirPodsService) // CrossDevice.init(this@AirPodsService)
Log.d(TAG, "CrossDevice initialized") // Log.d(TAG, "CrossDevice initialized")
} // }
sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE) sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE)
macAddress = sharedPreferences.getString("mac_address", "") ?: "" macAddress = sharedPreferences.getString("mac_address", "") ?: ""
@@ -554,7 +593,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
when (state) { when (state) {
TelephonyManager.CALL_STATE_RINGING -> { TelephonyManager.CALL_STATE_RINGING -> {
val leAvailableForAudio = bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true val leAvailableForAudio = bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true
if ((CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) || leAvailableForAudio) CoroutineScope(Dispatchers.IO).launch { // if ((CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) || leAvailableForAudio) CoroutineScope(Dispatchers.IO).launch {
if (leAvailableForAudio) runBlocking {
takeOver("call") takeOver("call")
} }
if (config.headGestures) { if (config.headGestures) {
@@ -564,7 +604,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} }
TelephonyManager.CALL_STATE_OFFHOOK -> { TelephonyManager.CALL_STATE_OFFHOOK -> {
val leAvailableForAudio = bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true val leAvailableForAudio = bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true
if ((CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) || leAvailableForAudio) CoroutineScope( // if ((CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) || leAvailableForAudio) CoroutineScope(
if (leAvailableForAudio) CoroutineScope(
Dispatchers.IO).launch { Dispatchers.IO).launch {
takeOver("call") takeOver("call")
} }
@@ -622,8 +663,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
sharedPreferences.edit { putString("name", config.deviceName) } sharedPreferences.edit { putString("name", config.deviceName) }
} }
Log.d("AirPodsCrossDevice", CrossDevice.isAvailable.toString()) // Log.d("AirPodsCrossDevice", CrossDevice.isAvailable.toString())
if (!CrossDevice.isAvailable) { // if (!CrossDevice.isAvailable) {
Log.d(TAG, "${config.deviceName} connected") Log.d(TAG, "${config.deviceName} connected")
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
connectToSocket(device!!) connectToSocket(device!!)
@@ -635,7 +676,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
sharedPreferences.edit { sharedPreferences.edit {
putString("mac_address", macAddress) putString("mac_address", macAddress)
} }
} // }
} else if (intent?.action == AirPodsNotifications.AIRPODS_DISCONNECTED) { } else if (intent?.action == AirPodsNotifications.AIRPODS_DISCONNECTED) {
device = null device = null
isConnectedLocally = false isConnectedLocally = false
@@ -700,7 +742,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
if (profile == BluetoothProfile.A2DP) { if (profile == BluetoothProfile.A2DP) {
val connectedDevices = proxy.connectedDevices val connectedDevices = proxy.connectedDevices
if (connectedDevices.isNotEmpty()) { if (connectedDevices.isNotEmpty()) {
if (!CrossDevice.isAvailable) { // if (!CrossDevice.isAvailable) {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
connectToSocket(device) connectToSocket(device)
} }
@@ -709,7 +751,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
sharedPreferences.edit { sharedPreferences.edit {
putString("mac_address", macAddress) putString("mac_address", macAddress)
} }
} // }
this@AirPodsService.sendBroadcast( this@AirPodsService.sendBroadcast(
Intent(AirPodsNotifications.AIRPODS_CONNECTED) Intent(AirPodsNotifications.AIRPODS_CONNECTED)
) )
@@ -726,9 +768,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} }
} }
if (!isConnectedLocally && !CrossDevice.isAvailable) { // if (!isConnectedLocally && !CrossDevice.isAvailable) {
clearPacketLogs() // clearPacketLogs()
} // }
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
bleManager.startScanning() bleManager.startScanning()
@@ -800,8 +842,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
.getString("name", device?.name), .getString("name", device?.name),
batteryNotification.getBattery() batteryNotification.getBattery()
) )
CrossDevice.sendRemotePacket(batteryInfo) // CrossDevice.sendRemotePacket(batteryInfo)
CrossDevice.batteryBytes = batteryInfo // CrossDevice.batteryBytes = batteryInfo
for (battery in batteryNotification.getBattery()) { for (battery in batteryNotification.getBattery()) {
Log.d( Log.d(
@@ -926,8 +968,54 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
) )
} }
override fun onDeviceMetadataReceived(deviceMetadata: ByteArray) { override fun onDeviceInformationReceived(deviceInformation: AACPManager.Companion.AirPodsInformation) {
Log.d(
"AirPodsParser",
"Device Information: name: ${deviceInformation.name}, modelNumber: ${deviceInformation.modelNumber}, manufacturer: ${deviceInformation.manufacturer}, serialNumber: ${deviceInformation.serialNumber}, version1: ${deviceInformation.version1}, version2: ${deviceInformation.version2}, hardwareRevision: ${deviceInformation.hardwareRevision}, updaterIdentifier: ${deviceInformation.updaterIdentifier}, leftSerialNumber: ${deviceInformation.leftSerialNumber}, rightSerialNumber: ${deviceInformation.rightSerialNumber}, version3: ${deviceInformation.version3}"
)
// Store in SharedPreferences
sharedPreferences.edit {
putString("airpods_name", deviceInformation.name)
putString("airpods_model_number", deviceInformation.modelNumber)
putString("airpods_manufacturer", deviceInformation.manufacturer)
putString("airpods_serial_number", deviceInformation.serialNumber)
putString("airpods_left_serial_number", deviceInformation.leftSerialNumber)
putString("airpods_right_serial_number", deviceInformation.rightSerialNumber)
putString("airpods_version1", deviceInformation.version1)
putString("airpods_version2", deviceInformation.version2)
putString("airpods_version3", deviceInformation.version3)
putString("airpods_hardware_revision", deviceInformation.hardwareRevision)
putString("airpods_updater_identifier", deviceInformation.updaterIdentifier)
}
// Update config
config.airpodsName = deviceInformation.name
config.airpodsModelNumber = deviceInformation.modelNumber
config.airpodsManufacturer = deviceInformation.manufacturer
config.airpodsSerialNumber = deviceInformation.serialNumber
config.airpodsLeftSerialNumber = deviceInformation.leftSerialNumber
config.airpodsRightSerialNumber = deviceInformation.rightSerialNumber
config.airpodsVersion1 = deviceInformation.version1
config.airpodsVersion2 = deviceInformation.version2
config.airpodsVersion3 = deviceInformation.version3
config.airpodsHardwareRevision = deviceInformation.hardwareRevision
config.airpodsUpdaterIdentifier = deviceInformation.updaterIdentifier
val model = AirPodsModels.getModelByModelNumber(config.airpodsModelNumber)
if (model != null) {
airpodsInstance = AirPodsInstance(
name = config.airpodsName,
model = model,
actualModelNumber = config.airpodsModelNumber,
serialNumber = config.airpodsSerialNumber,
leftSerialNumber = config.airpodsLeftSerialNumber,
rightSerialNumber = config.airpodsRightSerialNumber,
version1 = config.airpodsVersion1,
version2 = config.airpodsVersion2,
version3 = config.airpodsVersion3,
aacpManager = aacpManager,
attManager = attManager
)
}
} }
@SuppressLint("NewApi") @SuppressLint("NewApi")
@@ -970,8 +1058,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
byteArrayOf(0x00) byteArrayOf(0x00)
) )
// this also means that the other device has start playing the audio, and if that's true, we can again start listening for audio config changes // this also means that the other device has start playing the audio, and if that's true, we can again start listening for audio config changes
Log.d(TAG, "Another device started playing audio, listening for audio config changes again") // Log.d(TAG, "Another device started playing audio, listening for audio config changes again")
MediaController.pausedForOtherDevice = false // MediaController.pausedForOtherDevice = false
// future me: what the heck is this? this just means it will not be taking over again if audio source doesn't change???
} }
} }
@@ -1137,7 +1226,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
headGestures = sharedPreferences.getBoolean("head_gestures", true), headGestures = sharedPreferences.getBoolean("head_gestures", true),
disconnectWhenNotWearing = sharedPreferences.getBoolean("disconnect_when_not_wearing", false), disconnectWhenNotWearing = sharedPreferences.getBoolean("disconnect_when_not_wearing", false),
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43), conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43),
textColor = sharedPreferences.getLong("textColor", -1L),
qsClickBehavior = sharedPreferences.getString("qs_click_behavior", "cycle") ?: "cycle", qsClickBehavior = sharedPreferences.getString("qs_click_behavior", "cycle") ?: "cycle",
// AirPods state-based takeover // AirPods state-based takeover
@@ -1163,7 +1251,22 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
leftLongPressAction = StemAction.fromString(sharedPreferences.getString("left_long_press_action", "CYCLE_NOISE_CONTROL_MODES") ?: "CYCLE_NOISE_CONTROL_MODES")!!, leftLongPressAction = StemAction.fromString(sharedPreferences.getString("left_long_press_action", "CYCLE_NOISE_CONTROL_MODES") ?: "CYCLE_NOISE_CONTROL_MODES")!!,
rightLongPressAction = StemAction.fromString(sharedPreferences.getString("right_long_press_action", "DIGITAL_ASSISTANT") ?: "DIGITAL_ASSISTANT")!!, rightLongPressAction = StemAction.fromString(sharedPreferences.getString("right_long_press_action", "DIGITAL_ASSISTANT") ?: "DIGITAL_ASSISTANT")!!,
cameraAction = sharedPreferences.getString("camera_action", null)?.let { AACPManager.Companion.StemPressType.valueOf(it) }, cameraAction = sharedPreferences.getString("camera_action", null)?.let { StemPressType.valueOf(it) },
// AirPods device information
airpodsName = sharedPreferences.getString("airpods_name", "") ?: "",
airpodsModelNumber = sharedPreferences.getString("airpods_model_number", "") ?: "",
airpodsManufacturer = sharedPreferences.getString("airpods_manufacturer", "") ?: "",
airpodsSerialNumber = sharedPreferences.getString("airpods_serial_number", "") ?: "",
airpodsLeftSerialNumber = sharedPreferences.getString("airpods_left_serial_number", "") ?: "",
airpodsRightSerialNumber = sharedPreferences.getString("airpods_right_serial_number", "") ?: "",
airpodsVersion1 = sharedPreferences.getString("airpods_version1", "") ?: "",
airpodsVersion2 = sharedPreferences.getString("airpods_version2", "") ?: "",
airpodsVersion3 = sharedPreferences.getString("airpods_version3", "") ?: "",
airpodsHardwareRevision = sharedPreferences.getString("airpods_hardware_revision", "") ?: "",
airpodsUpdaterIdentifier = sharedPreferences.getString("airpods_updater_identifier", "") ?: "",
selfMacAddress = sharedPreferences.getString("self_mac_address", "") ?: ""
) )
} }
@@ -1172,6 +1275,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
when(key) { when(key) {
"name" -> config.deviceName = preferences.getString(key, "AirPods") ?: "AirPods" "name" -> config.deviceName = preferences.getString(key, "AirPods") ?: "AirPods"
"mac_address" -> macAddress = preferences.getString(key, "") ?: ""
"automatic_ear_detection" -> config.earDetectionEnabled = preferences.getBoolean(key, true) "automatic_ear_detection" -> config.earDetectionEnabled = preferences.getBoolean(key, true)
"conversational_awareness_pause_music" -> config.conversationalAwarenessPauseMusic = preferences.getBoolean(key, false) "conversational_awareness_pause_music" -> config.conversationalAwarenessPauseMusic = preferences.getBoolean(key, false)
"show_phone_battery_in_widget" -> { "show_phone_battery_in_widget" -> {
@@ -1183,7 +1287,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
"head_gestures" -> config.headGestures = preferences.getBoolean(key, true) "head_gestures" -> config.headGestures = preferences.getBoolean(key, true)
"disconnect_when_not_wearing" -> config.disconnectWhenNotWearing = preferences.getBoolean(key, false) "disconnect_when_not_wearing" -> config.disconnectWhenNotWearing = preferences.getBoolean(key, false)
"conversational_awareness_volume" -> config.conversationalAwarenessVolume = preferences.getInt(key, 43) "conversational_awareness_volume" -> config.conversationalAwarenessVolume = preferences.getInt(key, 43)
"textColor" -> config.textColor = preferences.getLong(key, -1L)
"qs_click_behavior" -> config.qsClickBehavior = preferences.getString(key, "cycle") ?: "cycle" "qs_click_behavior" -> config.qsClickBehavior = preferences.getString(key, "cycle") ?: "cycle"
// AirPods state-based takeover // AirPods state-based takeover
@@ -1244,11 +1347,22 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
)!! )!!
setupStemActions() setupStemActions()
} }
"camera_action" -> config.cameraAction = preferences.getString(key, null)?.let { AACPManager.Companion.StemPressType.valueOf(it) } "camera_action" -> config.cameraAction = preferences.getString(key, null)?.let { StemPressType.valueOf(it) }
}
if (key == "mac_address") { // AirPods device information
macAddress = preferences.getString(key, "") ?: "" "airpods_name" -> config.airpodsName = preferences.getString(key, "") ?: ""
"airpods_model_number" -> config.airpodsModelNumber = preferences.getString(key, "") ?: ""
"airpods_manufacturer" -> config.airpodsManufacturer = preferences.getString(key, "") ?: ""
"airpods_serial_number" -> config.airpodsSerialNumber = preferences.getString(key, "") ?: ""
"airpods_left_serial_number" -> config.airpodsLeftSerialNumber = preferences.getString(key, "") ?: ""
"airpods_right_serial_number" -> config.airpodsRightSerialNumber = preferences.getString(key, "") ?: ""
"airpods_version1" -> config.airpodsVersion1 = preferences.getString(key, "") ?: ""
"airpods_version2" -> config.airpodsVersion2 = preferences.getString(key, "") ?: ""
"airpods_version3" -> config.airpodsVersion3 = preferences.getString(key, "") ?: ""
"airpods_hardware_revision" -> config.airpodsHardwareRevision = preferences.getString(key, "") ?: ""
"airpods_updater_identifier" -> config.airpodsUpdaterIdentifier = preferences.getString(key, "") ?: ""
"self_mac_address" -> config.selfMacAddress = preferences.getString(key, "") ?: ""
} }
} }
@@ -1754,7 +1868,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
notificationManager.notify(1, updatedNotification) notificationManager.notify(1, updatedNotification)
notificationManager.cancel(2) notificationManager.cancel(2)
} else if (!config.bleOnlyMode && !socket.isConnected && isConnectedLocally) { } else if (!config.bleOnlyMode && !socket.isConnected && isConnectedLocally) {
Log.d(TAG, "<LogCollector:Complete:Failed> Socket not connected")
showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?") showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?")
} }
} }
@@ -1965,15 +2078,17 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
private fun setMetadatas(d: BluetoothDevice) { private fun setMetadatas(d: BluetoothDevice) {
d.let{ device -> d.let{ device ->
val metadataSet = SystemApisUtils.setMetadata( val instance = airpodsInstance
if (instance != null) {
val metadataSet = SystemApisUtils.setMetadata(
device, device,
device.METADATA_MAIN_ICON, device.METADATA_MAIN_ICON,
resToUri(R.drawable.pro_2).toString().toByteArray() resToUri(instance.model.budCaseRes).toString().toByteArray()
) && ) &&
SystemApisUtils.setMetadata( SystemApisUtils.setMetadata(
device, device,
device.METADATA_MODEL_NAME, device.METADATA_MODEL_NAME,
"AirPods Pro (2 Gen.)".toByteArray() instance.model.name.toByteArray()
) && ) &&
SystemApisUtils.setMetadata( SystemApisUtils.setMetadata(
device, device,
@@ -1983,27 +2098,27 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
SystemApisUtils.setMetadata( SystemApisUtils.setMetadata(
device, device,
device.METADATA_UNTETHERED_CASE_ICON, device.METADATA_UNTETHERED_CASE_ICON,
resToUri(R.drawable.pro_2_case).toString().toByteArray() resToUri(instance.model.caseRes).toString().toByteArray()
) && ) &&
SystemApisUtils.setMetadata( SystemApisUtils.setMetadata(
device, device,
device.METADATA_UNTETHERED_RIGHT_ICON, device.METADATA_UNTETHERED_RIGHT_ICON,
resToUri(R.drawable.pro_2_right).toString().toByteArray() resToUri(instance.model.rightBudsRes).toString().toByteArray()
) && ) &&
SystemApisUtils.setMetadata( SystemApisUtils.setMetadata(
device, device,
device.METADATA_UNTETHERED_LEFT_ICON, device.METADATA_UNTETHERED_LEFT_ICON,
resToUri(R.drawable.pro_2_left).toString().toByteArray() resToUri(instance.model.leftBudsRes).toString().toByteArray()
) && ) &&
SystemApisUtils.setMetadata( SystemApisUtils.setMetadata(
device, device,
device.METADATA_MANUFACTURER_NAME, device.METADATA_MANUFACTURER_NAME,
"Apple".toByteArray() instance.model.manufacturer.toByteArray()
) && ) &&
SystemApisUtils.setMetadata( SystemApisUtils.setMetadata(
device, device,
device.METADATA_COMPANION_APP, device.METADATA_COMPANION_APP,
"me.kavisdevar.librepods".toByteArray() "me.kavishdevar.librepods".toByteArray()
) && ) &&
SystemApisUtils.setMetadata( SystemApisUtils.setMetadata(
device, device,
@@ -2020,7 +2135,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
device.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD, device.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD,
"20".toByteArray() "20".toByteArray()
) )
Log.d(TAG, "Metadata set: $metadataSet") Log.d(TAG, "Metadata set: $metadataSet")
} else {
Log.w(TAG, "AirPods instance is not of type AirPodsInstance, skipping metadata setting")
}
} }
} }
@@ -2042,11 +2160,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
val name = context?.getSharedPreferences("settings", MODE_PRIVATE) val name = context?.getSharedPreferences("settings", MODE_PRIVATE)
?.getString("name", bluetoothDevice?.name) ?.getString("name", bluetoothDevice?.name)
if (bluetoothDevice != null && action != null && !action.isEmpty()) { if (bluetoothDevice != null && action != null && !action.isEmpty()) {
Log.d(TAG, "Received bluetooth connection broadcast") Log.d(TAG, "Received bluetooth connection broadcast: action=$action")
if (ServiceManager.getService()?.isConnectedLocally == true) {
ServiceManager.getService()?.manuallyCheckForAudioSource()
return
}
if (BluetoothDevice.ACTION_ACL_CONNECTED == action) { if (BluetoothDevice.ACTION_ACL_CONNECTED == action) {
val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
bluetoothDevice.fetchUuidsWithSdp() bluetoothDevice.fetchUuidsWithSdp()
@@ -2081,19 +2195,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
return START_STICKY return START_STICKY
} }
private lateinit var socket: BluetoothSocket
fun manuallyCheckForAudioSource() {
val shouldResume = MediaController.getMusicActive()
if ((earDetectionNotification.status[0] != 0.toByte() && earDetectionNotification.status[1] != 0.toByte()) || disconnectedBecauseReversed || otherDeviceTookOver) {
Log.d(
TAG,
"For some reason, Android connected to the audio profile itself even after disconnecting. Disconnecting audio profile again!"
)
disconnectAudio(this, device, shouldResume = shouldResume)
}
}
@RequiresApi(Build.VERSION_CODES.R) @RequiresApi(Build.VERSION_CODES.R)
@SuppressLint("MissingPermission", "HardwareIds") @SuppressLint("MissingPermission", "HardwareIds")
fun takeOver(takingOverFor: String, manualTakeOverAfterReversed: Boolean = false, startHeadTrackingAgain: Boolean = false) { fun takeOver(takingOverFor: String, manualTakeOverAfterReversed: Boolean = false, startHeadTrackingAgain: Boolean = false) {
@@ -2169,14 +2270,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
return return
} }
if (CrossDevice.isAvailable) { // if (CrossDevice.isAvailable) {
Log.d(TAG, "CrossDevice is available, continuing") // Log.d(TAG, "CrossDevice is available, continuing")
} // }
else if (bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true) { // else if (bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true) {
Log.d(TAG, "At least one AirPod is in ear, continuing") // Log.d(TAG, "At least one AirPod is in ear, continuing")
} // }
else { // else {
Log.d(TAG, "CrossDevice not available and AirPods not in ear, skipping") // Log.d(TAG, "CrossDevice not available and AirPods not in ear, skipping")
// return
// }
if (bleManager.getMostRecentStatus()?.isLeftInEar == false && bleManager.getMostRecentStatus()?.isRightInEar == false) {
Log.d(TAG, "Both AirPods are out of ear, not taking over audio")
return return
} }
@@ -2215,10 +2321,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} }
Log.d(TAG, "Taking over audio") Log.d(TAG, "Taking over audio")
CrossDevice.sendRemotePacket(CrossDevicePackets.REQUEST_DISCONNECT.packet) // CrossDevice.sendRemotePacket(CrossDevicePackets.REQUEST_DISCONNECT.packet)
Log.d(TAG, macAddress) Log.d(TAG, macAddress)
sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", false) } // sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", false) }
device = getSystemService(BluetoothManager::class.java).adapter.bondedDevices.find { device = getSystemService(BluetoothManager::class.java).adapter.bondedDevices.find {
it.address == macAddress it.address == macAddress
} }
@@ -2243,7 +2349,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
showIsland(this, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!), showIsland(this, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!),
IslandType.TAKING_OVER) IslandType.TAKING_OVER)
CrossDevice.isAvailable = false // CrossDevice.isAvailable = false
} }
private fun createBluetoothSocket(device: BluetoothDevice, uuid: ParcelUuid): BluetoothSocket { private fun createBluetoothSocket(device: BluetoothDevice, uuid: ParcelUuid): BluetoothSocket {
@@ -2284,16 +2390,16 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} }
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag") @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
fun connectToSocket(device: BluetoothDevice) { fun connectToSocket(device: BluetoothDevice, manual: Boolean = false) {
Log.d(TAG, "<LogCollector:Start> Connecting to socket") Log.d(TAG, "<LogCollector:Start> Connecting to socket")
HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;") HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;")
val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
if (!isConnectedLocally && !CrossDevice.isAvailable) { if (!isConnectedLocally) {
socket = try { socket = try {
createBluetoothSocket(device, uuid) createBluetoothSocket(device, uuid)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to create BluetoothSocket: ${e.message}") Log.e(TAG, "Failed to create BluetoothSocket: ${e.message}")
showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.message}") showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.localizedMessage}")
return return
} }
@@ -2310,6 +2416,26 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
attManager = ATTManager(device) attManager = ATTManager(device)
attManager!!.connect() attManager!!.connect()
// Create AirPodsInstance from stored config if available
if (airpodsInstance == null && config.airpodsModelNumber.isNotEmpty()) {
val model = AirPodsModels.getModelByModelNumber(config.airpodsModelNumber)
if (model != null) {
airpodsInstance = AirPodsInstance(
name = config.airpodsName,
model = model,
actualModelNumber = config.airpodsModelNumber,
serialNumber = config.airpodsSerialNumber,
leftSerialNumber = config.airpodsLeftSerialNumber,
rightSerialNumber = config.airpodsRightSerialNumber,
version1 = config.airpodsVersion1,
version2 = config.airpodsVersion2,
version3 = config.airpodsVersion3,
aacpManager = aacpManager,
attManager = attManager
)
}
}
updateNotificationContent( updateNotificationContent(
true, true,
config.deviceName, config.deviceName,
@@ -2317,15 +2443,29 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
) )
Log.d(TAG, "<LogCollector:Complete:Success> Socket connected") Log.d(TAG, "<LogCollector:Complete:Success> Socket connected")
} catch (e: Exception) { } catch (e: Exception) {
Log.d(TAG, "<LogCollector:Complete:Failed> Socket not connected") Log.d(TAG, "<LogCollector:Complete:Failed> Socket not connected, ${e.message}")
showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?") if (manual) {
throw e sendToast(
"Couldn't connect to socket: ${e.localizedMessage}"
)
} else {
showSocketConnectionFailureNotification("Couldn't connect to socket: ${e.localizedMessage}")
}
return@withTimeout
// throw e // lol how did i not catch this before... gonna comment this line instead of removing to preserve history
} }
} }
if (!socket.isConnected) { }
Log.d(TAG, "<LogCollector:Complete:Failed> Socket not connected") if (!socket.isConnected) {
showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?") Log.d(TAG, "<LogCollector:Complete:Failed> Socket not connected")
if (manual) {
sendToast(
"Couldn't connect to socket: timeout."
)
} else {
showSocketConnectionFailureNotification("Couldn't connect to socket: Timeout")
} }
return
} }
this@AirPodsService.device = device this@AirPodsService.device = device
socket.let { socket.let {
@@ -2372,7 +2512,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}) })
val bytes = buffer.copyOfRange(0, bytesRead) val bytes = buffer.copyOfRange(0, bytesRead)
val formattedHex = bytes.joinToString(" ") { "%02X".format(it) } val formattedHex = bytes.joinToString(" ") { "%02X".format(it) }
CrossDevice.sendReceivedPacket(bytes) // CrossDevice.sendReceivedPacket(bytes)
updateNotificationContent( updateNotificationContent(
true, true,
sharedPreferences.getString("name", device.name), sharedPreferences.getString("name", device.name),
@@ -2405,15 +2545,17 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
Log.d(TAG, "Failed to connect to socket: ${e.message}") Log.d(TAG, "Failed to connect to socket: ${e.message}")
showSocketConnectionFailureNotification("Failed to establish connection: ${e.message}") showSocketConnectionFailureNotification("Failed to establish connection: ${e.localizedMessage}")
isConnectedLocally = false isConnectedLocally = false
this@AirPodsService.device = device this@AirPodsService.device = device
updateNotificationContent(false) updateNotificationContent(false)
} }
} else {
Log.d(TAG, "Already connected locally, skipping socket connection (isConnectedLocally = $isConnectedLocally, socket.isConnected = ${this::socket.isInitialized && socket.isConnected})")
} }
} }
fun disconnect() { fun disconnectForCD() {
if (!this::socket.isInitialized) return if (!this::socket.isInitialized) return
socket.close() socket.close()
MediaController.pausedWhileTakingOver = false MediaController.pausedWhileTakingOver = false
@@ -2435,7 +2577,34 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
override fun onServiceDisconnected(profile: Int) {} override fun onServiceDisconnected(profile: Int) {}
}, BluetoothProfile.A2DP) }, BluetoothProfile.A2DP)
isConnectedLocally = false isConnectedLocally = false
CrossDevice.isAvailable = true // CrossDevice.isAvailable = true
}
fun disconnectAirPods() {
if (!this::socket.isInitialized) return
socket.close()
isConnectedLocally = false
aacpManager.disconnected()
attManager?.disconnect()
updateNotificationContent(false)
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED))
val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter
bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
if (profile == BluetoothProfile.A2DP) {
val connectedDevices = proxy.connectedDevices
if (connectedDevices.isNotEmpty()) {
MediaController.sendPause()
}
}
bluetoothAdapter.closeProfileProxy(profile, proxy)
}
override fun onServiceDisconnected(profile: Int) {}
}, BluetoothProfile.A2DP)
Log.d(TAG, "Disconnected AirPods upon user request")
} }
val earDetectionNotification = AirPodsNotifications.EarDetection() val earDetectionNotification = AirPodsNotifications.EarDetection()
@@ -2453,20 +2622,20 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} }
fun getBattery(): List<Battery> { fun getBattery(): List<Battery> {
if (!isConnectedLocally && CrossDevice.isAvailable) { // if (!isConnectedLocally && CrossDevice.isAvailable) {
batteryNotification.setBattery(CrossDevice.batteryBytes) // batteryNotification.setBattery(CrossDevice.batteryBytes)
} // }
return batteryNotification.getBattery() return batteryNotification.getBattery()
} }
fun getANC(): Int { fun getANC(): Int {
if (!isConnectedLocally && CrossDevice.isAvailable) { // if (!isConnectedLocally && CrossDevice.isAvailable) {
ancNotification.setStatus(CrossDevice.ancBytes) // ancNotification.setStatus(CrossDevice.ancBytes)
} // }
return ancNotification.status return ancNotification.status
} }
fun disconnectAudio(context: Context, device: BluetoothDevice?, shouldResume: Boolean = false) { fun disconnectAudio(context: Context, device: BluetoothDevice?) {
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
@@ -2477,13 +2646,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
return return
} }
val method = val method =
proxy.javaClass.getMethod("disconnect", BluetoothDevice::class.java) proxy.javaClass.getMethod("setConnectionPolicy", BluetoothDevice::class.java, Int::class.java)
method.invoke(proxy, device) method.invoke(proxy, device, 0)
if (shouldResume) {
Handler(Looper.getMainLooper()).postDelayed({
MediaController.sendPlay()
}, 150)
}
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} finally { } finally {
@@ -2500,8 +2664,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
if (profile == BluetoothProfile.HEADSET) { if (profile == BluetoothProfile.HEADSET) {
try { try {
val method = val method =
proxy.javaClass.getMethod("disconnect", BluetoothDevice::class.java) proxy.javaClass.getMethod("setConnectionPolicy", BluetoothDevice::class.java, Int::class.java)
method.invoke(proxy, device) method.invoke(proxy, device, 0)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} finally { } finally {
@@ -2521,9 +2685,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
if (profile == BluetoothProfile.A2DP) { if (profile == BluetoothProfile.A2DP) {
try { try {
val method = val policyMethod = proxy.javaClass.getMethod("setConnectionPolicy", BluetoothDevice::class.java, Int::class.java)
policyMethod.invoke(proxy, device, 100)
val connectMethod =
proxy.javaClass.getMethod("connect", BluetoothDevice::class.java) proxy.javaClass.getMethod("connect", BluetoothDevice::class.java)
method.invoke(proxy, device) connectMethod.invoke(proxy, device) // reduces the slight delay between allowing and actually connecting
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} finally { } finally {
@@ -2542,9 +2708,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
if (profile == BluetoothProfile.HEADSET) { if (profile == BluetoothProfile.HEADSET) {
try { try {
val method = val policyMethod = proxy.javaClass.getMethod("setConnectionPolicy", BluetoothDevice::class.java, Int::class.java)
policyMethod.invoke(proxy, device, 100)
val connectMethod =
proxy.javaClass.getMethod("connect", BluetoothDevice::class.java) proxy.javaClass.getMethod("connect", BluetoothDevice::class.java)
method.invoke(proxy, device) connectMethod.invoke(proxy, device)
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} finally { } finally {
@@ -2603,7 +2771,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} }
telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE) telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE)
isConnectedLocally = false isConnectedLocally = false
CrossDevice.isAvailable = true // CrossDevice.isAvailable = true
super.onDestroy() super.onDestroy()
} }
@@ -2636,6 +2804,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
isHeadTrackingActive = false isHeadTrackingActive = false
} }
@SuppressLint("MissingPermission")
fun reconnectFromSavedMac(){
val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter
device = bluetoothAdapter.bondedDevices.find {
it.address == macAddress
}
if (device != null) {
CoroutineScope(Dispatchers.IO).launch {
connectToSocket(device!!, manual = true)
}
}
}
} }
private fun Int.dpToPx(): Int { private fun Int.dpToPx(): Int {
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods Contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.ui.theme package me.kavishdevar.librepods.ui.theme
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.ui.theme package me.kavishdevar.librepods.ui.theme
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.ui.theme package me.kavishdevar.librepods.ui.theme
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -42,7 +42,7 @@ class AACPManager {
const val CONTROL_COMMAND: Byte = 0x09 const val CONTROL_COMMAND: Byte = 0x09
const val EAR_DETECTION: Byte = 0x06 const val EAR_DETECTION: Byte = 0x06
const val CONVERSATION_AWARENESS: Byte = 0x4B const val CONVERSATION_AWARENESS: Byte = 0x4B
const val DEVICE_METADATA: Byte = 0x1D const val INFORMATION: Byte = 0x1D
const val RENAME: Byte = 0x1E const val RENAME: Byte = 0x1E
const val HEADTRACKING: Byte = 0x17 const val HEADTRACKING: Byte = 0x17
const val PROXIMITY_KEYS_REQ: Byte = 0x30 const val PROXIMITY_KEYS_REQ: Byte = 0x30
@@ -118,7 +118,9 @@ class AACPManager {
ALLOW_AUTO_CONNECT(0x36), // not sure what this does, AUTOMATIC_CONNECTION is the only one used, but this is newer... so ¯\_(ツ)_/¯ ALLOW_AUTO_CONNECT(0x36), // not sure what this does, AUTOMATIC_CONNECTION is the only one used, but this is newer... so ¯\_(ツ)_/¯
EAR_DETECTION_CONFIG(0x0A), EAR_DETECTION_CONFIG(0x0A),
AUTOMATIC_CONNECTION_CONFIG(0x20), AUTOMATIC_CONNECTION_CONFIG(0x20),
OWNS_CONNECTION(0x06); OWNS_CONNECTION(0x06),
PPE_TOGGLE_CONFIG(0x37),
PPE_CAP_LEVEL_CONFIG(0x38);
companion object { companion object {
fun fromByte(byte: Byte): ControlCommandIdentifiers? = fun fromByte(byte: Byte): ControlCommandIdentifiers? =
@@ -181,6 +183,20 @@ class AACPManager {
val info2: Byte, val info2: Byte,
var type: String? var type: String?
) )
data class AirPodsInformation(
val name: String,
val modelNumber: String,
val manufacturer: String,
val serialNumber: String,
val version1: String,
val version2: String,
val hardwareRevision: String,
val updaterIdentifier: String,
val leftSerialNumber: String,
val rightSerialNumber: String,
val version3: String
)
} }
var controlCommandStatusList: MutableList<ControlCommandStatus> = var controlCommandStatusList: MutableList<ControlCommandStatus> =
@@ -239,7 +255,7 @@ class AACPManager {
fun onEarDetectionReceived(earDetection: ByteArray) fun onEarDetectionReceived(earDetection: ByteArray)
fun onConversationAwarenessReceived(conversationAwareness: ByteArray) fun onConversationAwarenessReceived(conversationAwareness: ByteArray)
fun onControlCommandReceived(controlCommand: ByteArray) fun onControlCommandReceived(controlCommand: ByteArray)
fun onDeviceMetadataReceived(deviceMetadata: ByteArray) fun onDeviceInformationReceived(deviceInformation: AirPodsInformation)
fun onHeadTrackingReceived(headTracking: ByteArray) fun onHeadTrackingReceived(headTracking: ByteArray)
fun onUnknownPacketReceived(packet: ByteArray) fun onUnknownPacketReceived(packet: ByteArray)
fun onProximityKeysReceived(proximityKeys: ByteArray) fun onProximityKeysReceived(proximityKeys: ByteArray)
@@ -481,10 +497,6 @@ class AACPManager {
callback?.onConversationAwarenessReceived(packet) callback?.onConversationAwarenessReceived(packet)
} }
Opcodes.DEVICE_METADATA -> {
callback?.onDeviceMetadataReceived(packet)
}
Opcodes.HEADTRACKING -> { Opcodes.HEADTRACKING -> {
if (packet.size < 70) { if (packet.size < 70) {
Log.w( Log.w(
@@ -585,7 +597,13 @@ class AACPManager {
Log.d(TAG, "EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia") Log.d(TAG, "EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia")
} }
Opcodes.INFORMATION -> {
Log.e(TAG, "Parsing Information Packet")
val information = parseInformationPacket(packet)
callback?.onDeviceInformationReceived(information)
}
else -> { else -> {
Log.d(TAG, "Unknown opcode received: ${opcode.toHexString()}")
callback?.onUnknownPacketReceived(packet) callback?.onUnknownPacketReceived(packet)
} }
} }
@@ -764,7 +782,9 @@ class AACPManager {
fun sendMediaInformationNewDevice(selfMacAddress: String, targetMacAddress: String): Boolean { fun sendMediaInformationNewDevice(selfMacAddress: String, targetMacAddress: String): Boolean {
if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) || targetMacAddress.length != 17 || !targetMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) { if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) || targetMacAddress.length != 17 || !targetMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) {
throw IllegalArgumentException("MAC address must be 6 bytes") // throw IllegalArgumentException("MAC address must be 6 bytes")
Log.w(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress")
return false
} }
Log.d(TAG, "SELFMAC: ${selfMacAddress}, TARGETMAC: $targetMacAddress") Log.d(TAG, "SELFMAC: ${selfMacAddress}, TARGETMAC: $targetMacAddress")
Log.d(TAG, "Sending Media Information packet to $targetMacAddress") Log.d(TAG, "Sending Media Information packet to $targetMacAddress")
@@ -804,7 +824,9 @@ class AACPManager {
fun sendHijackRequest(selfMacAddress: String): Boolean { fun sendHijackRequest(selfMacAddress: String): Boolean {
if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) { if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) {
throw IllegalArgumentException("MAC address must be 6 bytes") // throw IllegalArgumentException("MAC address must be 6 bytes")
Log.w(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress")
return false
} }
var success = false var success = false
for (connectedDevice in connectedDevices) { for (connectedDevice in connectedDevices) {
@@ -845,7 +867,9 @@ class AACPManager {
fun sendMediaInformataion(selfMacAddress: String, streamingState: Boolean = false): Boolean { fun sendMediaInformataion(selfMacAddress: String, streamingState: Boolean = false): Boolean {
if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) { if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) {
throw IllegalArgumentException("MAC address must be 6 bytes") // throw IllegalArgumentException("MAC address must be 6 bytes")
Log.d(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress")
return false
} }
Log.d(TAG, "SELFMAC: $selfMacAddress") Log.d(TAG, "SELFMAC: $selfMacAddress")
val targetMac = connectedDevices.find { it.mac != selfMacAddress }?.mac val targetMac = connectedDevices.find { it.mac != selfMacAddress }?.mac
@@ -904,7 +928,9 @@ class AACPManager {
fun sendSmartRoutingShowUI(selfMacAddress: String): Boolean { fun sendSmartRoutingShowUI(selfMacAddress: String): Boolean {
if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) { if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) {
throw IllegalArgumentException("MAC address must be 6 bytes") // throw IllegalArgumentException("MAC address must be 6 bytes")
Log.w(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress")
return false
} }
val targetMac = connectedDevices.find { it.mac != selfMacAddress }?.mac val targetMac = connectedDevices.find { it.mac != selfMacAddress }?.mac
@@ -980,7 +1006,9 @@ class AACPManager {
fun sendAddTiPiDevice(selfMacAddress: String, targetMacAddress: String): Boolean { fun sendAddTiPiDevice(selfMacAddress: String, targetMacAddress: String): Boolean {
if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) || targetMacAddress.length != 17 || !targetMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) { if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) || targetMacAddress.length != 17 || !targetMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) {
throw IllegalArgumentException("MAC address must be 6 bytes") // throw IllegalArgumentException("MAC address must be 6 bytes")
Log.w(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress")
return false
} }
Log.d(TAG, "Sending Add TiPi Device packet to $targetMacAddress") Log.d(TAG, "Sending Add TiPi Device packet to $targetMacAddress")
return sendDataPacket(createAddTiPiDevicePacket(selfMacAddress, targetMacAddress)) return sendDataPacket(createAddTiPiDevicePacket(selfMacAddress, targetMacAddress))
@@ -1173,7 +1201,8 @@ class AACPManager {
var offset = 9 var offset = 9
for (i in 0 until deviceCount) { for (i in 0 until deviceCount) {
if (offset + 8 > data.size) { if (offset + 8 > data.size) {
throw IllegalArgumentException("Data array too short to parse all connected devices") Log.w(TAG, "Data array too short to parse all connected devices, returning what we have")
break
} }
val macBytes = data.sliceArray(offset until offset + 6) val macBytes = data.sliceArray(offset until offset + 6)
val mac = macBytes.joinToString(":") { "%02X".format(it) } val mac = macBytes.joinToString(":") { "%02X".format(it) }
@@ -1208,4 +1237,39 @@ class AACPManager {
connectedDevices = listOf() connectedDevices = listOf()
audioSource = null audioSource = null
} }
fun parseInformationPacket(packet: ByteArray): AirPodsInformation {
val data = packet.sliceArray(6 until packet.size)
var index = 0
while (index < data.size && data[index] != 0x00.toByte()) index++
val strings = mutableListOf<String>()
while (index < data.size) {
// skip 0x00 bytes
while (index < data.size && data[index] == 0x00.toByte()) index++
if (index >= data.size) break
val start = index
// find next 0x00 byte
while (index < data.size && data[index] != 0x00.toByte()) index++
val str = data.sliceArray(start until index).decodeToString()
strings.add(str)
}
strings.removeAt(0) // I'm too lazy to adjust, just removing the first empty string
return AirPodsInformation(
name = strings.getOrNull(0) ?: "",
modelNumber = strings.getOrNull(1) ?: "",
manufacturer = strings.getOrNull(2) ?: "",
serialNumber = strings.getOrNull(3) ?: "",
version1 = strings.getOrNull(4) ?: "",
version2 = strings.getOrNull(5) ?: "",
hardwareRevision = strings.getOrNull(6) ?: "",
updaterIdentifier = strings.getOrNull(7) ?: "",
leftSerialNumber = strings.getOrNull(8) ?: "",
rightSerialNumber = strings.getOrNull(9) ?: "",
version3 = strings.getOrNull(10) ?: "",
)
}
} }
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. 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 /* 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, * what is necessary for LibrePods to function, i.e. reading and writing characteristics,
@@ -0,0 +1,235 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.utils
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTManager
import me.kavishdevar.librepods.R
open class AirPodsBase(
val modelNumber: List<String>,
val name: String,
val displayName: String = "AirPods",
val manufacturer: String = "Apple Inc.",
val budCaseRes: Int,
val budsRes: Int,
val leftBudsRes: Int,
val rightBudsRes: Int,
val caseRes: Int,
val capabilities: Set<Capability>
)
enum class Capability {
LISTENING_MODE,
CONVERSATION_AWARENESS,
STEM_CONFIG,
HEAD_GESTURES,
LOUD_SOUND_REDUCTION,
PPE,
SLEEP_DETECTION,
HEARING_AID,
ADAPTIVE_AUDIO,
ADAPTIVE_VOLUME,
SWIPE_FOR_VOLUME,
HRM
}
class AirPods: AirPodsBase(
modelNumber = listOf("A1523", "A1722"),
name = "AirPods 1",
budCaseRes = R.drawable.airpods_1,
budsRes = R.drawable.airpods_1_buds,
leftBudsRes = R.drawable.airpods_1_left,
rightBudsRes = R.drawable.airpods_1_right,
caseRes = R.drawable.airpods_1_case,
capabilities = emptySet()
)
class AirPods2: AirPodsBase(
modelNumber = listOf("A2032", "A2031"),
name = "AirPods 2",
budCaseRes = R.drawable.airpods_2,
budsRes = R.drawable.airpods_2_buds,
leftBudsRes = R.drawable.airpods_2_left,
rightBudsRes = R.drawable.airpods_2_right,
caseRes = R.drawable.airpods_2_case,
capabilities = emptySet()
)
class AirPods3: AirPodsBase(
modelNumber = listOf("A2565", "A2564"),
name = "AirPods 3",
budCaseRes = R.drawable.airpods_3,
budsRes = R.drawable.airpods_3_buds,
leftBudsRes = R.drawable.airpods_3_left,
rightBudsRes = R.drawable.airpods_3_right,
caseRes = R.drawable.airpods_3_case,
capabilities = setOf(
Capability.HEAD_GESTURES
)
)
class AirPods4: AirPodsBase(
modelNumber = listOf("A3053", "A3050", "A3054"),
name = "AirPods 4",
budCaseRes = R.drawable.airpods_4,
budsRes = R.drawable.airpods_4_buds,
leftBudsRes = R.drawable.airpods_4_left,
rightBudsRes = R.drawable.airpods_4_right,
caseRes = R.drawable.airpods_4_case,
capabilities = setOf(
Capability.HEAD_GESTURES,
Capability.SLEEP_DETECTION,
Capability.ADAPTIVE_VOLUME
)
)
class AirPods4ANC: AirPodsBase(
modelNumber = listOf("A3056", "A3055", "A3057"),
name = "AirPods 4 (ANC)",
budCaseRes = R.drawable.airpods_4,
budsRes = R.drawable.airpods_4_buds,
leftBudsRes = R.drawable.airpods_4_left,
rightBudsRes = R.drawable.airpods_4_right,
caseRes = R.drawable.airpods_4_case,
capabilities = setOf(
Capability.LISTENING_MODE,
Capability.CONVERSATION_AWARENESS,
Capability.HEAD_GESTURES,
Capability.ADAPTIVE_AUDIO,
Capability.SLEEP_DETECTION,
Capability.ADAPTIVE_VOLUME
)
)
class AirPodsPro1: AirPodsBase(
modelNumber = listOf("A2084", "A2083"),
name = "AirPods Pro 1",
displayName = "AirPods Pro",
budCaseRes = R.drawable.airpods_pro_1,
budsRes = R.drawable.airpods_pro_1_buds,
leftBudsRes = R.drawable.airpods_pro_1_left,
rightBudsRes = R.drawable.airpods_pro_1_right,
caseRes = R.drawable.airpods_pro_1_case,
capabilities = setOf(
Capability.LISTENING_MODE
)
)
class AirPodsPro2Lightning: AirPodsBase(
modelNumber = listOf("A2931", "A2699", "A2698"),
name = "AirPods Pro 2 with Magsafe Charging Case (Lightning)",
displayName = "AirPods Pro",
budCaseRes = R.drawable.airpods_pro_2,
budsRes = R.drawable.airpods_pro_2_buds,
leftBudsRes = R.drawable.airpods_pro_2_left,
rightBudsRes = R.drawable.airpods_pro_2_right,
caseRes = R.drawable.airpods_pro_2_case,
capabilities = setOf(
Capability.LISTENING_MODE,
Capability.CONVERSATION_AWARENESS,
Capability.STEM_CONFIG,
Capability.LOUD_SOUND_REDUCTION,
Capability.SLEEP_DETECTION,
Capability.HEARING_AID,
Capability.ADAPTIVE_AUDIO,
Capability.ADAPTIVE_VOLUME,
Capability.SWIPE_FOR_VOLUME,
Capability.HEAD_GESTURES
)
)
class AirPodsPro2USBC: AirPodsBase(
modelNumber = listOf("A3047", "A3048", "A3049"),
name = "AirPods Pro 2 with Magsafe Charging Case (USB-C)",
displayName = "AirPods Pro",
budCaseRes = R.drawable.airpods_pro_2,
budsRes = R.drawable.airpods_pro_2_buds,
leftBudsRes = R.drawable.airpods_pro_2_left,
rightBudsRes = R.drawable.airpods_pro_2_right,
caseRes = R.drawable.airpods_pro_2_case,
capabilities = setOf(
Capability.LISTENING_MODE,
Capability.CONVERSATION_AWARENESS,
Capability.STEM_CONFIG,
Capability.LOUD_SOUND_REDUCTION,
Capability.SLEEP_DETECTION,
Capability.HEARING_AID,
Capability.ADAPTIVE_AUDIO,
Capability.ADAPTIVE_VOLUME,
Capability.SWIPE_FOR_VOLUME,
Capability.HEAD_GESTURES
)
)
class AirPodsPro3: AirPodsBase(
modelNumber = listOf("A3063", "A3064", "A3065"),
name = "AirPods Pro 3",
displayName = "AirPods Pro",
budCaseRes = R.drawable.airpods_pro_3,
budsRes = R.drawable.airpods_pro_3_buds,
leftBudsRes = R.drawable.airpods_pro_3_left,
rightBudsRes = R.drawable.airpods_pro_3_right,
caseRes = R.drawable.airpods_pro_3_case,
capabilities = setOf(
Capability.LISTENING_MODE,
Capability.CONVERSATION_AWARENESS,
Capability.HEAD_GESTURES,
Capability.STEM_CONFIG,
Capability.LOUD_SOUND_REDUCTION,
Capability.PPE,
Capability.SLEEP_DETECTION,
Capability.HEARING_AID,
Capability.ADAPTIVE_AUDIO,
Capability.ADAPTIVE_VOLUME,
Capability.SWIPE_FOR_VOLUME,
Capability.HRM
)
)
data class AirPodsInstance(
val name: String,
val model: AirPodsBase,
val actualModelNumber: String,
val serialNumber: String?,
val leftSerialNumber: String?,
val rightSerialNumber: String?,
val version1: String?,
val version2: String?,
val version3: String?,
val aacpManager: AACPManager,
val attManager: ATTManager?
)
object AirPodsModels {
val models: List<AirPodsBase> = listOf(
AirPods(),
AirPods2(),
AirPods3(),
AirPods4(),
AirPods4ANC(),
AirPodsPro1(),
AirPodsPro2Lightning(),
AirPodsPro2USBC(),
AirPodsPro3()
)
fun getModelByModelNumber(modelNumber: String): AirPodsBase? {
return models.find { modelNumber in it.modelNumber }
}
}
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apple's ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods Contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.utils
@@ -390,6 +390,7 @@ class BLEManager(private val context: Context) {
private fun cleanupStaleDevices() { private fun cleanupStaleDevices() {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val staleCutoff = now - STALE_DEVICE_TIMEOUT_MS val staleCutoff = now - STALE_DEVICE_TIMEOUT_MS
val hadDevices = deviceStatusMap.isNotEmpty()
val staleDevices = deviceStatusMap.filter { it.value.lastSeen < staleCutoff } val staleDevices = deviceStatusMap.filter { it.value.lastSeen < staleCutoff }
@@ -398,7 +399,7 @@ class BLEManager(private val context: Context) {
Log.d(TAG, "Removed stale device from tracking: ${device.key}") Log.d(TAG, "Removed stale device from tracking: ${device.key}")
} }
if (deviceStatusMap.isEmpty()) { if (hadDevices && deviceStatusMap.isEmpty()) {
airPodsStatusListener?.onDeviceDisappeared() airPodsStatusListener?.onDeviceDisappeared()
} }
} }
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apple's ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods Contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.utils
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.utils
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -200,7 +200,7 @@ object CrossDevice {
notifyAirPodsDisconnectedRemotely(ServiceManager.getService()?.applicationContext!!) notifyAirPodsDisconnectedRemotely(ServiceManager.getService()?.applicationContext!!)
break break
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet) || packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet + CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) { } else if (packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet) || packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet + CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) {
ServiceManager.getService()?.disconnect() ServiceManager.getService()?.disconnectForCD()
disconnectionRequested = true disconnectionRequested = true
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
delay(1000) delay(1000)
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.utils
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:Suppress("PrivatePropertyName") @file:Suppress("PrivatePropertyName")
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.utils
@@ -0,0 +1,190 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.utils
import android.util.Log
import androidx.compose.runtime.MutableState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
private const val TAG = "HearingAidUtils"
data class HearingAidSettings(
val leftEQ: FloatArray,
val rightEQ: FloatArray,
val leftAmplification: Float,
val rightAmplification: Float,
val leftTone: Float,
val rightTone: Float,
val leftConversationBoost: Boolean,
val rightConversationBoost: Boolean,
val leftAmbientNoiseReduction: Float,
val rightAmbientNoiseReduction: Float,
val netAmplification: Float,
val balance: Float,
val ownVoiceAmplification: Float
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as HearingAidSettings
if (leftAmplification != other.leftAmplification) return false
if (rightAmplification != other.rightAmplification) return false
if (leftTone != other.leftTone) return false
if (rightTone != other.rightTone) return false
if (leftConversationBoost != other.leftConversationBoost) return false
if (rightConversationBoost != other.rightConversationBoost) return false
if (leftAmbientNoiseReduction != other.leftAmbientNoiseReduction) return false
if (rightAmbientNoiseReduction != other.rightAmbientNoiseReduction) return false
if (!leftEQ.contentEquals(other.leftEQ)) return false
if (!rightEQ.contentEquals(other.rightEQ)) return false
if (ownVoiceAmplification != other.ownVoiceAmplification) return false
return true
}
override fun hashCode(): Int {
var result = leftAmplification.hashCode()
result = 31 * result + rightAmplification.hashCode()
result = 31 * result + leftTone.hashCode()
result = 31 * result + rightTone.hashCode()
result = 31 * result + leftConversationBoost.hashCode()
result = 31 * result + rightConversationBoost.hashCode()
result = 31 * result + leftAmbientNoiseReduction.hashCode()
result = 31 * result + rightAmbientNoiseReduction.hashCode()
result = 31 * result + leftEQ.contentHashCode()
result = 31 * result + rightEQ.contentHashCode()
result = 31 * result + ownVoiceAmplification.hashCode()
return result
}
}
fun parseHearingAidSettingsResponse(data: ByteArray): HearingAidSettings? {
if (data.size < 104) return null
val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
buffer.get() // skip 0x02
buffer.get() // skip 0x02
buffer.getShort() // skip 0x60 0x00
val leftEQ = FloatArray(8)
for (i in 0..7) {
leftEQ[i] = buffer.float
}
val leftAmplification = buffer.float
val leftTone = buffer.float
val leftConvFloat = buffer.float
val leftConversationBoost = leftConvFloat > 0.5f
val leftAmbientNoiseReduction = buffer.float
val rightEQ = FloatArray(8)
for (i in 0..7) {
rightEQ[i] = buffer.float
}
val rightAmplification = buffer.float
val rightTone = buffer.float
val rightConvFloat = buffer.float
val rightConversationBoost = rightConvFloat > 0.5f
val rightAmbientNoiseReduction = buffer.float
val ownVoiceAmplification = buffer.float
val avg = (leftAmplification + rightAmplification) / 2
val amplification = avg.coerceIn(-1f, 1f)
val diff = rightAmplification - leftAmplification
val balance = diff.coerceIn(-1f, 1f)
return HearingAidSettings(
leftEQ = leftEQ,
rightEQ = rightEQ,
leftAmplification = leftAmplification,
rightAmplification = rightAmplification,
leftTone = leftTone,
rightTone = rightTone,
leftConversationBoost = leftConversationBoost,
rightConversationBoost = rightConversationBoost,
leftAmbientNoiseReduction = leftAmbientNoiseReduction,
rightAmbientNoiseReduction = rightAmbientNoiseReduction,
netAmplification = amplification,
balance = balance,
ownVoiceAmplification = ownVoiceAmplification
)
}
fun sendHearingAidSettings(
attManager: ATTManager,
hearingAidSettings: HearingAidSettings,
debounceJob: MutableState<Job?>
) {
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")
return@launch
}
val buffer = ByteBuffer.wrap(currentData).order(ByteOrder.LITTLE_ENDIAN)
// for some reason
buffer.put(2, 0x64)
// Left EQ
for (i in 0..7) {
buffer.putFloat(4 + i * 4, hearingAidSettings.leftEQ[i])
}
// Left ear adjustments
buffer.putFloat(36, hearingAidSettings.leftAmplification)
buffer.putFloat(40, hearingAidSettings.leftTone)
buffer.putFloat(44, if (hearingAidSettings.leftConversationBoost) 1.0f else 0.0f)
buffer.putFloat(48, hearingAidSettings.leftAmbientNoiseReduction)
// Right EQ
for (i in 0..7) {
buffer.putFloat(52 + i * 4, hearingAidSettings.rightEQ[i])
}
// Right ear adjustments
buffer.putFloat(84, hearingAidSettings.rightAmplification)
buffer.putFloat(88, hearingAidSettings.rightTone)
buffer.putFloat(92, if (hearingAidSettings.rightConversationBoost) 1.0f else 0.0f)
buffer.putFloat(96, hearingAidSettings.rightAmbientNoiseReduction)
// Own voice amplification
buffer.putFloat(100, hearingAidSettings.ownVoiceAmplification)
Log.d(TAG, "Sending updated settings: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
attManager.write(ATTHandles.HEARING_AID, currentData)
} catch (e: IOException) {
e.printStackTrace()
}
}
}
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.utils
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -196,7 +196,7 @@ object MediaController {
} }
} }
lastKnownIsMusicActive = isActive lastKnownIsMusicActive = hasNewMusicOrMovie && isActive
} }
} }
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.utils
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -115,6 +115,11 @@ class RadareOffsetFinder(context: Context) {
} }
fun isSdpOffsetAvailable(): Boolean { fun isSdpOffsetAvailable(): Boolean {
val sharedPreferences = ServiceManager.getService()?.applicationContext?.getSharedPreferences("settings", Context.MODE_PRIVATE) // ik not good practice- too lazy
if (sharedPreferences?.getBoolean("skip_setup", false) == true) {
Log.d(TAG, "Setup skipped, returning true for SDP offset.")
return true
}
try { try {
val process = Runtime.getRuntime().exec(arrayOf("/system/bin/getprop", SDP_OFFSET_PROP)) val process = Runtime.getRuntime().exec(arrayOf("/system/bin/getprop", SDP_OFFSET_PROP))
val reader = BufferedReader(InputStreamReader(process.inputStream)) val reader = BufferedReader(InputStreamReader(process.inputStream))
@@ -462,7 +467,7 @@ class RadareOffsetFinder(context: Context) {
// findAndSaveL2cuProcessCfgReqOffset(libraryPath, envSetup) // findAndSaveL2cuProcessCfgReqOffset(libraryPath, envSetup)
// findAndSaveL2cCsmConfigOffset(libraryPath, envSetup) // findAndSaveL2cCsmConfigOffset(libraryPath, envSetup)
// findAndSaveL2cuSendPeerInfoReqOffset(libraryPath, envSetup) // findAndSaveL2cuSendPeerInfoReqOffset(libraryPath, envSetup)
// findAndSaveSdpOffset(libraryPath, envSetup) Should not be run by default, only when user asks for it. // findAndSaveSdpOffset(libraryPath, envSetup) Should not be run by default, only when user asks for it.
} catch (e: Exception) { } catch (e: Exception) {
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.utils
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -1,20 +1,20 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem LibrePods - AirPods liberated from Apples ecosystem
* Copyright (C) 2025 LibrePods contributors
* Copyright (C) 2025 LibrePods contributors
* This program is free software: you can redistribute it and/or modify
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by
* it under the terms of the GNU Affero General Public License as published the Free Software Foundation, either version 3 of the License, or
* by the Free Software Foundation, either version 3 of the License. any later version.
*
* This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. GNU General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Before

Width:  |  Height:  |  Size: 605 KiB

After

Width:  |  Height:  |  Size: 605 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Some files were not shown because too many files have changed in this diff Show More