Compare commits

...

113 Commits

Author SHA1 Message Date
Kavish Devar
4ef3e4d4da ci: post nightly release to #android-ci on discord 2026-04-28 18:17:04 +05:30
Kavish Devar
b88b14de15 move things around 2026-04-28 17:27:11 +05:30
Kavish Devar
7cd4dfa3e0 remove head tracking 2026-04-28 17:27:00 +05:30
Kavish Devar
30d16e9977 docs: add some docs
I made this a while back, it is very incomplete, just adding in case anyone's looking for the opcodes
2026-04-28 16:19:30 +05:30
Kavish Devar
ddcf15eefe android: clear root module build dir on each run 2026-04-28 12:21:46 +05:30
Kavish Devar
3e89d7f41b android: version bump 2026-04-28 12:13:14 +05:30
Kavish Devar
9eb6010a25 android: connect to audio when reconnecting to last connected device 2026-04-28 12:12:45 +05:30
Kavish Devar
60e865fc1f android: call connect to audio even if BLUETOOTH_PRIVILEGED/MODIFY_PHONE_STATE is not granted 2026-04-28 12:12:07 +05:30
Kavish Devar
629b7b917e android: fix crash on some devices not properly closing socket 2026-04-28 12:10:47 +05:30
Kavish Devar
d4ee741224 android: add disconnect button 2026-04-28 12:10:00 +05:30
Kavish Devar
4c8b0d720d android: fix crash in reading call control settings 2026-04-28 12:08:22 +05:30
Kavish Devar
e20b0f7fd7 android: fix adaptive audio strength slider not being flipped
why... why, apple?
2026-04-28 12:07:00 +05:30
Kavish Devar
b64ff1d09e android: catch exceptions when closing IslandWindow 2026-04-28 12:04:59 +05:30
Kavish Devar
37056c6de7 android: fix icon size in select list 2026-04-28 12:04:29 +05:30
Kavish Devar
3a636e37a4 android: increase bottom padding on all screens 2026-04-28 12:04:08 +05:30
Kavish Devar
136e3e8995 android: do not log writeRaw in ATTManager if socket unavailable 2026-04-28 12:03:51 +05:30
Kavish Devar
e39c1cfeba android: fix ControlCommand.parseFromBytes outofbounds crash 2026-04-28 12:03:26 +05:30
Kavish Devar
b06d780eee android: fix xposed state being set true onResume 2026-04-27 13:13:40 +05:30
Kavish Devar
70f420dedb android: fix text color in email bottom sheet 2026-04-27 10:17:33 +05:30
Kavish Devar
23193ceb39 android: load native hook from split apks when base fails 2026-04-27 10:08:05 +05:30
Kavish Devar
cb246d1287 android: update dialog and add app info to incompatible page 2026-04-26 17:36:17 +05:30
Kavish Devar
95cd677da9 android: fix bypass on pixels on older A16 version; also check Xposed scope for compatibility 2026-04-26 16:23:29 +05:30
Kavish Devar
0d049d93fb ci: use latest release tag for changelog 2026-04-26 16:17:02 +05:30
Kavish Devar
469d948061 android: add xposed check and email form
too many emails with absolutely no content
2026-04-26 05:05:20 +05:30
Kavish Devar
f5d92768e2 android: rename util->utils in normal flavor 2026-04-26 01:07:12 +05:30
Kavish Devar
8cb2951bc6 ci: fix typo in release bundle asset name 2026-04-26 00:46:54 +05:30
Kavish Devar
bb578dab23 ci: upload artifacts separately 2026-04-26 00:43:09 +05:30
Kavish Devar
b1b47048a3 ci: fix keystore and add manual trigger 2026-04-26 00:29:14 +05:30
Hugo Holmqvist
bf09300dfe android: fix bypass_device_check.v2 being silently ignored (#543) 2026-04-26 00:21:58 +05:30
Kavish Devar
70165232c0 ci: fix ndk 2026-04-26 00:18:03 +05:30
Kavish Devar
aabbc902cb ci: release nightly builds on all changes 2026-04-25 23:58:52 +05:30
Kavish Devar
0ee7056600 android: fix versionName in builds 2026-04-25 23:58:23 +05:30
Kavish Devar
8b24ac49e2 android: add scroll on compatibility check 2026-04-24 21:35:22 +05:30
Kavish Devar
d2dd722bc7 android: change device bypass sharedPref key 2026-04-24 20:29:37 +05:30
Kavish Devar
67fc93bde5 android: add packaging task 2026-04-24 19:50:35 +05:30
Kavish Devar
0b578d62cf android: add more compatibility information, fix FOSS billing, hide upgrade button before first AACP connect
closes #538
2026-04-24 19:50:35 +05:30
Kavish Devar
072b9b4dac android: remove radare root module 2026-04-24 19:50:35 +05:30
Kavish Devar
0af60cd8a9 android: fix ATT on A16QPR3+ 2026-04-24 19:50:35 +05:30
Kavish Devar
be29a46dab android: check for A16 on OP/Oppo devices 2026-04-24 19:50:35 +05:30
Kavish Devar
7461f7dfb7 android: remove debugging logs 2026-04-24 19:50:35 +05:30
Kavish Devar
904c00afce android: fix xposed module in release builds 2026-04-24 19:50:35 +05:30
abc0922001
6272357d84 android: update zh-rTW translation (#536) 2026-04-23 20:47:12 +05:30
Kavish Devar
6ac6700be6 android: format l2c_fcr_hook.cpp 2026-04-23 18:45:00 +05:30
Kavish Devar
113ee0a966 android: fallback to .dynsym when .gnu_debugdata fails 2026-04-23 18:45:00 +05:30
Kavish Devar
d82e4e2427 android: bump version 2026-04-23 18:45:00 +05:30
Kavish Devar
481d5f13cf android: fix automatically pausing when media changes without vendorid hook enabled 2026-04-23 18:45:00 +05:30
Kavish Devar
ef221af505 android: bump version 2026-04-23 18:45:00 +05:30
Kavish Devar
c19190f031 android: fix convo detect not restoring volume when in Transparency mode 2026-04-23 18:45:00 +05:30
Kavish Devar
d0b8574c68 android: hide disconnect when not wearing config on play builds 2026-04-23 18:45:00 +05:30
Kavish Devar
294d733e71 android: add 'required xposed' text to vendorid config toggle 2026-04-23 18:45:00 +05:30
Kavish Devar
f6d7e97796 android: show price in buy button 2026-04-23 18:45:00 +05:30
Kavish Devar
ae174bc9ea android: add confirmation step for unsupported devices 2026-04-23 18:45:00 +05:30
Kavish Devar
1804e80cba docs: fix typo 2026-04-23 05:43:43 +05:30
Kavish Devar
0b8bd5a5b8 docs: fix issuetracker link in README 2026-04-23 05:42:58 +05:30
Kavish Devar
d1d48562d7 docs: clarify root requirements 2026-04-23 05:41:56 +05:30
Kavish Devar
c84e64e656 android: remove unsupported device message 2026-04-23 01:27:55 +05:30
Kavish Devar
51739514fa android: fix normal builds 2026-04-23 01:22:49 +05:30
Tomáš Ulrych
8f9a04390a android: cap popup width on tablets and large screens (#521)
On tablets in landscape mode, the AirPods connection popup covers the
entire screen because width is set to MATCH_PARENT. This caps the popup
width to 400dp on screens wider than 600dp and centers it horizontally.

Also removes a duplicate gravity assignment in init that was overwriting
the CENTER_HORIZONTAL flag.

Fixes #425

Co-authored-by: TomasULR <creator_d@nvias.org>
2026-04-23 01:11:33 +05:30
Kavish Devar
2363b80548 android: stability fixes and xposed api v101 (#531) 2026-04-23 00:48:33 +05:30
Tomáš Ulrych
99616d6a67 docs: add nightly/development builds section to README (#520) 2026-04-17 09:03:41 +05:30
Matthias Urhahn
4f2d11a4b2 android: Fix rename packet using wrong opcode (0x1E → 0x1A) (#511) 2026-04-15 12:51:36 +05:30
Nick
1f2d70744f docs: fix spelling error in Android version requirements (#507)
Corrected the spelling of 'Android' in the requirements section.
2026-04-06 08:20:23 +05:30
Kavish Devar
3c714c2a26 docs: add discord invite and other badges
Added badges for stars, license, release, downloads, and issues to the README.
2026-04-05 22:08:01 +05:30
Kavish Devar
7fbbe07320 docs: refactor and add alternates to README 2026-03-31 20:52:00 +05:30
Kavish Devar
8ef839d4c1 linux: update icon 2026-03-31 09:41:09 +05:30
Fabian Moor Pucar
af2622b68e feat(linux): add librepods-ctl CLI tool for IPC control (#494)
* feat(linux): expanded IPC socket handler with CLI command support

* feat(linux): added librepods-ctl CLI tool for IPC control

* build(linux): added librepods-ctl as a seperate binary

* docs(linux): added CLI control usage to README
2026-03-31 09:09:37 +05:30
Kavish Devar
b81e420398 docs: update root requirements in README 2026-03-30 15:10:48 +05:30
Niclas Eisenhut
fd33528218 fix: update radare2 download source to GitHub releases (#444)
fix(radare2): update radare2 download url
2026-02-03 08:21:54 +05:30
Kavish Devar
339c478564 docs: fix typo in README date 2025-12-29 18:21:35 +05:30
Kavish Devar
c9dd79bb82 docs: add development paused note in README 2025-12-28 17:31:26 +05:30
Nikhil Kumar
5cd7db574a android: remove unused permission from AndroidManifest (#372)
INTERACT_ACROSS_USERS is a privileged permission only granted to 1P android apps.
It can't be used here.
2025-12-18 00:39:20 +05:30
Andrew Dodd
e10fe21ba5 docs: clarify +1 instructions in README (#391)
Google's issue is being spammed aggressively by people misunderstanding these instructions and commenting +1 instead of clicking the +1 button on the top right.
2025-12-16 01:47:13 +05:30
luna
b4deccff7e android(i18n): complete Chinese Simplified translations (#368) 2025-12-14 10:28:46 +05:30
Francesco Carmelo Capria
dcbbb2ce98 linux(i18n): add Italian translations (#375) 2025-12-12 00:29:47 +05:30
Aryan 'Ba3a' Kumar
e5836c9869 docs: fix low-skies URL (#373) 2025-12-12 00:28:49 +05:30
abc0922001
287163e116 i18n: add Traditional Chinese translations (#365)
* android(i18n): add Traditional Chinese translations

* linux(i18n): add Traditional Chinese translations
2025-12-11 08:59:45 +05:30
Mümin Köykıran
a75557d6dc linux(i18n): add Turkish translations (#361)
* linux(i18n): add Turkish translations

- Add Qt Linguist translation infrastructure to CMakeLists.txt
- Wrap UI strings with qsTr() in Main.qml
- Wrap menu strings with tr() in trayiconmanager.cpp
- Add QTranslator loader in main.cpp for automatic locale detection
- Create Turkish translation file (librepods_tr.ts)

Translations include:
- Main window: connection status, noise control modes, settings
- Tray menu: all menu items and tooltips
- System notifications

* fix: allocate QTranslator on heap to ensure lifetime
2025-12-10 06:42:51 +05:30
Bersenyov Ilya
0e1f784737 linux(fix): hearing aid bug (#355)
fixed hearing aid bug

Co-authored-by: ressiwage <ressiwage@ya.ru>
2025-12-07 18:55:02 +05:30
dragsbruh
f3b1db2513 docs: turn absolute links to relative links in readme (#340)
docs: turn absolute links to relative links because they only work properly on github
2025-12-01 23:05:24 +05:30
Falluck
b5f0c32751 android(i18n): add French translations (#342)
Adds complete French translation (values-fr/strings.xml). Follows the same structure as existing translations
2025-12-01 23:01:46 +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
238 changed files with 18941 additions and 12690 deletions

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

View File

@@ -1,4 +1,4 @@
name: Build APK and root module (and create nightly release) name: Android CI
on: on:
push: push:
@@ -6,95 +6,188 @@ on:
- '*' - '*'
paths: paths:
- 'android/**' - 'android/**'
pull_request:
paths:
- 'android/**'
workflow_dispatch: workflow_dispatch:
inputs: inputs:
release: branch:
description: 'Create a nightly release' description: Branch to build
required: true required: true
type: boolean default: main
default: false
custom_notes:
description: 'Custom updates to add to What''s Changed section'
required: false
type: string
workflow_call: workflow_call:
jobs: jobs:
build-debug-apk: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
short_sha: ${{ steps.vars.outputs.short_sha }}
app_version: ${{ steps.version.outputs.app_version }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
submodules: true submodules: true
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.branch || github.ref }}
- uses: actions/setup-java@v4 - uses: actions/setup-java@v4
with: with:
distribution: 'zulu' distribution: zulu
java-version: 21 java-version: 21
- uses: gradle/actions/setup-gradle@v4 - uses: gradle/actions/setup-gradle@v4
- name: Build debug APK - name: Decode keystore
run: ./gradlew assembleDebug run: echo "${{ secrets.RELEASE_KEYSTORE_FILE }}" | base64 --decode > android/release.keystore
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Accept Licenses
run: yes | sdkmanager --licenses
- name: Install NDK
run: sdkmanager "ndk;30.0.14904198"
- name: Create local.properties
run: |
cat <<EOF > android/local.properties
RELEASE_STORE_FILE=../release.keystore
RELEASE_STORE_PASSWORD=${{ secrets.RELEASE_STORE_PASSWORD }}
RELEASE_KEY_ALIAS=${{ secrets.RELEASE_KEY_ALIAS }}
RELEASE_KEY_PASSWORD=${{ secrets.RELEASE_KEY_PASSWORD }}
EOF
- name: Build
run: ./gradlew packageReleaseArtifacts
working-directory: android working-directory: android
- name: Upload artifact - name: Get app version
uses: actions/upload-artifact@v4 id: version
run: echo "app_version=$(grep 'appVersionName =' android/app/build.gradle.kts | sed 's/.*= "\(.*\)"/\1/')" >> $GITHUB_OUTPUT
- id: vars
run: echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- uses: actions/upload-artifact@v4
with: with:
name: Debug APK name: apk-release
path: android/app/build/outputs/apk/**/*.apk path: release/*release.apk
nightly-release:
- uses: actions/upload-artifact@v4
with:
name: apk-debug
path: release/*debug.apk
- uses: actions/upload-artifact@v4
with:
name: root-module-release
path: release/*release.zip
- uses: actions/upload-artifact@v4
with:
name: root-module-debug
path: release/*debug.zip
- uses: actions/upload-artifact@v4
with:
name: release-bundle
path: release/*.aab
release:
if: github.event_name == 'push'
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/release-nightly' || github.event_name == 'workflow_dispatch' && github.event.inputs.release == 'true' needs: build
needs: build-debug-apk
permissions: permissions:
contents: write contents: write
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
- name: Export APK_NAME for later use with:
run: echo "APK_NAME=LibrePods-$(echo ${{ github.sha }} | cut -c1-7).apk" >> $GITHUB_ENV name: apk-release
- name: Rename .apk file path: artifacts/apk-release
run: mv "./Debug APK/debug/"*.apk "./$APK_NAME"
- name: Decode keystore file
run: echo "${{ secrets.DEBUG_KEYSTORE_FILE }}" | base64 --decode > debug.keystore
- name: Install apksigner
run: sudo apt-get update && sudo apt-get install -y apksigner
- name: Sign APK
run: |
apksigner sign --ks debug.keystore --ks-key-alias androiddebugkey --ks-pass pass:android --key-pass pass:android "./$APK_NAME"
- name: Verify APK
run: apksigner verify "./$APK_NAME"
- name: Fetch the latest non-nightly release tag
id: fetch-tag
run: echo "::set-output name=tag::$(git describe --tags $(git rev-list --tags --max-count=1))"
- name: Retrieve commits since the last release
id: get-commits
run: |
COMMITS=$(git log ${{ steps.fetch-tag.outputs.tag }}..HEAD --pretty=format:"- %s (%h)" --abbrev-commit)
echo "::set-output name=commits::${COMMITS}"
- name: Prepare release notes
id: release-notes
run: |
# Create a temporary file for release notes
NOTES_FILE=$(mktemp)
# Process custom notes if they exist - uses: actions/download-artifact@v4
if [ "${{ github.event_name }}" == "workflow_dispatch" ] && [ -n "${{ github.event.inputs.custom_notes }}" ]; then with:
CUSTOM_NOTES="${{ github.event.inputs.custom_notes }}" name: apk-debug
path: artifacts/apk-debug
# Check if custom notes already have bullet points or GitHub-style formatting - uses: actions/download-artifact@v4
if echo "$CUSTOM_NOTES" | grep -q "^\*\|^- \|http.*commit\|in #[0-9]\+"; then with:
# Already formatted, use as is name: root-module-release
echo "$CUSTOM_NOTES" > "$NOTES_FILE" path: artifacts/root-module-release
else
# Add bullet point formatting
echo "- $CUSTOM_NOTES" > "$NOTES_FILE"
fi
fi
echo "notes_file=$NOTES_FILE" >> $GITHUB_OUTPUT - uses: actions/download-artifact@v4
- name: Zip root-module directory with:
run: sh ./build-magisk-module.sh name: root-module-debug
- name: Delete release if exist then create release path: artifacts/root-module-debug
- id: prev
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
gh release view "nightly" && gh release delete "nightly" -y --cleanup-tag TAG=$(gh release list \
gh release create "nightly" "./$APK_NAME" "./btl2capfix.zip" -p -t "Nightly Release" --notes-file "${{ steps.release-notes.outputs.notes_file }}" --generate-notes --limit 1 \
--json tagName \
--jq '.[0].tagName')
echo "tag=$TAG" >> $GITHUB_OUTPUT
- id: changelog
run: |
if [ -z "${{ steps.prev.outputs.tag }}" ]; then
NOTES=$(git log --pretty=format:"- %s ([%h](https://github.com/kavishdevar/librepods/commit/%H))")
else
NOTES=$(git log ${{ steps.prev.outputs.tag }}..HEAD --pretty=format:"- %s ([%h](https://github.com/kavishdevar/librepods/commit/%H))")
fi
echo "notes<<EOF" >> $GITHUB_OUTPUT
echo "$NOTES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- id: tag
run: echo "tag=nightly-${{ needs.build.outputs.short_sha }}" >> $GITHUB_OUTPUT
- env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "${{ steps.tag.outputs.tag }}" \
artifacts/**/* \
-t "Nightly ${{ needs.build.outputs.short_sha }}" \
--notes "${{ steps.changelog.outputs.notes }}" \
--prerelease
- name: Get timestamp
id: timestamp
run: echo "timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_OUTPUT
- name: Post to Discord
run: |
curl -X POST "${{ secrets.DISCORD_ANDROID_CI_URL }}?with_components=true" \
-H "Content-Type: application/json" \
-d '{
"embeds": [
{
"title": "LibrePods Nightly Build",
"description": "Download the latest debug and release APKs.",
"color": 253060,
"fields": [
{
"name": "Changelog",
"value": "${{ steps.changelog.outputs.notes }}",
"inline": false
}
],
"timestamp": "${{ steps.timestamp.outputs.timestamp }}",
"footer": {
"text": "GitHub Actions"
}
}
],
"components": [
{
"type": 1,
"components": [
{
"type": 2,
"label": "Download Release APK",
"style": 5,
"url": "https://github.com/kavishdevar/librepods/releases/download/nightly-${{ needs.build.outputs.short_sha }}/LibrePods-FOSS-v${{ needs.build.outputs.app_version }}-release.apk"
},
{
"type": 2,
"label": "Download Debug APK",
"style": 5,
"url": "https://github.com/kavishdevar/librepods/releases/download/nightly-${{ needs.build.outputs.short_sha }}/LibrePods-FOSS-v${{ needs.build.outputs.app_version }}-debug.apk"
}
]
}
]
}'

7
.gitignore vendored
View File

@@ -1,10 +1,5 @@
root-module/radare2-5.9.9-android-aarch64.tar.gz release
wak.toml
log.txt
btl2capfix.zip
root-module-manual
.vscode .vscode
testing.py
.DS_Store .DS_Store
CMakeLists.txt.user* CMakeLists.txt.user*

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.

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.

145
LICENSE
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>.

View File

@@ -1,164 +0,0 @@
# Bluetooth Low Energy (BLE) - Apple Proximity Pairing Message
This document describes how the AirPods BLE "Proximity Pairing Message" is parsed and interpreted in the application. This message is broadcast by Apple devices (such as AirPods) and contains key information about the device's state, battery, and other properties.
## Overview
When scanning for BLE devices, the application looks for manufacturer data with Apple's ID (`0x004C`). If the data starts with `0x07`, it is identified as a Proximity Pairing Message. The message contains various fields, each representing a specific property of the AirPods.
## Proximity Pairing Message Structure
| Byte Index | Field Name | Description | Example Value(s) |
|------------|-------------------------|---------------------------------------------------------|--------------------------|
| 0 | Prefix | Message type (should be `0x07` for proximity pairing) | `0x07` |
| 1 | Length | Length of the message | `0x12` |
| 2 | Pairing Mode | `0x01` = Paired, `0x00` = Pairing mode | `0x01`, `0x00` |
| 3-4 | Device Model | Big-endian: [3]=high, [4]=low | `0x0E20` (AirPods Pro) |
| 5 | Status | Bitfield, see below | `0x62` |
| 6 | Pods Battery Byte | Nibbles for left/right pod battery | `0xA7` |
| 7 | Flags & Case Battery | Upper nibble: case battery, lower: flags | `0xB3` |
| 8 | Lid Indicator | Bits for lid state and open counter | `0x09` |
| 9 | Device Color | Color code | `0x02` |
| 10 | Connection State | Enum, see below | `0x04` |
| 11-26 | Encrypted Payload | 16 bytes, not parsed | |
## Field Details
### Device Model
| Value (hex) | Model Name |
|-------------|--------------------------|
| 0x0220 | AirPods 1st Gen |
| 0x0F20 | AirPods 2nd Gen |
| 0x1320 | AirPods 3rd Gen |
| 0x1920 | AirPods 4th Gen |
| 0x1B20 | AirPods 4th Gen (ANC) |
| 0x0A20 | AirPods Max |
| 0x1F20 | AirPods Max (USB-C) |
| 0x0E20 | AirPods Pro |
| 0x1420 | AirPods Pro 2nd Gen |
| 0x2420 | AirPods Pro 2nd Gen (USB-C) |
### Status Byte (Bitfield)
| Bit | Meaning | Value if Set |
|-----|--------------------------------|-------------|
| 0 | Right Pod In Ear (XOR logic) | true |
| 1 | Right Pod In Ear (XOR logic) | true |
| 2 | Both Pods In Case | true |
| 3 | Left Pod In Ear (XOR logic) | true |
| 4 | One Pod In Case | true |
| 5 | Primary Pod (1=Left, 0=Right) | true/false |
| 6 | This Pod In Case | true |
### Ear Detection Logic
The in-ear detection uses XOR logic based on:
- Whether the right pod is primary (`areValuesFlipped`)
- Whether this pod is in the case (`isThisPodInTheCase`)
```cpp
bool xorFactor = areValuesFlipped ^ deviceInfo.isThisPodInTheCase;
deviceInfo.isLeftPodInEar = xorFactor ? (status & 0x08) != 0 : (status & 0x02) != 0; // Bit 3 or 1
deviceInfo.isRightPodInEar = xorFactor ? (status & 0x02) != 0 : (status & 0x08) != 0; // Bit 1 or 3
```
### Primary Pod
Determined by bit 5 of the status byte:
- `1` = Left pod is primary
- `0` = Right pod is primary
This affects:
1. Battery level interpretation (which nibble corresponds to which pod)
2. Microphone assignment
3. Ear detection logic
### Microphone Status
The active microphone is determined by:
```cpp
deviceInfo.isLeftPodMicrophone = primaryLeft ^ deviceInfo.isThisPodInTheCase;
deviceInfo.isRightPodMicrophone = !primaryLeft ^ deviceInfo.isThisPodInTheCase;
```
### Pods Battery Byte
- Upper nibble: one pod battery (depends on primary)
- Lower nibble: other pod battery
| Value | Meaning |
|-------|----------------|
| 0x0-0x9 | 0-90% (x10) |
| 0xA-0xE | 100% |
| 0xF | Not available|
### Flags & Case Battery Byte
- Upper nibble: case battery (same encoding as pods)
- Lower nibble: flags
#### Flags (Lower Nibble)
| Bit | Meaning |
|-----|--------------------------|
| 0 | Right Pod Charging (XOR) |
| 1 | Left Pod Charging (XOR) |
| 2 | Case Charging |
### Lid Indicator
| Bits | Meaning |
|------|------------------------|
| 0-2 | Lid Open Counter |
| 3 | Lid State (0=Open, 1=Closed) |
### Device Color
| Value | Color |
|-------|-------------|
| 0x00 | White |
| 0x01 | Black |
| 0x02 | Red |
| 0x03 | Blue |
| 0x04 | Pink |
| 0x05 | Gray |
| 0x06 | Silver |
| 0x07 | Gold |
| 0x08 | Rose Gold |
| 0x09 | Space Gray |
| 0x0A | Dark Blue |
| 0x0B | Light Blue |
| 0x0C | Yellow |
| 0x0D+ | Unknown |
### Connection State
| Value | State |
|-------|--------------|
| 0x00 | Disconnected |
| 0x04 | Idle |
| 0x05 | Music |
| 0x06 | Call |
| 0x07 | Ringing |
| 0x09 | Hanging Up |
| 0xFF | Unknown |
## Example Message
| Byte Index | Example Value | Description |
|------------|--------------|----------------------------|
| 0 | 0x07 | Proximity Pairing Message |
| 1 | 0x12 | Length |
| 2 | 0x01 | Paired |
| 3-4 | 0x0E 0x20 | AirPods Pro |
| 5 | 0x62 | Status |
| 6 | 0xA7 | Pods Battery |
| 7 | 0xB3 | Flags & Case Battery |
| 8 | 0x09 | Lid Indicator |
| 9 | 0x02 | Device Color |
| 10 | 0x04 | Connection State (Idle) |
---
For further details, see [`BleManager`](linux/ble/blemanager.cpp) and [`BleScanner`](linux/ble/blescanner.cpp).

182
README.md
View File

@@ -1,29 +1,36 @@
![LibrePods Banner](/imgs/banner.png) <p align="center">
<img src="https://img.shields.io/github/stars/kavishdevar/librepods?style=for-the-badge&logoColor=white" />
<img src="https://img.shields.io/github/license/kavishdevar/librepods?style=for-the-badge" />
<img src="https://img.shields.io/github/v/release/kavishdevar/librepods?style=for-the-badge&logoColor=white&label=Release" />
<img src="https://img.shields.io/github/downloads/kavishdevar/librepods/total?style=for-the-badge&label=Downloads" />
<img src="https://img.shields.io/github/issues/kavishdevar/librepods?style=for-the-badge" />
[![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/) <a href="https://discord.gg/HhG4ycVum4">
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/kavishdevar/librepods)](https://github.com/kavishdevar/librepods/releases/latest) <img src="https://img.shields.io/discord/1441416992027574375?style=for-the-badge&logoColor=white&color=5865F2&label=Discord" />
[![GitHub all releases](https://img.shields.io/github/downloads/kavishdevar/librepods/total)](https://github.com/kavishdevar/librepods/releases) </a>
[![GitHub stars](https://img.shields.io/github/stars/kavishdevar/librepods)](https://github.com/kavishdevar/librepods/stargazers) </p>
[![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)
>[!IMPORTANT]
Development paused due to lack of time until 17th May 2026 (JEE Advanced). PRs and issues might not be responded to until then.
## What is LibrePods? ![LibrePods Banner](./imgs/banner.png)
# 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) |
| ✅ | AirPods Max | Fully supported (client shows unsupported features) |
| ⚠️ | Other AirPods models | Basic features (battery status, ear detection) should work | | ⚠️ | 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
- **Noise Control Modes**: Easily switch between noise control modes without having to reach out to your AirPods to long press - **Noise Control Modes**: Easily switch between noise control modes without having to reach out to your AirPods to long press
- **Ear Detection**: Controls your music automatically when you put your AirPods in or take them out, and switch to phone speaker when you take them out - **Ear Detection**: Controls your music automatically when you put your AirPods in or take them out, and switch to phone speaker when you take them out
@@ -36,73 +43,60 @@ 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] ## Android
> Work in progress, but core functionality is stable and usable.
For installation and detailed info, see the [Linux README](/linux/README.md). ### Screenshots
### Android
#### 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-test](/android/imgs/hearing-test.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
https://github.com/user-attachments/assets/43911243-0576-4093-8c55-89c1db5ea533 https://github.com/user-attachments/assets/43911243-0576-4093-8c55-89c1db5ea533
#### Root Requirement ### Root Requirement
> [!CAUTION] The app needs root because of a bug in the Android Bluetooth stack Fluoride/non-compliance of Apple with Bluetooth standards. You must have Xposed installed for the app to workaround this bug and connect to AirPods.
> **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.
>
> There are **no exceptions** to the root requirement until Google merges the fix.
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. [https://issuetracker.google.com/issues/371713238](https://issuetracker.google.com/issues/371713238)
## Bluetooth DID (Device Identification) Hook Please do not comment in the thread. The issue has already been resolved and should be available in Android 17 for all devices.
Turns out, if you change the manufacturerid to that of Apple, you get access to several special features! However, if you are using ColorOS/OxygenOS 16, Android 16 QPR3 on Pixel (ensure you're on the latest Play system update), you don't need root for most features.
### Multi-device Connectivity > [!IMPORTANT]
> This workaround with Xposed is not guaranteed to work on all devices.
Upto two devices can be simultaneously connected to AirPods, for audio and control both. Seamless connection switching. The same notification shows up on Apple device when Android takes over the AirPods as if it were an Apple device ("Move to iPhone"). Android also shows a popup when the other device takes over. Features requiring the VendorID hook will still require root. These features include customizing transparency mode, setting up hearing aid, and use Bluetooth Multipoint.
### Accessibility Settings and Hearing Aid ### Troubleshooting steps for common errors
- Ensure the correct scope is set in LSPosed/Vector.
- Ensure there is no root-hiding module preventing the hook from loading on the Bluetooth app.
- Restart your phone after confirming the scope.
Accessibility settings like customizing transparency mode (amplification, balance, tone, conversation boost, and ambient noise reduction), and loud sound reduction can be configured. ### A few notes
All hearing aid customizations can be done from Android, 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. - 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.
To enable these features, enable App Settings -> `act as Apple Device`.
#### 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.
- 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.
@@ -110,9 +104,74 @@ To enable these features, enable App Settings -> `act as Apple Device`.
- 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. - 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.
## Star History # Changing VendorID in the DID profile to that of Apple
[![Star History Chart](https://api.star-history.com/svg?repos=kavishdevar/librepods&type=Date)](https://star-history.com/#kavishdevar/librepods&Date) 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
Upto two devices can be simultaneously connected to AirPods, for audio and control both. Seamless connection switching. The same notification shows up on Apple device when Android takes over the AirPods as if it were an Apple device ("Move to iPhone"). Android also shows a popup when the other device takes over.
## Accessibility Settings and Hearing Aid
Accessibility settings like customizing transparency mode (amplification, balance, tone, conversation boost, and ambient noise reduction), and loud sound reduction can be configured.
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.
# 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](https://low-skies.hackclub.com)!
# Alternates for other platforms:
- CAPod - A companion app for AirPods on Android. ([play store](https://play.google.com/store/apps/details?id=eu.darken.capod) | [source code](https://github.com/d4rken-org/capod)). Use this if you're using Android version 16 QPR3 or below and are not rooted.
- MagicPods for Steam Deck ([website](https://magicpods.app/steamdeck/))
- MagicPods - if you're looking for "LibrePods for Windows" ([ms store](https://apps.microsoft.com/store/detail/9P6SKKFKSHKM) [installer](https://magicpods.app/installer/MagicPods.appinstaller) | [website](https://magicpods.app/))
# Nightly/Development Builds
Want to try the latest features before they're officially released? You can grab nightly builds from GitHub Actions:
### Android
1. Go to the [Actions tab](https://github.com/kavishdevar/librepods/actions/workflows/ci-android.yml)
2. Click on the most recent successful workflow run
3. Scroll down to **Artifacts** and download the **Debug APK** zip file
4. Extract the zip and install the `.apk` on your device
> [!NOTE]
> You need to be signed in to GitHub to download artifacts. Nightly builds are debug-signed and may not auto-update. You may need to uninstall the stable version first.
### Linux (Rust)
1. Go to the [Actions tab](https://github.com/kavishdevar/librepods/actions/workflows/ci-linux-rust.yml)
2. Click on the most recent successful workflow run
3. Download the **librepods-x86_64.AppImage** or **librepods** binary from **Artifacts**
> [!WARNING]
> Nightly builds are unstable and may contain bugs. Use at your own risk.
# Star History
<a href="https://www.star-history.com/#kavishdevar/librepods&type=date&legend=top-left">
<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
@@ -120,15 +179,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.

View File

@@ -1,42 +1,80 @@
import java.util.Properties
val appVersionName = "0.2.6"
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.aboutLibraries) alias(libs.plugins.aboutLibraries)
// alias(libs.plugins.hilt)
id("kotlin-parcelize") id("kotlin-parcelize")
} }
val props = Properties().apply {
load(rootProject.file("local.properties").inputStream())
}
android { android {
signingConfigs {
create("release") {
storeFile = file(props["RELEASE_STORE_FILE"] as String)
storePassword = props["RELEASE_STORE_PASSWORD"] as String
keyAlias = props["RELEASE_KEY_ALIAS"] as String
keyPassword = props["RELEASE_KEY_PASSWORD"] as String
}
}
namespace = "me.kavishdevar.librepods" namespace = "me.kavishdevar.librepods"
compileSdk = 36 compileSdk = 37
defaultConfig { defaultConfig {
applicationId = "me.kavishdevar.librepods" applicationId = "me.kavishdevar.librepods"
minSdk = 28 minSdk = 33
targetSdk = 36 targetSdk = 37
versionCode = 8 versionCode = 46
versionName = "0.2.0" versionName = appVersionName
} }
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = true
isShrinkResources = true
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
"proguard-rules.pro"
) )
externalNativeBuild {
cmake {
arguments += "-DCMAKE_BUILD_TYPE=Release"
}
}
buildConfigField("Boolean", "PLAY_BUILD", "false")
signingConfig = signingConfigs.getByName("release")
}
debug {
buildConfigField("Boolean", "PLAY_BUILD", "false")
signingConfig = signingConfigs.getByName("release")
versionNameSuffix = "-debug"
}
create("playRelease") {
initWith(getByName("release"))
buildConfigField("Boolean", "PLAY_BUILD", "true")
versionNameSuffix = "-play"
}
create("playDebug") {
initWith(getByName("debug"))
buildConfigField("Boolean", "PLAY_BUILD", "true")
versionNameSuffix = "-youshouldnothavethis"
} }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8 sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_21
}
kotlinOptions {
jvmTarget = "1.8"
} }
buildFeatures { buildFeatures {
compose = true compose = true
viewBinding = true viewBinding = true
buildConfig = true
}
androidResources {
generateLocaleConfig = true
} }
externalNativeBuild { externalNativeBuild {
cmake { cmake {
@@ -46,18 +84,41 @@ android {
} }
sourceSets { sourceSets {
getByName("main") { getByName("main") {
res.srcDirs("src/main/res", "src/main/res-apple") res.directories += "src/main/res-apple"
}
}
ndkVersion = "30.0.14904198"
flavorDimensions += "env"
productFlavors {
create("normal") {
dimension = "env"
externalNativeBuild {
cmake {
arguments += "-DIS_XPOSED=OFF"
}
}
}
create("xposed") {
dimension = "env"
externalNativeBuild {
cmake {
arguments += "-DIS_XPOSED=ON"
}
}
} }
} }
} }
dependencies { dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.accompanist.permissions) implementation(libs.accompanist.permissions)
implementation(libs.hiddenapibypass)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui) implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.ui.tooling.preview)
@@ -69,21 +130,100 @@ dependencies {
implementation(libs.haze.materials) implementation(libs.haze.materials)
implementation(libs.androidx.dynamicanimation) implementation(libs.androidx.dynamicanimation)
implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.material.icons.core)
implementation(libs.billing)
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)
implementation(libs.aboutlibraries.compose.m3) implementation(libs.aboutlibraries.compose.m3)
// compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar")))) implementation(libs.backdrop)
// implementation(fileTree(mapOf("dir" to "lib", "include" to listOf("*.aar")))) // implementation(libs.hilt)
compileOnly(files("libs/libxposed-api-100.aar")) // implementation(libs.hilt.compiler)
debugImplementation(files("libs/backdrop-debug.aar")) add("xposedCompileOnly", libs.libxposed.api)
releaseImplementation(files("libs/backdrop-release.aar")) add("xposedImplementation", libs.libxposed.service)
add("playReleaseImplementation", libs.billing)
} }
aboutLibraries { aboutLibraries {
export{ export {
prettyPrint = true prettyPrint = true
excludeFields = listOf("generated") excludeFields = listOf("generated")
outputFile = file("src/main/res/raw/aboutlibraries.json") outputFile = file("src/main/res/raw/aboutlibraries.json")
} }
} }
val rootModuleDir = rootProject.file("../root-module-manual")
val releaseDir = rootProject.file("../release")
fun cap(s: String) = s.replaceFirstChar { it.uppercase() }
fun registerRootModuleZipTask(
name: String,
flavor: String,
buildType: String
) = tasks.register<Zip>(name) {
val variantTask = "assemble${cap(flavor)}${cap(buildType)}"
dependsOn(variantTask)
val apkPath = "outputs/apk/$flavor/$buildType/app-$flavor-$buildType.apk"
from(rootModuleDir)
duplicatesStrategy = DuplicatesStrategy.WARN
from(layout.buildDirectory.file(apkPath)) {
into("system/priv-app/LibrePods")
rename { "LibrePods.apk" }
}
delete(layout.buildDirectory.dir("outputs/rootModuleZips"))
archiveFileName.set("LibrePods-FOSS-v$appVersionName-$buildType.zip")
destinationDirectory.set(layout.buildDirectory.dir("outputs/rootModuleZips"))
}
val zipRelease = registerRootModuleZipTask(
"zipXposedReleaseModule",
"xposed",
"release"
)
val zipDebug = registerRootModuleZipTask(
"zipXposedDebugModule",
"xposed",
"debug"
)
val collect = tasks.register<Copy>("collectReleaseArtifacts") {
dependsOn(
zipRelease,
zipDebug,
"bundleXposedPlayRelease"
)
into(releaseDir)
from(layout.buildDirectory.dir("outputs/apk/xposed/release")) {
include("*.apk")
rename(".*", "LibrePods-FOSS-v$appVersionName-release.apk")
}
from(layout.buildDirectory.dir("outputs/apk/xposed/debug")) {
include("*.apk")
rename(".*", "LibrePods-FOSS-v$appVersionName-debug.apk")
}
from(layout.buildDirectory.dir("outputs/bundle/xposedPlayRelease")) {
include("*.aab")
}
from(layout.buildDirectory.dir("outputs/rootModuleZips")) {
include("*.zip")
}
}
tasks.register("packageReleaseArtifacts") {
dependsOn(collect)
}

Binary file not shown.

View File

@@ -19,3 +19,5 @@
# If you keep the line number information, uncomment this to # If you keep the line number information, uncomment this to
# hide the original source file name. # hide the original source file name.
#-renamesourcefileattribute SourceFile #-renamesourcefileattribute SourceFile
-keep class me.kavishdevar.librepods.utils.KotlinModule { *; }

View File

@@ -10,8 +10,6 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" <uses-permission android:name="android.permission.FOREGROUND_SERVICE"
tools:ignore="ForegroundServicesPolicy" /> tools:ignore="ForegroundServicesPolicy" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.INTERACT_ACROSS_USERS"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
@@ -19,6 +17,15 @@
<uses-permission <uses-permission
android:name="android.permission.BLUETOOTH_PRIVILEGED" android:name="android.permission.BLUETOOTH_PRIVILEGED"
tools:ignore="ProtectedPermissions" /> tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="android.permission.MODIFY_PHONE_STATE"
tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="android.permission.LOCAL_MAC_ADDRESS"
tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="android.permission.INTERACT_ACROSS_USERS"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission <uses-permission
android:name="android.permission.BLUETOOTH_SCAN" android:name="android.permission.BLUETOOTH_SCAN"
@@ -28,15 +35,15 @@
<uses-permission android:name="android.permission.READ_PHONE_STATE" /> <uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" /> <uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.INTERNET" /> <!-- <uses-permission android:name="android.permission.INTERNET" />-->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" <!-- <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"-->
tools:ignore="ScopedStorage" /> <!-- android:maxSdkVersion="30" />-->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" <!-- <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"-->
android:maxSdkVersion="30" /> <!-- android:maxSdkVersion="30" />-->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" <uses-permission android:name="com.android.vending.BILLING" />
android:maxSdkVersion="30" />
<application <application
android:name=".LibrePodsApplication"
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="true" android:enableOnBackInvokedCallback="true"
@@ -47,10 +54,9 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.LibrePods" android:theme="@style/Theme.LibrePods"
android:description="@string/app_description" android:description="@string/app_description"
tools:ignore="UnusedAttribute" tools:ignore="UnusedAttribute" >
tools:targetApi="31">
<receiver <receiver
android:name=".widgets.NoiseControlWidget" android:name=".presentation.widgets.NoiseControlWidget"
android:exported="false"> android:exported="false">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" /> <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
@@ -62,7 +68,7 @@
</receiver> </receiver>
<receiver <receiver
android:name=".widgets.BatteryWidget" android:name=".presentation.widgets.BatteryWidget"
android:exported="false"> android:exported="false">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" /> <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
@@ -116,17 +122,17 @@
<action android:name="android.service.quicksettings.action.QS_TILE" /> <action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter> </intent-filter>
</service> </service>
<service <!-- <service-->
android:name=".services.AppListenerService" <!-- android:name=".services.AppListenerService"-->
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE" <!-- android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"-->
android:exported="true"> <!-- android:exported="true">-->
<intent-filter> <!-- <intent-filter>-->
<action android:name="android.accessibilityservice.AccessibilityService" /> <!-- <action android:name="android.accessibilityservice.AccessibilityService" />-->
</intent-filter> <!-- </intent-filter>-->
<meta-data <!-- <meta-data-->
android:name="android.accessibilityservice" <!-- android:name="android.accessibilityservice"-->
android:resource="@xml/app_listener_service_config" /> <!-- android:resource="@xml/app_listener_service_config" />-->
</service> <!-- </service>-->
<receiver <receiver
android:name=".receivers.BootReceiver" android:name=".receivers.BootReceiver"
android:enabled="true" android:enabled="true"

View File

@@ -3,10 +3,61 @@ cmake_minimum_required(VERSION 3.22.1)
project("l2c_fcr_hook") project("l2c_fcr_hook")
set(CMAKE_CXX_STANDARD 23) set(CMAKE_CXX_STANDARD 23)
add_library(${CMAKE_PROJECT_NAME} SHARED option(IS_XPOSED "Build Xposed components" OFF)
l2c_fcr_hook.cpp
l2c_fcr_hook.h)
target_link_libraries(${CMAKE_PROJECT_NAME} add_library(bluetooth_socket SHARED
bluetooth_socket.cpp
)
target_compile_options(bluetooth_socket PRIVATE
-O2
-fvisibility=hidden
)
target_link_options(bluetooth_socket PRIVATE
-Wl,--strip-all
-Wl,--gc-sections
)
target_link_libraries(bluetooth_socket
android android
log) log
)
if(IS_XPOSED)
set(XPOSED_SRC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../xposed/cpp)
add_library(l2c_fcr_hook SHARED
${XPOSED_SRC_DIR}/l2c_fcr_hook.cpp
${XPOSED_SRC_DIR}/xz/xz_crc32.c
${XPOSED_SRC_DIR}/xz/xz_crc64.c
${XPOSED_SRC_DIR}/xz/xz_sha256.c
${XPOSED_SRC_DIR}/xz/xz_dec_stream.c
${XPOSED_SRC_DIR}/xz/xz_dec_lzma2.c
${XPOSED_SRC_DIR}/xz/xz_dec_bcj.c
)
target_include_directories(l2c_fcr_hook PRIVATE
${XPOSED_SRC_DIR}
${XPOSED_SRC_DIR}/xz
)
target_compile_definitions(l2c_fcr_hook PRIVATE
XZ_DEC_X86
XZ_DEC_ARM
XZ_DEC_ARMTHUMB
XZ_DEC_ARM64
XZ_DEC_ANY_CHECK
XZ_USE_CRC64
XZ_USE_SHA256
XZ_DEC_CONCATENATED
)
target_link_libraries(l2c_fcr_hook
android
log
)
endif()

View File

@@ -0,0 +1,68 @@
#include <jni.h>
#include <pthread.h>
#include <array>
#include <string>
static JavaVM* gVm = nullptr;
template<size_t N>
constexpr auto encryptString(const char (&str)[N], char key) {
std::array<char, N> encrypted{};
for (size_t i = 0; i < N; i++) {
encrypted[i] = str[i] ^ key;
}
return encrypted;
}
template<size_t N>
static std::string decryptString(const std::array<char, N>& encrypted, char key) {
std::string result(N - 1, '\0');
for (size_t i = 0; i < N - 1; i++) {
result[i] = encrypted[i] ^ key;
}
return result;
}
#define ENC(str) encryptString(str, 0x47)
#define DEC(arr) decryptString(arr, 0x47).c_str()
__attribute__((visibility("hidden")))
static JavaVM* getVm() { return gVm; }
__attribute__((visibility("default")))
JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
gVm = vm;
auto fn = [](void*) -> void* {
constexpr auto c1 = ENC("dalvik/system/VMRuntime");
constexpr auto c2 = ENC("getRuntime");
constexpr auto c3 = ENC("()Ldalvik/system/VMRuntime;");
constexpr auto c4 = ENC("setHiddenApiExemptions");
constexpr auto c5 = ENC("([Ljava/lang/String;)V");
constexpr auto c6 = ENC("java/lang/String");
constexpr auto c7 = ENC("Landroid/bluetooth/BluetoothSocket;");
constexpr auto c8 = ENC("Landroid/bluetooth/BluetoothDevice;");
JNIEnv* env;
getVm()->AttachCurrentThread(&env, nullptr);
jclass vmRuntime = env->FindClass(DEC(c1));
jmethodID getRuntime = env->GetStaticMethodID(vmRuntime, DEC(c2), DEC(c3));
jmethodID setExemptions = env->GetMethodID(vmRuntime, DEC(c4), DEC(c5));
jobject runtime = env->CallStaticObjectMethod(vmRuntime, getRuntime);
jobjectArray prefixes = env->NewObjectArray(
2, env->FindClass(DEC(c6)), nullptr);
env->SetObjectArrayElement(prefixes, 0, env->NewStringUTF(DEC(c7)));
env->SetObjectArrayElement(prefixes, 1, env->NewStringUTF(DEC(c8)));
env->CallVoidMethod(runtime, setExemptions, prefixes);
getVm()->DetachCurrentThread();
return nullptr;
};
pthread_t t;
pthread_create(&t, nullptr, fn, nullptr);
pthread_join(t, nullptr);
return JNI_VERSION_1_6;
}

View File

@@ -1,491 +0,0 @@
/*
* 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/>.
*/
#include <cstdint>
#include <cstring>
#include <dlfcn.h>
#include <android/log.h>
#include <fstream>
#include <string>
#include <sys/system_properties.h>
#include "l2c_fcr_hook.h"
#include <cerrno>
#include <cstdlib>
#define LOG_TAG "AirPodsHook"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
static HookFunType hook_func = nullptr;
#define L2CEVT_L2CAP_CONFIG_REQ 4
#define L2CEVT_L2CAP_CONFIG_RSP 15
struct t_l2c_lcb;
typedef struct _BT_HDR {
uint16_t event;
uint16_t len;
uint16_t offset;
uint16_t layer_specific;
uint8_t data[];
} BT_HDR;
typedef struct {
uint8_t mode;
uint8_t tx_win_sz;
uint8_t max_transmit;
uint16_t rtrans_tout;
uint16_t mon_tout;
uint16_t mps;
} tL2CAP_FCR;
// Flow spec structure
typedef struct {
uint8_t qos_present;
uint8_t flow_direction;
uint8_t service_type;
uint32_t token_rate;
uint32_t token_bucket_size;
uint32_t peak_bandwidth;
uint32_t latency;
uint32_t delay_variation;
} FLOW_SPEC;
// Configuration info structure
typedef struct {
uint16_t result;
uint16_t mtu_present;
uint16_t mtu;
uint16_t flush_to_present;
uint16_t flush_to;
uint16_t qos_present;
FLOW_SPEC qos;
uint16_t fcr_present;
tL2CAP_FCR fcr;
uint16_t fcs_present;
uint16_t fcs;
uint16_t ext_flow_spec_present;
FLOW_SPEC ext_flow_spec;
} tL2CAP_CFG_INFO;
// Basic L2CAP link control block
typedef struct {
bool wait_ack;
// Other FCR fields - not needed for our specific hook
} tL2C_FCRB;
// Forward declarations for needed types
struct t_l2c_rcb;
struct t_l2c_lcb;
typedef struct t_l2c_ccb {
struct t_l2c_ccb* p_next_ccb; // Next CCB in the chain
struct t_l2c_ccb* p_prev_ccb; // Previous CCB in the chain
struct t_l2c_lcb* p_lcb; // Link this CCB belongs to
struct t_l2c_rcb* p_rcb; // Registration CB for this Channel
uint16_t local_cid; // Local CID
uint16_t remote_cid; // Remote CID
uint16_t p_lcb_next; // For linking CCBs to an LCB
uint8_t ccb_priority; // Channel priority
uint16_t tx_mps; // MPS for outgoing messages
uint16_t max_rx_mtu; // Max MTU we will receive
// State variables
bool in_use; // True when channel active
uint8_t chnl_state; // Channel state
uint8_t local_id; // Transaction ID for local trans
uint8_t remote_id; // Transaction ID for remote
uint8_t timer_entry; // Timer entry
uint8_t is_flushable; // True if flushable
// Configuration variables
uint16_t our_cfg_bits; // Bitmap of local config bits
uint16_t peer_cfg_bits; // Bitmap of peer config bits
uint16_t config_done; // Configuration bitmask
uint16_t remote_config_rsp_result; // Remote config response result
tL2CAP_CFG_INFO our_cfg; // Our saved configuration options
tL2CAP_CFG_INFO peer_cfg; // Peer's saved configuration options
// Additional control fields
uint8_t remote_credit_count; // Credits sent to peer
tL2C_FCRB fcrb; // FCR info
bool ecoc; // Enhanced Credit-based mode
} tL2C_CCB;
static uint8_t (*original_l2c_fcr_chk_chan_modes)(void* p_ccb) = nullptr;
static void (*original_l2cu_process_our_cfg_req)(tL2C_CCB* p_ccb, tL2CAP_CFG_INFO* p_cfg) = nullptr;
static void (*original_l2c_csm_config)(tL2C_CCB* p_ccb, uint8_t event, void* p_data) = nullptr;
static void (*original_l2cu_send_peer_info_req)(tL2C_LCB* p_lcb, uint16_t info_type) = nullptr;
// Add original pointer for BTA_DmSetLocalDiRecord
static tBTA_STATUS (*original_BTA_DmSetLocalDiRecord)(tSDP_DI_RECORD* p_device_info, uint32_t* p_handle) = nullptr;
uint8_t fake_l2c_fcr_chk_chan_modes(void* p_ccb) {
LOGI("l2c_fcr_chk_chan_modes hooked, returning true.");
return 1;
}
void fake_l2cu_process_our_cfg_req(tL2C_CCB* p_ccb, tL2CAP_CFG_INFO* p_cfg) {
original_l2cu_process_our_cfg_req(p_ccb, p_cfg);
p_ccb->our_cfg.fcr.mode = 0x00;
LOGI("Set FCR mode to Basic Mode in outgoing config request");
}
void fake_l2c_csm_config(tL2C_CCB* p_ccb, uint8_t event, void* p_data) {
// Call the original function first to handle the specific code path where the FCR mode is checked
original_l2c_csm_config(p_ccb, event, p_data);
// Check if this happens during CONFIG_RSP event handling
if (event == L2CEVT_L2CAP_CONFIG_RSP) {
p_ccb->our_cfg.fcr.mode = p_ccb->peer_cfg.fcr.mode;
LOGI("Forced compatibility in l2c_csm_config: set our_mode=%d to match peer_mode=%d",
p_ccb->our_cfg.fcr.mode, p_ccb->peer_cfg.fcr.mode);
}
}
// Replacement function that does nothing
void fake_l2cu_send_peer_info_req(tL2C_LCB* p_lcb, uint16_t info_type) {
LOGI("Intercepted l2cu_send_peer_info_req for info_type 0x%04x - doing nothing", info_type);
// Just return without doing anything
return;
}
// New loader for SDP hook offset (persist.librepods.sdp_offset)
uintptr_t loadSdpOffset() {
const char* property_name = "persist.librepods.sdp_offset";
char value[PROP_VALUE_MAX] = {0};
int len = __system_property_get(property_name, value);
if (len > 0) {
LOGI("Read sdp offset from property: %s", value);
uintptr_t offset;
char* endptr = nullptr;
const char* parse_start = value;
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
parse_start = value + 2;
}
errno = 0;
offset = strtoul(parse_start, &endptr, 16);
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
LOGI("Parsed sdp offset: 0x%x", offset);
return offset;
}
LOGE("Failed to parse sdp offset from property value: %s", value);
}
LOGI("No sdp offset property present - skipping SDP hook");
return 0;
}
// Fake BTA_DmSetLocalDiRecord: set vendor/vendor_id_source then call original
tBTA_STATUS fake_BTA_DmSetLocalDiRecord(tSDP_DI_RECORD* p_device_info, uint32_t* p_handle) {
LOGI("BTA_DmSetLocalDiRecord hooked - forcing vendor fields");
if (p_device_info) {
p_device_info->vendor = 0x004C;
p_device_info->vendor_id_source = 0x0001;
}
LOGI("Set vendor=0x%04x, vendor_id_source=0x%04x", p_device_info->vendor, p_device_info->vendor_id_source);
if (original_BTA_DmSetLocalDiRecord) {
return original_BTA_DmSetLocalDiRecord(p_device_info, p_handle);
}
LOGE("Original BTA_DmSetLocalDiRecord not available");
return BTA_FAILURE;
}
uintptr_t loadHookOffset([[maybe_unused]] const char* package_name) {
const char* property_name = "persist.librepods.hook_offset";
char value[PROP_VALUE_MAX] = {0};
int len = __system_property_get(property_name, value);
if (len > 0) {
LOGI("Read hook offset from property: %s", value);
uintptr_t offset;
char* endptr = nullptr;
const char* parse_start = value;
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
parse_start = value + 2;
}
errno = 0;
offset = strtoul(parse_start, &endptr, 16);
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
LOGI("Parsed offset: 0x%x", offset);
return offset;
}
LOGE("Failed to parse offset from property value: %s", value);
}
LOGI("Using hardcoded fallback offset");
return 0x00a55e30;
}
uintptr_t loadL2cuProcessCfgReqOffset() {
const char* property_name = "persist.librepods.cfg_req_offset";
char value[PROP_VALUE_MAX] = {0};
int len = __system_property_get(property_name, value);
if (len > 0) {
LOGI("Read l2cu_process_our_cfg_req offset from property: %s", value);
uintptr_t offset;
char* endptr = nullptr;
const char* parse_start = value;
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
parse_start = value + 2;
}
errno = 0;
offset = strtoul(parse_start, &endptr, 16);
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
LOGI("Parsed l2cu_process_our_cfg_req offset: 0x%x", offset);
return offset;
}
LOGE("Failed to parse l2cu_process_our_cfg_req offset from property value: %s", value);
}
// Return 0 if not found - we'll skip this hook
return 0;
}
uintptr_t loadL2cCsmConfigOffset() {
const char* property_name = "persist.librepods.csm_config_offset";
char value[PROP_VALUE_MAX] = {0};
int len = __system_property_get(property_name, value);
if (len > 0) {
LOGI("Read l2c_csm_config offset from property: %s", value);
uintptr_t offset;
char* endptr = nullptr;
const char* parse_start = value;
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
parse_start = value + 2;
}
errno = 0;
offset = strtoul(parse_start, &endptr, 16);
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
LOGI("Parsed l2c_csm_config offset: 0x%x", offset);
return offset;
}
LOGE("Failed to parse l2c_csm_config offset from property value: %s", value);
}
// Return 0 if not found - we'll skip this hook
return 0;
}
uintptr_t loadL2cuSendPeerInfoReqOffset() {
const char* property_name = "persist.librepods.peer_info_req_offset";
char value[PROP_VALUE_MAX] = {0};
int len = __system_property_get(property_name, value);
if (len > 0) {
LOGI("Read l2cu_send_peer_info_req offset from property: %s", value);
uintptr_t offset;
char* endptr = nullptr;
const char* parse_start = value;
if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
parse_start = value + 2;
}
errno = 0;
offset = strtoul(parse_start, &endptr, 16);
if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
LOGI("Parsed l2cu_send_peer_info_req offset: 0x%x", offset);
return offset;
}
LOGE("Failed to parse l2cu_send_peer_info_req offset from property value: %s", value);
}
// Return 0 if not found - we'll skip this hook
return 0;
}
uintptr_t getModuleBase(const char *module_name) {
FILE *fp;
char line[1024];
uintptr_t base_addr = 0;
fp = fopen("/proc/self/maps", "r");
if (!fp) {
LOGE("Failed to open /proc/self/maps");
return 0;
}
while (fgets(line, sizeof(line), fp)) {
if (strstr(line, module_name)) {
char *start_addr_str = line;
char *end_addr_str = strchr(line, '-');
if (end_addr_str) {
*end_addr_str = '\0';
base_addr = strtoull(start_addr_str, nullptr, 16);
break;
}
}
}
fclose(fp);
return base_addr;
}
bool findAndHookFunction(const char *library_name) {
if (!hook_func) {
LOGE("Hook function not initialized");
return false;
}
uintptr_t base_addr = getModuleBase(library_name);
if (!base_addr) {
LOGE("Failed to get base address of %s", library_name);
return false;
}
// Load all offsets from system properties - no hardcoding
uintptr_t l2c_fcr_offset = loadHookOffset(nullptr);
uintptr_t l2cu_process_our_cfg_req_offset = loadL2cuProcessCfgReqOffset();
uintptr_t l2c_csm_config_offset = loadL2cCsmConfigOffset();
uintptr_t l2cu_send_peer_info_req_offset = loadL2cuSendPeerInfoReqOffset();
uintptr_t sdp_offset = loadSdpOffset();
bool success = false;
// Hook l2c_fcr_chk_chan_modes - this is our primary hook
if (l2c_fcr_offset > 0) {
void* target = reinterpret_cast<void*>(base_addr + l2c_fcr_offset);
LOGI("Hooking l2c_fcr_chk_chan_modes at offset: 0x%x, base: %p, target: %p",
l2c_fcr_offset, (void*)base_addr, target);
int result = hook_func(target, (void*)fake_l2c_fcr_chk_chan_modes, (void**)&original_l2c_fcr_chk_chan_modes);
if (result != 0) {
LOGE("Failed to hook l2c_fcr_chk_chan_modes, error: %d", result);
return false;
}
LOGI("Successfully hooked l2c_fcr_chk_chan_modes");
success = true;
} else {
LOGE("No valid offset for l2c_fcr_chk_chan_modes found, cannot proceed");
return false;
}
// Hook l2cu_process_our_cfg_req if offset is available
if (l2cu_process_our_cfg_req_offset > 0) {
void* target = reinterpret_cast<void*>(base_addr + l2cu_process_our_cfg_req_offset);
LOGI("Hooking l2cu_process_our_cfg_req at offset: 0x%x, base: %p, target: %p",
l2cu_process_our_cfg_req_offset, (void*)base_addr, target);
int result = hook_func(target, (void*)fake_l2cu_process_our_cfg_req, (void**)&original_l2cu_process_our_cfg_req);
if (result != 0) {
LOGE("Failed to hook l2cu_process_our_cfg_req, error: %d", result);
// Continue even if this hook fails
} else {
LOGI("Successfully hooked l2cu_process_our_cfg_req");
}
} else {
LOGI("Skipping l2cu_process_our_cfg_req hook as offset is not available");
}
// Hook l2c_csm_config if offset is available
if (l2c_csm_config_offset > 0) {
void* target = reinterpret_cast<void*>(base_addr + l2c_csm_config_offset);
LOGI("Hooking l2c_csm_config at offset: 0x%x, base: %p, target: %p",
l2c_csm_config_offset, (void*)base_addr, target);
int result = hook_func(target, (void*)fake_l2c_csm_config, (void**)&original_l2c_csm_config);
if (result != 0) {
LOGE("Failed to hook l2c_csm_config, error: %d", result);
// Continue even if this hook fails
} else {
LOGI("Successfully hooked l2c_csm_config");
}
} else {
LOGI("Skipping l2c_csm_config hook as offset is not available");
}
// Hook l2cu_send_peer_info_req if offset is available
if (l2cu_send_peer_info_req_offset > 0) {
void* target = reinterpret_cast<void*>(base_addr + l2cu_send_peer_info_req_offset);
LOGI("Hooking l2cu_send_peer_info_req at offset: 0x%x, base: %p, target: %p",
l2cu_send_peer_info_req_offset, (void*)base_addr, target);
int result = hook_func(target, (void*)fake_l2cu_send_peer_info_req, (void**)&original_l2cu_send_peer_info_req);
if (result != 0) {
LOGE("Failed to hook l2cu_send_peer_info_req, error: %d", result);
// Continue even if this hook fails
} else {
LOGI("Successfully hooked l2cu_send_peer_info_req");
}
} else {
LOGI("Skipping l2cu_send_peer_info_req hook as offset is not available");
}
if (sdp_offset > 0) {
void* target = reinterpret_cast<void*>(base_addr + sdp_offset);
LOGI("Hooking BTA_DmSetLocalDiRecord at offset: 0x%x, base: %p, target: %p",
sdp_offset, (void*)base_addr, target);
int result = hook_func(target, (void*)fake_BTA_DmSetLocalDiRecord, (void**)&original_BTA_DmSetLocalDiRecord);
if (result != 0) {
LOGE("Failed to hook BTA_DmSetLocalDiRecord, error: %d", result);
} else {
LOGI("Successfully hooked BTA_DmSetLocalDiRecord (SDP)");
}
} else {
LOGI("Skipping BTA_DmSetLocalDiRecord hook as sdp offset is not available");
}
return success;
}
void on_library_loaded(const char *name, [[maybe_unused]] void *handle) {
if (strstr(name, "libbluetooth_jni.so")) {
LOGI("Detected Bluetooth JNI library: %s", name);
bool hooked = findAndHookFunction("libbluetooth_jni.so");
if (!hooked) {
LOGE("Failed to hook Bluetooth JNI library function");
}
} else if (strstr(name, "libbluetooth_qti.so")) {
LOGI("Detected Bluetooth QTI library: %s", name);
bool hooked = findAndHookFunction("libbluetooth_qti.so");
if (!hooked) {
LOGE("Failed to hook Bluetooth QTI library function");
}
}
}
extern "C" [[gnu::visibility("default")]] [[gnu::used]]
NativeOnModuleLoaded native_init(const NativeAPIEntries* entries) {
LOGI("L2C FCR Hook module initialized");
hook_func = entries->hook_func;
return on_library_loaded;
}

View File

@@ -1,50 +0,0 @@
#pragma once
#include <cstdint>
#include <vector>
typedef int (*HookFunType)(void *func, void *replace, void **backup);
typedef int (*UnhookFunType)(void *func);
typedef void (*NativeOnModuleLoaded)(const char *name, void *handle);
typedef struct {
uint32_t version;
HookFunType hook_func;
UnhookFunType unhook_func;
} NativeAPIEntries;
[[maybe_unused]] typedef NativeOnModuleLoaded (*NativeInit)(const NativeAPIEntries *entries);
typedef struct t_l2c_ccb tL2C_CCB;
typedef struct t_l2c_lcb tL2C_LCB;
uintptr_t loadHookOffset(const char* package_name);
uintptr_t getModuleBase(const char *module_name);
uintptr_t loadL2cuProcessCfgReqOffset();
uintptr_t loadL2cCsmConfigOffset();
uintptr_t loadL2cuSendPeerInfoReqOffset();
bool findAndHookFunction(const char *library_path);
#define SDP_MAX_ATTR_LEN 400
typedef struct t_sdp_di_record {
uint16_t vendor;
uint16_t vendor_id_source;
uint16_t product;
uint16_t version;
bool primary_record;
char client_executable_url[SDP_MAX_ATTR_LEN];
char service_description[SDP_MAX_ATTR_LEN];
char documentation_url[SDP_MAX_ATTR_LEN];
} tSDP_DI_RECORD;
typedef enum : uint8_t {
BTA_SUCCESS = 0, /* Successful operation. */
BTA_FAILURE = 1, /* Generic failure. */
BTA_PENDING = 2, /* API cannot be completed right now */
BTA_BUSY = 3,
BTA_NO_RESOURCES = 4,
BTA_WRONG_MODE = 5,
} tBTA_STATUS;

View File

@@ -1,25 +1,28 @@
/* /*
* 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)
package me.kavishdevar.librepods package me.kavishdevar.librepods
// import me.kavishdevar.librepods.screens.Onboarding
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
//import dagger.hilt.android.AndroidEntryPoint
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.ComponentName import android.content.ComponentName
@@ -27,13 +30,11 @@ 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.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import android.provider.Settings import android.provider.Settings
import android.util.Log import android.util.Log
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
@@ -52,6 +53,7 @@ import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -63,6 +65,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Notifications
@@ -73,7 +76,6 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -85,12 +87,13 @@ 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.scale
import androidx.compose.ui.focus.FocusRequester
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.graphics.drawscope.rotate import androidx.compose.ui.graphics.drawscope.rotate
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.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
@@ -103,6 +106,7 @@ 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 androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
@@ -112,59 +116,71 @@ 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.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.composables.StyledIconButton import dev.chrisbanes.haze.rememberHazeState
import me.kavishdevar.librepods.constants.AirPodsNotifications import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen import me.kavishdevar.librepods.data.ControlCommandRepository
import me.kavishdevar.librepods.screens.AdaptiveStrengthScreen import me.kavishdevar.librepods.presentation.components.AppInfoCard
import me.kavishdevar.librepods.screens.AirPodsSettingsScreen import me.kavishdevar.librepods.presentation.components.ConfirmationDialog
import me.kavishdevar.librepods.screens.AppSettingsScreen import me.kavishdevar.librepods.presentation.components.DeviceInfoCard
import me.kavishdevar.librepods.screens.CameraControlScreen import me.kavishdevar.librepods.presentation.components.SelectItem
import me.kavishdevar.librepods.screens.DebugScreen import me.kavishdevar.librepods.presentation.components.StyledBottomSheet
import me.kavishdevar.librepods.screens.HeadTrackingScreen import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.screens.HearingAidAdjustmentsScreen import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.screens.HearingAidScreen import me.kavishdevar.librepods.presentation.components.StyledInputField
import me.kavishdevar.librepods.screens.HearingProtectionScreen import me.kavishdevar.librepods.presentation.components.StyledSelectList
import me.kavishdevar.librepods.screens.LongPress import me.kavishdevar.librepods.presentation.screens.AccessibilitySettingsScreen
import me.kavishdevar.librepods.screens.Onboarding import me.kavishdevar.librepods.presentation.screens.AdaptiveStrengthScreen
import me.kavishdevar.librepods.screens.OpenSourceLicensesScreen import me.kavishdevar.librepods.presentation.screens.AirPodsSettingsScreen
import me.kavishdevar.librepods.screens.RenameScreen import me.kavishdevar.librepods.presentation.screens.AppSettingsScreen
import me.kavishdevar.librepods.screens.TransparencySettingsScreen import me.kavishdevar.librepods.presentation.screens.CameraControlScreen
import me.kavishdevar.librepods.screens.TroubleshootingScreen import me.kavishdevar.librepods.presentation.screens.DebugScreen
import me.kavishdevar.librepods.screens.UpdateHearingTestScreen import me.kavishdevar.librepods.presentation.screens.HeadTrackingScreen
import me.kavishdevar.librepods.screens.VersionScreen import me.kavishdevar.librepods.presentation.screens.HearingAidAdjustmentsScreen
import me.kavishdevar.librepods.presentation.screens.HearingAidScreen
import me.kavishdevar.librepods.presentation.screens.HearingProtectionScreen
import me.kavishdevar.librepods.presentation.screens.LongPress
import me.kavishdevar.librepods.presentation.screens.OpenSourceLicensesScreen
import me.kavishdevar.librepods.presentation.screens.PurchaseScreen
import me.kavishdevar.librepods.presentation.screens.RenameScreen
import me.kavishdevar.librepods.presentation.screens.TransparencySettingsScreen
import me.kavishdevar.librepods.presentation.screens.TroubleshootingScreen
import me.kavishdevar.librepods.presentation.screens.UpdateHearingTestScreen
import me.kavishdevar.librepods.presentation.screens.VersionScreen
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
import me.kavishdevar.librepods.services.AirPodsService import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme import me.kavishdevar.librepods.utils.XposedState
import me.kavishdevar.librepods.utils.RadareOffsetFinder import me.kavishdevar.librepods.utils.isSupported
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
lateinit var serviceConnection: ServiceConnection lateinit var serviceConnection: ServiceConnection
lateinit var connectionStatusReceiver: BroadcastReceiver lateinit var connectionStatusReceiver: BroadcastReceiver
//@AndroidEntryPoint
@ExperimentalMaterial3Api @ExperimentalMaterial3Api
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
companion object { companion object {
init { init {
if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) {
System.loadLibrary("l2c_fcr_hook") System.loadLibrary("l2c_fcr_hook")
} }
} }
}
@ExperimentalHazeMaterialsApi
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
LibrePodsTheme { LibrePodsTheme {
getSharedPreferences("settings", MODE_PRIVATE).edit {
putLong(
"textColor",
MaterialTheme.colorScheme.onSurface.toArgb().toLong())}
Main() Main()
} }
} }
handleIncomingIntent(intent)
} }
override fun onDestroy() { override fun onDestroy() {
@@ -199,82 +215,258 @@ class MainActivity : ComponentActivity() {
} }
super.onStop() super.onStop()
} }
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleIncomingIntent(intent)
}
private fun handleIncomingIntent(intent: Intent) {
val data: Uri? = intent.data
if (data != null && data.scheme == "librepods") {
when (data.host) {
"add-magic-keys" -> {
val queryParams = data.queryParameterNames
queryParams.forEach { param ->
val value = data.getQueryParameter(param)
Log.d("LibrePods", "Parameter: $param = $value")
}
handleAddMagicKeys(data)
}
}
}
}
private fun handleAddMagicKeys(uri: Uri) {
val sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE)
val irkHex = uri.getQueryParameter("irk")
val encKeyHex = uri.getQueryParameter("enc_key")
try {
if (irkHex != null && validateHexInput(irkHex)) {
val irkBytes = hexStringToByteArray(irkHex)
val irkBase64 = Base64.encode(irkBytes)
sharedPreferences.edit {putString("IRK", irkBase64)}
}
if (encKeyHex != null && validateHexInput(encKeyHex)) {
val encKeyBytes = hexStringToByteArray(encKeyHex)
val encKeyBase64 = Base64.encode(encKeyBytes)
sharedPreferences.edit { putString("ENC_KEY", encKeyBase64)}
}
Toast.makeText(this, "Magic keys added successfully!", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(this, "Error processing magic keys: ${e.message}", Toast.LENGTH_LONG).show()
}
}
private fun validateHexInput(input: String): Boolean {
val hexPattern = Regex("^[0-9a-fA-F]{32}$")
return hexPattern.matches(input)
}
private fun hexStringToByteArray(hex: String): ByteArray {
val result = ByteArray(16)
for (i in 0 until 16) {
val hexByte = hex.substring(i * 2, i * 2 + 2)
result[i] = hexByte.toInt(16).toByte()
}
return result
}
} }
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@SuppressLint("MissingPermission", "InlinedApi", "UnspecifiedRegisterReceiverFlag") @SuppressLint("MissingPermission", "InlinedApi", "UnspecifiedRegisterReceiverFlag")
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun Main() { fun Main() {
val isConnected = remember { mutableStateOf(false) }
val isRemotelyConnected = remember { mutableStateOf(false) }
val hookAvailable = RadareOffsetFinder(LocalContext.current).isHookOffsetAvailable()
val context = LocalContext.current val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
if (!isSupported(sharedPreferences) && !XposedState.bluetoothScopeEnabled) {
val showDialog = remember { mutableStateOf(false) }
val showPlayBypassVisible = remember { mutableStateOf(false) }
val hazeState = rememberHazeState()
val backdrop = rememberLayerBackdrop()
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val scrollState = rememberScrollState()
Box(
modifier = Modifier
.fillMaxSize()
.hazeSource(hazeState)
.layerBackdrop(backdrop)
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7)),
contentAlignment = Alignment.Center
) {
Column (
modifier = Modifier
.padding(horizontal = 16.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement
.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(48.dp))
Column(
modifier = Modifier,
verticalArrangement = Arrangement
.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.not_supported),
style = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.SemiBold,
color = textColor,
fontSize = 28.sp,
textAlign = TextAlign.Center
),
modifier = Modifier.fillMaxWidth()
)
Box(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.clip(RoundedCornerShape(28.dp))
) {
Text(
text = stringResource(R.string.check_the_repository_for_more_info),
style = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium,
color = if (isDarkTheme) Color.White else Color.Black,
fontSize = 16.sp
),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 16.dp)
)
}
StyledButton(
onClick = { showDialog.value = true },
backdrop = rememberLayerBackdrop(),
modifier = Modifier
.fillMaxWidth(),
isInteractive = false,
surfaceColor = if (isDarkTheme) Color(0xFF862424) else Color(0xFFC94646)
) {
Text(
text = stringResource(R.string.bypass_compatibility_check),
style = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium,
color = Color.White,
fontSize = 16.sp
),
)
}
Spacer(modifier = Modifier.height(24.dp))
DeviceInfoCard()
AppInfoCard()
}
Spacer(modifier = Modifier.height(48.dp))
}
}
ConfirmationDialog(
showDialog = showDialog,
title = stringResource(R.string.bypass_compatibility_check),
message = stringResource(R.string.bypass_compatiblity_check_confirmation),
confirmText = stringResource(R.string.yes),
dismissText = stringResource(R.string.no),
onConfirm = {
showDialog.value = false
if (BuildConfig.PLAY_BUILD) {
showPlayBypassVisible.value = true
} else {
sharedPreferences.edit {
putBoolean("bypass_device_check.v2", true)
}
val intent = Intent(context, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
context.startActivity(intent)
}
},
onDismiss = {
showDialog.value = false
},
backdrop = backdrop
// hazeState = hazeState
)
if (BuildConfig.PLAY_BUILD) {
StyledBottomSheet(
visible = showPlayBypassVisible.value,
onDismiss = {
showPlayBypassVisible.value = false
showDialog.value = true
},
backdrop = backdrop
) { innerBackdrop, _ ->
val contentColor = if (isDarkTheme) Color.White else Color.Black
var acknowledged by remember { mutableStateOf(false) }
val inputState = rememberTextFieldState("")
val isValid = acknowledged && inputState.text.trim() == "OK"
val sfPro = FontFamily(Font(R.font.sf_pro))
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = stringResource(R.string.bypass_compatibility_check),
style = TextStyle(
fontFamily = sfPro,
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp,
color = contentColor
),
modifier = Modifier.padding(horizontal = 12.dp)
)
Text(
text = stringResource(R.string.compatibility_play_dialog_confirmation),
style = TextStyle(
fontFamily = sfPro,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
color = contentColor
),
modifier = Modifier.padding(horizontal = 12.dp)
)
StyledSelectList(
items = listOf(
SelectItem(
name = stringResource(R.string.read_compatibility_requirements),
selected = acknowledged,
onClick = { acknowledged = !acknowledged }
)
)
)
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) {
focusRequester.requestFocus()
keyboardController?.show()
}
StyledInputField(
inputState = inputState,
focusRequester = focusRequester,
placeholder = stringResource(R.string.type_ok_to_continue, "OK")
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(24.dp)
) {
StyledButton(
onClick = { showPlayBypassVisible.value = false },
backdrop = innerBackdrop,
modifier = Modifier.weight(1f),
) {
Text(
text = stringResource(R.string.no),
style = TextStyle(
fontFamily = sfPro,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
color = contentColor
)
)
}
StyledButton(
onClick = {
showPlayBypassVisible.value = false
sharedPreferences.edit {
putBoolean("bypass_device_check.v2", true)
val intent = Intent(context, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
context.startActivity(intent)
}
},
backdrop = innerBackdrop,
isInteractive = isValid,
modifier = Modifier.weight(1f),
enabled = isValid,
surfaceColor = if (isDarkTheme) Color(0xFF0091FF) else Color(0xFF0088FF)
) {
Text(
text = stringResource(R.string.proceed),
style = TextStyle(
fontFamily = sfPro,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
color = if (isValid) contentColor else contentColor.copy(alpha = 0.4f)
)
)
}
}
}
}
}
return
}
val isConnected = remember { mutableStateOf(false) }
var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) } var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) }
val overlaySkipped = remember { mutableStateOf(context.getSharedPreferences("settings", MODE_PRIVATE).getBoolean("overlay_permission_skipped", false)) } val overlaySkipped = remember {
mutableStateOf(
context.getSharedPreferences("settings", MODE_PRIVATE)
.getBoolean("overlay_permission_skipped", false)
)
}
val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
listOf( listOf(
@@ -301,23 +493,33 @@ fun Main() {
val permissionState = rememberMultiplePermissionsState( val permissionState = rememberMultiplePermissionsState(
permissions = allPermissions permissions = allPermissions
) )
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) } val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
val airPodsViewModel = remember(airPodsService.value) {
airPodsService.value?.let { service ->
AirPodsViewModel(
service = service,
sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE),
controlRepo = ControlCommandRepository(service.aacpManager),
appContext = context.applicationContext
)
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
canDrawOverlays = Settings.canDrawOverlays(context) canDrawOverlays = Settings.canDrawOverlays(context)
} }
if (permissionState.allPermissionsGranted && (canDrawOverlays || overlaySkipped.value)) { if (permissionState.allPermissionsGranted && (canDrawOverlays || overlaySkipped.value)) {
val context = LocalContext.current
val navController = rememberNavController() val navController = rememberNavController()
Box ( Box(
modifier = Modifier modifier = Modifier.fillMaxSize()
.fillMaxSize() ) {
){
val backButtonBackdrop = rememberLayerBackdrop() val backButtonBackdrop = rememberLayerBackdrop()
Box ( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)) .background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7))
@@ -325,129 +527,126 @@ fun Main() {
) { ) {
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = if (hookAvailable) "settings" else "onboarding", startDestination = "settings",
enterTransition = { enterTransition = {
slideInHorizontally( slideInHorizontally(
initialOffsetX = { it }, initialOffsetX = { it }, animationSpec = tween(durationMillis = 300)
animationSpec = tween(durationMillis = 300) )
) // + fadeIn(animationSpec = tween(durationMillis = 300))
}, },
exitTransition = { exitTransition = {
slideOutHorizontally( slideOutHorizontally(
targetOffsetX = { -it/4 }, targetOffsetX = { -it / 4 }, animationSpec = tween(durationMillis = 300)
animationSpec = tween(durationMillis = 300) )
) // + fadeOut(animationSpec = tween(durationMillis = 150))
}, },
popEnterTransition = { popEnterTransition = {
slideInHorizontally( slideInHorizontally(
initialOffsetX = { -it/4 }, initialOffsetX = { -it / 4 },
animationSpec = tween(durationMillis = 300) animationSpec = tween(durationMillis = 300)
) // + fadeIn(animationSpec = tween(durationMillis = 300)) )
}, },
popExitTransition = { popExitTransition = {
slideOutHorizontally( slideOutHorizontally(
targetOffsetX = { it }, targetOffsetX = { it }, animationSpec = tween(durationMillis = 300)
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("settings") {
if (airPodsViewModel != null) AirPodsSettingsScreen(airPodsViewModel, navController)
} }
composable("debug") { composable("debug") {
DebugScreen(navController = navController) DebugScreen(navController = navController)
} }
composable("long_press/{bud}") { navBackStackEntry -> composable("long_press/{bud}") { navBackStackEntry ->
LongPress( if (airPodsViewModel != null) LongPress(
navController = navController, viewModel = airPodsViewModel,
name = navBackStackEntry.arguments?.getString("bud")!! name = navBackStackEntry.arguments?.getString("bud")!!,
navController = navController
) )
} }
composable("rename") { composable("rename") {
RenameScreen(navController) if (airPodsViewModel != null) RenameScreen(airPodsViewModel)
} }
composable("app_settings") { composable("app_settings") {
AppSettingsScreen(navController) val appSettingsViewModel: AppSettingsViewModel = viewModel()
AppSettingsScreen(navController, appSettingsViewModel)
} }
composable("troubleshooting") { composable("troubleshooting") {
TroubleshootingScreen(navController) TroubleshootingScreen(navController)
} }
composable("head_tracking") { composable("head_tracking") {
HeadTrackingScreen(navController) if (airPodsViewModel != null) HeadTrackingScreen(airPodsViewModel, navController)
}
composable("onboarding") {
Onboarding(navController, context)
} }
composable("accessibility") { composable("accessibility") {
AccessibilitySettingsScreen(navController) if (airPodsViewModel != null) AccessibilitySettingsScreen(airPodsViewModel, navController)
} }
composable("transparency_customization") { composable("transparency_customization") {
TransparencySettingsScreen(navController) if (airPodsViewModel != null) TransparencySettingsScreen(airPodsViewModel)
} }
composable("hearing_aid") { composable("hearing_aid") {
HearingAidScreen(navController) if (airPodsViewModel != null) HearingAidScreen(airPodsViewModel, navController)
} }
composable("hearing_aid_adjustments") { composable("hearing_aid_adjustments") {
HearingAidAdjustmentsScreen(navController) if (airPodsViewModel != null) HearingAidAdjustmentsScreen(airPodsViewModel)
} }
composable("adaptive_strength") { composable("adaptive_strength") {
AdaptiveStrengthScreen(navController) if (airPodsViewModel != null) AdaptiveStrengthScreen(airPodsViewModel, navController)
} }
composable("camera_control") { composable("camera_control") {
CameraControlScreen(navController) if (airPodsViewModel != null) CameraControlScreen(airPodsViewModel)
} }
composable("open_source_licenses") { composable("open_source_licenses") {
OpenSourceLicensesScreen(navController) OpenSourceLicensesScreen(navController)
} }
composable("update_hearing_test") { composable("update_hearing_test") {
UpdateHearingTestScreen(navController) if (airPodsViewModel != null) UpdateHearingTestScreen()
} }
composable("version_info") { composable("version_info") {
VersionScreen(navController) if (airPodsViewModel != null) VersionScreen(airPodsViewModel)
} }
composable("hearing_protection") { composable("hearing_protection") {
HearingProtectionScreen(navController) if (airPodsViewModel != null) HearingProtectionScreen(airPodsViewModel, navController)
}
composable("purchase_screen") {
val purchaseViewModel: PurchaseViewModel = viewModel()
PurchaseScreen(purchaseViewModel, navController)
} }
} }
} }
val showBackButton = remember{ mutableStateOf(false) } val showBackButton = remember { mutableStateOf(false) }
LaunchedEffect(navController) { LaunchedEffect(navController) {
navController.addOnDestinationChangedListener { _, destination, _ -> navController.addOnDestinationChangedListener { _, destination, _ ->
showBackButton.value = destination.route != "settings" && destination.route != "onboarding" showBackButton.value =
Log.d("MainActivity", "Navigated to ${destination.route}, showBackButton: ${showBackButton.value}") destination.route != "settings" // && destination.route != "onboarding"
} }
} }
AnimatedVisibility( AnimatedVisibility(
visible = showBackButton.value, visible = showBackButton.value,
enter = fadeIn(animationSpec = tween()) + scaleIn(initialScale = 0f, animationSpec = tween()), enter = fadeIn(animationSpec = tween()) + scaleIn(
exit = fadeOut(animationSpec = tween()) + scaleOut(targetScale = 0.5f, animationSpec = tween(100)), initialScale = 0f,
animationSpec = tween()
),
exit = fadeOut(animationSpec = tween()) + scaleOut(
targetScale = 0.5f,
animationSpec = tween(100)
),
modifier = Modifier modifier = Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
.padding( .padding(
start = 8.dp, start = 8.dp, top = (LocalWindowInfo.current.containerSize.width * 0.05f).dp
top = (LocalWindowInfo.current.containerSize.width * 0.05f).dp
) )
) { ) {
StyledIconButton( StyledIconButton(
onClick = { navController.popBackStack() }, onClick = { navController.popBackStack() },
icon = "􀯶", icon = "􀯶",
darkMode = isSystemInDarkTheme(),
backdrop = backButtonBackdrop backdrop = backButtonBackdrop
) )
} }
} }
context.startForegroundService(Intent(context, AirPodsService::class.java))
serviceConnection = remember { serviceConnection = remember {
object : ServiceConnection { object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) { override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
@@ -461,17 +660,20 @@ fun Main() {
} }
} }
context.bindService(Intent(context, AirPodsService::class.java), serviceConnection, Context.BIND_AUTO_CREATE) context.bindService(
Intent(context, AirPodsService::class.java),
serviceConnection,
Context.BIND_AUTO_CREATE
)
if (airPodsService.value?.isConnectedLocally == true) { if (airPodsService.value?.isConnected() == true) {
isConnected.value = true isConnected.value = true
} }
} else { } else {
PermissionsScreen( PermissionsScreen(
permissionState = permissionState, permissionState = permissionState,
canDrawOverlays = canDrawOverlays, canDrawOverlays = canDrawOverlays,
onOverlaySettingsReturn = { canDrawOverlays = Settings.canDrawOverlays(context) } onOverlaySettingsReturn = { canDrawOverlays = Settings.canDrawOverlays(context) })
)
} }
} }
@@ -494,13 +696,9 @@ fun PermissionsScreen(
val infiniteTransition = rememberInfiniteTransition(label = "pulse") val infiniteTransition = rememberInfiniteTransition(label = "pulse")
val pulseScale by infiniteTransition.animateFloat( val pulseScale by infiniteTransition.animateFloat(
initialValue = 1f, initialValue = 1f, targetValue = 1.05f, animationSpec = infiniteRepeatable(
targetValue = 1.05f, animation = tween(1000), repeatMode = RepeatMode.Reverse
animationSpec = infiniteRepeatable( ), label = "pulse scale"
animation = tween(1000),
repeatMode = RepeatMode.Reverse
),
label = "pulse scale"
) )
Column( Column(
@@ -508,18 +706,15 @@ fun PermissionsScreen(
.fillMaxSize() .fillMaxSize()
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7)) .background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
.padding(16.dp) .padding(16.dp)
.verticalScroll(scrollState), .verticalScroll(scrollState), horizontalAlignment = Alignment.CenterHorizontally
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(180.dp), .height(180.dp), contentAlignment = Alignment.Center
contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = "\uDBC2\uDEB7", text = "\uDBC2\uDEB7", style = TextStyle(
style = TextStyle(
fontSize = 48.sp, fontSize = 48.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro)),
@@ -555,29 +750,25 @@ fun PermissionsScreen(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
text = "Permission Required", text = "Permission Required", style = TextStyle(
style = TextStyle(
fontSize = 24.sp, fontSize = 24.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor, color = textColor,
textAlign = TextAlign.Center textAlign = TextAlign.Center
), ), modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = stringResource(R.string.permissions_required), text = stringResource(R.string.permissions_required), style = TextStyle(
style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor.copy(alpha = 0.7f), color = textColor.copy(alpha = 0.7f),
textAlign = TextAlign.Center textAlign = TextAlign.Center
), ), modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
@@ -750,8 +941,7 @@ fun PermissionCard(
if (isGranted) accentColor.copy(alpha = 0.15f) else Color.Gray.copy( if (isGranted) accentColor.copy(alpha = 0.15f) else Color.Gray.copy(
alpha = 0.15f alpha = 0.15f
) )
), ), contentAlignment = Alignment.Center
contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
imageVector = icon, imageVector = icon,
@@ -767,8 +957,7 @@ fun PermissionCard(
.padding(start = 16.dp) .padding(start = 16.dp)
) { ) {
Text( Text(
text = title, text = title, 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)),
@@ -777,8 +966,7 @@ fun PermissionCard(
) )
Text( Text(
text = description, text = description, style = TextStyle(
style = TextStyle(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro)),
@@ -795,11 +983,8 @@ fun PermissionCard(
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = if (isGranted) "" else "!", text = if (isGranted) "" else "!", style = TextStyle(
style = TextStyle( fontSize = 14.sp, fontWeight = FontWeight.Bold, color = Color.White
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = Color.White
) )
) )
} }

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/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
@@ -85,15 +85,15 @@ 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.compose.ui.unit.sp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.composables.AdaptiveRainbowBrush import me.kavishdevar.librepods.presentation.components.AdaptiveRainbowBrush
import me.kavishdevar.librepods.composables.ControlCenterNoiseControlSegmentedButton import me.kavishdevar.librepods.presentation.components.ControlCenterNoiseControlSegmentedButton
import me.kavishdevar.librepods.composables.IconAreaSize import me.kavishdevar.librepods.presentation.components.IconAreaSize
import me.kavishdevar.librepods.composables.VerticalVolumeSlider import me.kavishdevar.librepods.presentation.components.VerticalVolumeSlider
import me.kavishdevar.librepods.constants.AirPodsNotifications import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.constants.NoiseControlMode import me.kavishdevar.librepods.data.NoiseControlMode
import me.kavishdevar.librepods.services.AirPodsService import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs import kotlin.math.abs

View File

@@ -0,0 +1,5 @@
package me.kavishdevar.librepods.billing
object BillingManager {
lateinit var provider: BillingProvider
}

View File

@@ -0,0 +1,30 @@
/*
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.billing
import android.app.Activity
import kotlinx.coroutines.flow.StateFlow
interface BillingProvider {
val isPremium: StateFlow<Boolean>
val price: StateFlow<String>
fun purchase(activity: Activity)
fun queryPurchases()
fun restorePurchases()
}

View File

@@ -0,0 +1,33 @@
/*
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.billing
import android.content.Context
import me.kavishdevar.librepods.BuildConfig
object BillingProviderFactory {
fun create(context: Context): BillingProvider {
return if (BuildConfig.PLAY_BUILD) {
PlayBillingProvider(context)
} else {
FOSSBillingProvider(context)
}
}
}

View File

@@ -0,0 +1,77 @@
/*
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.billing
import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.core.content.edit
import androidx.core.net.toUri
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
class FOSSBillingProvider(context: Context): BillingProvider {
private val _isPremium = MutableStateFlow(false)
override val isPremium: StateFlow<Boolean> = _isPremium
private val _price = MutableStateFlow(context.getString(R.string.name_your_own_price))
override val price: StateFlow<String> = _price
private val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var purchaseJob: Job? = null
init {
queryPurchases()
}
override fun purchase(activity: Activity) {
activity.startActivity(
Intent(Intent.ACTION_VIEW, "https://github.com/sponsors/kavishdevar".toUri())
)
purchaseJob?.cancel()
purchaseJob = scope.launch {
delay(5_000)
_isPremium.value = true
sharedPreferences.edit { putBoolean("foss_upgraded", true) }
}
}
override fun queryPurchases() {
val stored = sharedPreferences.getBoolean("foss_upgraded", false)
if (stored != _isPremium.value) {
_isPremium.value = stored
}
}
override fun restorePurchases() {
_isPremium.value = true
sharedPreferences.edit { putBoolean("foss_upgraded", true) }
}
}

View File

@@ -0,0 +1,206 @@
/*
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.billing
import android.app.Activity
import android.content.Context
import android.util.Log
import com.android.billingclient.api.AcknowledgePurchaseParams
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingClientStateListener
import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.PendingPurchasesParams
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.PurchasesUpdatedListener
import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryPurchasesParams
import com.android.billingclient.api.acknowledgePurchase
import com.android.billingclient.api.queryProductDetails
import com.android.billingclient.api.queryPurchasesAsync
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
const val TAG = "PlayBillingProvider"
private const val PREMIUM_PRODUCT_ID = "librepods.advanced_features.v2"
class PlayBillingProvider(
context: Context
) : BillingProvider, PurchasesUpdatedListener {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _isPremium = MutableStateFlow(false)
override val isPremium: StateFlow<Boolean> = _isPremium
private val _price = MutableStateFlow("unknown")
override val price: StateFlow<String> = _price
private var productDetails: ProductDetails? = null
private val billingClient = BillingClient.newBuilder(context)
.setListener(this)
.enablePendingPurchases(
PendingPurchasesParams.newBuilder().enableOneTimeProducts().build()
)
.build()
init {
connect()
}
private fun connect() {
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(result: BillingResult) {
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
scope.launch {
queryProductDetails()
queryExistingPurchases()
}
} else {
Log.w(TAG, "Billing setup failed: ${result.debugMessage}")
}
}
override fun onBillingServiceDisconnected() {
connect()
}
})
}
private suspend fun queryProductDetails() {
val params = QueryProductDetailsParams.newBuilder()
.setProductList(
listOf(
QueryProductDetailsParams.Product.newBuilder()
.setProductId(PREMIUM_PRODUCT_ID)
.setProductType(BillingClient.ProductType.INAPP)
.build()
)
).build()
val result = billingClient.queryProductDetails(params)
if (result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
productDetails = result.productDetailsList?.firstOrNull()
Log.d(TAG, "Product loaded: ${productDetails?.name}")
val priceString = productDetails
?.oneTimePurchaseOfferDetails
?.formattedPrice
if (priceString != null) {
_price.value = priceString
}
} else {
Log.w(TAG, "queryProductDetails failed: ${result.billingResult.debugMessage}")
}
}
private suspend fun queryExistingPurchases() {
val result = billingClient.queryPurchasesAsync(
QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.INAPP)
.build()
)
processPurchases(result.purchasesList)
}
override fun purchase(activity: Activity) {
val details = productDetails ?: run {
Log.e(TAG, "Product details not loaded yet")
return
}
val billingFlowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(
listOf(
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(details)
.build()
)
).build()
val result = billingClient.launchBillingFlow(activity, billingFlowParams)
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
Log.e(TAG, "launchBillingFlow failed: ${result.debugMessage}")
}
}
override fun onPurchasesUpdated(result: BillingResult, purchases: List<Purchase>?) {
when (result.responseCode) {
BillingClient.BillingResponseCode.OK -> purchases?.let { processPurchases(it) }
BillingClient.BillingResponseCode.USER_CANCELED -> Log.d(TAG, "User cancelled")
else -> Log.w(TAG, "Purchase error ${result.responseCode}: ${result.debugMessage}")
}
}
private fun processPurchases(purchases: List<Purchase>) {
val hasPremium = purchases.any {
it.products.contains(PREMIUM_PRODUCT_ID) &&
it.purchaseState == Purchase.PurchaseState.PURCHASED
}
// val purchase = purchases.find {
// it.products.contains(PREMIUM_PRODUCT_ID) && it.purchaseState == Purchase.PurchaseState.PURCHASED
// }
//
// if (purchase != null) {
// val consumeParams = ConsumeParams.newBuilder()
// .setPurchaseToken(purchase.purchaseToken)
// .build()
// scope.launch {
// billingClient.consumeAsync(consumeParams) { _, _ ->}
// }
// }
_isPremium.value = hasPremium
scope.launch {
purchases
.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED && !it.isAcknowledged }
.forEach { acknowledge(it) }
}
}
private suspend fun acknowledge(purchase: Purchase) {
val params = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.purchaseToken)
.build()
val result = billingClient.acknowledgePurchase(params)
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
Log.e(TAG, "Acknowledgement failed: ${result.debugMessage}")
}
}
override fun queryPurchases() {
scope.launch {
queryExistingPurchases()
}
}
override fun restorePurchases() {
queryPurchases()
}
}

View File

@@ -1,24 +1,24 @@
/* /*
* 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)
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.bluetooth
import android.util.Log import android.util.Log
import java.nio.ByteBuffer import java.nio.ByteBuffer
@@ -43,7 +43,7 @@ class AACPManager {
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 INFORMATION: Byte = 0x1D const val INFORMATION: Byte = 0x1D
const val RENAME: Byte = 0x1E const val RENAME: Byte = 0x1A
const val HEADTRACKING: Byte = 0x17 const val HEADTRACKING: Byte = 0x17
const val PROXIMITY_KEYS_REQ: Byte = 0x30 const val PROXIMITY_KEYS_REQ: Byte = 0x30
const val PROXIMITY_KEYS_RSP: Byte = 0x31 const val PROXIMITY_KEYS_RSP: Byte = 0x31
@@ -55,13 +55,13 @@ class AACPManager {
const val TIPI_3: Byte = 0x0C // Don't know this one const val TIPI_3: Byte = 0x0C // Don't know this one
const val SMART_ROUTING_RESP: Byte = 0x11 const val SMART_ROUTING_RESP: Byte = 0x11
const val SEND_CONNECTED_MAC: Byte = 0x14 const val SEND_CONNECTED_MAC: Byte = 0x14
const val AUDIO_SOURCE_2: Byte = 0x0C // seems redundant?
} }
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00) private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
data class ControlCommandStatus( data class ControlCommandStatus(
val identifier: ControlCommandIdentifiers, val identifier: ControlCommandIdentifiers, val value: ByteArray
val value: ByteArray
) { ) {
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
@@ -84,42 +84,31 @@ class AACPManager {
// @Suppress("unused") // @Suppress("unused")
enum class ControlCommandIdentifiers(val value: Byte) { enum class ControlCommandIdentifiers(val value: Byte) {
MIC_MODE(0x01), MIC_MODE(0x01), BUTTON_SEND_MODE(0x05), VOICE_TRIGGER(0x12), SINGLE_CLICK_MODE(0x14), DOUBLE_CLICK_MODE(
BUTTON_SEND_MODE(0x05), 0x15
VOICE_TRIGGER(0x12), ),
SINGLE_CLICK_MODE(0x14), CLICK_HOLD_MODE(0x16), DOUBLE_CLICK_INTERVAL(0x17), CLICK_HOLD_INTERVAL(0x18), LISTENING_MODE_CONFIGS(
DOUBLE_CLICK_MODE(0x15), 0x1A
CLICK_HOLD_MODE(0x16), ),
DOUBLE_CLICK_INTERVAL(0x17), ONE_BUD_ANC_MODE(0x1B), CROWN_ROTATION_DIRECTION(0x1C), LISTENING_MODE(0x0D), AUTO_ANSWER_MODE(
CLICK_HOLD_INTERVAL(0x18), 0x1E
LISTENING_MODE_CONFIGS(0x1A), ),
ONE_BUD_ANC_MODE(0x1B), CHIME_VOLUME(0x1F), VOLUME_SWIPE_INTERVAL(0x23), CALL_MANAGEMENT_CONFIG(0x24), VOLUME_SWIPE_MODE(
CROWN_ROTATION_DIRECTION(0x1C), 0x25
LISTENING_MODE(0x0D), ),
AUTO_ANSWER_MODE(0x1E), ADAPTIVE_VOLUME_CONFIG(0x26), SOFTWARE_MUTE_CONFIG(0x27), CONVERSATION_DETECT_CONFIG(
CHIME_VOLUME(0x1F), 0x28
VOLUME_SWIPE_INTERVAL(0x23), ),
CALL_MANAGEMENT_CONFIG(0x24), SSL(0x29), HEARING_AID(0x2C), AUTO_ANC_STRENGTH(0x2E), HPS_GAIN_SWIPE(0x2F), HRM_STATE(
VOLUME_SWIPE_MODE(0x25), 0x30
ADAPTIVE_VOLUME_CONFIG(0x26), ),
SOFTWARE_MUTE_CONFIG(0x27), IN_CASE_TONE_CONFIG(0x31), SIRI_MULTITONE_CONFIG(0x32), HEARING_ASSIST_CONFIG(0x33), ALLOW_OFF_OPTION(
CONVERSATION_DETECT_CONFIG(0x28), 0x34
SSL(0x29), ),
HEARING_AID(0x2C), STEM_CONFIG(0x39), SLEEP_DETECTION_CONFIG(0x35), ALLOW_AUTO_CONNECT(0x36), // not sure what this does, AUTOMATIC_CONNECTION is the only one used, but this is newer... so ¯\_(ツ)_/¯
AUTO_ANC_STRENGTH(0x2E), EAR_DETECTION_CONFIG(0x0A), AUTOMATIC_CONNECTION_CONFIG(0x20), OWNS_CONNECTION(0x06), PPE_TOGGLE_CONFIG(
HPS_GAIN_SWIPE(0x2F), 0x37
HRM_STATE(0x30), ),
IN_CASE_TONE_CONFIG(0x31),
SIRI_MULTITONE_CONFIG(0x32),
HEARING_ASSIST_CONFIG(0x33),
ALLOW_OFF_OPTION(0x34),
STEM_CONFIG(0x39),
SLEEP_DETECTION_CONFIG(0x35),
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),
AUTOMATIC_CONNECTION_CONFIG(0x20),
OWNS_CONNECTION(0x06),
PPE_TOGGLE_CONFIG(0x37),
PPE_CAP_LEVEL_CONFIG(0x38); PPE_CAP_LEVEL_CONFIG(0x38);
companion object { companion object {
@@ -129,59 +118,44 @@ class AACPManager {
} }
enum class ProximityKeyType(val value: Byte) { enum class ProximityKeyType(val value: Byte) {
IRK(0x01), IRK(0x01), ENC_KEY(0x04);
ENC_KEY(0x04);
companion object { companion object {
fun fromByte(byte: Byte): ProximityKeyType = fun fromByte(byte: Byte): ProximityKeyType = entries.find { it.value == byte }
ProximityKeyType.entries.find { it.value == byte }
?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte") ?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte")
} }
} }
enum class StemPressType(val value: Byte) { enum class StemPressType(val value: Byte) {
SINGLE_PRESS(0x05), SINGLE_PRESS(0x05), DOUBLE_PRESS(0x06), TRIPLE_PRESS(0x07), LONG_PRESS(0x08);
DOUBLE_PRESS(0x06),
TRIPLE_PRESS(0x07),
LONG_PRESS(0x08);
companion object { companion object {
fun fromByte(byte: Byte): StemPressType? = fun fromByte(byte: Byte): StemPressType? = entries.find { it.value == byte }
entries.find { it.value == byte }
} }
} }
enum class StemPressBudType(val value: Byte) { enum class StemPressBudType(val value: Byte) {
LEFT(0x01), LEFT(0x01), RIGHT(0x02);
RIGHT(0x02);
companion object { companion object {
fun fromByte(byte: Byte): StemPressBudType? = fun fromByte(byte: Byte): StemPressBudType? = entries.find { it.value == byte }
entries.find { it.value == byte }
} }
} }
enum class AudioSourceType(val value: Byte) { enum class AudioSourceType(val value: Byte) {
NONE(0x00), NONE(0x00), CALL(0x01), MEDIA(0x02);
CALL(0x01),
MEDIA(0x02);
companion object { companion object {
fun fromByte(byte: Byte): AudioSourceType? = fun fromByte(byte: Byte): AudioSourceType? = entries.find { it.value == byte }
entries.find { it.value == byte }
} }
} }
data class AudioSource( data class AudioSource(
val mac: String, val mac: String, val type: AudioSourceType
val type: AudioSourceType
) )
data class ConnectedDevice( data class ConnectedDevice(
val mac: String, val mac: String, val info1: Byte, val info2: Byte, var type: String?
val info1: Byte,
val info2: Byte,
var type: String?
) )
data class AirPodsInformation( data class AirPodsInformation(
@@ -216,7 +190,7 @@ class AACPManager {
var audioSource: AudioSource? = null var audioSource: AudioSource? = null
private set private set
var eqData = FloatArray(8) { 0.0f } var eqData = FloatArray(8)
private set private set
var eqOnPhone: Boolean = false var eqOnPhone: Boolean = false
@@ -230,14 +204,10 @@ class AACPManager {
} }
private fun setControlCommandStatusValue( private fun setControlCommandStatusValue(
identifier: ControlCommandIdentifiers, identifier: ControlCommandIdentifiers, value: ByteArray
value: ByteArray
) { ) {
val existingStatus = getControlCommandStatus(identifier) val existingStatus = getControlCommandStatus(identifier)
if (existingStatus == value) { if (existingStatus?.value.contentEquals(value)) {
controlCommandStatusList.remove(existingStatus)
}
if (existingStatus != null) {
controlCommandStatusList.remove(existingStatus) controlCommandStatusList.remove(existingStatus)
} }
controlCommandListeners[identifier]?.forEach { listener -> controlCommandListeners[identifier]?.forEach { listener ->
@@ -265,6 +235,7 @@ class AACPManager {
fun onConnectedDevicesReceived(connectedDevices: List<ConnectedDevice>) fun onConnectedDevicesReceived(connectedDevices: List<ConnectedDevice>)
fun onOwnershipToFalseRequest(sender: String, reasonReverseTapped: Boolean) fun onOwnershipToFalseRequest(sender: String, reasonReverseTapped: Boolean)
fun onShowNearbyUI(sender: String) fun onShowNearbyUI(sender: String)
fun onEQPacketReceived(eqData: FloatArray)
} }
fun parseStemPressResponse(data: ByteArray): Pair<StemPressType, StemPressBudType> { fun parseStemPressResponse(data: ByteArray): Pair<StemPressType, StemPressBudType> {
@@ -287,15 +258,13 @@ class AACPManager {
} }
fun registerControlCommandListener( fun registerControlCommandListener(
identifier: ControlCommandIdentifiers, identifier: ControlCommandIdentifiers, callback: ControlCommandListener
callback: ControlCommandListener
) { ) {
controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback) controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback)
} }
fun unregisterControlCommandListener( fun unregisterControlCommandListener(
identifier: ControlCommandIdentifiers, identifier: ControlCommandIdentifiers, callback: ControlCommandListener
callback: ControlCommandListener
) { ) {
controlCommandListeners[identifier]?.remove(callback) controlCommandListeners[identifier]?.remove(callback)
} }
@@ -330,8 +299,7 @@ class AACPManager {
fun sendControlCommand(identifier: Byte, value: ByteArray): Boolean { fun sendControlCommand(identifier: Byte, value: ByteArray): Boolean {
val controlPacket = createControlCommandPacket(identifier, value) val controlPacket = createControlCommandPacket(identifier, value)
setControlCommandStatusValue( setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(identifier) ?: return false, ControlCommandIdentifiers.fromByte(identifier) ?: return false, value
value
) )
return sendDataPacket(controlPacket) return sendDataPacket(controlPacket)
} }
@@ -340,16 +308,14 @@ class AACPManager {
fun sendControlCommand(identifier: Byte, value: Byte): Boolean { fun sendControlCommand(identifier: Byte, value: Byte): Boolean {
val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value)) val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value))
setControlCommandStatusValue( setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(identifier) ?: return false, ControlCommandIdentifiers.fromByte(identifier) ?: return false, byteArrayOf(value)
byteArrayOf(value)
) )
return sendDataPacket(controlPacket) return sendDataPacket(controlPacket)
} }
fun sendControlCommand(identifier: Byte, value: Boolean): Boolean { fun sendControlCommand(identifier: Byte, value: Boolean): Boolean {
val controlPacket = createControlCommandPacket( val controlPacket = createControlCommandPacket(
identifier, identifier, if (value) byteArrayOf(0x01) else byteArrayOf(0x02)
if (value) byteArrayOf(0x01) else byteArrayOf(0x02)
) )
setControlCommandStatusValue( setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(identifier) ?: return false, ControlCommandIdentifiers.fromByte(identifier) ?: return false,
@@ -369,8 +335,7 @@ class AACPManager {
fun parseProximityKeysResponse(data: ByteArray): Map<ProximityKeyType, ByteArray> { fun parseProximityKeysResponse(data: ByteArray): Map<ProximityKeyType, ByteArray> {
Log.d( Log.d(
TAG, TAG, "Parsing Proximity Keys Response: ${data.joinToString(" ") { "%02X".format(it) }}"
"Parsing Proximity Keys Response: ${data.joinToString(" ") { "%02X".format(it) }}"
) )
if (data.size < 4) { if (data.size < 4) {
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response") throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
@@ -395,14 +360,18 @@ class AACPManager {
} }
val key = ByteArray(keyLength) val key = ByteArray(keyLength)
System.arraycopy(data, offset, key, 0, keyLength) System.arraycopy(data, offset, key, 0, keyLength)
try {
keys[ProximityKeyType.fromByte(keyType)] = key keys[ProximityKeyType.fromByte(keyType)] = key
} catch (e: Exception) {
Log.e(
TAG, "incorrect key type received: $keyType, ${key.toHexString()}"
)
}
offset += keyLength offset += keyLength
Log.d( Log.d(
TAG, TAG, "Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${
"Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${
key.joinToString(" ") { "%02X".format(it) } key.joinToString(" ") { "%02X".format(it) }
}" }")
)
} }
return keys return keys
} }
@@ -422,32 +391,33 @@ class AACPManager {
fun receivePacket(packet: ByteArray) { fun receivePacket(packet: ByteArray) {
if (!packet.toHexString().startsWith("04000400")) { if (!packet.toHexString().startsWith("04000400")) {
Log.w( Log.w(
TAG, TAG, "Received packet does not start with expected header: ${
"Received packet does not start with expected header: ${
packet.joinToString(" ") { packet.joinToString(" ") {
"%02X".format(it) "%02X".format(it)
} }
}" }")
)
return return
} }
if (packet.size < 6) { if (packet.size < 6) {
Log.w( Log.w(
TAG, TAG, "Received packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}"
"Received packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}"
) )
return return
} }
val opcode = packet[4] when (val opcode = packet[4]) {
when (opcode) {
Opcodes.BATTERY_INFO -> { Opcodes.BATTERY_INFO -> {
callback?.onBatteryInfoReceived(packet) callback?.onBatteryInfoReceived(packet)
} }
Opcodes.CONTROL_COMMAND -> { Opcodes.CONTROL_COMMAND -> {
val controlCommand = ControlCommand.fromByteArray(packet) val controlCommand = try {
ControlCommand.fromByteArray(packet)
} catch (e: Exception) {
Log.w(TAG, "Failed to parse control command: ${e.message}")
callback?.onUnknownPacketReceived(packet)
return
}
setControlCommandStatusValue( setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return, ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return,
controlCommand.value controlCommand.value
@@ -456,10 +426,9 @@ class AACPManager {
TAG, TAG,
"Control command received: ${controlCommand.identifier.toHexString()} - ${ "Control command received: ${controlCommand.identifier.toHexString()} - ${
controlCommand.value.joinToString(" ") { "%02X".format(it) } controlCommand.value.joinToString(" ") { "%02X".format(it) }
}" }")
)
Log.d( val controlCommandListText = try {
TAG, "Control command list is now: ${
controlCommandStatusList.joinToString(", ") { it -> controlCommandStatusList.joinToString(", ") { it ->
"${it.identifier.name} (${it.identifier.value.toHexString()}) - ${ "${it.identifier.name} (${it.identifier.value.toHexString()}) - ${
it.value.joinToString( it.value.joinToString(
@@ -467,12 +436,19 @@ class AACPManager {
) { "%02X".format(it) } ) { "%02X".format(it) }
}" }"
} }
}") } catch (e: Exception) {
e.message
}
Log.d(
TAG, "Control command list is now: $controlCommandListText"
)
val controlCommandIdentifier = val controlCommandIdentifier =
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ControlCommandIdentifiers.fromByte(controlCommand.identifier)
if (controlCommandIdentifier != null) { if (controlCommandIdentifier != null) {
controlCommandListeners[controlCommandIdentifier]?.forEach { listener -> controlCommandListeners[controlCommandIdentifier]?.forEach { listener ->
Log.d(TAG, "calling listener for ${controlCommandIdentifier.name}")
listener.onControlCommandReceived(controlCommand) listener.onControlCommandReceived(controlCommand)
} }
} else { } else {
@@ -500,13 +476,11 @@ class AACPManager {
Opcodes.HEADTRACKING -> { Opcodes.HEADTRACKING -> {
if (packet.size < 70) { if (packet.size < 70) {
Log.w( Log.w(
TAG, TAG, "Received HEADTRACKING packet too short: ${
"Received HEADTRACKING packet too short: ${
packet.joinToString(" ") { packet.joinToString(" ") {
"%02X".format(it) "%02X".format(it)
} }
}" }")
)
return return
} }
callback?.onHeadTrackingReceived(packet) callback?.onHeadTrackingReceived(packet)
@@ -538,7 +512,8 @@ class AACPManager {
Opcodes.SMART_ROUTING_RESP -> { Opcodes.SMART_ROUTING_RESP -> {
val packetString = packet.decodeToString() val packetString = packet.decodeToString()
val sender = packet.sliceArray(6..11).reversedArray().joinToString(":") { "%02X".format(it) } val sender =
packet.sliceArray(6..11).reversedArray().joinToString(":") { "%02X".format(it) }
// if (connectedDevices.find { it.mac == sender }?.type == null && packetString.contains("btName")) { // if (connectedDevices.find { it.mac == sender }?.type == null && packetString.contains("btName")) {
// val nameStartIndex = packetString.indexOf("btName") + 8 // val nameStartIndex = packetString.indexOf("btName") + 8
@@ -558,9 +533,15 @@ class AACPManager {
} else if ("Android" in packetString) { } else if ("Android" in packetString) {
connectedDevices.find { it.mac == sender }?.type = "Android" connectedDevices.find { it.mac == sender }?.type = "Android"
} }
Log.d(TAG, "Smart Routing Response from $sender: $packetString, type: ${connectedDevices.find { it.mac == sender }?.type}") Log.d(
TAG,
"Smart Routing Response from $sender: $packetString, type: ${connectedDevices.find { it.mac == sender }?.type}"
)
if (packetString.contains("SetOwnershipToFalse")) { if (packetString.contains("SetOwnershipToFalse")) {
callback?.onOwnershipToFalseRequest(sender, packetString.contains("ReverseBannerTapped")) callback?.onOwnershipToFalseRequest(
sender,
packetString.contains("ReverseBannerTapped")
)
} }
if (packetString.contains("ShowNearbyUI")) { if (packetString.contains("ShowNearbyUI")) {
callback?.onShowNearbyUI(sender) callback?.onShowNearbyUI(sender)
@@ -585,25 +566,33 @@ class AACPManager {
eqOnMedia = (packet[10] == 0x01.toByte()) eqOnMedia = (packet[10] == 0x01.toByte())
eqOnPhone = (packet[11] == 0x01.toByte()) eqOnPhone = (packet[11] == 0x01.toByte())
// there are 4 eqs. i am not sure what those are for, maybe all 4 listening modes, or maybe phone+media left+right, but then there shouldn't be another flag for phone/media enabled. just directly the EQ... weird. // there are 4 eqs. i am not sure what those are for, maybe all 4 listening modes, or maybe phone+media left+right, but then there shouldn't be another flag for phone/media visible. just directly the EQ... weird.
// the EQs are little endian floats // the EQs are little endian floats
val eq1 = ByteBuffer.wrap(packet, 12, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() val eq1 =
val eq2 = ByteBuffer.wrap(packet, 44, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() ByteBuffer.wrap(packet, 12, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
val eq3 = ByteBuffer.wrap(packet, 76, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() ByteBuffer.wrap(packet, 44, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
val eq4 = ByteBuffer.wrap(packet, 108, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() ByteBuffer.wrap(packet, 76, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
ByteBuffer.wrap(packet, 108, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
// for now, taking just the first EQ // for now, taking just the first EQ
eqData = FloatArray(8) { i -> eq1.get(i) } eqData = FloatArray(8) { i -> eq1.get(i) }
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"
)
callback?.onEQPacketReceived(eqData)
} }
Opcodes.INFORMATION -> { Opcodes.INFORMATION -> {
Log.e(TAG, "Parsing Information Packet") Log.d(TAG, "Parsing Information Packet")
val information = parseInformationPacket(packet) val information = parseInformationPacket(packet)
callback?.onDeviceInformationReceived(information) callback?.onDeviceInformationReceived(information)
} }
else -> { else -> {
Log.d(TAG, "Unknown opcode received: ${opcode.toHexString()}") Log.d(TAG, "Unhandled opcode received: ${opcode.toHexString()}")
callback?.onUnknownPacketReceived(packet) callback?.onUnknownPacketReceived(packet)
} }
} }
@@ -633,10 +622,22 @@ class AACPManager {
fun createHandshakePacket(): ByteArray { fun createHandshakePacket(): ByteArray {
return byteArrayOf( return byteArrayOf(
0x00, 0x00, 0x04, 0x00, 0x00,
0x01, 0x00, 0x02, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x04,
0x00, 0x00, 0x00, 0x00 0x00,
0x01,
0x00,
0x02,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00
) )
} }
@@ -773,25 +774,40 @@ class AACPManager {
val packet = ByteArray(5 + size) val packet = ByteArray(5 + size)
packet[0] = Opcodes.RENAME packet[0] = Opcodes.RENAME
packet[1] = 0x00 packet[1] = 0x00
packet[2] = size.toByte() packet[2] = 0x01
packet[3] = 0x00 packet[3] = size.toByte()
System.arraycopy(nameBytes, 0, packet, 4, size) packet[4] = 0x00
System.arraycopy(nameBytes, 0, packet, 5, size)
return packet return packet
} }
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") Log.w(
TAG,
"Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress"
)
return false 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")
return sendDataPacket(createMediaInformationNewDevicePacket(selfMacAddress, targetMacAddress)) return sendDataPacket(
createMediaInformationNewDevicePacket(
selfMacAddress,
targetMacAddress
)
)
} }
fun createMediaInformationNewDevicePacket(selfMacAddress: String, targetMacAddress: String): ByteArray { fun createMediaInformationNewDevicePacket(
selfMacAddress: String,
targetMacAddress: String
): ByteArray {
val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00) val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00)
val buffer = ByteBuffer.allocate(116) val buffer = ByteBuffer.allocate(116)
buffer.put( buffer.put(
@@ -880,17 +896,13 @@ class AACPManager {
Log.d(TAG, "Sending Media Information packet to $targetMac") Log.d(TAG, "Sending Media Information packet to $targetMac")
return sendDataPacket( return sendDataPacket(
createMediaInformationPacket( createMediaInformationPacket(
selfMacAddress, selfMacAddress, targetMac, streamingState
targetMac,
streamingState
) )
) )
} }
fun createMediaInformationPacket( fun createMediaInformationPacket(
selfMacAddress: String, selfMacAddress: String, targetMacAddress: String, streamingState: Boolean = true
targetMacAddress: String,
streamingState: Boolean = true
): ByteArray { ): ByteArray {
val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00) val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00)
val buffer = ByteBuffer.allocate(138) val buffer = ByteBuffer.allocate(138)
@@ -905,7 +917,7 @@ class AACPManager {
) )
buffer.put(byteArrayOf(0x01, 0xE5.toByte(), 0x4A)) // unknown, constant buffer.put(byteArrayOf(0x01, 0xE5.toByte(), 0x4A)) // unknown, constant
buffer.put("PlayingApp".toByteArray()) buffer.put("PlayingApp".toByteArray())
buffer.put(byteArrayOf(0x56)) // 'V', seems like a identifier or a separator buffer.put(byteArrayOf(0x56)) // 'V', seems like an identifier or a separator
buffer.put("com.google.ios.youtube".toByteArray()) // package name, hardcoding for now, aforementioned reason buffer.put("com.google.ios.youtube".toByteArray()) // package name, hardcoding for now, aforementioned reason
buffer.put(byteArrayOf(0x52)) // 'R' buffer.put(byteArrayOf(0x52)) // 'R'
buffer.put("HostStreamingState".toByteArray()) buffer.put("HostStreamingState".toByteArray())
@@ -923,7 +935,7 @@ class AACPManager {
buffer.put("AudioCategory".toByteArray()) buffer.put("AudioCategory".toByteArray())
buffer.put(byteArrayOf(0x31, 0x2D, 0x01)) buffer.put(byteArrayOf(0x31, 0x2D, 0x01))
return opcode+buffer.array() return opcode + buffer.array()
} }
fun sendSmartRoutingShowUI(selfMacAddress: String): Boolean { fun sendSmartRoutingShowUI(selfMacAddress: String): Boolean {
@@ -1005,9 +1017,15 @@ 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") Log.w(
TAG,
"Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress"
)
return false return false
} }
Log.d(TAG, "Sending Add TiPi Device packet to $targetMacAddress") Log.d(TAG, "Sending Add TiPi Device packet to $targetMacAddress")
@@ -1041,8 +1059,7 @@ class AACPManager {
} }
data class ControlCommand( data class ControlCommand(
val identifier: Byte, val identifier: Byte, val value: ByteArray
val value: ByteArray
) { ) {
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
@@ -1064,25 +1081,25 @@ class AACPManager {
companion object { companion object {
fun fromByteArray(data: ByteArray): ControlCommand { fun fromByteArray(data: ByteArray): ControlCommand {
if (data.size < 4) { var offset = 0
throw IllegalArgumentException("Data array too short to parse ControlCommand") while (data.size - offset >= 4 &&
data[offset] == 0x04.toByte() &&
data[offset + 1] == 0x00.toByte() &&
data[offset + 2] == 0x04.toByte() &&
data[offset + 3] == 0x00.toByte()
) {
offset += 4
} }
if (data[0] == 0x04.toByte() && data[1] == 0x00.toByte() && data[2] == 0x04.toByte() && data[3] == 0x00.toByte()) { if (data.size - offset < 7) {
val newData = ByteArray(data.size - 4) throw IllegalArgumentException("Too short for ControlCommand")
System.arraycopy(data, 4, newData, 0, data.size - 4)
return fromByteArray(newData)
} }
if (data[0] != Opcodes.CONTROL_COMMAND) { if (data[offset] != Opcodes.CONTROL_COMMAND) {
throw IllegalArgumentException("Data array does not start with CONTROL_COMMAND opcode") throw IllegalArgumentException("Invalid opcode")
} }
val identifier = data[2] val identifier = data[offset + 2]
val value = data.copyOfRange(offset + 3, offset + 7)
val value = ByteArray(4) val trimmed = value.dropLastWhile { it == 0x00.toByte() }.toByteArray()
System.arraycopy(data, 3, value, 0, 4) return ControlCommand(identifier, if (trimmed.isEmpty()) byteArrayOf(0x00) else trimmed)
val trimmedValue = value.dropLastWhile { it == 0x00.toByte() }.toByteArray()
val finalValue = if (trimmedValue.isEmpty()) byteArrayOf(0x00) else trimmedValue
return ControlCommand(identifier, finalValue)
} }
} }
} }
@@ -1094,10 +1111,8 @@ class AACPManager {
triplePressCustomized: Boolean = false, triplePressCustomized: Boolean = false,
longPressCustomized: Boolean = false longPressCustomized: Boolean = false
): Boolean { ): Boolean {
val value = ((if (singlePressCustomized) 0x01 else 0) or val value =
(if (doublePressCustomized) 0x02 else 0) or ((if (singlePressCustomized) 0x01 else 0) or (if (doublePressCustomized) 0x02 else 0) or (if (triplePressCustomized) 0x04 else 0) or (if (longPressCustomized) 0x08 else 0)).toByte()
(if (triplePressCustomized) 0x04 else 0) or
(if (longPressCustomized) 0x08 else 0)).toByte()
Log.d(TAG, "Sending Stem Config Packet with value: ${value.toHexString()}") Log.d(TAG, "Sending Stem Config Packet with value: ${value.toHexString()}")
return sendControlCommand( return sendControlCommand(
ControlCommandIdentifiers.STEM_CONFIG.value, value ControlCommandIdentifiers.STEM_CONFIG.value, value
@@ -1110,21 +1125,26 @@ class AACPManager {
Log.d(TAG, "Sending packet: ${packet.joinToString(" ") { "%02X".format(it) }}") Log.d(TAG, "Sending packet: ${packet.joinToString(" ") { "%02X".format(it) }}")
if (packet[4] == Opcodes.CONTROL_COMMAND) { if (packet[4] == Opcodes.CONTROL_COMMAND) {
val controlCommand = ControlCommand.fromByteArray(packet) val controlCommand = try {
ControlCommand.fromByteArray(packet)
} catch (e: Exception) {
Log.w(TAG, "Invalid control command: ${e.message}")
callback?.onUnknownPacketReceived(packet)
return false
}
Log.d( Log.d(
TAG, TAG, "Control command: ${controlCommand.identifier.toHexString()} - ${
"Control command: ${controlCommand.identifier.toHexString()} - ${
controlCommand.value.joinToString(" ") { "%02X".format(it) } controlCommand.value.joinToString(" ") { "%02X".format(it) }
}" }")
)
setControlCommandStatusValue( setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return false, ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return false,
controlCommand.value controlCommand.value
) )
} }
val socket = BluetoothConnectionManager.getCurrentSocket() val socket = BluetoothConnectionManager.getCurrentSocket() ?: return false
if (socket?.isConnected == true) {
if (socket.isConnected) {
socket.outputStream?.write(packet) socket.outputStream?.write(packet)
socket.outputStream?.flush() socket.outputStream?.flush()
return true return true
@@ -1201,7 +1221,11 @@ 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) }
@@ -1214,6 +1238,7 @@ class AACPManager {
return devices return devices
} }
fun sendSomePacketIDontKnowWhatItIs() { fun sendSomePacketIDontKnowWhatItIs() {
// 2900 00ff ffff ffff ffff -- enables setting EQ // 2900 00ff ffff ffff ffff -- enables setting EQ
sendDataPacket( sendDataPacket(

View File

@@ -1,37 +1,38 @@
/* /*
* 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,
* and receiving notifications. It is not a complete implementation of the ATT protocol. * and receiving notifications. It is not a complete implementation of the ATT protocol.
*/ */
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.bluetooth
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket import android.bluetooth.BluetoothSocket
import android.os.ParcelUuid import android.os.ParcelUuid
import android.util.Log import android.util.Log
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.lsposed.hiddenapibypass.HiddenApiBypass
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.LinkedBlockingQueue
@@ -49,7 +50,7 @@ enum class ATTCCCDHandles(val value: Int) {
HEARING_AID(ATTHandles.HEARING_AID.value + 1), HEARING_AID(ATTHandles.HEARING_AID.value + 1),
} }
class ATTManager(private val device: BluetoothDevice) { class ATTManager(private val adapter: BluetoothAdapter, private val device: BluetoothDevice) {
companion object { companion object {
private const val TAG = "ATTManager" private const val TAG = "ATTManager"
@@ -62,18 +63,26 @@ class ATTManager(private val device: BluetoothDevice) {
private var input: InputStream? = null private var input: InputStream? = null
private var output: OutputStream? = null private var output: OutputStream? = null
private val listeners = mutableMapOf<Int, MutableList<(ByteArray) -> Unit>>() private val listeners = mutableMapOf<Int, MutableList<(ByteArray) -> Unit>>()
private var notificationJob: kotlinx.coroutines.Job? = null private var notificationJob: Job? = null
// queue for non-notification PDUs (responses to requests) // queue for non-notification PDUs (responses to requests)
private val responses = LinkedBlockingQueue<ByteArray>() private val responses = LinkedBlockingQueue<ByteArray>()
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
fun connect() { fun connect() {
HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;")
val uuid = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000") val uuid = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000")
socket = createBluetoothSocket(device, uuid) try {
socket = createBluetoothSocket(adapter, device, uuid)
} catch (e: Exception) {
Log.w(TAG, "Failed to create socket")
}
try {
socket!!.connect() socket!!.connect()
} catch (e: Exception) {
Log.w(TAG, "ATT socket failed to connect")
return
}
input = socket!!.inputStream input = socket!!.inputStream
output = socket!!.outputStream output = socket!!.outputStream
Log.d(TAG, "Connected to ATT") Log.d(TAG, "Connected to ATT")
@@ -163,6 +172,7 @@ class ATTManager(private val device: BluetoothDevice) {
} }
private fun writeRaw(pdu: ByteArray) { private fun writeRaw(pdu: ByteArray) {
if (output == null) return
output?.write(pdu) output?.write(pdu)
output?.flush() output?.flush()
Log.d(TAG, "writeRaw: ${pdu.joinToString(" ") { String.format("%02X", it) }}") Log.d(TAG, "writeRaw: ${pdu.joinToString(" ") { String.format("%02X", it) }}")
@@ -195,9 +205,10 @@ class ATTManager(private val device: BluetoothDevice) {
} }
} }
private fun createBluetoothSocket(device: BluetoothDevice, uuid: ParcelUuid): BluetoothSocket { private fun createBluetoothSocket(adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid): BluetoothSocket {
val type = 3 // L2CAP val type = 3 // L2CAP
val constructorSpecs = listOf( val constructorSpecs = listOf(
arrayOf(adapter, device, type, true, true, 31, uuid),
arrayOf(device, type, true, true, 31, uuid), arrayOf(device, type, true, true, 31, uuid),
arrayOf(device, type, 1, true, true, 31, uuid), arrayOf(device, type, 1, true, true, 31, uuid),
arrayOf(type, 1, true, true, device, 31, uuid), arrayOf(type, 1, true, true, device, 31, uuid),
@@ -219,7 +230,12 @@ class ATTManager(private val device: BluetoothDevice) {
try { try {
Log.d("ATTManager", "Trying constructor signature #${index + 1}") Log.d("ATTManager", "Trying constructor signature #${index + 1}")
attemptedConstructors++ attemptedConstructors++
return HiddenApiBypass.newInstance(BluetoothSocket::class.java, *params) as BluetoothSocket
val paramTypes = params.map { it::class.javaPrimitiveType ?: it::class.java }.toTypedArray()
val constructor = BluetoothSocket::class.java.getDeclaredConstructor(*paramTypes)
constructor.isAccessible = true
return constructor.newInstance(*params) as BluetoothSocket
} catch (e: Exception) { } catch (e: Exception) {
Log.e("ATTManager", "Constructor signature #${index + 1} failed: ${e.message}") Log.e("ATTManager", "Constructor signature #${index + 1} failed: ${e.message}")
lastException = e lastException = e

View File

@@ -1,22 +1,22 @@
/* /*
* 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
* 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/>.
*/
package me.kavishdevar.librepods.utils 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.bluetooth
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothManager
@@ -30,8 +30,10 @@ import android.content.SharedPreferences
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import me.kavishdevar.librepods.utils.BluetoothCryptography
import javax.crypto.Cipher import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec import javax.crypto.spec.SecretKeySpec
import kotlin.collections.iterator
import kotlin.io.encoding.Base64 import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi

View File

@@ -0,0 +1,40 @@
/*
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.bluetooth
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import android.util.Log
object BluetoothConnectionManager {
private const val TAG = "BluetoothConnectionManager"
private var currentSocket: BluetoothSocket? = null
private var currentDevice: BluetoothDevice? = null
fun setCurrentConnection(socket: BluetoothSocket, device: BluetoothDevice) {
currentSocket = socket
currentDevice = device
Log.d(TAG, "Current connection set to device: ${device.address}")
}
fun getCurrentSocket(): BluetoothSocket? {
return currentSocket
}
}

View File

@@ -1,128 +0,0 @@
/*
* 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/>.
*/
package me.kavishdevar.librepods.composables
import android.content.res.Configuration
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
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.padding
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.height
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
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.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
@Composable
fun BatteryIndicator(
batteryPercentage: Int,
charging: Boolean = false,
prefix: String = "",
previousCharging: Boolean = false,
) {
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color.Black else Color(0xFFF2F2F7)
val batteryTextColor = if (isDarkTheme) Color.White else Color.Black
val batteryFillColor = if (batteryPercentage > 25)
if (isDarkTheme) Color(0xFF2ED158) else Color(0xFF35C759)
else if (isDarkTheme) Color(0xFFFC4244) else Color(0xFFfe373C)
val initialScale = if (previousCharging) 1f else 0f
val scaleAnim = remember { Animatable(initialScale) }
val targetScale = if (charging) 1f else 0f
LaunchedEffect(previousCharging, charging) {
scaleAnim.animateTo(targetScale, animationSpec = tween(durationMillis = 250))
}
Column(
modifier = Modifier
.background(backgroundColor), // just for haze to work
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier.padding(bottom = 4.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
progress = { batteryPercentage / 100f },
modifier = Modifier.size(40.dp),
color = batteryFillColor,
gapSize = 0.dp,
strokeCap = StrokeCap.Round,
strokeWidth = 4.dp,
trackColor = if (isDarkTheme) Color(0xFF0E0E0F) else Color(0xFFE3E3E8)
)
Text(
text = "\uDBC0\uDEE6",
style = TextStyle(
fontSize = 12.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = batteryFillColor,
textAlign = TextAlign.Center
),
modifier = Modifier.scale(scaleAnim.value)
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "$prefix $batteryPercentage%",
color = batteryTextColor,
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
textAlign = TextAlign.Center
),
)
}
}
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun BatteryIndicatorPreview() {
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
Box(
modifier = Modifier.background(bg)
) {
BatteryIndicator(batteryPercentage = 24, charging = true, prefix = "\uDBC6\uDCE5", previousCharging = false)
}
}

View File

@@ -1,235 +0,0 @@
/*
* 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/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.res.Configuration
import android.os.Build
import android.util.Log
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.graphics.ImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.constants.Battery
import me.kavishdevar.librepods.constants.BatteryComponent
import me.kavishdevar.librepods.constants.BatteryStatus
import me.kavishdevar.librepods.services.AirPodsService
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun BatteryView(service: AirPodsService, preview: Boolean = false) {
val batteryStatus = remember { mutableStateOf<List<Battery>>(listOf()) }
val previousBatteryStatus = remember { mutableStateOf<List<Battery>>(listOf()) }
@Suppress("DEPRECATION") val batteryReceiver = remember {
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == AirPodsNotifications.BATTERY_DATA) {
batteryStatus.value =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra("data", Battery::class.java)
} else {
intent.getParcelableArrayListExtra("data")
}?.toList() ?: listOf()
}
else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
try {
context.unregisterReceiver(this)
}
catch (_: IllegalArgumentException) {
Log.wtf("BatteryReceiver", "Receiver already unregistered")
}
}
}
}
}
val context = LocalContext.current
LaunchedEffect(context) {
val batteryIntentFilter = IntentFilter()
.apply {
addAction(AirPodsNotifications.BATTERY_DATA)
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(
batteryReceiver,
batteryIntentFilter,
Context.RECEIVER_EXPORTED
)
}
}
previousBatteryStatus.value = batteryStatus.value
batteryStatus.value = service.getBattery()
if (preview) {
batteryStatus.value = listOf(
Battery(BatteryComponent.LEFT, 100, BatteryStatus.NOT_CHARGING),
Battery(BatteryComponent.RIGHT, 94, BatteryStatus.CHARGING),
Battery(BatteryComponent.CASE, 40, BatteryStatus.CHARGING)
)
previousBatteryStatus.value = batteryStatus.value
}
val left = batteryStatus.value.find { it.component == BatteryComponent.LEFT }
val right = batteryStatus.value.find { it.component == BatteryComponent.RIGHT }
val case = batteryStatus.value.find { it.component == BatteryComponent.CASE }
val leftLevel = left?.level ?: 0
val rightLevel = right?.level ?: 0
val caseLevel = case?.level ?: 0
val leftCharging = left?.status == BatteryStatus.CHARGING
val rightCharging = right?.status == BatteryStatus.CHARGING
val caseCharging = case?.status == BatteryStatus.CHARGING
val prevLeft = previousBatteryStatus.value.find { it.component == BatteryComponent.LEFT }
val prevRight = previousBatteryStatus.value.find { it.component == BatteryComponent.RIGHT }
val prevCase = previousBatteryStatus.value.find { it.component == BatteryComponent.CASE }
val prevLeftCharging = prevLeft?.status == BatteryStatus.CHARGING
val prevRightCharging = prevRight?.status == BatteryStatus.CHARGING
val prevCaseCharging = prevCase?.status == BatteryStatus.CHARGING
val singleDisplayed = remember { mutableStateOf(false) }
val airpodsInstance = service.airpodsInstance
if (airpodsInstance == null) {
return
}
val budsRes = airpodsInstance.model.budsRes
val caseRes = airpodsInstance.model.caseRes
Row {
Column (
modifier = Modifier
.fillMaxWidth(0.5f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image (
bitmap = ImageBitmap.imageResource(budsRes),
contentDescription = stringResource(R.string.buds),
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
)
if (
leftCharging == rightCharging &&
(leftLevel - rightLevel) in -3..3
)
{
BatteryIndicator(
leftLevel.coerceAtMost(rightLevel),
leftCharging,
previousCharging = (prevLeftCharging && prevRightCharging)
)
singleDisplayed.value = true
}
else {
singleDisplayed.value = false
Row (
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
if (leftLevel > 0 || left?.status != BatteryStatus.DISCONNECTED) {
BatteryIndicator(
leftLevel,
leftCharging,
"\uDBC6\uDCE5",
previousCharging = prevLeftCharging
)
}
if (leftLevel > 0 && rightLevel > 0)
{
Spacer(modifier = Modifier.width(16.dp))
}
if (rightLevel > 0 || right?.status != BatteryStatus.DISCONNECTED)
{
BatteryIndicator(
rightLevel,
rightCharging,
"\uDBC6\uDCE8",
previousCharging = prevRightCharging
)
}
}
}
}
Column (
modifier = Modifier
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
bitmap = ImageBitmap.imageResource(caseRes),
contentDescription = stringResource(R.string.case_alt),
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
)
if (caseLevel > 0 || case?.status != BatteryStatus.DISCONNECTED) {
BatteryIndicator(
caseLevel,
caseCharging,
prefix = if (!singleDisplayed.value) "\uDBC3\uDE6C" else "",
previousCharging = prevCaseCharging
)
}
}
}
}
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun BatteryViewPreview() {
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
Box(
modifier = Modifier.background(bg)
) {
BatteryView(AirPodsService(), preview = true)
}
}

View File

@@ -1,217 +0,0 @@
/*
* 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/>.
*/
package me.kavishdevar.librepods.composables
import androidx.compose.foundation.background
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.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredWidthIn
import androidx.compose.foundation.layout.width
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.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
@ExperimentalHazeMaterialsApi
@Composable
fun ConfirmationDialog(
showDialog: MutableState<Boolean>,
title: String,
message: String,
confirmText: String = "Enable",
dismissText: String = "Cancel",
onConfirm: () -> Unit,
onDismiss: () -> Unit = { showDialog.value = false },
hazeState: HazeState,
) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
if (showDialog.value) {
Dialog(onDismissRequest = { showDialog.value = false }) {
Box(
modifier = Modifier
// .fillMaxWidth(0.75f)
.requiredWidthIn(min = 200.dp, max = 360.dp)
.background(Color.Transparent, RoundedCornerShape(14.dp))
.clip(RoundedCornerShape(14.dp))
.hazeEffect(
hazeState,
style = CupertinoMaterials.regular(
containerColor = if (isDarkTheme) Color(0xFF1C1C1E).copy(alpha = 0.95f) else Color.White.copy(alpha = 0.95f)
)
)
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(24.dp))
Text(
title,
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp)
)
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(12.dp))
Text(
message,
style = TextStyle(
fontSize = 14.sp,
color = textColor.copy(alpha = 0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp)
)
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.fillMaxWidth()
)
var leftPressed by remember { mutableStateOf(false) }
var rightPressed by remember { mutableStateOf(false) }
val pressedColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
Row(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
val position = event.changes.first().position
val width = size.width.toFloat()
val height = size.height.toFloat()
val isWithinBounds = position.y >= 0 && position.y <= height
val isLeft = position.x < width / 2
event.changes.first().consume()
when (event.type) {
PointerEventType.Press -> {
if (isWithinBounds) {
leftPressed = isLeft
rightPressed = !isLeft
} else {
leftPressed = false
rightPressed = false
}
}
PointerEventType.Move -> {
if (isWithinBounds) {
leftPressed = isLeft
rightPressed = !isLeft
} else {
leftPressed = false
rightPressed = false
}
}
PointerEventType.Release -> {
if (isWithinBounds) {
if (leftPressed) {
onDismiss()
} else if (rightPressed) {
onConfirm()
}
}
leftPressed = false
rightPressed = false
}
}
}
}
},
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.background(if (leftPressed) pressedColor else Color.Transparent),
contentAlignment = Alignment.Center
) {
Text(
text = dismissText,
style = TextStyle(
color = accentColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
Box(
modifier = Modifier
.width(1.dp)
.fillMaxHeight()
.background(Color(0x40888888))
)
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.background(if (rightPressed) pressedColor else Color.Transparent),
contentAlignment = Alignment.Center
) {
Text(
text = confirmText,
style = TextStyle(
color = accentColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
}
}
}
}

View File

@@ -1,82 +0,0 @@
/*
* 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/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.content.Context.MODE_PRIVATE
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
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.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun ConnectionSettings() {
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp)
) {
StyledToggle(
label = stringResource(R.string.ear_detection),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.EAR_DETECTION_CONFIG,
sharedPreferenceKey = "automatic_ear_detection",
sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE),
independent = false
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
StyledToggle(
label = stringResource(R.string.automatically_connect),
description = stringResource(R.string.automatically_connect_description),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG,
sharedPreferenceKey = "automatic_connection_ctrl_cmd",
sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE),
independent = false
)
}
}
@Preview
@Composable
fun ConnectionSettingsPreview() {
ConnectionSettings()
}

View File

@@ -1,109 +0,0 @@
/*
* 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/>.
*/
@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
)
}
}
}

View File

@@ -1,682 +0,0 @@
/*
* 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/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.content.SharedPreferences
import android.util.Log
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.edit
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun StyledToggle(
title: String? = null,
label: String,
description: String? = null,
checkedState: MutableState<Boolean> = remember { mutableStateOf(false) } ,
sharedPreferenceKey: String? = null,
sharedPreferences: SharedPreferences? = null,
independent: Boolean = true,
enabled: Boolean = true,
onCheckedChange: ((Boolean) -> Unit)? = null,
) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
var checked by checkedState
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
if (sharedPreferenceKey != null && sharedPreferences != null) {
checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)
}
fun cb() {
if (sharedPreferences != null) {
if (sharedPreferenceKey == null) {
Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.")
return
}
sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) }
}
onCheckedChange?.invoke(checked)
}
if (independent) {
Column(modifier = Modifier.padding(vertical = 8.dp)) {
if (title != null) {
Text(
text = title,
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
),
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
)
}
Box(
modifier = Modifier
.background(animatedBackgroundColor, RoundedCornerShape(28.dp))
.padding(4.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
backgroundColor =
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor =
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
},
onTap = {
if (enabled) {
checked = !checked
cb()
}
}
)
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(55.dp)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
modifier = Modifier.weight(1f),
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Normal,
color = textColor
)
)
StyledSwitch(
checked = checked,
enabled = enabled,
onCheckedChange = {
if (enabled) {
checked = it
cb()
}
}
)
}
}
if (description != null) {
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.padding(horizontal = 16.dp)
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
) {
Text(
text = description,
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
} else {
val isPressed = remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.background(
shape = RoundedCornerShape(28.dp),
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
)
.padding(16.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed.value = true
tryAwaitRelease()
isPressed.value = false
}
)
}
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
if (enabled) {
checked = !checked
cb()
}
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = label,
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Normal,
color = textColor
)
)
Spacer(modifier = Modifier.height(4.dp))
if (description != null) {
Text(
text = description,
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
}
StyledSwitch(
checked = checked,
enabled = enabled,
onCheckedChange = {
if (enabled) {
checked = it
cb()
}
}
)
}
}
}
@Composable
fun StyledToggle(
title: String? = null,
label: String,
description: String? = null,
controlCommandIdentifier: AACPManager.Companion.ControlCommandIdentifiers,
independent: Boolean = true,
enabled: Boolean = true,
sharedPreferenceKey: String? = null,
sharedPreferences: SharedPreferences? = null,
onCheckedChange: ((Boolean) -> Unit)? = null,
) {
val service = ServiceManager.getService() ?: return
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val checkedValue = service.aacpManager.controlCommandStatusList.find {
it.identifier == controlCommandIdentifier
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
var checked by remember { mutableStateOf(checkedValue == 1.toByte()) }
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
if (sharedPreferenceKey != null && sharedPreferences != null) {
checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)
}
fun cb() {
service.aacpManager.sendControlCommand(identifier = controlCommandIdentifier.value, value = checked)
if (sharedPreferences != null) {
if (sharedPreferenceKey == null) {
Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.")
return
}
sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) }
}
onCheckedChange?.invoke(checked)
}
val listener = remember {
object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == controlCommandIdentifier.value) {
Log.d("StyledToggle", "Received control command for $label: ${controlCommand.value}")
checked = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte()
}
}
}
}
LaunchedEffect(Unit) {
service.aacpManager.registerControlCommandListener(controlCommandIdentifier, listener)
}
DisposableEffect(Unit) {
onDispose {
service.aacpManager.unregisterControlCommandListener(controlCommandIdentifier, listener)
}
}
if (independent) {
Column(modifier = Modifier.padding(vertical = 8.dp)) {
if (title != null) {
Text(
text = title,
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
),
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
)
}
Box(
modifier = Modifier
.background(animatedBackgroundColor, RoundedCornerShape(28.dp))
.padding(4.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
backgroundColor =
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor =
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
},
onTap = {
if (enabled) {
checked = !checked
cb()
}
}
)
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(55.dp)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
modifier = Modifier.weight(1f),
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Normal,
color = textColor
)
)
StyledSwitch(
checked = checked,
enabled = enabled,
onCheckedChange = {
if (enabled) {
checked = it
cb()
}
}
)
}
}
if (description != null) {
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.padding(horizontal = 16.dp)
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
) {
Text(
text = description,
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
} else {
val isPressed = remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.background(
shape = RoundedCornerShape(28.dp),
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
)
.padding(16.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed.value = true
tryAwaitRelease()
isPressed.value = false
}
)
}
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
if (enabled) {
checked = !checked
cb()
}
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = label,
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Normal,
color = textColor
)
)
Spacer(modifier = Modifier.height(4.dp))
if (description != null) {
Text(
text = description,
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
}
StyledSwitch(
checked = checked,
enabled = enabled,
onCheckedChange = {
if (enabled) {
checked = it
cb()
}
}
)
}
}
}
@Composable
fun StyledToggle(
title: String? = null,
label: String,
description: String? = null,
attHandle: ATTHandles,
independent: Boolean = true,
enabled: Boolean = true,
sharedPreferenceKey: String? = null,
sharedPreferences: SharedPreferences? = null,
onCheckedChange: ((Boolean) -> Unit)? = null,
) {
val attManager = ServiceManager.getService()?.attManager ?: return
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val checkedValue = attManager.read(attHandle).getOrNull(0)?.toInt()
var checked by remember { mutableStateOf(checkedValue !=0) }
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
attManager.enableNotifications(attHandle)
if (sharedPreferenceKey != null && sharedPreferences != null) {
checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)
}
fun cb() {
if (sharedPreferences != null) {
if (sharedPreferenceKey == null) {
Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.")
return
}
sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) }
}
onCheckedChange?.invoke(checked)
}
LaunchedEffect(checked) {
if (attManager.socket?.isConnected != true) return@LaunchedEffect
attManager.write(attHandle, if (checked) byteArrayOf(1) else byteArrayOf(0))
}
val listener = remember {
object : (ByteArray) -> Unit {
override fun invoke(value: ByteArray) {
if (value.isNotEmpty()) {
checked = value[0].toInt() != 0
Log.d("StyledToggle", "Updated from notification for $label: enabled=$checked")
} else {
Log.w("StyledToggle", "Empty value in notification for $label")
}
}
}
}
LaunchedEffect(Unit) {
attManager.registerListener(attHandle, listener)
}
DisposableEffect(Unit) {
onDispose {
attManager.unregisterListener(attHandle, listener)
}
}
if (independent) {
Column(modifier = Modifier.padding(vertical = 8.dp)) {
if (title != null) {
Text(
text = title,
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
),
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
)
}
Box(
modifier = Modifier
.background(animatedBackgroundColor, RoundedCornerShape(28.dp))
.padding(4.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
backgroundColor =
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor =
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
},
onTap = {
if (enabled) {
checked = !checked
cb()
}
}
)
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(55.dp)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
modifier = Modifier.weight(1f),
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Normal,
color = textColor
)
)
StyledSwitch(
checked = checked,
enabled = enabled,
onCheckedChange = {
if (enabled) {
checked = it
cb()
}
}
)
}
}
if (description != null) {
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.padding(horizontal = 16.dp)
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
) {
Text(
text = description,
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
} else {
val isPressed = remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.background(
shape = RoundedCornerShape(28.dp),
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
)
.padding(16.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed.value = true
tryAwaitRelease()
isPressed.value = false
}
)
}
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
if (enabled) {
checked = !checked
cb()
}
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = label,
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
if (description != null) {
Text(
text = description,
fontSize = 12.sp,
color = textColor.copy(0.6f),
lineHeight = 14.sp,
)
}
}
StyledSwitch(
checked = checked,
enabled = enabled,
onCheckedChange = {
if (enabled) {
checked = it
cb()
}
}
)
}
}
}
@Preview
@Composable
fun StyledTogglePreview() {
val context = LocalContext.current
val sharedPrefs = context.getSharedPreferences("preview", 0)
StyledToggle(
label = "Example Toggle",
description = "This is an example description for the styled toggle.",
sharedPreferences = sharedPrefs
)
}

View File

@@ -1,40 +0,0 @@
/*
* 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/>.
*/
package me.kavishdevar.librepods.constants
import me.kavishdevar.librepods.utils.AACPManager
enum class StemAction {
PLAY_PAUSE,
PREVIOUS_TRACK,
NEXT_TRACK,
DIGITAL_ASSISTANT,
CYCLE_NOISE_CONTROL_MODES;
companion object {
fun fromString(action: String): StemAction? {
return entries.find { it.name == action }
}
val defaultActions: Map<AACPManager.Companion.StemPressType, StemAction> = mapOf(
AACPManager.Companion.StemPressType.SINGLE_PRESS to PLAY_PAUSE,
AACPManager.Companion.StemPressType.DOUBLE_PRESS to NEXT_TRACK,
AACPManager.Companion.StemPressType.TRIPLE_PRESS to PREVIOUS_TRACK,
AACPManager.Companion.StemPressType.LONG_PRESS to CYCLE_NOISE_CONTROL_MODES,
)
}
}

View File

@@ -0,0 +1,277 @@
/*
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.data
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
budCaseRes = R.drawable.airpods_pro_2,
// budsRes = R.drawable.airpods_1_buds
budsRes = R.drawable.airpods_pro_2_buds,
// leftBudsRes = R.drawable.airpods_1_left
leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_1_right
rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_1_case
caseRes = R.drawable.airpods_pro_2_case,
capabilities = emptySet()
)
class AirPods2: AirPodsBase(
modelNumber = listOf("A2032", "A2031"),
name = "AirPods 2",
// budCaseRes = R.drawable.airpods_2
budCaseRes = R.drawable.airpods_pro_2,
// budsRes = R.drawable.airpods_2_buds
budsRes = R.drawable.airpods_pro_2_buds,
// leftBudsRes = R.drawable.airpods_2_left
leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_2_right
rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_2_case
caseRes = R.drawable.airpods_pro_2_case,
capabilities = emptySet()
)
class AirPods3: AirPodsBase(
modelNumber = listOf("A2565", "A2564"),
name = "AirPods 3",
// budCaseRes = R.drawable.airpods_3
budCaseRes = R.drawable.airpods_pro_2,
// budsRes = R.drawable.airpods_3_buds
budsRes = R.drawable.airpods_pro_2_buds,
// leftBudsRes = R.drawable.airpods_3_left
leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_3_right
rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_3_case
caseRes = R.drawable.airpods_pro_2_case,
capabilities = setOf(
Capability.HEAD_GESTURES
)
)
class AirPods4: AirPodsBase(
modelNumber = listOf("A3053", "A3050", "A3054"),
name = "AirPods 4",
// budCaseRes = R.drawable.airpods_4
budCaseRes = R.drawable.airpods_pro_2,
// budsRes = R.drawable.airpods_4_buds
budsRes = R.drawable.airpods_pro_2_buds,
// leftBudsRes = R.drawable.airpods_4_left
leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_4_right
rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_4_case
caseRes = R.drawable.airpods_pro_2_case,
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
budCaseRes = R.drawable.airpods_pro_2,
// budsRes = R.drawable.airpods_4_buds
budsRes = R.drawable.airpods_pro_2_buds,
// leftBudsRes = R.drawable.airpods_4_left
leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_4_right
rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_4_case
caseRes = R.drawable.airpods_pro_2_case,
capabilities = setOf(
Capability.LISTENING_MODE,
Capability.CONVERSATION_AWARENESS,
Capability.HEAD_GESTURES,
Capability.ADAPTIVE_AUDIO,
Capability.SLEEP_DETECTION,
Capability.ADAPTIVE_VOLUME,
Capability.STEM_CONFIG
)
)
class AirPodsPro1: AirPodsBase(
modelNumber = listOf("A2084", "A2083"),
name = "AirPods Pro 1",
displayName = "AirPods Pro",
// budCaseRes = R.drawable.airpods_pro_1
budCaseRes = R.drawable.airpods_pro_2,
// budsRes = R.drawable.airpods_pro_1_buds
budsRes = R.drawable.airpods_pro_2_buds,
// leftBudsRes = R.drawable.airpods_pro_1_left
leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_pro_1_right
rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_pro_1_case
caseRes = R.drawable.airpods_pro_2_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
budCaseRes = R.drawable.airpods_pro_2,
// budsRes = R.drawable.airpods_pro_2_buds
budsRes = R.drawable.airpods_pro_2_buds,
// leftBudsRes = R.drawable.airpods_pro_2_left
leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_pro_2_right
rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_pro_2_case
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
budCaseRes = R.drawable.airpods_pro_2,
// budsRes = R.drawable.airpods_pro_2_buds
budsRes = R.drawable.airpods_pro_2_buds,
// leftBudsRes = R.drawable.airpods_pro_2_left
leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_pro_2_right
rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_pro_2_case
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
budCaseRes = R.drawable.airpods_pro_2,
// budsRes = R.drawable.airpods_pro_3_buds
budsRes = R.drawable.airpods_pro_2_buds,
// leftBudsRes = R.drawable.airpods_pro_3_left
leftBudsRes = R.drawable.airpods_pro_2_left,
// rightBudsRes = R.drawable.airpods_pro_3_right
rightBudsRes = R.drawable.airpods_pro_2_right,
// caseRes = R.drawable.airpods_pro_3_case
caseRes = R.drawable.airpods_pro_2_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?,
)
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 }
}
}

View File

@@ -0,0 +1,70 @@
/*
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.data
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
class ControlCommandRepository(
private val aacpManager: AACPManager
) {
fun getValue(
identifier: ControlCommandIdentifiers
): ByteArray? {
return aacpManager.controlCommandStatusList
.find { it.identifier == identifier }
?.value
}
fun setValue(
id: ControlCommandIdentifiers,
value: ByteArray
) {
aacpManager.sendControlCommand(id.value, value)
}
fun observe(
identifier: ControlCommandIdentifiers,
onChange: (ByteArray) -> Unit
): AACPManager.ControlCommandListener {
val listener = object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
onChange(controlCommand.value)
}
}
aacpManager.registerControlCommandListener(identifier, listener)
return listener
}
fun remove(
identifier: ControlCommandIdentifiers,
listener: AACPManager.ControlCommandListener
) {
aacpManager.unregisterControlCommandListener(identifier, listener)
}
fun getMap(): Map<ControlCommandIdentifiers, ByteArray> {
return aacpManager.controlCommandStatusList.associate {
it.identifier to it.value
}
}
}

View File

@@ -1,22 +1,22 @@
/* /*
* 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
* 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/>.
*/
package me.kavishdevar.librepods.utils 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.data
import android.util.Log import android.util.Log
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
@@ -25,6 +25,8 @@ 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 kotlinx.coroutines.launch
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.bluetooth.ATTManager
import java.io.IOException import java.io.IOException
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder

View File

@@ -1,22 +1,22 @@
/* /*
* 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
* 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/>.
*/
package me.kavishdevar.librepods.constants 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.data
import android.os.Parcelable import android.os.Parcelable
import android.util.Log import android.util.Log
@@ -40,6 +40,7 @@ object BatteryStatus {
const val CHARGING = 1 const val CHARGING = 1
const val NOT_CHARGING = 2 const val NOT_CHARGING = 2
const val DISCONNECTED = 4 const val DISCONNECTED = 4
const val OPTIMIZED_CHARGING = 5
} }
@Parcelize @Parcelize
@@ -58,6 +59,7 @@ data class Battery(val component: Int, val level: Int, val status: Int) : Parcel
BatteryStatus.CHARGING -> "CHARGING" BatteryStatus.CHARGING -> "CHARGING"
BatteryStatus.NOT_CHARGING -> "NOT_CHARGING" BatteryStatus.NOT_CHARGING -> "NOT_CHARGING"
BatteryStatus.DISCONNECTED -> "DISCONNECTED" BatteryStatus.DISCONNECTED -> "DISCONNECTED"
BatteryStatus.OPTIMIZED_CHARGING -> "OPTIMIZED_CHARGING"
else -> null else -> null
} }
} }
@@ -70,6 +72,7 @@ enum class NoiseControlMode {
class AirPodsNotifications { class AirPodsNotifications {
companion object { companion object {
const val AIRPODS_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED" const val AIRPODS_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED"
const val AIRPODS_L2CAP_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED"
const val AIRPODS_DATA = "me.kavishdevar.librepods.AIRPODS_DATA" const val AIRPODS_DATA = "me.kavishdevar.librepods.AIRPODS_DATA"
const val EAR_DETECTION_DATA = "me.kavishdevar.librepods.EAR_DETECTION_DATA" const val EAR_DETECTION_DATA = "me.kavishdevar.librepods.EAR_DETECTION_DATA"
const val ANC_DATA = "me.kavishdevar.librepods.ANC_DATA" const val ANC_DATA = "me.kavishdevar.librepods.ANC_DATA"
@@ -78,6 +81,8 @@ class AirPodsNotifications {
const val AIRPODS_DISCONNECTED = "me.kavishdevar.librepods.AIRPODS_DISCONNECTED" const val AIRPODS_DISCONNECTED = "me.kavishdevar.librepods.AIRPODS_DISCONNECTED"
const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTION_DETECTED" const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTION_DETECTED"
const val DISCONNECT_RECEIVERS = "me.kavishdevar.librepods.DISCONNECT_RECEIVERS" const val DISCONNECT_RECEIVERS = "me.kavishdevar.librepods.DISCONNECT_RECEIVERS"
const val EQ_DATA = "me.kavishdevar.librepods.EQ_DATA"
const val AIRPODS_INFORMATION_UPDATED = "me.kavishdevar.librepods.AIRPODS_INFORMATION_UPDATED"
} }
class EarDetection { class EarDetection {

View File

@@ -0,0 +1,40 @@
/*
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.data
import me.kavishdevar.librepods.bluetooth.AACPManager
enum class StemAction {
PLAY_PAUSE,
PREVIOUS_TRACK,
NEXT_TRACK,
DIGITAL_ASSISTANT,
CYCLE_NOISE_CONTROL_MODES;
companion object {
fun fromString(action: String): StemAction? {
return entries.find { it.name == action }
}
val defaultActions: Map<AACPManager.Companion.StemPressType, StemAction> = mapOf(
AACPManager.Companion.StemPressType.SINGLE_PRESS to PLAY_PAUSE,
AACPManager.Companion.StemPressType.DOUBLE_PRESS to NEXT_TRACK,
AACPManager.Companion.StemPressType.TRIPLE_PRESS to PREVIOUS_TRACK,
AACPManager.Companion.StemPressType.LONG_PRESS to CYCLE_NOISE_CONTROL_MODES,
)
}
}

View File

@@ -1,28 +1,29 @@
/* /*
* 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
* 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/>.
*/
package me.kavishdevar.librepods.utils 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.data
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers 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 kotlinx.coroutines.launch
import me.kavishdevar.librepods.bluetooth.ATTHandles
import java.io.IOException import java.io.IOException
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
@@ -139,7 +140,7 @@ fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings {
private var debounceJob: Job? = null private var debounceJob: Job? = null
fun sendTransparencySettings(attManager: ATTManager, transparencySettings: TransparencySettings) { fun sendTransparencySettings(writer: (ATTHandles, ByteArray) -> Unit, transparencySettings: TransparencySettings) {
debounceJob?.cancel() debounceJob?.cancel()
debounceJob = CoroutineScope(Dispatchers.IO).launch { debounceJob = CoroutineScope(Dispatchers.IO).launch {
delay(100) delay(100)
@@ -171,7 +172,7 @@ fun sendTransparencySettings(attManager: ATTManager, transparencySettings: Trans
} }
val data = buffer.array() val data = buffer.array()
attManager.write(ATTHandles.TRANSPARENCY, value = data) writer(ATTHandles.TRANSPARENCY, data)
} catch (e: IOException) { } catch (e: IOException) {
e.printStackTrace() e.printStackTrace()
} }

View File

@@ -0,0 +1,8 @@
package me.kavishdevar.librepods.data
interface XposedRemotePref {
fun isAvailable(): Boolean
fun getBoolean(key: String, def: Boolean): Boolean
fun putBoolean(key: String, value: Boolean)
}

View File

@@ -0,0 +1,5 @@
package me.kavishdevar.librepods.data
object XposedRemotePrefProvider {
fun create(): XposedRemotePref = XposedRemotePrefImpl()
}

View File

@@ -1,29 +1,29 @@
/* /*
* 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)
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -34,35 +34,35 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
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.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
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
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
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
import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@Composable @Composable
fun AboutCard(navController: NavController) { fun AboutCard(
navController: NavController,
modelName: String,
actualModel: String,
serialNumbers: List<String>,
version: String?
) {
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
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Box( Box(
@@ -75,7 +75,8 @@ fun AboutCard(navController: NavController) {
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))
) )
) )
} }
@@ -108,7 +109,7 @@ fun AboutCard(navController: NavController) {
) )
) )
Text( Text(
text = airpodsInstance.model.displayName, text = modelName,
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f), color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
@@ -137,7 +138,7 @@ fun AboutCard(navController: NavController) {
) )
) )
Text( Text(
text = airpodsInstance.actualModelNumber, text = actualModel,
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f), color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
@@ -152,11 +153,11 @@ fun AboutCard(navController: NavController) {
.padding(horizontal = 12.dp) .padding(horizontal = 12.dp)
) )
val serialNumbers = listOf( val serialNumbers = listOf(
airpodsInstance.serialNumber?: "", serialNumbers[0],
"􀀛 ${airpodsInstance.leftSerialNumber}", "􀀛 ${serialNumbers[1]}",
"􀀧 ${airpodsInstance.rightSerialNumber}" "􀀧 ${serialNumbers[2]}"
) )
val serialNumber = remember { mutableStateOf(0) } val serialNumber = remember { mutableIntStateOf(0) }
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -172,7 +173,7 @@ fun AboutCard(navController: NavController) {
), ),
) )
Text( Text(
text = serialNumbers[serialNumber.value], text = serialNumbers[serialNumber.intValue],
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f), color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
@@ -183,7 +184,7 @@ fun AboutCard(navController: NavController) {
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null indication = null
) { ) {
serialNumber.value = (serialNumber.value + 1) % serialNumbers.size serialNumber.intValue = (serialNumber.intValue + 1) % serialNumbers.size
} }
) )
} }
@@ -197,7 +198,7 @@ fun AboutCard(navController: NavController) {
to = "version_info", to = "version_info",
navController = navController, navController = navController,
name = stringResource(R.string.version), name = stringResource(R.string.version),
currentState = airpodsInstance.version3, currentState = version,
independent = false, independent = false,
height = rowHeight.value + 32.dp height = rowHeight.value + 32.dp
) )

View File

@@ -0,0 +1,193 @@
/*
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.presentation.components
import androidx.compose.foundation.background
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.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.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
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 me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R
@Composable
fun AppInfoCard() {
val rowHeight = remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
Column {
Box(
modifier = Modifier
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
.padding(start = 16.dp, bottom = 8.dp, end = 4.dp)
) {
Text(
text = stringResource(R.string.about), style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
Column(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.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.version), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = BuildConfig.VERSION_NAME, 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.version_code), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = BuildConfig.VERSION_CODE.toString(), 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.flavor), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = BuildConfig.FLAVOR, 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.build_type), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = BuildConfig.BUILD_TYPE,
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))
)
)
}
}
}
}

View File

@@ -1,24 +1,24 @@
/* /*
* 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)
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@@ -35,6 +35,8 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource 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.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
@@ -42,25 +44,32 @@ 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.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,
adaptiveVolumeCapability: Boolean,
conversationalAwarenessCapability: Boolean,
loudSoundReductionCapability: Boolean,
adaptiveAudioCapability: Boolean,
adaptiveVolumeChecked: Boolean,
onAdaptiveVolumeCheckedChange: (Boolean) -> Unit,
conversationalAwarenessChecked: Boolean,
onConversationalAwarenessCheckedChange: (Boolean) -> Unit,
loudSoundReductionChecked: Boolean,
onLoudSoundReductionCheckedChange: (Boolean) -> Unit,
vendorIdHook: Boolean,
isPremium: Boolean
) {
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 if (!adaptiveVolumeCapability && !conversationalAwarenessCapability && !loudSoundReductionCapability && !adaptiveAudioCapability) {
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 return
} }
Box( Box(
@@ -73,7 +82,8 @@ fun AudioSettings(navController: NavController) {
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))
) )
) )
} }
@@ -88,12 +98,14 @@ fun AudioSettings(navController: NavController) {
.padding(top = 2.dp) .padding(top = 2.dp)
) { ) {
if (airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_VOLUME)) { if (adaptiveVolumeCapability) {
StyledToggle( StyledToggle(
label = stringResource(R.string.personalized_volume), label = stringResource(R.string.personalized_volume),
description = stringResource(R.string.personalized_volume_description), description = stringResource(R.string.personalized_volume_description),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG, independent = false,
independent = false checked = adaptiveVolumeChecked,
onCheckedChange = onAdaptiveVolumeCheckedChange,
enabled = isPremium
) )
HorizontalDivider( HorizontalDivider(
@@ -104,12 +116,14 @@ fun AudioSettings(navController: NavController) {
) )
} }
if (airpodsInstance.model.capabilities.contains(Capability.CONVERSATION_AWARENESS)) { if (conversationalAwarenessCapability) {
StyledToggle( StyledToggle(
label = stringResource(R.string.conversational_awareness), label = stringResource(R.string.conversational_awareness),
description = stringResource(R.string.conversational_awareness_description), description = stringResource(R.string.conversational_awareness_description),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG, independent = false,
independent = false checked = conversationalAwarenessChecked,
onCheckedChange = onConversationalAwarenessCheckedChange,
enabled = isPremium
) )
HorizontalDivider( HorizontalDivider(
thickness = 1.dp, thickness = 1.dp,
@@ -119,12 +133,14 @@ fun AudioSettings(navController: NavController) {
) )
} }
if (airpodsInstance.model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION)){ if (loudSoundReductionCapability && vendorIdHook){
StyledToggle( StyledToggle(
label = stringResource(R.string.loud_sound_reduction), label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description), description = stringResource(R.string.loud_sound_reduction_description),
attHandle = ATTHandles.LOUD_SOUND_REDUCTION, independent = false,
independent = false checked = loudSoundReductionChecked,
onCheckedChange = onLoudSoundReductionCheckedChange,
enabled = isPremium
) )
HorizontalDivider( HorizontalDivider(
thickness = 1.dp, thickness = 1.dp,
@@ -134,7 +150,7 @@ fun AudioSettings(navController: NavController) {
) )
} }
if (airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_AUDIO)) { if (adaptiveAudioCapability) {
NavigationButton( NavigationButton(
to = "adaptive_strength", to = "adaptive_strength",
name = stringResource(R.string.adaptive_audio), name = stringResource(R.string.adaptive_audio),
@@ -148,5 +164,19 @@ fun AudioSettings(navController: NavController) {
@Preview @Preview
@Composable @Composable
fun AudioSettingsPreview() { fun AudioSettingsPreview() {
AudioSettings(rememberNavController()) AudioSettings(
navController = rememberNavController(),
adaptiveVolumeCapability = true,
conversationalAwarenessCapability = true,
loudSoundReductionCapability = true,
adaptiveAudioCapability = true,
adaptiveVolumeChecked = true,
onAdaptiveVolumeCheckedChange = { },
conversationalAwarenessChecked = true,
onConversationalAwarenessCheckedChange = { },
loudSoundReductionChecked = true,
onLoudSoundReductionCheckedChange = { },
vendorIdHook = true,
isPremium = true
)
} }

View File

@@ -0,0 +1,214 @@
/*
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.presentation.components
import android.content.res.Configuration
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
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.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
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.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.BatteryStatus
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.sin
import kotlin.math.sqrt
@Composable
fun BatteryIndicator(
batteryPercentage: Int,
status: Int,
prefix: String = "",
previousCharging: Boolean = false,
) {
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color.Black else Color(0xFFF2F2F7)
val batteryTextColor = if (isDarkTheme) Color.White else Color.Black
val batteryFillColor =
if (batteryPercentage > 25) if (isDarkTheme) Color(0xFF2ED158) else Color(0xFF35C759)
else if (isDarkTheme) Color(0xFFFC4244) else Color(0xFFfe373C)
val initialScale = if (previousCharging) 1f else 0f
val scaleAnim = remember { Animatable(initialScale) }
val charging = status == BatteryStatus.CHARGING || status == BatteryStatus.OPTIMIZED_CHARGING
val targetScale = if (charging) 1f else 0f
LaunchedEffect(previousCharging, charging) {
scaleAnim.animateTo(targetScale, animationSpec = tween(durationMillis = 250))
}
Column(
modifier = Modifier.background(backgroundColor).padding(4.dp), // just for haze to work
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier.padding(bottom = 4.dp), contentAlignment = Alignment.Center
) {
val strokeWidthPx = with(LocalDensity.current) { 4.dp.toPx() }
val gapFromCenterPx = with(LocalDensity.current) { 8.sp.toPx() }
val trackColor = if (isDarkTheme) Color(0xFF272728) else Color(0xFFE3E3E8)
val optimizedLimit = 0.8f
val progress = batteryPercentage / 100f
Canvas(modifier = Modifier.size(34.dp)) {
val startAngle = -90f
val stroke = Stroke(width = strokeWidthPx, cap = StrokeCap.Round)
val inset = strokeWidthPx / 2
Rect(
left = inset,
top = inset,
right = size.width - inset,
bottom = size.height - inset
)
val radius = size.minDimension / 2
if (status == BatteryStatus.OPTIMIZED_CHARGING) {
drawArc(
color = trackColor,
startAngle = startAngle,
sweepAngle = 360f * optimizedLimit,
useCenter = false,
style = stroke
)
val sweep = 360f * min(progress, optimizedLimit)
drawArc(
color = batteryFillColor,
startAngle = startAngle,
sweepAngle = sweep,
useCenter = false,
style = stroke
)
// ---- PILL MARKER AT 80% ----
val angleDeg = startAngle + 360f * optimizedLimit
val angleRad = Math.toRadians(angleDeg.toDouble())
val arcRadius = radius - strokeWidthPx
val outerX = center.x + arcRadius * cos(angleRad).toFloat()
val outerY = center.y + arcRadius * sin(angleRad).toFloat()
val dirX = center.x - outerX
val dirY = center.y - outerY
val length = sqrt(dirX * dirX + dirY * dirY)
val normX = dirX / length
val normY = dirY / length
val startX = outerX - normX * strokeWidthPx / 2
val startY = outerY - normY * strokeWidthPx / 2
val endX = center.x - normX * gapFromCenterPx
val endY = center.y - normY * gapFromCenterPx
drawLine(
color = if (batteryPercentage >= 80) batteryFillColor else trackColor,
start = Offset(startX, startY),
end = Offset(endX, endY),
strokeWidth = strokeWidthPx,
cap = StrokeCap.Round
)
} else {
drawArc(
color = trackColor,
startAngle = 0f,
sweepAngle = 360f,
useCenter = false,
style = stroke
)
drawArc(
color = batteryFillColor,
startAngle = startAngle,
sweepAngle = 360f * progress,
useCenter = false,
style = stroke
)
}
}
Text(
text = "\uDBC0\uDEE6", style = TextStyle(
fontSize = 14.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = batteryFillColor,
textAlign = TextAlign.Center
), modifier = Modifier.scale(scaleAnim.value)
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "$prefix $batteryPercentage%",
color = batteryTextColor,
style = TextStyle(
fontSize = 14.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
textAlign = TextAlign.Center
),
)
}
}
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun BatteryIndicatorPreview() {
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
Box(
modifier = Modifier.background(bg)
) {
BatteryIndicator(
batteryPercentage = 50,
status = BatteryStatus.OPTIMIZED_CHARGING,
prefix = "\uDBC6\uDCE5",
previousCharging = false
)
}
}

View File

@@ -0,0 +1,177 @@
/*
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.presentation.components
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.Composable
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.graphics.ImageBitmap
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.Battery
import me.kavishdevar.librepods.data.BatteryComponent
import me.kavishdevar.librepods.data.BatteryStatus
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun BatteryView(
batteryList: List<Battery>,
budsRes: Int,
caseRes: Int
) {
val left = batteryList.find { it.component == BatteryComponent.LEFT }
val right = batteryList.find { it.component == BatteryComponent.RIGHT }
val case = batteryList.find { it.component == BatteryComponent.CASE }
val leftLevel = left?.level ?: 0
val rightLevel = right?.level ?: 0
val caseLevel = case?.level ?: 0
val caseCharging = case?.status == BatteryStatus.CHARGING ||
case?.status == BatteryStatus.OPTIMIZED_CHARGING
val singleDisplayed = remember { mutableStateOf(false) }
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Row(
modifier = Modifier.widthIn(max = 500.dp),
horizontalArrangement = Arrangement.Center
) {
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
bitmap = ImageBitmap.imageResource(budsRes),
contentDescription = stringResource(R.string.buds),
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
)
if (
left?.status == right?.status &&
(leftLevel - rightLevel) in -3..3
) {
BatteryIndicator(
leftLevel.coerceAtMost(rightLevel),
left?.status ?: BatteryStatus.NOT_CHARGING
)
singleDisplayed.value = true
} else {
singleDisplayed.value = false
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
if (leftLevel > 0 || left?.status != BatteryStatus.DISCONNECTED) {
BatteryIndicator(
leftLevel,
left?.status ?: BatteryStatus.NOT_CHARGING,
"\uDBC6\uDCE5"
)
}
if (leftLevel > 0 && rightLevel > 0) {
Spacer(modifier = Modifier.width(16.dp))
}
if (rightLevel > 0 || right?.status != BatteryStatus.DISCONNECTED) {
BatteryIndicator(
rightLevel,
right?.status ?: BatteryStatus.NOT_CHARGING,
"\uDBC6\uDCE8"
)
}
}
}
}
Column(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
bitmap = ImageBitmap.imageResource(caseRes),
contentDescription = stringResource(R.string.case_alt),
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
)
if (caseLevel > 0 || case?.status != BatteryStatus.DISCONNECTED) {
BatteryIndicator(
caseLevel,
case?.status ?: BatteryStatus.NOT_CHARGING,
prefix = if (!singleDisplayed.value) "\uDBC3\uDE6C" else ""
)
}
}
}
}
}
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun BatteryViewPreview() {
val fakeBattery = listOf(
Battery(BatteryComponent.LEFT, 85, BatteryStatus.CHARGING),
Battery(BatteryComponent.RIGHT, 40, BatteryStatus.OPTIMIZED_CHARGING),
Battery(BatteryComponent.CASE, 60, BatteryStatus.NOT_CHARGING)
)
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
Box(
modifier = Modifier
.background(bg)
.padding(16.dp)
) {
BatteryView(
batteryList = fakeBattery,
budsRes = R.drawable.airpods_pro_2_buds,
caseRes = R.drawable.airpods_pro_2_case
)
}
}

View File

@@ -1,24 +1,24 @@
/* /*
* 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)
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import android.util.Log import android.util.Log
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -36,39 +36,43 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
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.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue 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.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource 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
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.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@Composable @Composable
fun CallControlSettings(hazeState: HazeState) { fun CallControlSettings(
hazeState: HazeState,
flipped: Boolean,
onCallControlValueChanged: (Boolean) -> Unit
) {
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 backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
@@ -82,7 +86,8 @@ fun CallControlSettings(hazeState: HazeState) {
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))
) )
) )
} }
@@ -93,24 +98,13 @@ fun CallControlSettings(hazeState: HazeState) {
.background(backgroundColor, RoundedCornerShape(28.dp)) .background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp) .padding(top = 2.dp)
) { ) {
val service = ServiceManager.getService()!!
val callControlEnabledValue = service.aacpManager.controlCommandStatusList.find { val scope = rememberCoroutineScope()
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG val haptics = LocalHapticFeedback.current
}?.value ?: byteArrayOf(0x00, 0x03)
val pressOnceText = stringResource(R.string.press_once) val pressOnceText = stringResource(R.string.press_once)
val pressTwiceText = stringResource(R.string.press_twice) val pressTwiceText = stringResource(R.string.press_twice)
var flipped by remember {
mutableStateOf(
callControlEnabledValue.contentEquals(
byteArrayOf(
0x00,
0x02
)
)
)
}
var singlePressAction by remember { mutableStateOf(if (flipped) pressTwiceText else pressOnceText) } var singlePressAction by remember { mutableStateOf(if (flipped) pressTwiceText else pressOnceText) }
var doublePressAction by remember { mutableStateOf(if (flipped) pressOnceText else pressTwiceText) } var doublePressAction by remember { mutableStateOf(if (flipped) pressOnceText else pressTwiceText) }
@@ -120,6 +114,7 @@ fun CallControlSettings(hazeState: HazeState) {
var lastDismissTimeSingle by remember { mutableLongStateOf(0L) } var lastDismissTimeSingle by remember { mutableLongStateOf(0L) }
var parentHoveredIndexSingle by remember { mutableStateOf<Int?>(null) } var parentHoveredIndexSingle by remember { mutableStateOf<Int?>(null) }
var parentDragActiveSingle by remember { mutableStateOf(false) } var parentDragActiveSingle by remember { mutableStateOf(false) }
var previousIdxSingle by remember { mutableStateOf<Int?>(null) }
var showDoublePressDropdown by remember { mutableStateOf(false) } var showDoublePressDropdown by remember { mutableStateOf(false) }
var touchOffsetDouble by remember { mutableStateOf<Offset?>(null) } var touchOffsetDouble by remember { mutableStateOf<Offset?>(null) }
@@ -127,36 +122,8 @@ fun CallControlSettings(hazeState: HazeState) {
var lastDismissTimeDouble by remember { mutableLongStateOf(0L) } var lastDismissTimeDouble by remember { mutableLongStateOf(0L) }
var parentHoveredIndexDouble by remember { mutableStateOf<Int?>(null) } var parentHoveredIndexDouble by remember { mutableStateOf<Int?>(null) }
var parentDragActiveDouble by remember { mutableStateOf(false) } var parentDragActiveDouble by remember { mutableStateOf(false) }
var previousIdxDouble by remember { mutableStateOf<Int?>(null) }
LaunchedEffect(Unit) {
val listener = object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (AACPManager.Companion.ControlCommandIdentifiers.fromByte(controlCommand.identifier) ==
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG
) {
val newFlipped = controlCommand.value.contentEquals(byteArrayOf(0x00, 0x02))
flipped = newFlipped
singlePressAction = if (newFlipped) pressTwiceText else pressOnceText
doublePressAction = if (newFlipped) pressOnceText else pressTwiceText
Log.d(
"CallControlSettings",
"Control command received, flipped: $newFlipped"
)
}
}
}
service.aacpManager.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
listener
)
}
DisposableEffect(Unit) {
onDispose {
service.aacpManager.controlCommandListeners[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.clear()
}
}
LaunchedEffect(flipped) { LaunchedEffect(flipped) {
Log.d("CallControlSettings", "Call control flipped: $flipped") Log.d("CallControlSettings", "Call control flipped: $flipped")
} }
@@ -231,7 +198,11 @@ fun CallControlSettings(hazeState: HazeState) {
val touch = touchOffsetSingle ?: current val touch = touchOffsetSingle ?: current
val posInPopupY = current.y - touch.y val posInPopupY = current.y - touch.y
val idx = (posInPopupY / itemHeightPx).toInt() val idx = (posInPopupY / itemHeightPx).toInt()
if (idx != previousIdxSingle) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
}
parentHoveredIndexSingle = idx parentHoveredIndexSingle = idx
previousIdxSingle = idx
}, },
onDragEnd = { onDragEnd = {
parentDragActiveSingle = false parentDragActiveSingle = false
@@ -244,13 +215,13 @@ fun CallControlSettings(hazeState: HazeState) {
if (option == pressOnceText) pressTwiceText else pressOnceText if (option == pressOnceText) pressTwiceText else pressOnceText
showSinglePressDropdown = false showSinglePressDropdown = false
lastDismissTimeSingle = System.currentTimeMillis() lastDismissTimeSingle = System.currentTimeMillis()
val bytes = if (option == pressOnceText) byteArrayOf( onCallControlValueChanged(option != pressOnceText)
0x00,
0x03
) else byteArrayOf(0x00, 0x02)
service.aacpManager.sendControlCommand(0x24, bytes)
} }
} }
if (parentHoveredIndexSingle != null && parentHoveredIndexSingle in 0..1) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
}
parentHoveredIndexSingle = null parentHoveredIndexSingle = null
}, },
onDragCancel = { onDragCancel = {
@@ -313,11 +284,8 @@ fun CallControlSettings(hazeState: HazeState) {
doublePressAction = doublePressAction =
if (option == pressOnceText) pressTwiceText else pressOnceText if (option == pressOnceText) pressTwiceText else pressOnceText
showSinglePressDropdown = false showSinglePressDropdown = false
val bytes = if (option == pressOnceText) byteArrayOf( val flipped = option != pressOnceText
0x00, onCallControlValueChanged(flipped)
0x03
) else byteArrayOf(0x00, 0x02)
service.aacpManager.sendControlCommand(0x24, bytes)
}, },
hazeState = hazeState hazeState = hazeState
) )
@@ -366,7 +334,11 @@ fun CallControlSettings(hazeState: HazeState) {
val touch = touchOffsetDouble ?: current val touch = touchOffsetDouble ?: current
val posInPopupY = current.y - touch.y val posInPopupY = current.y - touch.y
val idx = (posInPopupY / itemHeightPx).toInt() val idx = (posInPopupY / itemHeightPx).toInt()
if (idx != previousIdxDouble) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
}
parentHoveredIndexDouble = idx parentHoveredIndexDouble = idx
previousIdxDouble = idx
}, },
onDragEnd = { onDragEnd = {
parentDragActiveDouble = false parentDragActiveDouble = false
@@ -379,13 +351,13 @@ fun CallControlSettings(hazeState: HazeState) {
if (option == pressOnceText) pressTwiceText else pressOnceText if (option == pressOnceText) pressTwiceText else pressOnceText
showDoublePressDropdown = false showDoublePressDropdown = false
lastDismissTimeDouble = System.currentTimeMillis() lastDismissTimeDouble = System.currentTimeMillis()
val bytes = if (option == pressOnceText) byteArrayOf( val flipped = option == pressOnceText
0x00, onCallControlValueChanged(flipped)
0x02
) else byteArrayOf(0x00, 0x03)
service.aacpManager.sendControlCommand(0x24, bytes)
} }
} }
if (parentHoveredIndexDouble != null && parentHoveredIndexDouble in 0..1) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
}
parentHoveredIndexDouble = null parentHoveredIndexDouble = null
}, },
onDragCancel = { onDragCancel = {
@@ -448,11 +420,8 @@ fun CallControlSettings(hazeState: HazeState) {
singlePressAction = singlePressAction =
if (option == pressOnceText) pressTwiceText else pressOnceText if (option == pressOnceText) pressTwiceText else pressOnceText
showDoublePressDropdown = false showDoublePressDropdown = false
val bytes = if (option == pressOnceText) byteArrayOf( val flipped = option == pressOnceText
0x00, onCallControlValueChanged(flipped)
0x02
) else byteArrayOf(0x00, 0x03)
service.aacpManager.sendControlCommand(0x24, bytes)
}, },
hazeState = hazeState hazeState = hazeState
) )
@@ -461,10 +430,3 @@ fun CallControlSettings(hazeState: HazeState) {
} }
} }
} }
@ExperimentalHazeMaterialsApi
@Preview
@Composable
fun CallControlSettingsPreview() {
CallControlSettings(HazeState())
}

View File

@@ -0,0 +1,179 @@
/*
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.presentation.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.requiredWidthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.kyant.backdrop.backdrops.LayerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.effects.vibrancy
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
@ExperimentalHazeMaterialsApi
@Composable
fun ConfirmationDialog(
showDialog: MutableState<Boolean>,
title: String,
message: String,
confirmText: String = "Enable",
dismissText: String = "Cancel",
onConfirm: () -> Unit,
onDismiss: () -> Unit = { showDialog.value = false },
backdrop: LayerBackdrop,
) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val accentColor = if (isDarkTheme) Color(0xFF0091FF) else Color(0xFF0088FF)
AnimatedVisibility(
visible = showDialog.value,
enter = scaleIn(initialScale = 1.05f) + fadeIn(),
exit = scaleOut(targetScale = 1.05f) + fadeOut()
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
val innerBackdrop = rememberLayerBackdrop()
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.4f))
.clickable(enabled = false, onClick = {}),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.requiredWidthIn(min = 200.dp, max = 360.dp)
.clip(RoundedCornerShape(48.dp))
.drawBackdrop(
backdrop = backdrop,
exportedBackdrop = innerBackdrop,
shape = { RoundedCornerShape(48.dp) },
effects = {
vibrancy()
blur(4f.dp.toPx())
lens(12f.dp.toPx(), 48f.dp.toPx(), true)
},
onDrawSurface = {
drawRect(
if (isDarkTheme) Color(0xFF1F1F1F).copy(alpha = 0.35f) else Color(0xFFE0E0E0).copy(alpha = 0.7f)
)
})) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(modifier = Modifier.height(24.dp))
Text(
title,
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(12.dp))
Text(
message,
style = TextStyle(
fontSize = 14.sp,
color = textColor.copy(alpha = 0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(0.9f),
horizontalArrangement = Arrangement.spacedBy(24.dp)
) {
StyledButton(
onClick = onDismiss,
backdrop = innerBackdrop,
modifier = Modifier.weight(1f),
) {
Text(
text = dismissText, style = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
color = textColor
)
)
}
StyledButton(
onClick = onConfirm,
backdrop = innerBackdrop,
modifier = Modifier.weight(1f),
surfaceColor = accentColor
) {
Text(
text = confirmText, style = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
color = Color.White
)
)
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
}
}
}

View File

@@ -0,0 +1,75 @@
/*
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.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
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.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 me.kavishdevar.librepods.R
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun ConnectionSettings(
automaticEarDetectionEnabled: Boolean,
onAutomaticEarDetectionChanged: (Boolean) -> Unit,
automaticConnectionEnabled: Boolean,
onAutomaticConnectionChanged: (Boolean) -> Unit,
) {
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp)
) {
StyledToggle(
label = stringResource(R.string.ear_detection),
independent = false,
checked = automaticEarDetectionEnabled,
onCheckedChange = onAutomaticEarDetectionChanged
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
StyledToggle(
label = stringResource(R.string.automatically_connect),
description = stringResource(R.string.automatically_connect_description),
independent = false,
checked = automaticConnectionEnabled,
onCheckedChange = onAutomaticConnectionChanged
)
}
}

View File

@@ -1,24 +1,24 @@
/* /*
* 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")
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background

View File

@@ -1,22 +1,22 @@
/* /*
* 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
* 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/>.
*/
package me.kavishdevar.librepods.composables 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.presentation.components
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
@@ -56,7 +56,7 @@ 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 me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.NoiseControlMode import me.kavishdevar.librepods.data.NoiseControlMode
private val ContainerColor = Color(0x593C3C3E) private val ContainerColor = Color(0x593C3C3E)
private val SelectedIndicatorColorGray = Color(0xFF6C6C6E) private val SelectedIndicatorColorGray = Color(0xFF6C6C6E)

View File

@@ -0,0 +1,235 @@
package me.kavishdevar.librepods.presentation.components
import android.os.Build
import androidx.compose.foundation.background
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.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.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
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 me.kavishdevar.librepods.R
import me.kavishdevar.librepods.utils.XposedState
@Composable
fun DeviceInfoCard() {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val rowHeight = remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
Column (
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Box(
modifier = Modifier
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
.padding(start = 16.dp, top = 24.dp, end = 4.dp)
) {
Text(
text = stringResource(R.string.device_info), style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
Column(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.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.manufacturer), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = Build.MANUFACTURER, 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_number), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = Build.MODEL, 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.build_id), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = Build.DISPLAY, 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.version), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = Build.ID + " (${Build.VERSION.SDK_INT_FULL})",
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.xposed_available), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = if (XposedState.isAvailable) stringResource(R.string.yes) else stringResource(R.string.no), 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.app_enabled_in_xposed), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = if (XposedState.bluetoothScopeEnabled) stringResource(R.string.yes) else stringResource(R.string.no), 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))
)
)
}
}
}
}

View File

@@ -0,0 +1,118 @@
/*
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.presentation.components
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.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 me.kavishdevar.librepods.R
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun HearingHealthSettings(
navController: NavController,
hasPPECapability: Boolean,
hasHearingAidCapability: Boolean,
vendorIdHook: Boolean
) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val shouldShowHearingAid = hasHearingAidCapability && vendorIdHook
if (hasPPECapability && shouldShowHearingAid) {
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),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
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 if (shouldShowHearingAid) {
NavigationButton(
to = "hearing_aid",
name = stringResource(R.string.hearing_aid),
navController = navController
)
} else if (hasPPECapability) {
NavigationButton(
to = "hearing_protection",
name = stringResource(R.string.hearing_protection),
title = stringResource(R.string.hearing_health),
navController = navController
)
}
}

View File

@@ -1,26 +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/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import android.util.Log
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,38 +34,41 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
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.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue 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.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource 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
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 dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@Composable @Composable
fun MicrophoneSettings(hazeState: HazeState) { fun MicrophoneSettings(
hazeState: HazeState,
micModeValue: Byte,
onMicModeValueChanged: (Byte) -> Unit
) {
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 backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
@@ -77,11 +79,6 @@ fun MicrophoneSettings(hazeState: HazeState) {
.background(backgroundColor, RoundedCornerShape(28.dp)) .background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp) .padding(top = 2.dp)
) { ) {
val service = ServiceManager.getService()!!
val micModeValue = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
}?.value?.get(0) ?: 0x00.toByte()
var selectedMode by remember { var selectedMode by remember {
mutableStateOf( mutableStateOf(
when (micModeValue) { when (micModeValue) {
@@ -98,42 +95,13 @@ fun MicrophoneSettings(hazeState: HazeState) {
var lastDismissTime by remember { mutableLongStateOf(0L) } var lastDismissTime by remember { mutableLongStateOf(0L) }
val reopenThresholdMs = 250L val reopenThresholdMs = 250L
val listener = object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (AACPManager.Companion.ControlCommandIdentifiers.fromByte(controlCommand.identifier) ==
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
) {
selectedMode = when (controlCommand.value[0]) {
0x00.toByte() -> "Automatic"
0x01.toByte() -> "Always Right"
0x02.toByte() -> "Always Left"
else -> "Automatic"
}
Log.d("MicrophoneSettings", "Microphone mode received: $selectedMode")
}
}
}
LaunchedEffect(Unit) {
service.aacpManager.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE,
listener
)
}
DisposableEffect(Unit) {
onDispose {
service.aacpManager.unregisterControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE,
listener
)
}
}
val density = LocalDensity.current val density = LocalDensity.current
val itemHeightPx = with(density) { 48.dp.toPx() } val itemHeightPx = with(density) { 48.dp.toPx() }
var parentHoveredIndex by remember { mutableStateOf<Int?>(null) } var parentHoveredIndex by remember { mutableStateOf<Int?>(null) }
var parentDragActive by remember { mutableStateOf(false) } var parentDragActive by remember { mutableStateOf(false) }
var previousIdx by remember { mutableStateOf<Int?>(null) }
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
val microphoneAutomaticText = stringResource(R.string.microphone_automatic) val microphoneAutomaticText = stringResource(R.string.microphone_automatic)
val microphoneAlwaysRightText = stringResource(R.string.microphone_always_right) val microphoneAlwaysRightText = stringResource(R.string.microphone_always_right)
val microphoneAlwaysLeftText = stringResource(R.string.microphone_always_left) val microphoneAlwaysLeftText = stringResource(R.string.microphone_always_left)
@@ -173,7 +141,11 @@ fun MicrophoneSettings(hazeState: HazeState) {
val touch = touchOffset ?: current val touch = touchOffset ?: current
val posInPopupY = current.y - touch.y val posInPopupY = current.y - touch.y
val idx = (posInPopupY / itemHeightPx).toInt() val idx = (posInPopupY / itemHeightPx).toInt()
if (idx != previousIdx) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
}
parentHoveredIndex = idx parentHoveredIndex = idx
previousIdx = idx
}, },
onDragEnd = { onDragEnd = {
parentDragActive = false parentDragActive = false
@@ -194,12 +166,16 @@ fun MicrophoneSettings(hazeState: HazeState) {
options[2] -> 0x02 options[2] -> 0x02
else -> 0x00 else -> 0x00
} }
service.aacpManager.sendControlCommand( // service.aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value, // AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value,
byteArrayOf(byteValue.toByte()) // byteArrayOf(byteValue.toByte())
) // )
onMicModeValueChanged(byteValue.toByte())
} }
} }
if (parentHoveredIndex != null && parentHoveredIndex in 0..2) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
}
parentHoveredIndex = null parentHoveredIndex = null
}, },
onDragCancel = { onDragCancel = {
@@ -277,10 +253,7 @@ fun MicrophoneSettings(hazeState: HazeState) {
microphoneAlwaysLeftText -> 0x02 microphoneAlwaysLeftText -> 0x02
else -> 0x00 else -> 0x00
} }
service.aacpManager.sendControlCommand( onMicModeValueChanged(byteValue.toByte())
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value,
byteArrayOf(byteValue.toByte())
)
}, },
hazeState = hazeState hazeState = hazeState
) )
@@ -288,10 +261,3 @@ fun MicrophoneSettings(hazeState: HazeState) {
} }
} }
} }
@ExperimentalHazeMaterialsApi
@Preview
@Composable
fun MicrophoneSettingsPreview() {
MicrophoneSettings(HazeState())
}

View File

@@ -1,22 +1,22 @@
/* /*
* 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
* 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/>.
*/
package me.kavishdevar.librepods.composables 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.presentation.components
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
@@ -35,21 +35,25 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue 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.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
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
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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController import androidx.navigation.NavController
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
@Composable @Composable
@@ -62,10 +66,14 @@ fun NavigationButton(
description: String? = null, description: String? = null,
currentState: String? = null, currentState: String? = null,
height: Dp = 58.dp, height: Dp = 58.dp,
enabled: Boolean = true
) { ) {
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)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500)) val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
Column { Column {
if (title != null) { if (title != null) {
Box( Box(
@@ -79,24 +87,35 @@ fun NavigationButton(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f), color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
) )
) )
} }
} }
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(height) .height(height)
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures( detectTapGestures(
onPress = { onPress = {
backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9) if (enabled) {
backgroundColor =
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease() tryAwaitRelease()
backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) backgroundColor =
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
}
}, },
onTap = { onTap = {
if (enabled) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
if (onClick != null) onClick() else navController.navigate(to) if (onClick != null) onClick() else navController.navigate(to)
} }
}
) )
} }
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),

View File

@@ -1,22 +1,22 @@
/* /*
* 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
* 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/>.
*/
package me.kavishdevar.librepods.composables 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.presentation.components
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource

View File

@@ -1,31 +1,26 @@
/* /*
* 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)
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.SpringSpec import androidx.compose.animation.core.SpringSpec
@@ -60,64 +55,49 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.imageResource import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.stringResource 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.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
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 me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications import me.kavishdevar.librepods.data.NoiseControlMode
import me.kavishdevar.librepods.constants.NoiseControlMode
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.roundToInt import kotlin.math.roundToInt
@SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope") @SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope")
@Composable @Composable
fun NoiseControlSettings( fun NoiseControlSettings(
service: AirPodsService, showOffListeningMode: Boolean,
noiseControlModeValue: Int,
onNoiseControlModeChanged: (Int) -> Unit
) { ) {
val context = LocalContext.current
val offListeningModeConfigValue = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
}?.value?.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte()
val offListeningMode = remember { mutableStateOf(offListeningModeConfigValue) }
val offListeningModeListener = object: AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
offListeningMode.value = controlCommand.value[0] == 1.toByte()
}
}
service.aacpManager.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION,
offListeningModeListener
)
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8)
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val textColorSelected = if (isDarkTheme) Color.White else Color.Black val textColorSelected = if (isDarkTheme) Color.White else Color.Black
val selectedBackground = if (isDarkTheme) Color(0xBF5C5A5F) else Color(0xFFFFFFFF) val selectedBackground = if (isDarkTheme) Color(0xBF5C5A5F) else Color(0xFFFFFFFF)
val noiseControlMode = remember { mutableStateOf(NoiseControlMode.OFF) } val noiseControlMode = remember { mutableStateOf(NoiseControlMode.OFF) }
val d1a = remember { mutableFloatStateOf(0f) } val d1a = remember { mutableFloatStateOf(0f) }
val d2a = remember { mutableFloatStateOf(0f) } val d2a = remember { mutableFloatStateOf(0f) }
val d3a = remember { mutableFloatStateOf(0f) } val d3a = remember { mutableFloatStateOf(0f) }
// this function exists solely for the dividers, should get rid of it
fun onModeSelected(mode: NoiseControlMode, received: Boolean = false) { fun onModeSelected(mode: NoiseControlMode, received: Boolean = false) {
val previousMode = noiseControlMode.value val previousMode = noiseControlMode.value
val targetMode = if (!offListeningMode.value && mode == NoiseControlMode.OFF) { val targetMode = if (!showOffListeningMode && mode == NoiseControlMode.OFF) {
NoiseControlMode.TRANSPARENCY NoiseControlMode.TRANSPARENCY
} else { } else {
mode mode
@@ -125,9 +105,8 @@ fun NoiseControlSettings(
noiseControlMode.value = targetMode noiseControlMode.value = targetMode
if (!received && targetMode != previousMode) { if (!received && targetMode != previousMode) onNoiseControlModeChanged(targetMode.ordinal + 1)
service.aacpManager.sendControlCommand(identifier = AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, value = targetMode.ordinal + 1)
}
when (noiseControlMode.value) { when (noiseControlMode.value) {
NoiseControlMode.NOISE_CANCELLATION -> { NoiseControlMode.NOISE_CANCELLATION -> {
@@ -153,32 +132,12 @@ fun NoiseControlSettings(
} }
} }
val noiseControlReceiver = remember {
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == AirPodsNotifications.ANC_DATA) {
noiseControlMode.value = NoiseControlMode.entries.toTypedArray()[intent.getIntExtra("data", 3) - 1]
onModeSelected(noiseControlMode.value, true)
} else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
try {
context.unregisterReceiver(this)
} catch (e: IllegalArgumentException) {
e.printStackTrace()
}
}
}
}
}
val noiseControlIntentFilter = IntentFilter().apply { val index = (noiseControlModeValue - 1).coerceIn(0, NoiseControlMode.entries.size - 1)
addAction(AirPodsNotifications.ANC_DATA) noiseControlMode.value = NoiseControlMode.entries[index]
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
} onModeSelected(noiseControlMode.value, received = true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter, Context.RECEIVER_EXPORTED)
} else {
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter)
}
Box( Box(
modifier = Modifier modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)) .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
@@ -190,17 +149,18 @@ fun NoiseControlSettings(
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))
) )
) )
} }
@Suppress("COMPOSE_APPLIER_CALL_MISMATCH")
BoxWithConstraints( BoxWithConstraints(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 8.dp) .padding(vertical = 8.dp)
) { ) {
val density = LocalDensity.current val density = LocalDensity.current
val buttonCount = if (offListeningMode.value) 4 else 3 val buttonCount = if (showOffListeningMode) 4 else 3
val buttonWidth = maxWidth / buttonCount val buttonWidth = maxWidth / buttonCount
val isDragging = remember { mutableStateOf(false) } val isDragging = remember { mutableStateOf(false) }
@@ -208,10 +168,10 @@ fun NoiseControlSettings(
mutableFloatStateOf( mutableFloatStateOf(
with(density) { with(density) {
when(noiseControlMode.value) { when(noiseControlMode.value) {
NoiseControlMode.OFF -> if (offListeningMode.value) 0f else buttonWidth.toPx() NoiseControlMode.OFF -> if (showOffListeningMode) 0f else buttonWidth.toPx()
NoiseControlMode.TRANSPARENCY -> if (offListeningMode.value) buttonWidth.toPx() else 0f NoiseControlMode.TRANSPARENCY -> if (showOffListeningMode) buttonWidth.toPx() else 0f
NoiseControlMode.ADAPTIVE -> if (offListeningMode.value) (buttonWidth * 2).toPx() else buttonWidth.toPx() NoiseControlMode.ADAPTIVE -> if (showOffListeningMode) (buttonWidth * 2).toPx() else buttonWidth.toPx()
NoiseControlMode.NOISE_CANCELLATION -> if (offListeningMode.value) (buttonWidth * 3).toPx() else (buttonWidth * 2).toPx() NoiseControlMode.NOISE_CANCELLATION -> if (showOffListeningMode) (buttonWidth * 3).toPx() else (buttonWidth * 2).toPx()
} }
} }
) )
@@ -224,10 +184,10 @@ fun NoiseControlSettings(
) )
val targetOffset = buttonWidth * when(noiseControlMode.value) { val targetOffset = buttonWidth * when(noiseControlMode.value) {
NoiseControlMode.OFF -> if (offListeningMode.value) 0 else 1 NoiseControlMode.OFF -> if (showOffListeningMode) 0 else 1
NoiseControlMode.TRANSPARENCY -> if (offListeningMode.value) 1 else 0 NoiseControlMode.TRANSPARENCY -> if (showOffListeningMode) 1 else 0
NoiseControlMode.ADAPTIVE -> if (offListeningMode.value) 2 else 1 NoiseControlMode.ADAPTIVE -> if (showOffListeningMode) 2 else 1
NoiseControlMode.NOISE_CANCELLATION -> if (offListeningMode.value) 3 else 2 NoiseControlMode.NOISE_CANCELLATION -> if (showOffListeningMode) 3 else 2
} }
val animatedOffset by animateFloatAsState( val animatedOffset by animateFloatAsState(
@@ -250,7 +210,7 @@ fun NoiseControlSettings(
Row( Row(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
if (offListeningMode.value) { if (showOffListeningMode) {
NoiseControlButton( NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation), icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
onClick = { onModeSelected(NoiseControlMode.OFF) }, onClick = { onModeSelected(NoiseControlMode.OFF) },
@@ -323,13 +283,12 @@ fun NoiseControlSettings(
val position = dragOffset / with(density) { buttonWidth.toPx() } val position = dragOffset / with(density) { buttonWidth.toPx() }
val newIndex = position.roundToInt() val newIndex = position.roundToInt()
val newMode = when(newIndex) { val newMode = when(newIndex) {
0 -> if (offListeningMode.value) NoiseControlMode.OFF else NoiseControlMode.TRANSPARENCY 0 -> if (showOffListeningMode) NoiseControlMode.OFF else NoiseControlMode.TRANSPARENCY
1 -> if (offListeningMode.value) NoiseControlMode.TRANSPARENCY else NoiseControlMode.ADAPTIVE 1 -> if (showOffListeningMode) NoiseControlMode.TRANSPARENCY else NoiseControlMode.ADAPTIVE
2 -> if (offListeningMode.value) NoiseControlMode.ADAPTIVE else NoiseControlMode.NOISE_CANCELLATION 2 -> if (showOffListeningMode) NoiseControlMode.ADAPTIVE else NoiseControlMode.NOISE_CANCELLATION
3 -> NoiseControlMode.NOISE_CANCELLATION 3 -> NoiseControlMode.NOISE_CANCELLATION
else -> noiseControlMode.value // Keep current if index is invalid else -> noiseControlMode.value // Keep current if index is invalid
} }
// Call onModeSelected which now handles service call but not callback
onModeSelected(newMode) onModeSelected(newMode)
} }
) )
@@ -347,7 +306,7 @@ fun NoiseControlSettings(
.fillMaxWidth() .fillMaxWidth()
.zIndex(1f) .zIndex(1f)
) { ) {
if (offListeningMode.value) { if (showOffListeningMode) {
NoiseControlButton( NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation), icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
onClick = { onModeSelected(NoiseControlMode.OFF) }, onClick = { onModeSelected(NoiseControlMode.OFF) },
@@ -406,7 +365,7 @@ fun NoiseControlSettings(
.fillMaxWidth() .fillMaxWidth()
.padding(top = 4.dp) .padding(top = 4.dp)
) { ) {
if (offListeningMode.value) { if (showOffListeningMode) {
Text( Text(
text = stringResource(R.string.off), text = stringResource(R.string.off),
style = TextStyle(fontSize = 12.sp, color = textColor), style = TextStyle(fontSize = 12.sp, color = textColor),
@@ -436,9 +395,3 @@ fun NoiseControlSettings(
} }
} }
} }
@Preview
@Composable
fun NoiseControlSettingsPreview() {
NoiseControlSettings(AirPodsService())
}

View File

@@ -1,32 +1,28 @@
/* /*
* 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
* 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/>.
*/
package me.kavishdevar.librepods.composables 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.presentation.components
import android.content.Context
import android.content.res.Configuration
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
@@ -35,38 +31,34 @@ import androidx.compose.runtime.Composable
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.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource 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
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.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
import me.kavishdevar.librepods.constants.StemAction import me.kavishdevar.librepods.data.StemAction
@Composable @Composable
fun PressAndHoldSettings(navController: NavController) { fun PressAndHoldSettings(
navController: NavController,
leftAction: StemAction,
rightAction: StemAction
) {
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 dividerColor = Color(0x40888888) val dividerColor = Color(0x40888888)
val context = LocalContext.current val leftActionText = when (leftAction) {
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val leftAction = sharedPreferences.getString("left_long_press_action", StemAction.CYCLE_NOISE_CONTROL_MODES.name)
val rightAction = sharedPreferences.getString("right_long_press_action", StemAction.CYCLE_NOISE_CONTROL_MODES.name)
val leftActionText = when (StemAction.valueOf(leftAction ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) {
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control) StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant" StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
else -> "INVALID!!" else -> "INVALID!!"
} }
val rightActionText = when (StemAction.valueOf(rightAction ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) { val rightActionText = when (rightAction) {
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control) StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant" StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
else -> "INVALID!!" else -> "INVALID!!"
@@ -114,9 +106,3 @@ fun PressAndHoldSettings(navController: NavController) {
) )
} }
} }
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun PressAndHoldSettingsPreview() {
PressAndHoldSettings(navController = NavController(LocalContext.current))
}

View File

@@ -0,0 +1,87 @@
package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import com.kyant.backdrop.backdrops.LayerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.effects.vibrancy
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StyledBottomSheet(
visible: Boolean,
onDismiss: () -> Unit,
backdrop: LayerBackdrop,
content: @Composable (innerBackdrop: LayerBackdrop, progress: Float) -> Unit
) {
if (!visible) return
val isDarkTheme = isSystemInDarkTheme()
val sheetState = rememberModalBottomSheetState(false) // move this to parent composable
val isExpanded = sheetState.targetValue == SheetValue.Expanded
val progress by animateFloatAsState(
targetValue = if (isExpanded) 1f else 0f,
label = "sheetProgress"
)
val animatedCorner = lerp(48.dp, 42.dp, progress)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = Color.Transparent,
dragHandle = { },
shape = RoundedCornerShape(animatedCorner),
scrimColor = Color.Transparent,
modifier = Modifier.padding(4.dp)
) {
val innerBackdrop = rememberLayerBackdrop()
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(animatedCorner))
.drawBackdrop(
backdrop = backdrop,
exportedBackdrop = innerBackdrop,
shape = { RoundedCornerShape(animatedCorner) },
effects = {
vibrancy()
blur(4f.dp.toPx())
lens(12f.dp.toPx(), 48f.dp.toPx(), true)
},
onDrawSurface = {
drawRect(
if (isDarkTheme) Color.DarkGray.copy(alpha = 0.3f) else Color(
0xFFE0E0E0
).copy(alpha = 0.45f)
)
}
)
.padding(top = 24.dp)
.padding(horizontal = 16.dp)
) {
content(innerBackdrop, progress)
}
}
}

View File

@@ -1,22 +1,22 @@
/* /*
* 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
* 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/>.
*/
package me.kavishdevar.librepods.composables 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.presentation.components
import android.graphics.RuntimeShader import android.graphics.RuntimeShader
import android.os.Build import android.os.Build
@@ -46,7 +46,9 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ShaderBrush import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastCoerceAtMost import androidx.compose.ui.util.fastCoerceAtMost
@@ -55,7 +57,7 @@ import androidx.compose.ui.util.lerp
import com.kyant.backdrop.Backdrop import com.kyant.backdrop.Backdrop
import com.kyant.backdrop.drawBackdrop import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.refraction import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.effects.vibrancy import com.kyant.backdrop.effects.vibrancy
import com.kyant.backdrop.highlight.Highlight import com.kyant.backdrop.highlight.Highlight
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -75,9 +77,12 @@ fun StyledButton(
tint: Color = Color.Unspecified, tint: Color = Color.Unspecified,
surfaceColor: Color = Color.Unspecified, surfaceColor: Color = Color.Unspecified,
maxScale: Float = 0.1f, maxScale: Float = 0.1f,
enabled: Boolean = true,
content: @Composable RowScope.() -> Unit, content: @Composable RowScope.() -> Unit,
) { ) {
val animationScope = rememberCoroutineScope() val isInteractive = enabled && isInteractive
val scope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current
val progressAnimation = remember { Animatable(0f) } val progressAnimation = remember { Animatable(0f) }
var pressStartPosition by remember { mutableStateOf(Offset.Zero) } var pressStartPosition by remember { mutableStateOf(Offset.Zero) }
val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) } val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
@@ -122,8 +127,8 @@ half4 main(float2 coord) {
} else { } else {
drawRect(Color.White.copy(0.1f)) drawRect(Color.White.copy(0.1f))
} }
if (surfaceColor.isSpecified) { if (surfaceColor.isSpecified && enabled) {
val color = if (!isInteractive && isPressed) { val color = if (isPressed) {
Color( Color(
red = surfaceColor.red * 0.5f, red = surfaceColor.red * 0.5f,
green = surfaceColor.green * 0.5f, green = surfaceColor.green * 0.5f,
@@ -134,6 +139,11 @@ half4 main(float2 coord) {
surfaceColor surfaceColor
} }
drawRect(color) drawRect(color)
} else {
if (isPressed) {
drawRect(Color.Black.copy(alpha = 0.4f))
drawRect(Color.White.copy(alpha = 0.2f))
}
} }
}, },
onDrawFront = null, onDrawFront = null,
@@ -146,7 +156,12 @@ half4 main(float2 coord) {
effects = { effects = {
vibrancy() vibrancy()
blur(2f.dp.toPx()) blur(2f.dp.toPx())
refraction(12f.dp.toPx(), 24f.dp.toPx()) lens(
refractionHeight = 12f.dp.toPx(),
refractionAmount = 24f.dp.toPx(),
depthEffect = true,
chromaticAberration = true
)
}, },
layerBlock = { layerBlock = {
val width = size.width val width = size.width
@@ -158,8 +173,10 @@ half4 main(float2 coord) {
val maxOffset = size.minDimension val maxOffset = size.minDimension
val initialDerivative = 0.05f val initialDerivative = 0.05f
val offset = offsetAnimation.value val offset = offsetAnimation.value
translationX = maxOffset * tanh(initialDerivative * offset.x / maxOffset) translationX =
translationY = maxOffset * tanh(initialDerivative * offset.y / maxOffset) maxOffset * tanh(initialDerivative * offset.x / maxOffset)
translationY =
maxOffset * tanh(initialDerivative * offset.y / maxOffset)
val maxDragScale = 0.1f val maxDragScale = 0.1f
val offsetAngle = atan2(offset.y, offset.x) val offsetAngle = atan2(offset.y, offset.x)
@@ -204,7 +221,10 @@ half4 main(float2 coord) {
interactiveHighlightShader.apply { interactiveHighlightShader.apply {
val offset = pressStartPosition + offsetAnimation.value val offset = pressStartPosition + offsetAnimation.value
setFloatUniform("size", size.width, size.height) setFloatUniform("size", size.width, size.height)
setColorUniform("color", Color.White.copy(0.15f * progress).toArgb()) setColorUniform(
"color",
Color.White.copy(0.15f * progress).toArgb()
)
setFloatUniform("radius", size.maxDimension) setFloatUniform("radius", size.maxDimension)
setFloatUniform( setFloatUniform(
"offset", "offset",
@@ -231,31 +251,53 @@ half4 main(float2 coord) {
interactionSource = null, interactionSource = null,
indication = null, indication = null,
role = Role.Button, role = Role.Button,
onClick = onClick onClick = {
if (enabled) {
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
onClick()
}
}
) )
.then( .then(
if (isInteractive) { if (isInteractive) {
Modifier.pointerInput(animationScope) { Modifier.pointerInput(scope) {
val progressAnimationSpec = spring(0.5f, 300f, 0.001f) val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold) val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
val onDragStop: () -> Unit = { val onDragStop: () -> Unit = {
animationScope.launch { scope.launch {
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
launch { progressAnimation.animateTo(0f, progressAnimationSpec) } launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) } launch {
offsetAnimation.animateTo(
Offset.Zero,
offsetAnimationSpec
)
}
} }
} }
inspectDragGestures( inspectDragGestures(
onDragStart = { down -> onDragStart = { down ->
pressStartPosition = down.position pressStartPosition = down.position
animationScope.launch { scope.launch {
launch { progressAnimation.animateTo(1f, progressAnimationSpec) } launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
launch {
progressAnimation.animateTo(
1f,
progressAnimationSpec
)
}
launch { offsetAnimation.snapTo(Offset.Zero) } launch { offsetAnimation.snapTo(Offset.Zero) }
} }
}, },
onDragEnd = { onDragStop() }, onDragEnd = {
onDragStop()
},
onDragCancel = onDragStop onDragCancel = onDragStop
) { _, dragAmount -> ) { _, dragAmount ->
animationScope.launch { scope.launch {
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
HapticFeedbackType.SegmentFrequentTick
)
offsetAnimation.snapTo(offsetAnimation.value + dragAmount) offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
} }
} }
@@ -269,8 +311,11 @@ half4 main(float2 coord) {
isPressed = false isPressed = false
}, },
onTap = { onTap = {
if (enabled) {
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
onClick() onClick()
} }
}
) )
} }
} }

View File

@@ -1,22 +1,22 @@
/* /*
* 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
* 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/>.
*/
package me.kavishdevar.librepods.composables 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.presentation.components
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
@@ -49,14 +49,17 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue 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.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalHapticFeedback
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
@@ -71,6 +74,7 @@ import dev.chrisbanes.haze.HazeTint
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.CupertinoMaterials import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@@ -110,6 +114,9 @@ fun StyledDropdown(
var hoveredIndex by remember { mutableStateOf<Int?>(null) } var hoveredIndex by remember { mutableStateOf<Int?>(null) }
val itemHeight = 48.dp val itemHeight = 48.dp
val scope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current
var popupSize by remember { mutableStateOf(IntSize(0, 0)) } var popupSize by remember { mutableStateOf(IntSize(0, 0)) }
var lastDragPosition by remember { mutableStateOf<Offset?>(null) } var lastDragPosition by remember { mutableStateOf<Offset?>(null) }
@@ -132,7 +139,12 @@ fun StyledDropdown(
}, },
onDrag = { change, _ -> onDrag = { change, _ ->
val y = change.position.y val y = change.position.y
hoveredIndex = (y / itemHeight.toPx()).toInt() val newHoveredIndex = (y / itemHeight.toPx()).toInt()
if (newHoveredIndex != hoveredIndex) {
scope.launch { haptics.performHapticFeedback(
HapticFeedbackType.SegmentTick) }
}
hoveredIndex = newHoveredIndex
lastDragPosition = change.position lastDragPosition = change.position
}, },
onDragEnd = { onDragEnd = {
@@ -144,6 +156,8 @@ fun StyledDropdown(
if (withinBounds) { if (withinBounds) {
hoveredIndex?.let { idx -> hoveredIndex?.let { idx ->
if (idx in options.indices) { if (idx in options.indices) {
scope.launch { haptics.performHapticFeedback(
HapticFeedbackType.GestureEnd) }
onOptionSelected(options[idx]) onOptionSelected(options[idx])
} }
} }
@@ -174,6 +188,7 @@ fun StyledDropdown(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null indication = null
) { ) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
onOptionSelected(text) onOptionSelected(text)
onDismissRequest() onDismissRequest()
} }

View File

@@ -1,22 +1,22 @@
/* /*
* 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
* 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/>.
*/
package me.kavishdevar.librepods.composables 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.presentation.components
import android.graphics.RuntimeShader import android.graphics.RuntimeShader
import android.os.Build import android.os.Build
@@ -50,7 +50,10 @@ import androidx.compose.ui.graphics.layer.CompositingStrategy
import androidx.compose.ui.graphics.layer.drawLayer import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.rememberGraphicsLayer import androidx.compose.ui.graphics.rememberGraphicsLayer
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
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
@@ -63,8 +66,7 @@ import androidx.compose.ui.util.lerp
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 com.kyant.backdrop.drawBackdrop import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.effects.refractionWithDispersion
import com.kyant.backdrop.highlight.Highlight import com.kyant.backdrop.highlight.Highlight
import com.kyant.backdrop.shadow.Shadow import com.kyant.backdrop.shadow.Shadow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -78,14 +80,17 @@ import kotlin.math.tanh
@Composable @Composable
fun StyledIconButton( fun StyledIconButton(
onClick: () -> Unit,
icon: String,
darkMode: Boolean,
tint: Color = Color.Unspecified,
backdrop: LayerBackdrop = rememberLayerBackdrop(),
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
icon: String,
iconTint: Color = Color.Unspecified,
surfaceColor: Color = Color.Unspecified,
backdrop: LayerBackdrop = rememberLayerBackdrop(),
onClick: () -> Unit,
enabled: Boolean = true
) { ) {
val animationScope = rememberCoroutineScope() val haptics = LocalHapticFeedback.current
val darkMode = isSystemInDarkTheme()
val scope = rememberCoroutineScope()
val progressAnimationSpec = spring(0.5f, 300f, 0.001f) val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold) val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
val progressAnimation = remember { Animatable(0f) } val progressAnimation = remember { Animatable(0f) }
@@ -94,6 +99,7 @@ fun StyledIconButton(
val innerShadowLayer = rememberGraphicsLayer().apply { val innerShadowLayer = rememberGraphicsLayer().apply {
compositingStrategy = CompositingStrategy.Offscreen compositingStrategy = CompositingStrategy.Offscreen
} }
val density = LocalDensity.current
val interactiveHighlightShader = remember { val interactiveHighlightShader = remember {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -117,7 +123,12 @@ half4 main(float2 coord) {
} }
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
TextButton( TextButton(
onClick = onClick, onClick = {
if (enabled) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
onClick()
}
},
shape = RoundedCornerShape(56.dp), shape = RoundedCornerShape(56.dp),
modifier = modifier modifier = modifier
.padding(horizontal = 12.dp) .padding(horizontal = 12.dp)
@@ -132,6 +143,7 @@ half4 main(float2 coord) {
) )
}, },
layerBlock = { layerBlock = {
if (!enabled) return@drawBackdrop
val width = size.width val width = size.width
val height = size.height val height = size.height
@@ -156,6 +168,12 @@ half4 main(float2 coord) {
(height / width).fastCoerceAtMost(1f) (height / width).fastCoerceAtMost(1f)
}, },
onDrawSurface = { onDrawSurface = {
if (!enabled) {
drawRect(
(if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(0.5f)
)
return@drawBackdrop
}
val progress = progressAnimation.value.coerceIn(0f, 1f) val progress = progressAnimation.value.coerceIn(0f, 1f)
val shape = RoundedCornerShape(56.dp) val shape = RoundedCornerShape(56.dp)
@@ -182,11 +200,21 @@ half4 main(float2 coord) {
} }
drawLayer(innerShadowLayer) drawLayer(innerShadowLayer)
if (surfaceColor.isSpecified) {
drawRect(surfaceColor)
}
drawRect( drawRect(
(if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(progress.coerceIn(0.15f, 0.35f)) (if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(
progress.coerceIn(
0.15f,
0.35f
)
)
) )
}, },
onDrawFront = { onDrawFront = {
if (!enabled) return@drawBackdrop
val progress = progressAnimation.value.fastCoerceIn(0f, 1f) val progress = progressAnimation.value.fastCoerceIn(0f, 1f)
if (progress > 0f) { if (progress > 0f) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && interactiveHighlightShader != null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && interactiveHighlightShader != null) {
@@ -197,7 +225,10 @@ half4 main(float2 coord) {
interactiveHighlightShader.apply { interactiveHighlightShader.apply {
val offset = pressStartPosition + offsetAnimation.value val offset = pressStartPosition + offsetAnimation.value
setFloatUniform("size", size.width, size.height) setFloatUniform("size", size.width, size.height)
setColorUniform("color", Color.White.copy(0.15f * progress).toArgb()) setColorUniform(
"color",
Color.White.copy(0.15f * progress).toArgb()
)
setFloatUniform("radius", size.maxDimension) setFloatUniform("radius", size.maxDimension)
setFloatUniform( setFloatUniform(
"offset", "offset",
@@ -218,41 +249,56 @@ half4 main(float2 coord) {
} }
}, },
effects = { effects = {
refractionWithDispersion(6f.dp.toPx(), size.height / 2f) lens(
// blur(24f, TileMode.Decal) refractionHeight = 6f.dp.toPx(),
refractionAmount = size.height / 2f,
depthEffect = true,
chromaticAberration = true
)
}, },
) )
.pointerInput(animationScope) { .pointerInput(scope) {
val onDragStop: () -> Unit = { val onDragStop: () -> Unit = {
animationScope.launch { if (enabled) {
scope.launch {
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
launch { progressAnimation.animateTo(0f, progressAnimationSpec) } launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) } launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) }
} }
} }
}
inspectDragGestures( inspectDragGestures(
onDragStart = { down -> onDragStart = { down ->
if (enabled) {
pressStartPosition = down.position pressStartPosition = down.position
animationScope.launch { scope.launch {
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
launch { progressAnimation.animateTo(1f, progressAnimationSpec) } launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
launch { offsetAnimation.snapTo(Offset.Zero) } launch { offsetAnimation.snapTo(Offset.Zero) }
} }
}
}, },
onDragEnd = { onDragStop() }, onDragEnd = { onDragStop() },
onDragCancel = onDragStop onDragCancel = onDragStop
) { _, dragAmount -> ) { _, dragAmount ->
animationScope.launch { scope.launch {
if (enabled) {
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
HapticFeedbackType.SegmentFrequentTick
)
offsetAnimation.snapTo(offsetAnimation.value + dragAmount) offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
} }
} }
} }
.size(48.dp), }
.size(with(density) { 48.sp.toDp() }),
) { ) {
Text( Text(
text = icon, text = icon,
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 20.sp,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
color = if (tint.isSpecified) tint else if (darkMode) Color.White else Color.Black, color = if (iconTint.isSpecified) iconTint else if (darkMode) Color.White else Color.Black,
fontFamily = FontFamily(Font(R.font.sf_pro)) fontFamily = FontFamily(Font(R.font.sf_pro))
) )
) )

View File

@@ -0,0 +1,154 @@
package me.kavishdevar.librepods.presentation.components
import android.R.attr.singleLine
import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.pointer.pointerInput
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.sp
import me.kavishdevar.librepods.R
@Composable
fun StyledInputField(
inputState: TextFieldState,
focusRequester: FocusRequester,
placeholder: String = "",
singleLine: Boolean = true
){
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val minHeight = if (singleLine) 58.dp else 120.dp
val verticalAlignment = if (singleLine) Alignment.CenterVertically else Alignment.Top
val hasText = inputState.text.isNotEmpty()
val density = LocalDensity.current
val spacerHeight by animateDpAsState(
targetValue = if (hasText) with(density) { 32.sp.toDp() } else 0.dp,
label = "labelSpacer"
)
val transition = updateTransition(hasText, label = "floating")
val yOffset by transition.animateDp(label = "y") {
if (it) with (density) { (-48).sp.toDp() } else 0.dp
}
Spacer(modifier = Modifier.height(spacerHeight))
Box(
modifier = Modifier.fillMaxWidth()
) {
Row(
verticalAlignment = verticalAlignment,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = minHeight)
.background(
backgroundColor,
RoundedCornerShape(28.dp)
)
.padding(horizontal = 16.dp, vertical = 8.dp)
.pointerInput(Unit) {
detectTapGestures {
focusRequester.requestFocus()
}
}
) {
BasicTextField(
state = inputState,
lineLimits = if (singleLine) TextFieldLineLimits.SingleLine else TextFieldLineLimits.Default,
textStyle = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
cursorBrush = SolidColor(textColor),
decorator = { innerTextField ->
Row(
modifier = Modifier.padding(top = if (singleLine) 0.dp else 16.dp),
verticalAlignment = verticalAlignment,
) {
Row(
modifier = Modifier
.weight(1f)
) {
Box(
modifier = Modifier
.weight(1f),
contentAlignment = if (singleLine) Alignment.CenterStart else Alignment.TopStart
) {
Text(
text = placeholder,
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Light,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor.copy(alpha = 0.8f)
),
modifier = Modifier
.offset(y = yOffset)
)
innerTextField()
}
}
if (singleLine && !inputState.text.isEmpty()) {
IconButton(
onClick = {
inputState.clearText()
}
) {
Text(
text = "􀁡",
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.6f
)
),
)
}
}
}
},
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp)
.focusRequester(focusRequester)
)
}
}
}

View File

@@ -1,22 +1,22 @@
/* /*
* 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
* 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/>.
*/
package me.kavishdevar.librepods.composables 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.presentation.components
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -49,7 +49,6 @@ 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.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
@@ -57,17 +56,15 @@ 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
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.rememberHazeState import dev.chrisbanes.haze.rememberHazeState
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
@ExperimentalHazeMaterialsApi
@Composable @Composable
fun StyledScaffold( fun StyledScaffold(
title: String, title: String,
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(), actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
content: @Composable (spacerValue: Dp, hazeState: HazeState) -> Unit content: @Composable (spacerValue: Dp, hazeState: HazeState, bottomPadding: Dp) -> Unit
) { ) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val hazeState = rememberHazeState(blurEnabled = true) val hazeState = rememberHazeState(blurEnabled = true)
@@ -80,14 +77,14 @@ fun StyledScaffold(
.clip(RoundedCornerShape(52.dp)) .clip(RoundedCornerShape(52.dp))
) { paddingValues -> ) { paddingValues ->
val topPadding = paddingValues.calculateTopPadding() val topPadding = paddingValues.calculateTopPadding()
val bottomPadding = paddingValues.calculateBottomPadding() val bottomPadding = paddingValues.calculateBottomPadding() + 16.dp
val startPadding = paddingValues.calculateLeftPadding(LocalLayoutDirection.current) val startPadding = paddingValues.calculateLeftPadding(LocalLayoutDirection.current)
val endPadding = paddingValues.calculateRightPadding(LocalLayoutDirection.current) val endPadding = paddingValues.calculateRightPadding(LocalLayoutDirection.current)
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(start = startPadding, end = endPadding, bottom = bottomPadding) .padding(start = startPadding, end = endPadding)
) { ) {
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
Box( Box(
@@ -127,13 +124,12 @@ fun StyledScaffold(
} }
} }
content(topPadding + 64.dp, hazeState) content(topPadding + 64.dp, hazeState, bottomPadding + 12.dp)
} }
} }
} }
@ExperimentalHazeMaterialsApi
@Composable @Composable
fun StyledScaffold( fun StyledScaffold(
title: String, title: String,
@@ -145,12 +141,11 @@ fun StyledScaffold(
title = title, title = title,
actionButtons = actionButtons, actionButtons = actionButtons,
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,
) { _, _ -> ) { _, _, _->
content() content()
} }
} }
@ExperimentalHazeMaterialsApi
@Composable @Composable
fun StyledScaffold( fun StyledScaffold(
title: String, title: String,
@@ -162,7 +157,7 @@ fun StyledScaffold(
title = title, title = title,
actionButtons = actionButtons, actionButtons = actionButtons,
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,
) { spacerValue, _ -> ) { spacerValue, _, _ ->
content(spacerValue) content(spacerValue)
} }
} }

View File

@@ -1,22 +1,22 @@
/* /*
* 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
* 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/>.
*/
package me.kavishdevar.librepods.composables 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.presentation.components
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
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.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -43,12 +44,13 @@ 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.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
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
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.compose.ui.unit.sp
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
@@ -59,19 +61,10 @@ data class SelectItem(
val iconRes: Int? = null, val iconRes: Int? = null,
val selected: Boolean, val selected: Boolean,
val onClick: () -> Unit, val onClick: () -> Unit,
val visible: Boolean = true,
val enabled: Boolean = true val enabled: Boolean = true
) )
data class SelectItem2(
val name: String,
val description: String? = null,
val iconRes: Int? = null,
val selected: () -> Boolean,
val onClick: () -> Unit,
val enabled: Boolean = true
)
@Composable @Composable
fun StyledSelectList( fun StyledSelectList(
items: List<SelectItem>, items: List<SelectItem>,
@@ -81,38 +74,49 @@ fun StyledSelectList(
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val haptics = LocalHapticFeedback.current
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp)), .background(backgroundColor, RoundedCornerShape(28.dp)),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
val visibleItems = items.filter { it.enabled } val visibleItems = items.filter { it.visible }
visibleItems.forEachIndexed { index, item -> visibleItems.forEachIndexed { index, item ->
val isFirst = index == 0 val isFirst = index == 0
val isLast = index == visibleItems.size - 1 val isLast = index == visibleItems.size - 1
val hasIcon = item.iconRes != null val hasIcon = item.iconRes != null
val shape = when { val shape = when {
isFirst && isLast -> RoundedCornerShape(28.dp)
isFirst -> RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp) isFirst -> RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
isLast -> RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp) isLast -> RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
else -> RoundedCornerShape(0.dp) else -> RoundedCornerShape(0.dp)
} }
var itemBackgroundColor by remember { mutableStateOf(backgroundColor) } var itemBackgroundColor by remember { mutableStateOf(if (item.enabled) backgroundColor else if (isDarkTheme) Color(0x40050505) else Color(0x40D9D9D9)) }
val animatedBackgroundColor by animateColorAsState(targetValue = itemBackgroundColor, animationSpec = tween(durationMillis = 500)) val animatedBackgroundColor by animateColorAsState(targetValue = itemBackgroundColor, animationSpec = tween(durationMillis = 500))
Row( Row(
modifier = Modifier modifier = Modifier
.height(if (hasIcon) 72.dp else 55.dp) .heightIn(min = if (hasIcon) 72.dp else 55.dp)
.background(animatedBackgroundColor, shape) .background(animatedBackgroundColor, shape)
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures( detectTapGestures(
onPress = { onPress = {
itemBackgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9) if (item.enabled) {
itemBackgroundColor =
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease() tryAwaitRelease()
itemBackgroundColor = backgroundColor itemBackgroundColor = backgroundColor
}
},
onTap = {
if (item.enabled) {
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
item.onClick() item.onClick()
} }
}
) )
} }
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
@@ -121,7 +125,7 @@ fun StyledSelectList(
) { ) {
if (hasIcon) { if (hasIcon) {
Icon( Icon(
painter = painterResource(item.iconRes!!), painter = painterResource(item.iconRes),
contentDescription = "Icon", contentDescription = "Icon",
tint = Color(0xFF007AFF), tint = Color(0xFF007AFF),
modifier = Modifier modifier = Modifier

View File

@@ -1,23 +1,24 @@
/* /*
* 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
* 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/>.
*/
package me.kavishdevar.librepods.composables 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.presentation.components
import android.annotation.SuppressLint
import android.content.res.Configuration import android.content.res.Configuration
import android.util.Log import android.util.Log
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
@@ -43,7 +44,6 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableFloatState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
@@ -56,6 +56,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.input.pointer.util.addPointerInputChange import androidx.compose.ui.input.pointer.util.addPointerInputChange
@@ -64,6 +65,7 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
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
@@ -81,7 +83,7 @@ import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
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.effects.blur import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.refractionWithDispersion import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.highlight.Highlight import com.kyant.backdrop.highlight.Highlight
import com.kyant.backdrop.shadow.InnerShadow import com.kyant.backdrop.shadow.InnerShadow
import com.kyant.backdrop.shadow.Shadow import com.kyant.backdrop.shadow.Shadow
@@ -203,10 +205,11 @@ class MomentumAnimation(
} }
} }
@SuppressLint("UnrememberedMutableState")
@Composable @Composable
fun StyledSlider( fun StyledSlider(
label: String? = null, label: String? = null,
mutableFloatState: MutableFloatState, value: Float,
onValueChange: (Float) -> Unit, onValueChange: (Float) -> Unit,
valueRange: ClosedFloatingPointRange<Float>, valueRange: ClosedFloatingPointRange<Float>,
backdrop: Backdrop = rememberLayerBackdrop(), backdrop: Backdrop = rememberLayerBackdrop(),
@@ -217,24 +220,27 @@ fun StyledSlider(
startLabel: String? = null, startLabel: String? = null,
endLabel: String? = null, endLabel: String? = null,
independent: Boolean = false, independent: Boolean = false,
description: String? = null description: String? = null,
enabled: Boolean = true
) { ) {
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val isLightTheme = !isSystemInDarkTheme() val isLightTheme = !isSystemInDarkTheme()
val accentColor =
if (isLightTheme) Color(0xFF0088FF)
else Color(0xFF0091FF)
val trackColor = val trackColor =
if (isLightTheme) Color(0xFF787878).copy(0.2f) if (isLightTheme) Color(0xFF787878).copy(0.2f)
else Color(0xFF787880).copy(0.36f) else Color(0xFF787880).copy(0.36f)
val accentColor =
if (enabled) {
if (isLightTheme) Color(0xFF0088FF)
else Color(0xFF0091FF)
} else {
trackColor
}
val labelTextColor = if (isLightTheme) Color.Black else Color.White val labelTextColor = if (isLightTheme) Color.Black else Color.White
val fraction by remember { val fraction by derivedStateOf {
derivedStateOf { ((value - valueRange.start) / (valueRange.endInclusive - valueRange.start))
((mutableFloatState.floatValue - valueRange.start) / (valueRange.endInclusive - valueRange.start))
.fastCoerceIn(0f, 1f) .fastCoerceIn(0f, 1f)
} }
}
val sliderBackdrop = rememberLayerBackdrop() val sliderBackdrop = rememberLayerBackdrop()
val trackWidthState = remember { mutableFloatStateOf(0f) } val trackWidthState = remember { mutableFloatStateOf(0f) }
@@ -242,6 +248,8 @@ fun StyledSlider(
val startIconWidthState = remember { mutableFloatStateOf(0f) } val startIconWidthState = remember { mutableFloatStateOf(0f) }
val endIconWidthState = remember { mutableFloatStateOf(0f) } val endIconWidthState = remember { mutableFloatStateOf(0f) }
val density = LocalDensity.current val density = LocalDensity.current
val haptics = LocalHapticFeedback.current
var lastDragValue by remember { mutableFloatStateOf(value) }
val momentumAnimation = rememberMomentumAnimation(maxScale = 1.5f) val momentumAnimation = rememberMomentumAnimation(maxScale = 1.5f)
@@ -427,18 +435,35 @@ fun StyledSlider(
) )
translationY = if (startLabel != null || endLabel != null) trackPositionState.floatValue + with(density) { 26.dp.toPx() } + size.height / 2f else trackPositionState.floatValue + with(density) { 8.dp.toPx() } translationY = if (startLabel != null || endLabel != null) trackPositionState.floatValue + with(density) { 26.dp.toPx() } + size.height / 2f else trackPositionState.floatValue + with(density) { 8.dp.toPx() }
} }
.then(
if (enabled) {
Modifier
.draggable( .draggable(
rememberDraggableState { delta -> rememberDraggableState { delta ->
val trackWidth = trackWidthState.floatValue val trackWidth = trackWidthState.floatValue
if (trackWidth > 0f) { if (trackWidth > 0f) {
val targetFraction = fraction + delta / trackWidth val targetFraction = fraction + delta / trackWidth
val targetValue = val targetValue =
lerp(valueRange.start, valueRange.endInclusive, targetFraction) lerp(
.fastCoerceIn(valueRange.start, valueRange.endInclusive) valueRange.start,
valueRange.endInclusive,
targetFraction
)
.fastCoerceIn(
valueRange.start,
valueRange.endInclusive
)
snapPoints.forEach { snap ->
if ((lastDragValue < snap && targetValue >= snap) ||
(snap in targetValue..<lastDragValue)) {
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
}
}
lastDragValue = targetValue
val snappedValue = if (snapPoints.isNotEmpty()) snapIfClose( val snappedValue = if (snapPoints.isNotEmpty()) snapIfClose(
targetValue, targetValue,
snapPoints, snapPoints,
snapThreshold snapThreshold,
) else targetValue ) else targetValue
onValueChange(snappedValue) onValueChange(snappedValue)
} }
@@ -446,11 +471,10 @@ fun StyledSlider(
Orientation.Horizontal, Orientation.Horizontal,
startDragImmediately = true, startDragImmediately = true,
onDragStarted = { onDragStarted = {
// Remove this block as momentumAnimation handles pressing lastDragValue = value
}, },
onDragStopped = { onDragStopped = {
// Remove this block as momentumAnimation handles pressing onValueChange((value * 100).roundToInt() / 100f)
onValueChange((mutableFloatState.floatValue * 100).roundToInt() / 100f)
} }
) )
.then(momentumAnimation.modifier) .then(momentumAnimation.modifier)
@@ -488,12 +512,18 @@ fun StyledSlider(
effects = { effects = {
val progress = momentumAnimation.progress val progress = momentumAnimation.progress
blur(8f.dp.toPx() * (1f - progress)) blur(8f.dp.toPx() * (1f - progress))
refractionWithDispersion( lens(
height = 6f.dp.toPx() * progress, refractionHeight = 6f.dp.toPx() * progress,
amount = size.height / 2f * progress refractionAmount = size.height / 2f * progress,
depthEffect = true,
chromaticAberration = true
) )
} }
) )
} else {
Modifier.background(trackColor, RoundedCornerShape(28.dp))
}
)
.size(40f.dp, 24f.dp) .size(40f.dp, 24f.dp)
) )
} }
@@ -566,12 +596,13 @@ fun StyledSliderPreview() {
.padding(16.dp) .padding(16.dp)
.fillMaxSize() .fillMaxSize()
) { ) {
Box ( Column (
Modifier.align(Alignment.Center) Modifier.align(Alignment.Center),
verticalArrangement = Arrangement.spacedBy(16.dp)
) )
{ {
StyledSlider( StyledSlider(
mutableFloatState = a, value = a.floatValue,
onValueChange = { onValueChange = {
a.floatValue = it a.floatValue = it
}, },
@@ -582,6 +613,19 @@ fun StyledSliderPreview() {
startIcon = "A", startIcon = "A",
endIcon = "B", endIcon = "B",
) )
StyledSlider(
value = a.floatValue,
onValueChange = {
a.floatValue = it
},
valueRange = 0f..2f,
snapPoints = listOf(1f),
snapThreshold = 0.1f,
independent = true,
startIcon = "A",
endIcon = "B",
enabled = false
)
} }
} }
} }

View File

@@ -1,25 +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
* 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/>.
*/
package me.kavishdevar.librepods.composables 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.presentation.components
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.animation.Animatable import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
@@ -58,8 +58,10 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.layer.CompositingStrategy import androidx.compose.ui.graphics.layer.CompositingStrategy
import androidx.compose.ui.graphics.layer.drawLayer import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.rememberGraphicsLayer import androidx.compose.ui.graphics.rememberGraphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
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.util.fastCoerceIn import androidx.compose.ui.util.fastCoerceIn
@@ -68,11 +70,12 @@ import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberCombinedBackdrop import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
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.effects.refractionWithDispersion import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.highlight.Highlight import com.kyant.backdrop.highlight.Highlight
import com.kyant.backdrop.shadow.Shadow import com.kyant.backdrop.shadow.Shadow
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.abs
@Composable @Composable
fun StyledSwitch( fun StyledSwitch(
@@ -81,9 +84,12 @@ fun StyledSwitch(
enabled: Boolean = true, enabled: Boolean = true,
) { ) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val haptics = LocalHapticFeedback.current
val onColor = if (enabled) Color(0xFF34C759) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) val onColor = if (enabled) Color(0xFF34C759) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6)
val offColor = if (enabled) if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) val offColor = if (enabled) if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) else if (isDarkTheme) Color(
0x805B5B5E
) else Color(0xFFD1D1D6)
val trackWidth = 64.dp val trackWidth = 64.dp
val trackHeight = 28.dp val trackHeight = 28.dp
@@ -98,24 +104,25 @@ fun StyledSwitch(
val animatedFraction = remember { Animatable(fraction) } val animatedFraction = remember { Animatable(fraction) }
val trackWidthPx = remember { mutableFloatStateOf(0f) } val trackWidthPx = remember { mutableFloatStateOf(0f) }
val density = LocalDensity.current val density = LocalDensity.current
val animationScope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val progressAnimationSpec = spring(0.5f, 300f, 0.001f) val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
val colorAnimationSpec = tween<Color>(200, easing = FastOutSlowInEasing)
val progressAnimation = remember { Animatable(0f) } val progressAnimation = remember { Animatable(0f) }
val innerShadowLayer = rememberGraphicsLayer().apply { val innerShadowLayer = rememberGraphicsLayer().apply {
compositingStrategy = CompositingStrategy.Offscreen compositingStrategy = CompositingStrategy.Offscreen
} }
val animatedTrackColor = remember { Animatable(if (checked) onColor else offColor) } val targetColor = if (checked) onColor else offColor
val animatedTrackColor by animateColorAsState(targetColor)
val totalDrag = remember { mutableFloatStateOf(0f) } val totalDrag = remember { mutableFloatStateOf(0f) }
val tapThreshold = 10f val tapThreshold = 10f
val isFirstComposition = remember { mutableStateOf(true) } val isFirstComposition = remember { mutableStateOf(true) }
LaunchedEffect(checked) { LaunchedEffect(checked) {
if (!isFirstComposition.value) { if (!isFirstComposition.value) {
coroutineScope { if (checked) {
launch { haptics.performHapticFeedback(HapticFeedbackType.ToggleOn)
val targetColor = if (checked) onColor else offColor } else {
animatedTrackColor.animateTo(targetColor, colorAnimationSpec) haptics.performHapticFeedback(HapticFeedbackType.ToggleOff)
} }
coroutineScope {
launch { launch {
val targetFrac = if (checked) 1f else 0f val targetFrac = if (checked) 1f else 0f
animatedFraction.animateTo(targetFrac, progressAnimationSpec) animatedFraction.animateTo(targetFrac, progressAnimationSpec)
@@ -140,7 +147,7 @@ fun StyledSwitch(
modifier = Modifier modifier = Modifier
.layerBackdrop(switchBackdrop) .layerBackdrop(switchBackdrop)
.clip(RoundedCornerShape(trackHeight / 2)) .clip(RoundedCornerShape(trackHeight / 2))
.background(animatedTrackColor.value) .background(animatedTrackColor)
.width(trackWidth) .width(trackWidth)
.height(trackHeight) .height(trackHeight)
.onSizeChanged { trackWidthPx.floatValue = it.width.toFloat() } .onSizeChanged { trackWidthPx.floatValue = it.width.toFloat() }
@@ -154,27 +161,31 @@ fun StyledSwitch(
.then(if (enabled) Modifier.draggable( .then(if (enabled) Modifier.draggable(
rememberDraggableState { delta -> rememberDraggableState { delta ->
if (trackWidthPx.floatValue > 0f) { if (trackWidthPx.floatValue > 0f) {
val oldFraction = animatedFraction.value
val newFraction = (animatedFraction.value + delta / trackWidthPx.floatValue).fastCoerceIn(-0.3f, 1.3f) val newFraction = (animatedFraction.value + delta / trackWidthPx.floatValue).fastCoerceIn(-0.3f, 1.3f)
animationScope.launch { scope.launch {
animatedFraction.snapTo(newFraction) animatedFraction.snapTo(newFraction)
} }
totalDrag.floatValue += kotlin.math.abs(delta) totalDrag.floatValue += abs(delta)
val newChecked = newFraction >= 0.5f val newChecked = newFraction >= 0.5f
if (newChecked != checked) { if (newChecked != checked) {
onCheckedChange(newChecked) onCheckedChange(newChecked)
} }
if ((oldFraction < 0.5f && newFraction >= 0.5f) || (oldFraction >= 0.5f && newFraction < 0.5f)) {
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
}
} }
}, },
Orientation.Horizontal, Orientation.Horizontal,
startDragImmediately = true, startDragImmediately = true,
onDragStarted = { onDragStarted = {
totalDrag.floatValue = 0f totalDrag.floatValue = 0f
animationScope.launch { scope.launch {
progressAnimation.animateTo(1f, progressAnimationSpec) progressAnimation.animateTo(1f, progressAnimationSpec)
} }
}, },
onDragStopped = { onDragStopped = {
animationScope.launch { scope.launch {
if (totalDrag.floatValue < tapThreshold) { if (totalDrag.floatValue < tapThreshold) {
val newChecked = !checked val newChecked = !checked
onCheckedChange(newChecked) onCheckedChange(newChecked)
@@ -262,7 +273,12 @@ fun StyledSwitch(
drawRect(Color.White.copy(1f - progress)) drawRect(Color.White.copy(1f - progress))
}, },
effects = { effects = {
refractionWithDispersion(6f.dp.toPx(), size.height / 2f) lens(
refractionHeight = 6f.dp.toPx(),
refractionAmount = size.height / 2f,
depthEffect = true,
chromaticAberration = true
)
} }
) )
.width(thumbWidth) .width(thumbWidth)

View File

@@ -0,0 +1,273 @@
/*
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.presentation.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun StyledToggle(
title: String? = null,
label: String,
description: String? = null,
checked: Boolean = false,
independent: Boolean = true,
enabled: Boolean = true,
onCheckedChange: (Boolean) -> Unit,
) {
val currentChecked by rememberUpdatedState(checked)
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
var backgroundColor by remember {
mutableStateOf(
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
)
}
val animatedBackgroundColor by animateColorAsState(
targetValue = backgroundColor,
animationSpec = tween(durationMillis = 500)
)
if (independent) {
Column(modifier = Modifier.padding(vertical = 8.dp)) {
if (title != null) {
Text(
text = title,
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
top = 8.dp,
bottom = 4.dp
)
)
}
Box(
modifier = Modifier
.background(animatedBackgroundColor, RoundedCornerShape(28.dp))
.padding(4.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
if (enabled) {
backgroundColor =
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor =
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
}
},
onTap = {
if (enabled) {
scope.launch { haptics.performHapticFeedback(if (!currentChecked) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) }
onCheckedChange(!currentChecked)
}
}
)
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(55.dp)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
modifier = Modifier.weight(1f),
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Normal,
color = textColor
)
)
StyledSwitch(
checked = checked,
enabled = enabled,
onCheckedChange = {
if (enabled) {
scope.launch { haptics.performHapticFeedback(if (it) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) }
onCheckedChange(it)
}
}
)
}
}
if (description != null) {
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.padding(horizontal = 16.dp)
.background(
if (isDarkTheme) Color(0xFF000000)
else Color(0xFFF2F2F7)
)
) {
Text(
text = description,
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
} else {
val isPressed = remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.background(
shape = RoundedCornerShape(28.dp),
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
)
.padding(16.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed.value = true
tryAwaitRelease()
isPressed.value = false
}
)
}
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
if (enabled) {
scope.launch { haptics.performHapticFeedback(if (!currentChecked) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) }
onCheckedChange(!currentChecked)
}
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = label,
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Normal,
color = textColor
)
)
Spacer(modifier = Modifier.height(4.dp))
if (description != null) {
Text(
text = description,
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
}
StyledSwitch(
checked = checked,
enabled = enabled,
onCheckedChange = {
if (enabled) {
onCheckedChange(it)
}
}
)
}
}
}
@Preview
@Composable
fun StyledTogglePreview() {
val checked = remember { mutableStateOf(false) }
StyledToggle(
label = "Example Toggle",
description = "This is an example description for the styled toggle.",
checked = checked.value,
onCheckedChange = { checked.value = !checked.value }
)
}

View File

@@ -1,22 +1,22 @@
/* /*
* 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
* 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/>.
*/
package me.kavishdevar.librepods.composables 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.presentation.components
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState

View File

@@ -1,24 +1,24 @@
/* /*
* 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)
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.presentation.overlays
import android.animation.Animator import android.animation.Animator
import android.animation.AnimatorListenerAdapter import android.animation.AnimatorListenerAdapter
@@ -58,10 +58,10 @@ import androidx.dynamicanimation.animation.DynamicAnimation
import androidx.dynamicanimation.animation.SpringAnimation import androidx.dynamicanimation.animation.SpringAnimation
import androidx.dynamicanimation.animation.SpringForce import androidx.dynamicanimation.animation.SpringForce
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.constants.Battery import me.kavishdevar.librepods.data.Battery
import me.kavishdevar.librepods.constants.BatteryComponent import me.kavishdevar.librepods.data.BatteryComponent
import me.kavishdevar.librepods.constants.BatteryStatus import me.kavishdevar.librepods.data.BatteryStatus
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs import kotlin.math.abs
@@ -240,6 +240,7 @@ class IslandWindow(private val context: Context) {
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.WRAP_CONTENT FrameLayout.LayoutParams.WRAP_CONTENT
) )
containerView.addView(islandView, containerParams) containerView.addView(islandView, containerParams)
params = WindowManager.LayoutParams( params = WindowManager.LayoutParams(
@@ -379,7 +380,11 @@ class IslandWindow(private val context: Context) {
videoView.start() videoView.start()
} }
try {
windowManager.addView(containerView, params) windowManager.addView(containerView, params)
} catch (e: Exception) {
e.printStackTrace()
}
islandView.post { islandView.post {
initialHeight = islandView.height initialHeight = islandView.height
@@ -707,8 +712,16 @@ class IslandWindow(private val context: Context) {
} }
isClosing = false isClosing = false
// Make sure all animations are canceled // Make sure all animations are canceled
try {
springAnimation.cancel() springAnimation.cancel()
} catch (e: Exception) {
e("IslandWindow", "Error cancelling spring animation $e")
}
try {
flingAnimator.cancel() flingAnimator.cancel()
} catch (e: Exception) {
e("IslandWindow", "Error cancelling fling animation $e")
}
} }
fun forceClose() { fun forceClose() {

View File

@@ -1,23 +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/>.
*/ */
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.presentation.overlays
import android.animation.Animator import android.animation.Animator
import android.animation.AnimatorListenerAdapter import android.animation.AnimatorListenerAdapter
@@ -45,10 +45,10 @@ import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import android.widget.VideoView import android.widget.VideoView
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.constants.Battery import me.kavishdevar.librepods.data.Battery
import me.kavishdevar.librepods.constants.BatteryComponent import me.kavishdevar.librepods.data.BatteryComponent
import me.kavishdevar.librepods.constants.BatteryStatus import me.kavishdevar.librepods.data.BatteryStatus
@SuppressLint("InflateParams", "ClickableViewAccessibility") @SuppressLint("InflateParams", "ClickableViewAccessibility")
class PopupWindow( class PopupWindow(
@@ -64,10 +64,16 @@ class PopupWindow(
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
private val mParams: WindowManager.LayoutParams = WindowManager.LayoutParams().apply { private val mParams: WindowManager.LayoutParams = WindowManager.LayoutParams().apply {
height = WindowManager.LayoutParams.WRAP_CONTENT height = WindowManager.LayoutParams.WRAP_CONTENT
width = WindowManager.LayoutParams.MATCH_PARENT val displayMetrics = context.resources.displayMetrics
val screenWidthDp = displayMetrics.widthPixels / displayMetrics.density
width = if (screenWidthDp >= 600) {
(400 * displayMetrics.density).toInt()
} else {
WindowManager.LayoutParams.MATCH_PARENT
}
type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
format = PixelFormat.TRANSLUCENT format = PixelFormat.TRANSLUCENT
gravity = Gravity.BOTTOM gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
dimAmount = 0.3f dimAmount = 0.3f
flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
WindowManager.LayoutParams.FLAG_FULLSCREEN or WindowManager.LayoutParams.FLAG_FULLSCREEN or
@@ -84,7 +90,6 @@ class PopupWindow(
mParams.x = 0 mParams.x = 0
mParams.y = 0 mParams.y = 0
mParams.gravity = Gravity.BOTTOM
mView.setOnClickListener { mView.setOnClickListener {
close() close()
} }
@@ -139,7 +144,11 @@ class PopupWindow(
vid.start() vid.start()
} }
try {
mWindowManager.addView(mView, mParams) mWindowManager.addView(mView, mParams)
} catch (e: Exception) {
e.printStackTrace()
}
val displayMetrics = mView.context.resources.displayMetrics val displayMetrics = mView.context.resources.displayMetrics
val screenHeight = displayMetrics.heightPixels val screenHeight = displayMetrics.heightPixels

View File

@@ -0,0 +1,844 @@
/*
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.presentation.screens
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
import android.annotation.SuppressLint
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
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.HazeState
import dev.chrisbanes.haze.hazeSource
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.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.data.Capability
import me.kavishdevar.librepods.presentation.components.NavigationButton
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledDropdown
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
private var phoneMediaDebounceJob: Job? = null
private var toneVolumeDebounceJob: Job? = null
//private const val TAG = "AccessibilitySettings"
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavController) {
val state by viewModel.uiState.collectAsState()
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val hearingAidEnabled =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(
1
)
?.toInt() == 1 && state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(
0
)?.toInt() == 1
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.accessibility)
) { topPadding, hazeState, bottomPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.hazeSource(hazeState)
.layerBackdrop(backdrop)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
if (!state.isPremium) {
StyledButton(
onClick = {
navController.navigate("purchase_screen")
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
)
}
}
// val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
// val phoneEQEnabled = remember { mutableStateOf(false) }
// val mediaEQEnabled = remember { mutableStateOf(false) }
val pressSpeedOptions = mapOf(
0.toByte() to stringResource(R.string.default_option),
1.toByte() to stringResource(R.string.slower),
2.toByte() to stringResource(R.string.slowest)
)
val selectedPressSpeedValue =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL]?.getOrNull(
0
)
var selectedPressSpeed by remember {
mutableStateOf(
pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]
)
}
val pressAndHoldDurationOptions = mapOf(
0.toByte() to stringResource(R.string.default_option),
1.toByte() to stringResource(R.string.slower),
2.toByte() to stringResource(R.string.slowest)
)
val selectedPressAndHoldDurationValue =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL]?.getOrNull(
0
)
var selectedPressAndHoldDuration by remember {
mutableStateOf(
pressAndHoldDurationOptions[selectedPressAndHoldDurationValue]
?: pressAndHoldDurationOptions[0]
)
}
val volumeSwipeSpeedOptions = mapOf(
1.toByte() to stringResource(R.string.default_option),
2.toByte() to stringResource(R.string.longer),
3.toByte() to stringResource(R.string.longest)
)
val selectedVolumeSwipeSpeedValue =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL]?.getOrNull(
0
)
var selectedVolumeSwipeSpeed by remember {
mutableStateOf(
volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue]
?: volumeSwipeSpeedOptions[1]
)
}
val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
val phoneEQEnabled = remember { mutableStateOf(false) }
val mediaEQEnabled = remember { mutableStateOf(false) }
LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) {
phoneMediaDebounceJob?.cancel()
phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
delay(150)
try {
val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte()
val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte()
Log.d(
"AccessibilitySettingsScreen",
"Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})"
)
viewModel.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte)
} catch (e: Exception) {
Log.w(
"AccessibilitySettingsScreen",
"Error sending phone/media EQ: ${e.message}"
)
}
}
}
Box(
modifier = Modifier.then(
if (!state.isPremium) {
Modifier.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
event.changes.forEach { it.consume() }
}
}
}
} else Modifier)) {
DropdownMenuComponent(
label = stringResource(R.string.press_speed),
description = stringResource(R.string.press_speed_description),
options = pressSpeedOptions.values.toList(),
selectedOption = selectedPressSpeed ?: stringResource(R.string.default_option),
onOptionSelected = { newValue ->
selectedPressSpeed = newValue
viewModel.setControlCommandByte(
identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
?: 0.toByte()
)
},
textColor = textColor,
hazeState = hazeState,
independent = true
)
}
Box(
modifier = Modifier.then(
if (!state.isPremium) {
Modifier.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
event.changes.forEach { it.consume() }
}
}
}
} else Modifier)) {
DropdownMenuComponent(
label = stringResource(R.string.press_and_hold_duration),
description = stringResource(R.string.press_and_hold_duration_description),
options = pressAndHoldDurationOptions.values.toList(),
selectedOption = selectedPressAndHoldDuration
?: stringResource(R.string.default_option),
onOptionSelected = { newValue ->
selectedPressAndHoldDuration = newValue
viewModel.setControlCommandByte(
identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull()
?: 0.toByte()
)
},
textColor = textColor,
hazeState = hazeState,
independent = true
)
}
StyledToggle(
title = stringResource(R.string.noise_control),
label = stringResource(R.string.noise_cancellation_single_airpod),
description = stringResource(R.string.noise_cancellation_single_airpod_description),
independent = true,
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE]?.getOrNull(
0
) == 0x01.toByte(),
onCheckedChange = {
viewModel.setControlCommandBoolean(
AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE, it
)
},
enabled = state.isPremium
)
if (state.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) && state.vendorIdHook) {
StyledToggle(
label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description),
checked = state.loudSoundReductionEnabled,
onCheckedChange = {
viewModel.setATTCharacteristicValue(
ATTHandles.LOUD_SOUND_REDUCTION,
if (it) byteArrayOf(0x01) else byteArrayOf(0x00)
)
},
enabled = state.isPremium
)
}
if (!hearingAidEnabled && state.vendorIdHook) {
NavigationButton(
to = "transparency_customization",
name = stringResource(R.string.customize_transparency_mode),
navController = navController,
enabled = state.isPremium
)
}
val toneVolumeValue =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME]?.getOrNull(
0
)?.toFloat() ?: 75f
StyledSlider(
label = stringResource(R.string.tone_volume),
description = stringResource(R.string.tone_volume_description),
value = toneVolumeValue,
onValueChange = {
viewModel.setControlCommandValue(
AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME,
byteArrayOf(it.toInt().toByte(), 0x50)
)
},
valueRange = 0f..100f,
snapPoints = listOf(75f),
startIcon = "\uDBC0\uDEA1",
endIcon = "\uDBC0\uDEA9",
independent = true,
enabled = state.isPremium
)
if (state.capabilities.contains(Capability.SWIPE_FOR_VOLUME)) {
val volumeSwipeEnabled =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE]?.getOrNull(
0
)?.toInt() == 0x01
StyledToggle(
label = stringResource(R.string.volume_control),
description = stringResource(R.string.volume_control_description),
checked = volumeSwipeEnabled,
onCheckedChange = {
viewModel.setControlCommandBoolean(
AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, it
)
},
enabled = state.isPremium
)
Box(
modifier = Modifier.then(
if (!state.isPremium) {
Modifier.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
event.changes.forEach { it.consume() }
}
}
}
} else Modifier)) {
DropdownMenuComponent(
label = stringResource(R.string.volume_swipe_speed),
description = stringResource(R.string.volume_swipe_speed_description),
options = volumeSwipeSpeedOptions.values.toList(),
selectedOption = selectedVolumeSwipeSpeed
?: stringResource(R.string.default_option),
onOptionSelected = { newValue ->
selectedVolumeSwipeSpeed = newValue
viewModel.setControlCommandByte(
identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
?: 1.toByte()
)
},
textColor = textColor,
hazeState = hazeState,
independent = true
)
}
}
// if (!hearingAidEnabled && XposedState.isAvailable) {
// Text(
// text = stringResource(R.string.apply_eq_to), style = TextStyle(
// fontSize = 14.sp,
// fontWeight = FontWeight.Bold,
// color = textColor.copy(alpha = 0.6f),
// fontFamily = FontFamily(Font(R.font.sf_pro))
// ), modifier = Modifier.padding(8.dp, bottom = 0.dp)
// )
// Column(
// modifier = Modifier
// .fillMaxWidth()
// .background(
// if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF),
// RoundedCornerShape(28.dp)
// )
// .padding(vertical = 0.dp)
// ) {
// 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)
// )
// }
// }
//
//// EQ Settings. Don't seem to have an effect?
// Column(
// modifier = Modifier
// .fillMaxWidth()
// .background(
// if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF),
// RoundedCornerShape(28.dp)
// )
// .padding(12.dp), horizontalAlignment = Alignment.CenterHorizontally
// ) {
// val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
// val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
// val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
//
// 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)
// )
// }
// }
// }
// }
Spacer(modifier = Modifier.height(bottomPadding))
}
}
}
@ExperimentalHazeMaterialsApi
@Composable
private fun DropdownMenuComponent(
label: String,
options: List<String>,
selectedOption: String,
onOptionSelected: (String) -> Unit,
textColor: Color,
hazeState: HazeState,
description: String? = null,
independent: Boolean = true
) {
val density = LocalDensity.current
val itemHeightPx = with(density) { 48.dp.toPx() }
var expanded by remember { mutableStateOf(false) }
var touchOffset by remember { mutableStateOf<Offset?>(null) }
var boxPosition by remember { mutableStateOf(Offset.Zero) }
var lastDismissTime by remember { mutableLongStateOf(0L) }
var parentHoveredIndex by remember { mutableStateOf<Int?>(null) }
var parentDragActive by remember { mutableStateOf(false) }
var previousIdx by remember { mutableStateOf<Int?>(null) }
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
Column(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.then(
if (independent) {
if (description != null) {
Modifier.padding(top = 8.dp, bottom = 4.dp)
} else {
Modifier.padding(vertical = 8.dp)
}
} else Modifier
)
.background(
if (independent) (if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(
0xFFFFFFFF
)) else Color.Transparent,
if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp)
) then (if (independent) Modifier.padding(horizontal = 4.dp) else Modifier).clip(
if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp)
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 12.dp, end = 12.dp)
.height(58.dp)
.pointerInput(Unit) {
detectTapGestures { offset ->
val now = System.currentTimeMillis()
if (expanded) {
expanded = false
lastDismissTime = now
} else {
if (now - lastDismissTime > 250L) {
touchOffset = offset
expanded = true
}
}
}
}
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(onDragStart = { offset ->
val now = System.currentTimeMillis()
touchOffset = offset
if (!expanded && now - lastDismissTime > 250L) {
expanded = true
}
lastDismissTime = now
parentDragActive = true
parentHoveredIndex = 0
}, onDrag = { change, _ ->
val current = change.position
val touch = touchOffset ?: current
val posInPopupY = current.y - touch.y
val idx = (posInPopupY / itemHeightPx).toInt()
if (idx != previousIdx) {
scope.launch {
haptics.performHapticFeedback(
HapticFeedbackType.SegmentTick
)
}
}
parentHoveredIndex = idx
previousIdx = idx
}, onDragEnd = {
parentDragActive = false
parentHoveredIndex?.let { idx ->
if (idx in options.indices) {
onOptionSelected(options[idx])
expanded = false
lastDismissTime = System.currentTimeMillis()
}
}
if (parentHoveredIndex != null && parentHoveredIndex in options.indices) {
scope.launch {
haptics.performHapticFeedback(
HapticFeedbackType.GestureEnd
)
}
}
parentHoveredIndex = null
}, onDragCancel = {
parentDragActive = false
parentHoveredIndex = null
})
},
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = label,
fontSize = 16.sp,
color = textColor,
modifier = Modifier.padding(bottom = 4.dp)
)
if (!independent && description != null) {
Text(
text = description, style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
), modifier = Modifier.padding(16.dp, top = 0.dp, bottom = 2.dp)
)
}
}
Box(
modifier = Modifier.onGloballyPositioned { coordinates ->
boxPosition = coordinates.positionInParent()
}) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = selectedOption, style = TextStyle(
fontSize = 16.sp,
color = textColor.copy(alpha = 0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = "􀆏", style = TextStyle(
fontSize = 16.sp,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
), modifier = Modifier.padding(start = 6.dp)
)
}
StyledDropdown(
expanded = expanded,
onDismissRequest = {
expanded = false
lastDismissTime = System.currentTimeMillis()
},
options = options,
selectedOption = selectedOption,
touchOffset = touchOffset,
boxPosition = boxPosition,
externalHoveredIndex = parentHoveredIndex,
externalDragActive = parentDragActive,
onOptionSelected = { option ->
onOptionSelected(option)
expanded = false
},
hazeState = hazeState
)
}
}
}
if (independent && description != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.background(
if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7)
)
) {
Text(
text = description, style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Light,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
alpha = 0.6f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
}

View File

@@ -0,0 +1,126 @@
/*
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.presentation.screens
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
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.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 kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable
fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel, navController: NavController) {
val state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.customize_adaptive_audio)
) { spacerHeight ->
Column(
modifier = Modifier
.fillMaxSize()
.layerBackdrop(backdrop)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(spacerHeight))
if (!state.isPremium) {
StyledButton(
onClick = {
navController.navigate("purchase_screen")
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
)
}
}
val sliderValue = remember {
mutableFloatStateOf(100f - (state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH]?.getOrNull(0)?.toFloat() ?: 50f))
}
var job by remember { mutableStateOf<Job?>(null) }
val scope = rememberCoroutineScope()
StyledSlider(
label = stringResource(R.string.customize_adaptive_audio),
value = sliderValue.floatValue,
onValueChange = {
sliderValue.floatValue = it
job?.cancel()
job = scope.launch {
delay(150)
viewModel.setControlCommandValue(
AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
byteArrayOf((100 - it).toInt().toByte())
)
}
},
valueRange = 0f..100f,
snapPoints = listOf(0f, 50f, 100f),
startIcon = "􀊥",
endIcon = "􀊩",
independent = true,
description = stringResource(R.string.adaptive_audio_description),
enabled = state.isPremium
)
}
}
}

View File

@@ -0,0 +1,562 @@
/*
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.presentation.screens
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
import android.annotation.SuppressLint
import android.content.Context.MODE_PRIVATE
import android.content.SharedPreferences
import androidx.compose.foundation.gestures.detectTapGestures
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
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.text.input.TextFieldValue
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.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.highlight.Highlight
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.delay
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.data.AirPodsPro3
import me.kavishdevar.librepods.data.Capability
import me.kavishdevar.librepods.presentation.components.AboutCard
import me.kavishdevar.librepods.presentation.components.AudioSettings
import me.kavishdevar.librepods.presentation.components.BatteryView
import me.kavishdevar.librepods.presentation.components.CallControlSettings
import me.kavishdevar.librepods.presentation.components.ConnectionSettings
import me.kavishdevar.librepods.presentation.components.HearingHealthSettings
import me.kavishdevar.librepods.presentation.components.MicrophoneSettings
import me.kavishdevar.librepods.presentation.components.NavigationButton
import me.kavishdevar.librepods.presentation.components.NoiseControlSettings
import me.kavishdevar.librepods.presentation.components.PressAndHoldSettings
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
@Composable
fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavController) {
val state by viewModel.uiState.collectAsState()
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
var deviceName by remember {
mutableStateOf(
TextFieldValue(
sharedPreferences.getString("name", state.deviceName).toString()
)
)
}
val nameChangeListener = remember {
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == "name") {
deviceName =
TextFieldValue(sharedPreferences.getString("name", "AirPods Pro").toString())
}
}
}
DisposableEffect(Unit) {
sharedPreferences.registerOnSharedPreferenceChangeListener(nameChangeListener)
onDispose {
sharedPreferences.unregisterOnSharedPreferenceChangeListener(nameChangeListener)
}
}
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.refreshInitialData()
}
isSystemInDarkTheme()
val hazeStateS = remember { mutableStateOf(HazeState()) }
StyledScaffold(
title = deviceName.text, actionButtons = listOf(
{ scaffoldBackdrop ->
StyledIconButton(
onClick = { navController.navigate("app_settings") },
icon = "􀍟",
backdrop = scaffoldBackdrop
)
}), snackbarHostState = snackbarHostState
) { topPadding, hazeState, bottomPadding ->
hazeStateS.value = hazeState
var blockTouches by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.demoActivated.collect {
blockTouches = true
delay(1000)
blockTouches = false
}
}
if (state.isLocallyConnected) {
val capabilities = state.capabilities
LazyColumn(
modifier = Modifier
.fillMaxSize()
.hazeSource(hazeState)
.padding(horizontal = 16.dp)
.then(if (blockTouches) Modifier.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
event.changes.forEach { it.consume() }
}
}
} else Modifier)) {
item(key = "spacer_top") { Spacer(modifier = Modifier.height(topPadding)) }
item(key = "battery") {
BatteryView(
batteryList = state.battery,
budsRes = state.instance?.model?.budsRes ?: R.drawable.airpods_pro_2_case,
caseRes = state.instance?.model?.caseRes ?: R.drawable.airpods_pro_2_case
)
}
item(key = "spacer_battery") { Spacer(modifier = Modifier.height(32.dp)) }
item(key = "name") {
NavigationButton(
to = "rename",
name = stringResource(R.string.name),
currentState = deviceName.text,
navController = navController,
independent = true
)
}
val hasHearingAidCapability =
state.instance?.model?.capabilities?.contains(Capability.HEARING_AID) == true
val hasPPECapability =
state.instance?.model?.capabilities?.contains(Capability.PPE) == true
if (hasHearingAidCapability || hasPPECapability) {
if (hasPPECapability || (state.vendorIdHook && hasHearingAidCapability)) item(
key = "spacer_hearing_health"
) { Spacer(modifier = Modifier.height(24.dp)) }
item(key = "hearing_health") {
HearingHealthSettings(
navController = navController,
hasPPECapability = hasPPECapability,
hasHearingAidCapability = hasHearingAidCapability,
vendorIdHook = state.vendorIdHook
)
}
}
if (capabilities.contains(Capability.LISTENING_MODE)) {
item(key = "spacer_noise") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "noise_control") {
NoiseControlSettings(
showOffListeningMode = state.offListeningMode,
noiseControlModeValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE]?.getOrNull(
0
)?.toInt() ?: 3,
onNoiseControlModeChanged = {
viewModel.setControlCommandInt(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE,
it
)
},
)
}
}
if (capabilities.contains(Capability.STEM_CONFIG)) {
item(key = "spacer_press_hold") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "press_hold") {
PressAndHoldSettings(
navController = navController,
leftAction = state.leftAction,
rightAction = state.rightAction
)
}
}
item(key = "spacer_call") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "call_control") {
val bytes = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.take(2)?.toByteArray() ?: byteArrayOf(0x00, 0x00)
val flipped = try { bytes[1] == 0x02.toByte() } catch (e: Exception) { false }
CallControlSettings(
hazeState = hazeState,
flipped = flipped,
onCallControlValueChanged = {
viewModel.setControlCommandValue(
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
if (it) byteArrayOf(0x00, 0x02) else byteArrayOf(0x00, 0x03)
)
})
}
// if (capabilities.contains(Capability.STEM_CONFIG) && !BuildConfig.PLAY_BUILD) {
// item(key = "spacer_camera") { Spacer(modifier = Modifier.height(16.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 = "upgrade_button") {
if (!state.isPremium) {
Spacer(modifier = Modifier.height(28.dp))
StyledButton(
onClick = {
navController.navigate("purchase_screen")
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(
0xFFE59900
)
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
)
}
Spacer(modifier = Modifier.height(16.dp))
}
}
item(key = "spacer_audio") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "audio") {
val model = state.instance?.model ?: AirPodsPro3()
val adaptiveVolumeCapability =
model.capabilities.contains(Capability.ADAPTIVE_VOLUME)
val conversationalAwarenessCapability =
model.capabilities.contains(Capability.CONVERSATION_AWARENESS)
val loudSoundReductionCapability =
model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION)
val adaptiveAudioCapability =
model.capabilities.contains(Capability.ADAPTIVE_VOLUME)
val adaptiveVolumeChecked =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG]?.getOrNull(
0
) == 0x01.toByte()
val conversationalAwarenessChecked =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG]?.getOrNull(
0
) == 0x01.toByte()
AudioSettings(
navController = navController,
adaptiveVolumeCapability = adaptiveVolumeCapability,
conversationalAwarenessCapability = conversationalAwarenessCapability,
loudSoundReductionCapability = loudSoundReductionCapability,
adaptiveAudioCapability = adaptiveAudioCapability,
adaptiveVolumeChecked = adaptiveVolumeChecked,
onAdaptiveVolumeCheckedChange = { checked ->
viewModel.setControlCommandBoolean(
AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG,
checked
)
},
conversationalAwarenessChecked = conversationalAwarenessChecked && state.isPremium,
onConversationalAwarenessCheckedChange = { checked ->
viewModel.setControlCommandBoolean(
AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
checked
)
},
loudSoundReductionChecked = state.loudSoundReductionEnabled,
onLoudSoundReductionCheckedChange = {
viewModel.setATTCharacteristicValue(
ATTHandles.LOUD_SOUND_REDUCTION,
byteArrayOf(if (it) 0x01.toByte() else 0x00.toByte())
)
},
vendorIdHook = state.vendorIdHook,
isPremium = state.isPremium
)
}
item(key = "spacer_connection") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "connection") {
ConnectionSettings(
automaticEarDetectionEnabled = state.automaticEarDetectionEnabled,
onAutomaticEarDetectionChanged = {
viewModel.setAutomaticEarDetectionEnabled(it)
},
automaticConnectionEnabled = state.automaticConnectionEnabled,
onAutomaticConnectionChanged = { viewModel.setAutomaticConnectionEnabled(it) })
}
item(key = "spacer_microphone") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "microphone") {
val id = AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
MicrophoneSettings(
hazeState = hazeState,
micModeValue = state.controlStates[id]?.getOrNull(0) ?: 0x00.toByte(),
onMicModeValueChanged = { viewModel.setControlCommandByte(id, it) })
}
if (capabilities.contains(Capability.SLEEP_DETECTION)) {
item(key = "spacer_sleep") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "sleep_detection") {
val id =
AACPManager.Companion.ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG
StyledToggle(
label = stringResource(R.string.sleep_detection),
checked = state.controlStates[id]?.getOrNull(0) == 0x01.toByte(),
onCheckedChange = {
viewModel.setControlCommandBoolean(id, it)
},
enabled = state.isPremium
)
}
}
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") {
val id = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
StyledToggle(
label = stringResource(R.string.off_listening_mode),
description = stringResource(R.string.off_listening_mode_description),
checked = state.controlStates[id]?.getOrNull(0) == 0x01.toByte(),
onCheckedChange = viewModel::setOffListeningMode
)
}
}
item(key = "spacer_about") { Spacer(modifier = Modifier.height(32.dp)) }
item(key = "about") {
AboutCard(
navController = navController,
modelName = state.modelName,
actualModel = state.actualModel,
serialNumbers = state.serialNumbers,
version = state.version3,
)
}
item(key = "spacer_disconnect") { Spacer(modifier = Modifier.height(28.dp)) }
item(key = "disconnect_button") {
StyledButton(
onClick = viewModel::disconnect,
backdrop = rememberLayerBackdrop(),
isInteractive = false,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 56.dp)
) {
Text(
text = stringResource(R.string.disconnect),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = if (isSystemInDarkTheme()) Color(0xFF0091FF) else Color(0xFF0088FF),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
textAlign = TextAlign.Start,
modifier = Modifier.fillMaxWidth()
)
}
}
// item(key = "spacer_debug") { Spacer(modifier = Modifier.height(16.dp)) }
// item(key = "debug") { NavigationButton("debug", "Debug", navController) }
item(key = "spacer_bottom") { Spacer(Modifier.height(bottomPadding)) }
}
} else {
val backdrop = rememberLayerBackdrop()
Column(
modifier = Modifier
.fillMaxSize()
.drawBackdrop(
backdrop = rememberLayerBackdrop(),
exportedBackdrop = backdrop,
shape = { RoundedCornerShape(0.dp) },
highlight = {
Highlight.Ambient.copy(alpha = 0f)
},
effects = {})
.hazeSource(hazeState)
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
val tapCount = remember { mutableIntStateOf(0) }
val lastTapTime = remember { mutableLongStateOf(0L) }
Column(
modifier = Modifier
.fillMaxWidth()
.pointerInput(Unit) {
detectTapGestures(
onTap = {
val now = System.currentTimeMillis()
if (now - lastTapTime.longValue > 400) {
tapCount.intValue = 0
}
tapCount.intValue++
lastTapTime.longValue = now
if (tapCount.intValue >= 5) {
tapCount.intValue = 0
viewModel.activateDemoMode()
}
})
}) {
Text(
text = stringResource(R.string.airpods_not_connected), style = TextStyle(
fontSize = 24.sp,
fontWeight = FontWeight.Medium,
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
fontFamily = FontFamily(Font(R.font.sf_pro))
), textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(24.dp))
Text(
text = stringResource(R.string.airpods_not_connected_description),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Light,
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(Modifier.height(32.dp))
if (!BuildConfig.PLAY_BUILD) {
StyledButton(
onClick = { navController.navigate("troubleshooting") },
backdrop = backdrop,
modifier = Modifier
.fillMaxWidth(0.9f)
) {
Text(
text = stringResource(R.string.troubleshooting),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = if (isSystemInDarkTheme()) Color.White else Color.Black
)
)
}
Spacer(Modifier.height(16.dp))
}
StyledButton(
onClick = {
viewModel.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
)
)
}
}
}
}
}

View File

@@ -0,0 +1,687 @@
/*
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.presentation.screens
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.widget.Toast
import androidx.compose.foundation.background
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.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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
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.text.input.KeyboardCapitalization
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.lerp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.AppInfoCard
import me.kavishdevar.librepods.presentation.components.DeviceInfoCard
import me.kavishdevar.librepods.presentation.components.NavigationButton
import me.kavishdevar.librepods.presentation.components.StyledBottomSheet
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.presentation.components.StyledInputField
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
import me.kavishdevar.librepods.utils.XposedState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppSettingsScreen(
navController: NavController, viewModel: AppSettingsViewModel = viewModel()
) {
val context = LocalContext.current
val scrollState = rememberScrollState()
val state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop()
val contactBottomSheet = remember { mutableStateOf(false) }
val subjectState = remember { TextFieldState() }
val descriptionState = remember { TextFieldState() }
val subjectFocusRequester = remember { FocusRequester() }
val descriptionFocusRequester = remember { FocusRequester() }
StyledScaffold(
title = stringResource(R.string.settings)
) { topPadding, hazeState, bottomPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.layerBackdrop(backdrop)
.hazeSource(state = hazeState)
.verticalScroll(scrollState)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
if (!state.isPremium && state.connectionSuccessful) {
StyledButton(
onClick = {
navController.navigate("purchase_screen")
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
)
}
}
if (state.connectionSuccessful) {
StyledToggle(
title = stringResource(R.string.widget),
label = stringResource(R.string.show_phone_battery_in_widget),
description = stringResource(R.string.show_phone_battery_in_widget_description),
checked = state.showPhoneBatteryInWidget,
onCheckedChange = viewModel::setShowPhoneBatteryInWidget,
enabled = state.isPremium
)
Text(
text = stringResource(R.string.conversational_awareness), style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
)
Spacer(modifier = Modifier.height(2.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.background(
backgroundColor, RoundedCornerShape(28.dp)
)
.padding(vertical = 4.dp)
) {
StyledToggle(
label = stringResource(R.string.conversational_awareness_pause_music),
description = stringResource(R.string.conversational_awareness_pause_music_description),
checked = state.conversationalAwarenessPauseMusicEnabled,
onCheckedChange = viewModel::setConversationalAwarenessPauseMusicEnabled,
independent = false,
enabled = state.isPremium
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
StyledToggle(
label = stringResource(R.string.relative_conversational_awareness_volume),
description = stringResource(R.string.relative_conversational_awareness_volume_description),
checked = state.relativeConversationalAwarenessVolumeEnabled,
onCheckedChange = viewModel::setRelativeConversationalAwarenessVolumeEnabled,
independent = false,
enabled = state.isPremium,
)
}
Spacer(modifier = Modifier.height(16.dp))
val conversationalAwarenessVolume = state.conversationalAwarenessVolume
LaunchedEffect(conversationalAwarenessVolume) {
viewModel.setConversationalAwarenessVolume(conversationalAwarenessVolume)
}
StyledSlider(
label = stringResource(R.string.conversational_awareness_volume),
value = conversationalAwarenessVolume,
valueRange = 10f..85f,
snapPoints = listOf(44f),
startLabel = "10%",
endLabel = "85%",
onValueChange = { newValue ->
viewModel.setConversationalAwarenessVolume(
newValue
)
},
independent = true,
enabled = state.isPremium
)
// if (!BuildConfig.PLAY_BUILD) {
// Spacer(modifier = Modifier.height(16.dp))
//
// NavigationButton(
// to = "",
// title = stringResource(R.string.camera_control),
// name = stringResource(R.string.set_custom_camera_package),
// navController = navController,
// onClick = {
// if (state.isPremium) viewModel.setShowCameraDialog(true)
// },
// independent = true,
// description = stringResource(R.string.camera_control_app_description)
// )
// }
Spacer(modifier = Modifier.height(16.dp))
if (context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) {
StyledToggle(
title = stringResource(R.string.ear_detection),
label = stringResource(R.string.disconnect_when_not_wearing),
description = stringResource(R.string.disconnect_when_not_wearing_description),
checked = state.disconnectWhenNotWearing,
onCheckedChange = viewModel::setDisconnectWhenNotWearing,
enabled = state.isPremium
)
}
Text(
text = stringResource(R.string.takeover_airpods_state), style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
)
Spacer(modifier = Modifier.height(4.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.background(
backgroundColor, RoundedCornerShape(28.dp)
)
.padding(vertical = 4.dp)
) {
StyledToggle(
label = stringResource(R.string.takeover_disconnected),
description = stringResource(R.string.takeover_disconnected_desc),
checked = state.takeoverWhenDisconnected,
onCheckedChange = viewModel::setTakeoverWhenDisconnected,
independent = false,
enabled = state.isPremium
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
StyledToggle(
label = stringResource(R.string.takeover_idle),
description = stringResource(R.string.takeover_idle_desc),
checked = state.takeoverWhenIdle,
onCheckedChange = viewModel::setTakeoverWhenIdle,
independent = false,
enabled = state.isPremium
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
StyledToggle(
label = stringResource(R.string.takeover_music),
description = stringResource(R.string.takeover_music_desc),
checked = state.takeoverWhenMusic,
onCheckedChange = viewModel::setTakeoverWhenMusic,
independent = false,
enabled = state.isPremium
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
StyledToggle(
label = stringResource(R.string.takeover_call),
description = stringResource(R.string.takeover_call_desc),
checked = state.takeoverWhenCall,
onCheckedChange = viewModel::setTakeoverWhenCall,
independent = false,
enabled = state.isPremium
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.takeover_phone_state), style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
), modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(4.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.background(
backgroundColor, RoundedCornerShape(28.dp)
)
.padding(vertical = 4.dp)
) {
StyledToggle(
label = stringResource(R.string.takeover_ringing_call),
description = stringResource(R.string.takeover_ringing_call_desc),
checked = state.takeoverWhenRingingCall,
onCheckedChange = viewModel::setTakeoverWhenRingingCall,
independent = false,
enabled = state.isPremium
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
StyledToggle(
label = stringResource(R.string.takeover_media_start),
description = stringResource(R.string.takeover_media_start_desc),
checked = state.takeoverWhenMediaStart,
onCheckedChange = viewModel::setTakeoverWhenMediaStart,
independent = false,
enabled = state.isPremium
)
}
Text(
text = stringResource(R.string.advanced_options), style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
)
Spacer(modifier = Modifier.height(2.dp))
StyledToggle(
label = stringResource(R.string.use_alternate_head_tracking_packets),
description = stringResource(R.string.use_alternate_head_tracking_packets_description),
checked = state.useAlternateHeadTrackingPackets,
onCheckedChange = viewModel::setUseAlternateHeadTrackingPackets,
independent = true,
enabled = state.isPremium
)
Spacer(modifier = Modifier.height(16.dp))
} else {
Box(
modifier = Modifier
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
.padding(horizontal = 16.dp)
.padding(top = 16.dp, bottom = 2.dp)
) {
Text(
text = stringResource(R.string.customizations_unavailable),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor.copy(alpha = 0.6f),
),
modifier = Modifier
)
}
}
if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) {
val restartBluetoothText =
stringResource(R.string.found_offset_restart_bluetooth)
StyledToggle(
label = stringResource(R.string.act_as_an_apple_device) + " (${
stringResource(
R.string.requires_xposed
)
})",
description = stringResource(R.string.act_as_an_apple_device_description),
checked = state.vendorIdHook,
onCheckedChange = { enabled ->
Toast.makeText(context, restartBluetoothText, Toast.LENGTH_SHORT).show()
viewModel.setVendorIdHook(enabled)
},
independent = true,
enabled = state.isPremium
)
}
if (!BuildConfig.PLAY_BUILD) {
Spacer(modifier = Modifier.height(16.dp))
NavigationButton(
to = "troubleshooting",
name = stringResource(R.string.troubleshooting),
navController = navController,
independent = true,
description = stringResource(R.string.troubleshooting_description)
)
}
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
.padding(start = 16.dp, bottom = 2.dp, top = 24.dp, end = 4.dp)
) {
Text(
text = stringResource(R.string.contact), style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
Spacer(modifier = Modifier.height(4.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.background(
backgroundColor, RoundedCornerShape(28.dp)
)
.clip(RoundedCornerShape(28.dp))
) {
NavigationButton(
to = "",
name = stringResource(R.string.email),
navController = navController,
onClick = { contactBottomSheet.value = true },
independent = false
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
NavigationButton(
to = "",
name = stringResource(R.string.discord),
navController = navController,
onClick = {
val intent =
Intent(Intent.ACTION_VIEW, "https://discord.gg/Ts4wupXcmc".toUri())
context.startActivity(intent)
},
independent = false
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
NavigationButton(
to = "",
name = stringResource(R.string.github_issues),
navController = navController,
onClick = {
val intent = Intent(
Intent.ACTION_VIEW,
"https://github.com/kavishdevar/librepods/issues".toUri()
)
context.startActivity(intent)
},
independent = false
)
}
Spacer(modifier = Modifier.height(20.dp))
DeviceInfoCard()
Spacer(modifier = Modifier.height(16.dp))
AppInfoCard()
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(bottomPadding))
if (state.showCameraDialog) {
AlertDialog(onDismissRequest = { viewModel.setShowCameraDialog(false) }, title = {
Text(
stringResource(R.string.set_custom_camera_package),
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium
)
}, text = {
Column {
Text(
stringResource(R.string.enter_custom_camera_package),
fontFamily = FontFamily(Font(R.font.sf_pro)),
modifier = Modifier.padding(bottom = 8.dp)
)
OutlinedTextField(
value = state.cameraPackageValue,
onValueChange = {
viewModel.setCameraPackageValue(it)
viewModel.setCameraPackageError(null)
},
modifier = Modifier.fillMaxWidth(),
isError = state.cameraPackageError != null,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Ascii,
capitalization = KeyboardCapitalization.None
),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = if (isDarkTheme) Color(0xFF007AFF) else Color(
0xFF3C6DF5
),
unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray
),
supportingText = {
if (state.cameraPackageError != null) {
Text(
state.cameraPackageError ?: "",
color = MaterialTheme.colorScheme.error
)
}
},
label = { Text(stringResource(R.string.custom_camera_package)) })
}
}, confirmButton = {
val successText = stringResource(R.string.custom_camera_package_set_success)
TextButton(
onClick = {
viewModel.saveCameraPackage()
Toast.makeText(context, successText, Toast.LENGTH_SHORT).show()
}) {
Text(
"Save",
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium
)
}
}, dismissButton = {
TextButton(
onClick = { viewModel.setShowCameraDialog(false) }) {
Text(
"Cancel",
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium
)
}
})
}
}
StyledBottomSheet(
visible = contactBottomSheet.value,
onDismiss = { contactBottomSheet.value = false },
backdrop = backdrop
) { innerBackdrop, progress ->
val animatedPadding = lerp(16.dp, 2.dp, progress)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = animatedPadding)
.padding(bottom = 16.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
StyledIconButton(
icon = "\uDBC0\uDD84",
backdrop = innerBackdrop,
onClick = { contactBottomSheet.value = false }
)
Text (
text = stringResource(R.string.describe_your_issue),
style = TextStyle(
fontSize = 18.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = if (isSystemInDarkTheme()) Color.White else Color.Black
)
)
StyledIconButton(
icon = "\uDBC0\uDE1F",
backdrop = innerBackdrop,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF0091FF) else Color(0xFF0088FF),
iconTint = if (subjectState.text.isNotEmpty() && descriptionState.text.isNotEmpty()) Color.White else Color.Gray,
enabled = subjectState.text.isNotEmpty() && descriptionState.text.isNotEmpty(),
onClick = {
contactBottomSheet.value = false
val intent = Intent(Intent.ACTION_SENDTO).apply {
data = "mailto:".toUri()
putExtra(Intent.EXTRA_EMAIL, arrayOf("contact@kavish.xyz"))
putExtra(Intent.EXTRA_SUBJECT, "LibrePods: ${subjectState.text}")
putExtra(
Intent.EXTRA_TEXT,
"${descriptionState.text}" +
"\n\n----------" +
"\nPhone details:" +
"\nMANUFACTURER: ${Build.MANUFACTURER}" +
"\nMODEL: ${Build.MODEL} (${Build.PRODUCT})" +
"\nDISPLAY_VERSION: ${Build.DISPLAY}" +
"\nID: ${Build.ID} (SDK ${Build.VERSION.SDK_INT_FULL})" +
"\nXposed enabled/active: ${XposedState.isAvailable}/${XposedState.bluetoothScopeEnabled}" +
"\n\nApp details:" +
"\nVERSION: ${BuildConfig.VERSION_NAME}" +
"\nVERSION_CODE: ${BuildConfig.VERSION_CODE}" +
"\nFLAVOR: ${BuildConfig.FLAVOR}" +
"\nBUILD_TYPE: ${BuildConfig.BUILD_TYPE}"
)
}
context.startActivity(intent)
subjectState.clearText()
descriptionState.clearText()
}
)
}
Spacer(modifier = Modifier.height(8.dp))
StyledInputField(
inputState = subjectState,
focusRequester = subjectFocusRequester,
placeholder = stringResource(R.string.subject),
)
Spacer(modifier = Modifier.height(12.dp))
StyledInputField(
inputState = descriptionState,
focusRequester = descriptionFocusRequester,
placeholder = stringResource(R.string.describe_your_issue),
singleLine = false
)
}
}
}
}

View File

@@ -0,0 +1,111 @@
/*
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.presentation.screens
import android.accessibilityservice.AccessibilityServiceInfo
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.provider.Settings
import android.view.accessibility.AccessibilityManager
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.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.SelectItem
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSelectList
import me.kavishdevar.librepods.services.AppListenerService
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.StemPressType
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable
fun CameraControlScreen(viewModel: AirPodsViewModel) {
val context = LocalContext.current
val currentCameraAction by viewModel.cameraAction.collectAsState()
fun isAppListenerServiceEnabled(context: Context): Boolean {
val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
val enabledServices =
am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK)
val serviceComponent = ComponentName(context, AppListenerService::class.java)
return enabledServices.any {
it.resolveInfo.serviceInfo.packageName == serviceComponent.packageName &&
it.resolveInfo.serviceInfo.name == serviceComponent.className
}
}
fun handleSelection(action: StemPressType?) {
if (action != null && !isAppListenerServiceEnabled(context)) {
context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
} else {
viewModel.setCameraAction(action)
}
}
val cameraOptions = remember(currentCameraAction) {
listOf(
SelectItem(
name = "Off",
selected = currentCameraAction == null,
onClick = { handleSelection(null) }
),
SelectItem(
name = "Press once",
selected = currentCameraAction == StemPressType.SINGLE_PRESS,
onClick = { handleSelection(StemPressType.SINGLE_PRESS) }
),
SelectItem(
name = "Press and hold AirPods",
selected = currentCameraAction == StemPressType.LONG_PRESS,
onClick = { handleSelection(StemPressType.LONG_PRESS) }
)
)
}
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.camera_control)
) { spacerHeight ->
Column(
modifier = Modifier
.fillMaxSize()
.layerBackdrop(backdrop)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(spacerHeight))
StyledSelectList(items = cameraOptions)
}
}
}

View File

@@ -1,24 +1,24 @@
/* /*
* 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)
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.presentation.screens
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.ClipData import android.content.ClipData
@@ -82,10 +82,10 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledIconButton import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.constants.BatteryStatus import me.kavishdevar.librepods.data.BatteryStatus
import me.kavishdevar.librepods.constants.isHeadTrackingData import me.kavishdevar.librepods.data.isHeadTrackingData
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -335,12 +335,11 @@ fun DebugScreen(navController: NavController) {
expandedItems.value = emptySet() expandedItems.value = emptySet()
}, },
icon = "􀈑", icon = "􀈑",
darkMode = isDarkTheme,
backdrop = scaffoldBackdrop backdrop = scaffoldBackdrop
) )
} }
), ),
) { spacerHeight, hazeState -> ) { topPadding, hazeState, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -349,7 +348,7 @@ fun DebugScreen(navController: NavController) {
.layerBackdrop(backdrop) .layerBackdrop(backdrop)
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(topPadding))
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier modifier = Modifier

View File

@@ -1,29 +1,33 @@
/* /*
* 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)
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.presentation.screens
import android.content.Context import android.graphics.Paint
import android.os.Build import android.graphics.RadialGradient
import android.graphics.Shader
import android.graphics.Typeface
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
@@ -42,6 +46,7 @@ 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.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
@@ -71,7 +76,6 @@ import androidx.compose.ui.graphics.asAndroidPath
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText import androidx.compose.ui.text.drawText
@@ -80,7 +84,6 @@ 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.text.rememberTextMeasurer import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController import androidx.navigation.NavController
@@ -92,12 +95,13 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledButton import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.composables.StyledIconButton import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.HeadTracking import me.kavishdevar.librepods.utils.HeadTracking
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.cos import kotlin.math.cos
@@ -105,14 +109,14 @@ import kotlin.math.sin
import kotlin.random.Random import kotlin.random.Random
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@RequiresApi(Build.VERSION_CODES.Q)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
@Composable @Composable
fun HeadTrackingScreen(navController: NavController) { fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController) {
val state by viewModel.uiState.collectAsState()
DisposableEffect(Unit) { DisposableEffect(Unit) {
ServiceManager.getService()?.startHeadTracking() viewModel.startHeadTracking()
onDispose { onDispose {
ServiceManager.getService()?.stopHeadTracking() viewModel.stopHeadTracking()
} }
} }
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
@@ -125,25 +129,22 @@ fun HeadTrackingScreen(navController: NavController) {
title = stringResource(R.string.head_tracking), title = stringResource(R.string.head_tracking),
actionButtons = listOf( actionButtons = listOf(
{ scaffoldBackdrop -> { scaffoldBackdrop ->
var isActive by remember { mutableStateOf(ServiceManager.getService()?.isHeadTrackingActive == true) }
StyledIconButton( StyledIconButton(
onClick = { onClick = {
if (ServiceManager.getService()?.isHeadTrackingActive == false) { if (!state.headTrackingActive) {
ServiceManager.getService()?.startHeadTracking() viewModel.startHeadTracking()
Log.d("HeadTrackingScreen", "Head tracking started") Log.d("HeadTrackingScreen", "Head tracking started")
} else { } else {
ServiceManager.getService()?.stopHeadTracking() viewModel.stopHeadTracking()
Log.d("HeadTrackingScreen", "Head tracking stopped") Log.d("HeadTrackingScreen", "Head tracking stopped")
} }
}, },
icon = if (isActive) "􀊅" else "􀊃", icon = if (state.headTrackingActive) "􀊅" else "􀊃",
darkMode = isDarkTheme,
backdrop = scaffoldBackdrop backdrop = scaffoldBackdrop
) )
} }
), ),
) { spacerHeight, hazeState -> ) { topPadding, hazeState, _ ->
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
var gestureText by remember { mutableStateOf("") } var gestureText by remember { mutableStateOf("") }
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@@ -164,35 +165,49 @@ fun HeadTrackingScreen(navController: NavController) {
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.verticalScroll(scrollState) .verticalScroll(scrollState)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(topPadding))
if (!state.isPremium) {
StyledButton(
onClick = {
navController.navigate("purchase_screen")
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
)
}
Spacer(modifier = Modifier.height(8.dp))
}
StyledToggle( StyledToggle(
label = "Head Gestures", label = "Head Gestures",
sharedPreferences = sharedPreferences, checked = state.headGesturesEnabled,
sharedPreferenceKey = "head_gestures", onCheckedChange = { viewModel.setHeadGesturesEnabled(it) },
) enabled = state.isPremium,
description = stringResource(R.string.head_gestures_details)
Spacer(modifier = Modifier.height(2.dp))
Text(
stringResource(R.string.head_gestures_details),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor.copy(0.6f)
),
modifier = Modifier.padding(start = 4.dp)
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
"Head Orientation", "Head Orientation",
style = TextStyle( style = TextStyle(
fontSize = 18.sp, fontSize = 14.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Bold,
fontFamily = FontFamily(Font(R.font.sf_pro)), color = textColor.copy(alpha = 0.6f),
color = textColor fontFamily = FontFamily(Font(R.font.sf_pro))
), ),
modifier = Modifier.padding(start = 4.dp, bottom = 8.dp, top = 8.dp) modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
) )
HeadVisualization() HeadVisualization()
@@ -200,12 +215,12 @@ fun HeadTrackingScreen(navController: NavController) {
Text( Text(
"Velocity", "Velocity",
style = TextStyle( style = TextStyle(
fontSize = 18.sp, fontSize = 14.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Bold,
fontFamily = FontFamily(Font(R.font.sf_pro)), color = textColor.copy(alpha = 0.6f),
color = textColor fontFamily = FontFamily(Font(R.font.sf_pro))
), ),
modifier = Modifier.padding(start = 4.dp, bottom = 8.dp, top = 8.dp) modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
) )
AccelerationPlot() AccelerationPlot()
@@ -381,7 +396,8 @@ private fun HeadVisualization() {
.aspectRatio(2f), .aspectRatio(2f),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = backgroundColor containerColor = backgroundColor
) ),
shape = RoundedCornerShape(28.dp)
) { ) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
@@ -456,9 +472,9 @@ private fun HeadVisualization() {
spherePath.close() spherePath.close()
drawContext.canvas.nativeCanvas.apply { drawContext.canvas.nativeCanvas.apply {
val paint = android.graphics.Paint().apply { val paint = Paint().apply {
style = android.graphics.Paint.Style.FILL style = Paint.Style.FILL
shader = android.graphics.RadialGradient( shader = RadialGradient(
center.x + sinY * faceRadius * 0.3f, center.x + sinY * faceRadius * 0.3f,
center.y - sinP * faceRadius * 0.3f, center.y - sinP * faceRadius * 0.3f,
faceRadius * 1.4f, faceRadius * 1.4f,
@@ -470,14 +486,14 @@ private fun HeadVisualization() {
backgroundColor.copy(alpha = 0.7f).toArgb() backgroundColor.copy(alpha = 0.7f).toArgb()
), ),
floatArrayOf(0.3f, 0.5f, 0.7f, 0.8f, 1f), floatArrayOf(0.3f, 0.5f, 0.7f, 0.8f, 1f),
android.graphics.Shader.TileMode.CLAMP Shader.TileMode.CLAMP
) )
} }
drawPath(spherePath.asAndroidPath(), paint) drawPath(spherePath.asAndroidPath(), paint)
val highlightPaint = android.graphics.Paint().apply { val highlightPaint = Paint().apply {
style = android.graphics.Paint.Style.FILL style = Paint.Style.FILL
shader = android.graphics.RadialGradient( shader = RadialGradient(
center.x - faceRadius * 0.4f - sinY * faceRadius * 0.5f, center.x - faceRadius * 0.4f - sinY * faceRadius * 0.5f,
center.y - faceRadius * 0.4f - sinP * faceRadius * 0.5f, center.y - faceRadius * 0.4f - sinP * faceRadius * 0.5f,
faceRadius * 0.9f, faceRadius * 0.9f,
@@ -487,15 +503,15 @@ private fun HeadVisualization() {
android.graphics.Color.TRANSPARENT android.graphics.Color.TRANSPARENT
), ),
floatArrayOf(0f, 0.3f, 1f), floatArrayOf(0f, 0.3f, 1f),
android.graphics.Shader.TileMode.CLAMP Shader.TileMode.CLAMP
) )
alpha = if (darkTheme) 30 else 60 alpha = if (darkTheme) 30 else 60
} }
drawPath(spherePath.asAndroidPath(), highlightPaint) drawPath(spherePath.asAndroidPath(), highlightPaint)
val secondaryHighlightPaint = android.graphics.Paint().apply { val secondaryHighlightPaint = Paint().apply {
style = android.graphics.Paint.Style.FILL style = Paint.Style.FILL
shader = android.graphics.RadialGradient( shader = RadialGradient(
center.x + faceRadius * 0.3f + sinY * faceRadius * 0.3f, center.x + faceRadius * 0.3f + sinY * faceRadius * 0.3f,
center.y + faceRadius * 0.3f - sinP * faceRadius * 0.3f, center.y + faceRadius * 0.3f - sinP * faceRadius * 0.3f,
faceRadius * 0.7f, faceRadius * 0.7f,
@@ -504,15 +520,15 @@ private fun HeadVisualization() {
android.graphics.Color.TRANSPARENT android.graphics.Color.TRANSPARENT
), ),
floatArrayOf(0f, 1f), floatArrayOf(0f, 1f),
android.graphics.Shader.TileMode.CLAMP Shader.TileMode.CLAMP
) )
alpha = if (darkTheme) 15 else 30 alpha = if (darkTheme) 15 else 30
} }
drawPath(spherePath.asAndroidPath(), secondaryHighlightPaint) drawPath(spherePath.asAndroidPath(), secondaryHighlightPaint)
val shadowPaint = android.graphics.Paint().apply { val shadowPaint = Paint().apply {
style = android.graphics.Paint.Style.FILL style = Paint.Style.FILL
shader = android.graphics.RadialGradient( shader = RadialGradient(
center.x + sinY * faceRadius * 0.5f, center.x + sinY * faceRadius * 0.5f,
center.y - sinP * faceRadius * 0.5f, center.y - sinP * faceRadius * 0.5f,
faceRadius * 1.1f, faceRadius * 1.1f,
@@ -521,7 +537,7 @@ private fun HeadVisualization() {
android.graphics.Color.BLACK android.graphics.Color.BLACK
), ),
floatArrayOf(0.7f, 1f), floatArrayOf(0.7f, 1f),
android.graphics.Shader.TileMode.CLAMP Shader.TileMode.CLAMP
) )
alpha = if (darkTheme) 40 else 20 alpha = if (darkTheme) 40 else 20
} }
@@ -581,13 +597,13 @@ private fun HeadVisualization() {
} }
drawContext.canvas.nativeCanvas.apply { drawContext.canvas.nativeCanvas.apply {
val paint = android.graphics.Paint().apply { val paint = Paint().apply {
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
textSize = 12.sp.toPx() textSize = 12.sp.toPx()
textAlign = android.graphics.Paint.Align.RIGHT textAlign = Paint.Align.RIGHT
typeface = android.graphics.Typeface.create( typeface = Typeface.create(
"SF Pro", "SF Pro",
android.graphics.Typeface.NORMAL Typeface.NORMAL
) )
} }
@@ -632,7 +648,8 @@ private fun AccelerationPlot() {
.height(300.dp), .height(300.dp),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = if (darkTheme) Color(0xFF1C1C1E) else Color.White containerColor = if (darkTheme) Color(0xFF1C1C1E) else Color.White
) ),
shape = RoundedCornerShape(28.dp)
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -700,10 +717,10 @@ private fun AccelerationPlot() {
} }
drawContext.canvas.nativeCanvas.apply { drawContext.canvas.nativeCanvas.apply {
val paint = android.graphics.Paint().apply { val paint = Paint().apply {
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
textSize = 12.sp.toPx() textSize = 12.sp.toPx()
textAlign = android.graphics.Paint.Align.RIGHT textAlign = Paint.Align.RIGHT
} }
drawText("${maxAbs.toInt()}", 30.dp.toPx(), 20.dp.toPx(), paint) drawText("${maxAbs.toInt()}", 30.dp.toPx(), 20.dp.toPx(), paint)
@@ -716,20 +733,20 @@ private fun AccelerationPlot() {
drawCircle(Color(0xFF007AFF), 5.dp.toPx(), Offset(width - 150.dp.toPx(), legendY)) drawCircle(Color(0xFF007AFF), 5.dp.toPx(), Offset(width - 150.dp.toPx(), legendY))
drawContext.canvas.nativeCanvas.apply { drawContext.canvas.nativeCanvas.apply {
val paint = android.graphics.Paint().apply { val paint = Paint().apply {
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
textSize = 12.sp.toPx() textSize = 12.sp.toPx()
textAlign = android.graphics.Paint.Align.LEFT textAlign = Paint.Align.LEFT
} }
drawText("Horizontal", width - 140.dp.toPx(), textOffsetY, paint) drawText("Horizontal", width - 140.dp.toPx(), textOffsetY, paint)
} }
drawCircle(Color(0xFFFF3B30), 5.dp.toPx(), Offset(width - 70.dp.toPx(), legendY)) drawCircle(Color(0xFFFF3B30), 5.dp.toPx(), Offset(width - 70.dp.toPx(), legendY))
drawContext.canvas.nativeCanvas.apply { drawContext.canvas.nativeCanvas.apply {
val paint = android.graphics.Paint().apply { val paint = Paint().apply {
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
textSize = 12.sp.toPx() textSize = 12.sp.toPx()
textAlign = android.graphics.Paint.Align.LEFT textAlign = Paint.Align.LEFT
} }
drawText("Vertical", width - 60.dp.toPx(), textOffsetY, paint) drawText("Vertical", width - 60.dp.toPx(), textOffsetY, paint)
} }
@@ -737,11 +754,3 @@ private fun AccelerationPlot() {
} }
} }
} }
@ExperimentalHazeMaterialsApi
@RequiresApi(Build.VERSION_CODES.Q)
@Preview
@Composable
fun HeadTrackingScreenPreview() {
HeadTrackingScreen(navController = NavController(LocalContext.current))
}

View File

@@ -1,22 +1,22 @@
/* /*
* 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
* 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/>.
*/
package me.kavishdevar.librepods.screens 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.presentation.screens
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.util.Log import android.util.Log
@@ -31,9 +31,10 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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
@@ -41,7 +42,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
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.HazeState import dev.chrisbanes.haze.HazeState
@@ -50,15 +50,16 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.utils.HearingAidSettings import me.kavishdevar.librepods.data.HearingAidSettings
import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.utils.sendHearingAidSettings import me.kavishdevar.librepods.data.sendHearingAidSettings
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import java.io.IOException import java.io.IOException
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -69,13 +70,14 @@ private const val TAG = "HearingAidAdjustments"
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController) { fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
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")
val aacpManager = remember { ServiceManager.getService()?.aacpManager } val state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold(
title = stringResource(R.string.adjustments) title = stringResource(R.string.adjustments)
@@ -96,13 +98,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) }
@@ -111,8 +110,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,
@@ -128,25 +127,6 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
) )
} }
val hearingAidEnabled = remember {
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte()))
}
val hearingAidListener = remember {
object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value ||
controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) {
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())
}
}
}
}
val hearingAidATTListener = remember { val hearingAidATTListener = remember {
object : (ByteArray) -> Unit { object : (ByteArray) -> Unit {
override fun invoke(value: ByteArray) { override fun invoke(value: ByteArray) {
@@ -157,7 +137,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 {
@@ -167,19 +148,6 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
} }
} }
LaunchedEffect(Unit) {
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
}
DisposableEffect(Unit) {
onDispose {
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
attManager.unregisterListener(ATTHandles.HEARING_AID, hearingAidATTListener)
}
}
LaunchedEffect(amplificationSliderValue.floatValue, balanceSliderValue.floatValue, toneSliderValue.floatValue, conversationBoostEnabled.value, ambientNoiseReductionSliderValue.floatValue, ownVoiceAmplification.floatValue, initialLoadComplete.value, initialReadSucceeded.value) { LaunchedEffect(amplificationSliderValue.floatValue, balanceSliderValue.floatValue, toneSliderValue.floatValue, conversationBoostEnabled.value, ambientNoiseReductionSliderValue.floatValue, ownVoiceAmplification.floatValue, initialLoadComplete.value, initialReadSucceeded.value) {
if (!initialLoadComplete.value) { if (!initialLoadComplete.value) {
Log.d(TAG, "Initial device load not complete - skipping send") Log.d(TAG, "Initial device load not complete - skipping send")
@@ -192,8 +160,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,
@@ -216,26 +184,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
@@ -261,7 +209,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 {
@@ -277,7 +226,7 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
StyledSlider( StyledSlider(
label = stringResource(R.string.amplification), label = stringResource(R.string.amplification),
valueRange = -1f..1f, valueRange = -1f..1f,
mutableFloatState = amplificationSliderValue, value = amplificationSliderValue.floatValue,
onValueChange = { onValueChange = {
amplificationSliderValue.floatValue = it amplificationSliderValue.floatValue = it
}, },
@@ -289,14 +238,15 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
StyledToggle( StyledToggle(
label = stringResource(R.string.swipe_to_control_amplification), label = stringResource(R.string.swipe_to_control_amplification),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE, checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(),
onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE, it) },
description = stringResource(R.string.swipe_amplification_description) description = stringResource(R.string.swipe_amplification_description)
) )
StyledSlider( StyledSlider(
label = stringResource(R.string.balance), label = stringResource(R.string.balance),
valueRange = -1f..1f, valueRange = -1f..1f,
mutableFloatState = balanceSliderValue, value = balanceSliderValue.floatValue,
onValueChange = { onValueChange = {
balanceSliderValue.floatValue = it balanceSliderValue.floatValue = it
}, },
@@ -309,7 +259,7 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
StyledSlider( StyledSlider(
label = stringResource(R.string.tone), label = stringResource(R.string.tone),
valueRange = -1f..1f, valueRange = -1f..1f,
mutableFloatState = toneSliderValue, value = toneSliderValue.floatValue,
onValueChange = { onValueChange = {
toneSliderValue.floatValue = it toneSliderValue.floatValue = it
}, },
@@ -321,7 +271,7 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
StyledSlider( StyledSlider(
label = stringResource(R.string.ambient_noise_reduction), label = stringResource(R.string.ambient_noise_reduction),
valueRange = 0f..1f, valueRange = 0f..1f,
mutableFloatState = ambientNoiseReductionSliderValue, value = ambientNoiseReductionSliderValue.floatValue,
onValueChange = { onValueChange = {
ambientNoiseReductionSliderValue.floatValue = it ambientNoiseReductionSliderValue.floatValue = it
}, },
@@ -332,7 +282,8 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
StyledToggle( StyledToggle(
label = stringResource(R.string.conversation_boost), label = stringResource(R.string.conversation_boost),
checkedState = conversationBoostEnabled, checked = conversationBoostEnabled.value,
onCheckedChange = { conversationBoostEnabled.value = it },
independent = true, independent = true,
description = stringResource(R.string.conversation_boost_description) description = stringResource(R.string.conversation_boost_description)
) )

View File

@@ -1,22 +1,22 @@
/* /*
* 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
* 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/>.
*/
package me.kavishdevar.librepods.screens 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.presentation.screens
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.util.Log import android.util.Log
@@ -37,8 +37,9 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
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.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -61,15 +62,14 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.ConfirmationDialog import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.composables.NavigationButton import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.data.sendTransparencySettings
import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.presentation.components.ConfirmationDialog
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.presentation.components.NavigationButton
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.utils.ATTHandles import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.utils.sendTransparencySettings
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
private const val TAG = "AccessibilitySettings" private const val TAG = "AccessibilitySettings"
@@ -78,23 +78,22 @@ private const val TAG = "AccessibilitySettings"
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun HearingAidScreen(navController: NavController) { fun HearingAidScreen(viewModel: AirPodsViewModel, 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 snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val attManager = ServiceManager.getService()?.attManager ?: return
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
val showDialog = remember { mutableStateOf(false) } val showDialog = remember { mutableStateOf(false) }
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
val initialLoad = remember { mutableStateOf(true) } val initialLoad = remember { mutableStateOf(true) }
val state by viewModel.uiState.collectAsState()
val hearingAidEnabled = remember { val hearingAidEnabled = remember {
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID } val aidStatus = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG } val assistStatus = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG]
mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())) mutableStateOf((aidStatus?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.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 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
@@ -102,7 +101,7 @@ fun HearingAidScreen(navController: NavController) {
StyledScaffold( StyledScaffold(
title = stringResource(R.string.hearing_aid), title = stringResource(R.string.hearing_aid),
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,
) { spacerHeight, hazeState -> ) { topPadding, hazeState, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.layerBackdrop(backdrop) .layerBackdrop(backdrop)
@@ -113,43 +112,18 @@ fun HearingAidScreen(navController: NavController) {
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
hazeStateS.value = hazeState hazeStateS.value = hazeState
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(topPadding))
val hearingAidListener = remember {
object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value ||
controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) {
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())
}
}
}
}
// 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) {
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
}
DisposableEffect(Unit) {
onDispose {
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
}
}
LaunchedEffect(hearingAidEnabled.value) { LaunchedEffect(hearingAidEnabled.value) {
if (hearingAidEnabled.value && !initialLoad.value) { if (hearingAidEnabled.value && !initialLoad.value) {
showDialog.value = true showDialog.value = true
} else if (!hearingAidEnabled.value && !initialLoad.value) { } else if (!hearingAidEnabled.value && !initialLoad.value) {
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x02)) viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x02))
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x02.toByte()) viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x02.toByte())
hearingAidEnabled.value = false hearingAidEnabled.value = false
} }
initialLoad.value = false initialLoad.value = false
@@ -186,7 +160,8 @@ fun HearingAidScreen(navController: NavController) {
) { ) {
StyledToggle( StyledToggle(
label = stringResource(R.string.hearing_aid), label = stringResource(R.string.hearing_aid),
checkedState = hearingAidEnabled, checked = hearingAidEnabled.value,
onCheckedChange = { hearingAidEnabled.value = it },
independent = false independent = false
) )
HorizontalDivider( HorizontalDivider(
@@ -198,7 +173,7 @@ fun HearingAidScreen(navController: NavController) {
NavigationButton( NavigationButton(
to = "hearing_aid_adjustments", to = "hearing_aid_adjustments",
name = stringResource(R.string.adjustments), name = stringResource(R.string.adjustments),
navController, navController = navController,
independent = false independent = false
) )
} }
@@ -217,7 +192,7 @@ fun HearingAidScreen(navController: NavController) {
NavigationButton( NavigationButton(
to = "update_hearing_test", to = "update_hearing_test",
name = stringResource(R.string.update_hearing_test), name = stringResource(R.string.update_hearing_test),
navController, navController = navController,
independent = true independent = true
) )
@@ -258,9 +233,9 @@ fun HearingAidScreen(navController: NavController) {
// independent = false // independent = false
// ) // )
// } // }
Spacer(modifier = Modifier.height(bottomPadding))
} }
} }
ConfirmationDialog( ConfirmationDialog(
showDialog = showDialog, showDialog = showDialog,
title = "Enable Hearing Aid", title = "Enable Hearing Aid",
@@ -269,26 +244,33 @@ fun HearingAidScreen(navController: NavController) {
dismissText = "Cancel", dismissText = "Cancel",
onConfirm = { onConfirm = {
showDialog.value = false showDialog.value = false
val enrolled = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }?.value?.getOrNull(0) == 0x01.toByte() val enrolled = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(0) == 0x01.toByte()
if (!enrolled) { if (!enrolled) {
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01)) viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x01))
} else { } else {
aacpManager.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01)) viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x01))
} }
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x01.toByte()) viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x01.toByte())
hearingAidEnabled.value = true hearingAidEnabled.value = true
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
try { try {
val data = attManager.read(ATTHandles.TRANSPARENCY) if (state.hearingAidData.isEmpty()) {
val parsed = parseTransparencySettingsResponse(data) Log.w(TAG, "read failed")
return@launch
}
val parsed = parseTransparencySettingsResponse(state.hearingAidData)
val disabledSettings = parsed.copy(enabled = false) val disabledSettings = parsed.copy(enabled = false)
sendTransparencySettings(attManager, disabledSettings) sendTransparencySettings(viewModel::setATTCharacteristicValue, disabledSettings)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error disabling transparency: ${e.message}") Log.e(TAG, "Error disabling transparency: ${e.message}")
} }
} }
}, },
hazeState = hazeStateS.value, onDismiss = {
// backdrop = backdrop hearingAidEnabled.value = false
showDialog.value = false
},
// hazeState = hazeStateS.value,
backdrop = backdrop
) )
} }

View File

@@ -0,0 +1,120 @@
/*
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.presentation.screens
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.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 me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable
fun HearingProtectionScreen(viewModel: AirPodsViewModel, navController: NavController) {
val backdrop = rememberLayerBackdrop()
val state by viewModel.uiState.collectAsState()
StyledScaffold(
title = stringResource(R.string.hearing_protection),
) { spacerHeight ->
Column(
modifier = Modifier
.fillMaxSize()
.layerBackdrop(backdrop)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(spacerHeight))
if (!state.isPremium) {
StyledButton(
onClick = {
navController.navigate("purchase_screen")
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
)
}
}
if (state.vendorIdHook) {
StyledToggle(
title = stringResource(R.string.environmental_noise),
label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description),
checked = state.loudSoundReductionEnabled,
onCheckedChange = {
viewModel.setATTCharacteristicValue(
ATTHandles.LOUD_SOUND_REDUCTION,
byteArrayOf(if (it) 1.toByte() else 0.toByte())
)
},
enabled = state.isPremium
)
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),
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG]?.getOrNull(
0
)?.toInt() == 1,
onCheckedChange = {
viewModel.setControlCommandBoolean(
AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG, it
)
},
enabled = state.isPremium
)
}
}
}

View File

@@ -1,22 +1,22 @@
/* /*
* 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
* 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/>.
*/
package me.kavishdevar.librepods.screens 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.presentation.screens
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@@ -28,12 +28,9 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable 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.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController import androidx.navigation.NavController
@@ -42,17 +39,9 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
import com.mikepenz.aboutlibraries.ui.compose.produceLibraries import com.mikepenz.aboutlibraries.ui.compose.produceLibraries
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.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledIconButton import me.kavishdevar.librepods.presentation.components.StyledScaffold
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 import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: Job? = null private var debounceJob: Job? = null
@@ -76,7 +65,7 @@ fun OpenSourceLicensesScreen(navController: NavController) {
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(spacerHeight))
val context = androidx.compose.ui.platform.LocalContext.current val context = LocalContext.current
val libraries by produceLibraries { val libraries by produceLibraries {
context.resources.openRawResource(R.raw.aboutlibraries) context.resources.openRawResource(R.raw.aboutlibraries)
.bufferedReader() .bufferedReader()

View File

@@ -1,58 +1,46 @@
/* /*
* 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)
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.presentation.screens
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize 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.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource 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
@@ -66,54 +54,34 @@ import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.SelectItem import me.kavishdevar.librepods.presentation.components.SelectItem
import me.kavishdevar.librepods.composables.StyledIconButton import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSelectList import me.kavishdevar.librepods.presentation.components.StyledSelectList
import me.kavishdevar.librepods.constants.StemAction import me.kavishdevar.librepods.data.StemAction
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.experimental.and import kotlin.experimental.and
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun RightDivider() {
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(start = 72.dp, end = 20.dp)
)
}
@Composable
fun RightDividerNoIcon() {
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(start = 20.dp, end = 20.dp)
)
}
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LongPress(navController: NavController, name: String) { fun LongPress(viewModel: AirPodsViewModel, name: String, 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 modesByte = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find { val state by viewModel.uiState.collectAsState()
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
}?.value?.takeIf { it.isNotEmpty() }?.get(0) val modesByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0) ?: 0
if (modesByte != null) {
Log.d("PressAndHoldSettingsScreen", "Current modes state: ${modesByte.toString(2)}") Log.d("PressAndHoldSettingsScreen", "Current modes state: ${modesByte.toString(2)}")
Log.d("PressAndHoldSettingsScreen", "Off mode: ${(modesByte and 0x01) != 0.toByte()}") Log.d("PressAndHoldSettingsScreen", "Off mode: ${(modesByte and 0x01) != 0.toByte()}")
Log.d("PressAndHoldSettingsScreen", "Transparency mode: ${(modesByte and 0x04) != 0.toByte()}") Log.d("PressAndHoldSettingsScreen", "Transparency mode: ${(modesByte and 0x04) != 0.toByte()}")
Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x02) != 0.toByte()}") Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x02) != 0.toByte()}")
Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}") Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}")
}
val context = LocalContext.current val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE) val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val prefKey = if (name.lowercase() == "left") "left_long_press_action" else "right_long_press_action" val prefKey = if (name.lowercase() == "left") "left_long_press_action" else "right_long_press_action"
@@ -124,7 +92,6 @@ fun LongPress(navController: NavController, name: String) {
StyledScaffold( StyledScaffold(
title = name title = name
) { spacerHeight -> ) { spacerHeight ->
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Column ( Column (
modifier = Modifier modifier = Modifier
.layerBackdrop(backdrop) .layerBackdrop(backdrop)
@@ -148,11 +115,36 @@ fun LongPress(navController: NavController, name: String) {
onClick = { onClick = {
longPressAction = StemAction.DIGITAL_ASSISTANT longPressAction = StemAction.DIGITAL_ASSISTANT
sharedPreferences.edit { putString(prefKey, StemAction.DIGITAL_ASSISTANT.name) } sharedPreferences.edit { putString(prefKey, StemAction.DIGITAL_ASSISTANT.name) }
} },
enabled = state.isPremium
) )
) )
StyledSelectList(items = actionItems) StyledSelectList(items = actionItems)
if (!state.isPremium) {
Spacer(modifier = Modifier.height(24.dp))
StyledButton(
onClick = {
navController.navigate("purchase_screen")
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
)
}
Spacer(modifier = Modifier.height(8.dp))
}
if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) { if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) {
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
Text( Text(
@@ -161,6 +153,7 @@ fun LongPress(navController: NavController, name: String) {
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)), fontFamily = FontFamily(Font(R.font.sf_pro)),
modifier = Modifier modifier = Modifier
@@ -176,17 +169,18 @@ fun LongPress(navController: NavController, name: String) {
val allowOff = offListeningModeValue == 1.toByte() val allowOff = offListeningModeValue == 1.toByte()
Log.d("PressAndHoldSettingsScreen", "Allow Off option: $allowOff") Log.d("PressAndHoldSettingsScreen", "Allow Off option: $allowOff")
val initialByte = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find { val initialByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS ?.get(0)?.toInt()
}?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toInt() ?: sharedPreferences.getInt("long_press_byte", 0b0101) ?: sharedPreferences.getInt("long_press_byte", 0b0101)
var currentByte by remember { mutableStateOf(initialByte) }
var currentByte by remember { mutableIntStateOf(initialByte) }
val listeningModeItems = mutableListOf<SelectItem>() val listeningModeItems = mutableListOf<SelectItem>()
if (allowOff) { if (allowOff) {
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 = {
@@ -197,8 +191,8 @@ fun LongPress(navController: NavController, name: String) {
} else { } else {
currentByte or bit currentByte or bit
} }
ServiceManager.getService()!!.aacpManager.sendControlCommand( viewModel.setControlCommandByte(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value, AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
newValue.toByte() newValue.toByte()
) )
sharedPreferences.edit { sharedPreferences.edit {
@@ -212,7 +206,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 = {
@@ -223,8 +217,8 @@ fun LongPress(navController: NavController, name: String) {
} else { } else {
currentByte or bit currentByte or bit
} }
ServiceManager.getService()!!.aacpManager.sendControlCommand( viewModel.setControlCommandByte(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value, AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
newValue.toByte() newValue.toByte()
) )
sharedPreferences.edit { sharedPreferences.edit {
@@ -235,7 +229,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 = {
@@ -246,8 +240,8 @@ fun LongPress(navController: NavController, name: String) {
} else { } else {
currentByte or bit currentByte or bit
} }
ServiceManager.getService()!!.aacpManager.sendControlCommand( viewModel.setControlCommandByte(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value, AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
newValue.toByte() newValue.toByte()
) )
sharedPreferences.edit { sharedPreferences.edit {
@@ -258,7 +252,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 = {
@@ -269,8 +263,8 @@ fun LongPress(navController: NavController, name: String) {
} else { } else {
currentByte or bit currentByte or bit
} }
ServiceManager.getService()!!.aacpManager.sendControlCommand( viewModel.setControlCommandByte(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value, AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
newValue.toByte() newValue.toByte()
) )
sharedPreferences.edit { sharedPreferences.edit {
@@ -296,9 +290,7 @@ fun LongPress(navController: NavController, name: String) {
} }
} }
} }
Log.d("PressAndHoldSettingsScreen", "Current byte: ${ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find { Log.d("PressAndHoldSettingsScreen", "Current byte: ${modesByte.toString(2)}")
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
}?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toString(2)}")
} }
fun countEnabledModes(byteValue: Int): Int { fun countEnabledModes(byteValue: Int): Int {

View File

@@ -0,0 +1,528 @@
/*
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.presentation.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
@Composable
fun PurchaseScreen(
viewModel: PurchaseViewModel = viewModel(),
navController: NavController
) {
val context = LocalContext.current
val scrollState = rememberScrollState()
val state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.unlock_advanced_features)
) { topPadding, hazeState, bottomPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.layerBackdrop(backdrop)
.hazeSource(state = hazeState)
.verticalScroll(scrollState)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)
val cardBackgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
LaunchedEffect(state.isPremium) {
if (state.isPremium) {
navController.popBackStack()
}
}
if (!state.isPremium) {
Box(
modifier = Modifier
.background(backgroundColor)
.padding(horizontal = 16.dp, vertical = 4.dp)
) {
Text(
text = stringResource(R.string.free_features),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(cardBackgroundColor, RoundedCornerShape(28.dp))
.padding(horizontal = 8.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.ear_detection),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.ear_detection_description),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.battery),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.battery_description),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.noise_control),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.noise_control_description),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
if (BuildConfig.FLAVOR == "xposed") {
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.hearing_aid) + " (" + stringResource(
R.string.requires_xposed
) + ")",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.hearing_aid_description).split("\n\n")[0],
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
}
}
Spacer(modifier = Modifier.height(24.dp))
Box(
modifier = Modifier
.background(backgroundColor)
.padding(horizontal = 16.dp, vertical = 4.dp)
) {
Text(
text = stringResource(R.string.advanced_features),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(cardBackgroundColor, RoundedCornerShape(28.dp))
.padding(horizontal = 8.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.conversational_awareness),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.conversational_awareness_description),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.digital_assistant_on_long_press),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.digital_assistant_on_long_press_description),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.head_gestures),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.head_gestures_details),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.advanced_device_settings),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.advanced_device_settings_description),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.automatic_connection),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.automatic_connection_description),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.customizations),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.customizations_description),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.support_the_development),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.support_development_description),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.feature_availability_disclaimer),
modifier = Modifier.fillMaxWidth(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor.copy(alpha = 0.6f),
textAlign = TextAlign.Center
),
)
Spacer(modifier = Modifier.height(24.dp))
StyledButton(
onClick = {
viewModel.purchase(context)
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF0091FF)
else Color(0xFF0088FF) // if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) {
Text(
stringResource(R.string.buy_price, state.price),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
)
}
Spacer(modifier = Modifier.height(8.dp))
StyledButton(
onClick = {
viewModel.restorePurchases()
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
isInteractive = false
) {
Text(
stringResource(R.string.restore_purchases),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
),
)
}
}
Spacer(modifier = Modifier.height(bottomPadding))
}
}
}

View File

@@ -0,0 +1,84 @@
/*
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.presentation.screens
import android.content.Context
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.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledInputField
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable
fun RenameScreen(viewModel: AirPodsViewModel) {
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) {
focusRequester.requestFocus()
keyboardController?.show()
}
StyledScaffold(
title = stringResource(R.string.name),
) { spacerHeight ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(spacerHeight))
val textFieldState = rememberTextFieldState()
textFieldState.edit { sharedPreferences.getString("name", "") ?: "" }
LaunchedEffect(textFieldState.text) {
sharedPreferences.edit {putString("name", textFieldState.text as String?)}
viewModel.setName(textFieldState.text.toString())
}
StyledInputField(
textFieldState,
focusRequester
)
}
}
}

View File

@@ -1,23 +1,24 @@
/* /*
* 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
* 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/>.
*/
package me.kavishdevar.librepods.screens 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.presentation.screens
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.util.Log import android.util.Log
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -41,8 +42,9 @@ import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults 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.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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
@@ -58,23 +60,20 @@ 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.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
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledIconButton import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.composables.StyledSlider import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.data.TransparencySettings
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
import me.kavishdevar.librepods.utils.ATTHandles import me.kavishdevar.librepods.data.sendTransparencySettings
import me.kavishdevar.librepods.utils.RadareOffsetFinder import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.utils.TransparencySettings
import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse
import me.kavishdevar.librepods.utils.sendTransparencySettings
import java.io.IOException import java.io.IOException
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -84,14 +83,10 @@ private const val TAG = "TransparencySettings"
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun TransparencySettingsScreen(navController: NavController) { fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
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 attManager = ServiceManager.getService()?.attManager ?: return
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
val isSdpOffsetAvailable =
remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) }
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491) val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5) val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
@@ -99,9 +94,12 @@ fun TransparencySettingsScreen(navController: NavController) {
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
val state by viewModel.uiState.collectAsState()
StyledScaffold( StyledScaffold(
title = stringResource(R.string.customize_transparency_mode) title = stringResource(R.string.customize_transparency_mode)
){ spacerHeight, hazeState -> ){ topPadding, hazeState, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.hazeSource(hazeState) .hazeSource(hazeState)
@@ -111,7 +109,7 @@ fun TransparencySettingsScreen(navController: NavController) {
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(topPadding))
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val enabled = remember { mutableStateOf(false) } val enabled = remember { mutableStateOf(false) }
@@ -148,23 +146,6 @@ fun TransparencySettingsScreen(navController: NavController) {
) )
} }
val transparencyListener = remember {
object : (ByteArray) -> Unit {
override fun invoke(value: ByteArray) {
val parsed = parseTransparencySettingsResponse(value)
enabled.value = parsed.enabled
amplificationSliderValue.floatValue = parsed.netAmplification
balanceSliderValue.floatValue = parsed.balance
toneSliderValue.floatValue = parsed.leftTone
ambientNoiseReductionSliderValue.floatValue =
parsed.leftAmbientNoiseReduction
conversationBoostEnabled.value = parsed.leftConversationBoost
eq.value = parsed.leftEQ.copyOf()
Log.d(TAG, "Updated transparency settings from notification")
}
}
}
LaunchedEffect( LaunchedEffect(
enabled.value, enabled.value,
amplificationSliderValue.floatValue, amplificationSliderValue.floatValue,
@@ -205,26 +186,16 @@ fun TransparencySettingsScreen(navController: NavController) {
balance = balanceSliderValue.floatValue balance = balanceSliderValue.floatValue
) )
Log.d("TransparencySettings", "Updated settings: ${transparencySettings.value}") Log.d("TransparencySettings", "Updated settings: ${transparencySettings.value}")
sendTransparencySettings(attManager, transparencySettings.value) sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value)
}
DisposableEffect(Unit) {
onDispose {
attManager.unregisterListener(ATTHandles.TRANSPARENCY, transparencyListener)
}
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
Log.d(TAG, "Connecting to ATT...") Log.d(TAG, "Connecting to ATT...")
try { try {
attManager.enableNotifications(ATTHandles.TRANSPARENCY)
attManager.registerListener(ATTHandles.TRANSPARENCY, transparencyListener)
// If we have an AACP manager, prefer its EQ data to populate EQ controls first // If we have an AACP manager, prefer its EQ data to populate EQ controls first
try { try {
if (aacpManager != null) {
Log.d(TAG, "Found AACPManager, reading cached EQ data") Log.d(TAG, "Found AACPManager, reading cached EQ data")
val aacpEQ = aacpManager.eqData val aacpEQ = state.eqData
if (aacpEQ.isNotEmpty()) { if (aacpEQ.isNotEmpty()) {
eq.value = aacpEQ.copyOf() eq.value = aacpEQ.copyOf()
phoneMediaEQ.value = aacpEQ.copyOf() phoneMediaEQ.value = aacpEQ.copyOf()
@@ -232,9 +203,6 @@ fun TransparencySettingsScreen(navController: NavController) {
} else { } else {
Log.d(TAG, "AACPManager EQ data empty") Log.d(TAG, "AACPManager EQ data empty")
} }
} else {
Log.d(TAG, "No AACPManager available")
}
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}") Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}")
} }
@@ -243,7 +211,7 @@ fun TransparencySettingsScreen(navController: NavController) {
for (attempt in 1..3) { for (attempt in 1..3) {
initialReadAttempts.intValue = attempt initialReadAttempts.intValue = attempt
try { try {
val data = attManager.read(ATTHandles.TRANSPARENCY) val data = state.transparencyData
parsedSettings = parseTransparencySettingsResponse(data = data) parsedSettings = parseTransparencySettingsResponse(data = data)
Log.d(TAG, "Parsed settings on attempt $attempt") Log.d(TAG, "Parsed settings on attempt $attempt")
} catch (e: Exception) { } catch (e: Exception) {
@@ -276,19 +244,19 @@ fun TransparencySettingsScreen(navController: NavController) {
} }
} }
// Only show transparency mode section if SDP offset is available if (state.vendorIdHook) {
if (isSdpOffsetAvailable.value) {
StyledToggle( StyledToggle(
label = stringResource(R.string.transparency_mode), label = stringResource(R.string.transparency_mode),
checkedState = enabled, checked = enabled.value,
independent = true, independent = true,
description = stringResource(R.string.customize_transparency_mode_description) description = stringResource(R.string.customize_transparency_mode_description),
onCheckedChange = { enabled.value = it }
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
StyledSlider( StyledSlider(
label = stringResource(R.string.amplification), label = stringResource(R.string.amplification),
valueRange = -1f..1f, valueRange = -1f..1f,
mutableFloatState = amplificationSliderValue, value = amplificationSliderValue.floatValue,
onValueChange = { onValueChange = {
amplificationSliderValue.floatValue = it amplificationSliderValue.floatValue = it
}, },
@@ -300,7 +268,7 @@ fun TransparencySettingsScreen(navController: NavController) {
StyledSlider( StyledSlider(
label = stringResource(R.string.balance), label = stringResource(R.string.balance),
valueRange = -1f..1f, valueRange = -1f..1f,
mutableFloatState = balanceSliderValue, value = balanceSliderValue.floatValue,
onValueChange = { onValueChange = {
balanceSliderValue.floatValue = it balanceSliderValue.floatValue = it
}, },
@@ -313,7 +281,7 @@ fun TransparencySettingsScreen(navController: NavController) {
StyledSlider( StyledSlider(
label = stringResource(R.string.tone), label = stringResource(R.string.tone),
valueRange = -1f..1f, valueRange = -1f..1f,
mutableFloatState = toneSliderValue, value = toneSliderValue.floatValue,
onValueChange = { onValueChange = {
toneSliderValue.floatValue = it toneSliderValue.floatValue = it
}, },
@@ -325,7 +293,7 @@ fun TransparencySettingsScreen(navController: NavController) {
StyledSlider( StyledSlider(
label = stringResource(R.string.ambient_noise_reduction), label = stringResource(R.string.ambient_noise_reduction),
valueRange = 0f..1f, valueRange = 0f..1f,
mutableFloatState = ambientNoiseReductionSliderValue, value = ambientNoiseReductionSliderValue.floatValue,
onValueChange = { onValueChange = {
ambientNoiseReductionSliderValue.floatValue = it ambientNoiseReductionSliderValue.floatValue = it
}, },
@@ -336,14 +304,12 @@ fun TransparencySettingsScreen(navController: NavController) {
StyledToggle( StyledToggle(
label = stringResource(R.string.conversation_boost), label = stringResource(R.string.conversation_boost),
checkedState = conversationBoostEnabled, checked = conversationBoostEnabled.value,
independent = true, independent = true,
description = stringResource(R.string.conversation_boost_description) description = stringResource(R.string.conversation_boost_description),
onCheckedChange = { conversationBoostEnabled.value = it }
) )
}
// Only show transparency mode EQ section if SDP offset is available
if (isSdpOffsetAvailable.value) {
Text( Text(
text = stringResource(R.string.equalizer), text = stringResource(R.string.equalizer),
style = TextStyle( style = TextStyle(
@@ -443,6 +409,8 @@ fun TransparencySettingsScreen(navController: NavController) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
} }
Spacer(modifier = Modifier.height(bottomPadding))
} }
} }
} }

View File

@@ -1,22 +1,22 @@
/* /*
* 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
* 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/>.
*/
package me.kavishdevar.librepods.screens 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.presentation.screens
import android.content.Intent import android.content.Intent
import android.widget.Toast import android.widget.Toast
@@ -94,7 +94,7 @@ 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.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.utils.LogCollector import me.kavishdevar.librepods.utils.LogCollector
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@@ -216,7 +216,7 @@ fun TroubleshootingScreen(navController: NavController) {
) { ) {
StyledScaffold( StyledScaffold(
title = stringResource(R.string.troubleshooting) title = stringResource(R.string.troubleshooting)
){ spacerHeight, hazeState -> ){ topPadding, hazeState, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -225,7 +225,7 @@ fun TroubleshootingScreen(navController: NavController) {
.verticalScroll(scrollState) .verticalScroll(scrollState)
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(topPadding))
Text( Text(
text = stringResource(R.string.saved_logs), text = stringResource(R.string.saved_logs),

View File

@@ -1,25 +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
* 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/>.
*/
package me.kavishdevar.librepods.screens 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.presentation.screens
import android.annotation.SuppressLint
import android.util.Log import android.util.Log
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -32,18 +32,19 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
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
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
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
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.graphics.Color
import androidx.compose.ui.res.stringResource 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
@@ -52,32 +53,25 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
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
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.utils.ATTHandles import me.kavishdevar.librepods.data.HearingAidSettings
import me.kavishdevar.librepods.utils.HearingAidSettings import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse import me.kavishdevar.librepods.data.sendHearingAidSettings
import me.kavishdevar.librepods.utils.sendHearingAidSettings
import java.io.IOException import java.io.IOException
import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: MutableState<Job?> = mutableStateOf(null) private var debounceJob: MutableState<Job?> = mutableStateOf(null)
private const val TAG = "HearingAidAdjustments" private const val TAG = "HearingAidAdjustments"
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) { fun UpdateHearingTestScreen() {
val verticalScrollState = rememberScrollState() val verticalScrollState = rememberScrollState()
val attManager = ServiceManager.getService()?.attManager val attManager = ServiceManager.getService()?.attManager
if (attManager == null) { if (attManager == null) {
@@ -91,11 +85,10 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
return return
} }
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold(
title = stringResource(R.string.hearing_test) title = stringResource(R.string.hearing_test)
) { spacerHeight, hazeState -> ) { topPadding, hazeState, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.hazeSource(hazeState) .hazeSource(hazeState)
@@ -105,16 +98,25 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight)) val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black
Spacer(modifier = Modifier.height(topPadding))
Text( Text(
text = stringResource(R.string.hearing_test_value_instruction), text = stringResource(R.string.hearing_test_value_instruction),
fontSize = 16.sp,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center, style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro)) 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 conversationBoostEnabled = remember { mutableStateOf(false) }
val leftEQ = remember { mutableStateOf(FloatArray(8)) } val leftEQ = remember { mutableStateOf(FloatArray(8)) }
val rightEQ = remember { mutableStateOf(FloatArray(8)) } val rightEQ = remember { mutableStateOf(FloatArray(8)) }
@@ -128,40 +130,21 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
HearingAidSettings( HearingAidSettings(
leftEQ = leftEQ.value, leftEQ = leftEQ.value,
rightEQ = rightEQ.value, rightEQ = rightEQ.value,
leftAmplification = 0.5f, leftAmplification = leftAmplification.floatValue,
rightAmplification = 0.5f, rightAmplification = rightAmplification.floatValue,
leftTone = 0.5f, leftTone = tone.floatValue,
rightTone = 0.5f, rightTone = tone.floatValue,
leftConversationBoost = conversationBoostEnabled.value, leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value, rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = 0.0f, leftAmbientNoiseReduction = ambientNoiseReduction.floatValue,
rightAmbientNoiseReduction = 0.0f, rightAmbientNoiseReduction = ambientNoiseReduction.floatValue,
netAmplification = 0.5f, netAmplification = leftAmplification.floatValue + rightAmplification.floatValue / 2,
balance = 0.5f, balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2,
ownVoiceAmplification = 0.5f ownVoiceAmplification = ownVoiceAmplification.floatValue
) )
) )
} }
val hearingAidEnabled = remember {
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte()))
}
val hearingAidListener = remember {
object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value ||
controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) {
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())
}
}
}
}
val hearingAidATTListener = remember { val hearingAidATTListener = remember {
object : (ByteArray) -> Unit { object : (ByteArray) -> Unit {
override fun invoke(value: ByteArray) { override fun invoke(value: ByteArray) {
@@ -170,6 +153,11 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
leftEQ.value = parsed.leftEQ.copyOf() leftEQ.value = parsed.leftEQ.copyOf()
rightEQ.value = parsed.rightEQ.copyOf() rightEQ.value = parsed.rightEQ.copyOf()
conversationBoostEnabled.value = parsed.leftConversationBoost conversationBoostEnabled.value = parsed.leftConversationBoost
tone.floatValue = parsed.leftTone
ambientNoiseReduction.floatValue = parsed.leftAmbientNoiseReduction
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
leftAmplification.floatValue = parsed.leftAmplification
rightAmplification.floatValue = parsed.rightAmplification
Log.d(TAG, "Updated hearing aid settings from notification") Log.d(TAG, "Updated hearing aid settings from notification")
} else { } else {
Log.w(TAG, "Failed to parse hearing aid settings from notification") Log.w(TAG, "Failed to parse hearing aid settings from notification")
@@ -178,44 +166,52 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
} }
} }
LaunchedEffect(Unit) {
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
}
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
attManager.unregisterListener(ATTHandles.HEARING_AID, hearingAidATTListener) attManager.unregisterListener(ATTHandles.HEARING_AID, hearingAidATTListener)
} }
} }
LaunchedEffect(leftEQ.value, rightEQ.value, conversationBoostEnabled.value, initialLoadComplete.value, initialReadSucceeded.value) { LaunchedEffect(
leftEQ.value,
rightEQ.value,
conversationBoostEnabled.value,
initialLoadComplete.value,
initialReadSucceeded.value,
leftAmplification.floatValue,
rightAmplification.floatValue,
tone.floatValue,
ambientNoiseReduction.floatValue,
ownVoiceAmplification.floatValue
) {
if (!initialLoadComplete.value) { if (!initialLoadComplete.value) {
Log.d(TAG, "Initial device load not complete - skipping send") Log.d(TAG, "Initial device load not complete - skipping send")
return@LaunchedEffect return@LaunchedEffect
} }
if (!initialReadSucceeded.value) { if (!initialReadSucceeded.value) {
Log.d(TAG, "Initial device read not successful yet - skipping send until read succeeds") Log.d(
TAG,
"Initial device read not successful yet - skipping send until read succeeds"
)
return@LaunchedEffect return@LaunchedEffect
} }
hearingAidSettings.value = HearingAidSettings( hearingAidSettings.value = HearingAidSettings(
leftEQ = leftEQ.value, leftEQ = leftEQ.value,
rightEQ = rightEQ.value, rightEQ = rightEQ.value,
leftAmplification = 0.5f, leftAmplification = leftAmplification.floatValue,
rightAmplification = 0.5f, rightAmplification = rightAmplification.floatValue,
leftTone = 0.5f, leftTone = tone.floatValue,
rightTone = 0.5f, rightTone = tone.floatValue,
leftConversationBoost = conversationBoostEnabled.value, leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value, rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = 0.0f, leftAmbientNoiseReduction = ambientNoiseReduction.floatValue,
rightAmbientNoiseReduction = 0.0f, rightAmbientNoiseReduction = ambientNoiseReduction.floatValue,
netAmplification = 0.5f, netAmplification = leftAmplification.floatValue + rightAmplification.floatValue / 2,
balance = 0.5f, balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2,
ownVoiceAmplification = 0.5f ownVoiceAmplification = ownVoiceAmplification.floatValue
) )
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}") Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob) sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
@@ -227,24 +223,6 @@ fun UpdateHearingTestScreen(@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()) {
leftEQ.value = aacpEQ.copyOf()
rightEQ.value = aacpEQ.copyOf()
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
@@ -268,9 +246,17 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
leftEQ.value = parsedSettings.leftEQ.copyOf() leftEQ.value = parsedSettings.leftEQ.copyOf()
rightEQ.value = parsedSettings.rightEQ.copyOf() rightEQ.value = parsedSettings.rightEQ.copyOf()
conversationBoostEnabled.value = parsedSettings.leftConversationBoost conversationBoostEnabled.value = parsedSettings.leftConversationBoost
tone.floatValue = parsedSettings.leftTone
ambientNoiseReduction.floatValue = parsedSettings.leftAmbientNoiseReduction
ownVoiceAmplification.floatValue = parsedSettings.ownVoiceAmplification
leftAmplification.floatValue = parsedSettings.leftAmplification
rightAmplification.floatValue = parsedSettings.rightAmplification
initialReadSucceeded.value = true initialReadSucceeded.value = true
} else { } else {
Log.d(TAG, "Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts") Log.d(
TAG,
"Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts"
)
} }
} catch (e: IOException) { } catch (e: IOException) {
e.printStackTrace() e.printStackTrace()
@@ -279,7 +265,8 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
} }
} }
val frequencies = listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz") val frequencies =
listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz")
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -288,17 +275,23 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
Spacer(modifier = Modifier.width(60.dp)) Spacer(modifier = Modifier.width(60.dp))
Text( Text(
text = stringResource(R.string.left), text = stringResource(R.string.left),
fontSize = 18.sp,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
fontFamily = FontFamily(Font(R.font.sf_pro)) style = TextStyle(
fontSize = 18.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
) )
Text( Text(
text = stringResource(R.string.right), text = stringResource(R.string.right),
fontSize = 18.sp,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
fontFamily = FontFamily(Font(R.font.sf_pro)) style = TextStyle(
fontSize = 18.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
) )
} }
@@ -313,8 +306,11 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
.width(60.dp) .width(60.dp)
.align(Alignment.CenterVertically), .align(Alignment.CenterVertically),
textAlign = TextAlign.End, textAlign = TextAlign.End,
style = TextStyle(
color = textColor,
fontSize = 16.sp, fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro))
),
) )
OutlinedTextField( OutlinedTextField(
value = leftEQ.value[index].toString(), value = leftEQ.value[index].toString(),
@@ -324,10 +320,11 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
val newArray = leftEQ.value.copyOf() val newArray = leftEQ.value.copyOf()
newArray[index] = parsed newArray[index] = parsed
leftEQ.value = newArray 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))) }, // label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
textStyle = TextStyle( textStyle = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro)),
fontSize = 14.sp fontSize = 14.sp
@@ -342,10 +339,11 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
val newArray = rightEQ.value.copyOf() val newArray = rightEQ.value.copyOf()
newArray[index] = parsed newArray[index] = parsed
rightEQ.value = newArray 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))) }, // label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
textStyle = TextStyle( textStyle = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro)),
fontSize = 14.sp fontSize = 14.sp
@@ -354,6 +352,7 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
) )
} }
} }
Spacer(modifier = Modifier.height(bottomPadding))
} }
} }
} }

View File

@@ -1,40 +1,40 @@
/* /*
* 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
* 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/>.
*/
package me.kavishdevar.librepods.screens 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.presentation.screens
import androidx.compose.foundation.background import androidx.compose.foundation.background
import android.annotation.SuppressLint
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.graphics.Color import androidx.compose.ui.graphics.Color
@@ -45,36 +45,23 @@ 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.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.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Job
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: Job? = null
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun VersionScreen(navController: NavController) { fun VersionScreen(viewModel: AirPodsViewModel) {
val state by viewModel.uiState.collectAsState()
val isDarkTheme = isSystemInDarkTheme() 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 backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold(
title = stringResource(R.string.customize_adaptive_audio) title = stringResource(R.string.version)
) { spacerHeight -> ) { spacerHeight ->
Column( Column(
modifier = Modifier modifier = Modifier
@@ -93,7 +80,8 @@ fun VersionScreen(navController: NavController) {
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))
) )
) )
} }
@@ -120,7 +108,7 @@ fun VersionScreen(navController: NavController) {
) )
) )
Text( Text(
text = airpodsInstance.version1 ?: "N/A", text = state.version1,
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
color = textColor.copy(0.8f), color = textColor.copy(0.8f),
@@ -149,7 +137,7 @@ fun VersionScreen(navController: NavController) {
) )
) )
Text( Text(
text = airpodsInstance.version2 ?: "N/A", text = state.version2,
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
color = textColor.copy(0.8f), color = textColor.copy(0.8f),
@@ -178,7 +166,7 @@ fun VersionScreen(navController: NavController) {
) )
) )
Text( Text(
text = airpodsInstance.version3 ?: "N/A", text = state.version3,
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
color = textColor.copy(0.8f), color = textColor.copy(0.8f),

View File

@@ -0,0 +1,30 @@
/*
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.presentation.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)

View File

@@ -1,22 +1,22 @@
/* /*
* 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
* 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/>.
*/
package me.kavishdevar.librepods.ui.theme 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.presentation.theme
import android.os.Build import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme

View File

@@ -1,22 +1,22 @@
/* /*
* 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
* 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/>.
*/
package me.kavishdevar.librepods.ui.theme 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.presentation.theme
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle

View File

@@ -0,0 +1,550 @@
/*
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.presentation.viewmodel
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.widget.Toast
import androidx.core.content.edit
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.data.AirPodsInstance
import me.kavishdevar.librepods.data.AirPodsModels
import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.data.Battery
import me.kavishdevar.librepods.data.BatteryComponent
import me.kavishdevar.librepods.data.BatteryStatus
import me.kavishdevar.librepods.data.Capability
import me.kavishdevar.librepods.data.ControlCommandRepository
import me.kavishdevar.librepods.data.StemAction
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
import me.kavishdevar.librepods.services.AirPodsService
@Suppress("ArrayInDataClass")
data class AirPodsUiState(
val deviceName: String,
val isLocallyConnected: Boolean = false,
val instance: AirPodsInstance? = null,
val capabilities: Set<Capability> = emptySet(),
val controlStates: Map<ControlCommandIdentifiers, ByteArray> = emptyMap(),
val offListeningMode: Boolean = true,
val battery: List<Battery> = emptyList(),
val ancMode: Int = 3,
val modelName: String = "",
val actualModel: String = "",
val serialNumbers: List<String> = emptyList(),
val version1: String = "",
val version2: String = "",
val version3: String = "",
val headTrackingActive: Boolean = false,
val headGesturesEnabled: Boolean = true,
val eqData: FloatArray = floatArrayOf(),
val automaticEarDetectionEnabled: Boolean = true,
val automaticConnectionEnabled: Boolean = true,
val leftAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES,
val rightAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES,
val loudSoundReductionEnabled: Boolean = false,
val transparencyData: ByteArray = byteArrayOf(),
val hearingAidData: ByteArray = byteArrayOf(),
val isPremium: Boolean = false,
val vendorIdHook: Boolean = false
)
class AirPodsViewModel(
private val service: AirPodsService,
private val sharedPreferences: SharedPreferences,
private val controlRepo: ControlCommandRepository,
private val appContext: Context
) : ViewModel() {
private val _uiState = MutableStateFlow(
AirPodsUiState(
deviceName = sharedPreferences.getString(
"name",
"AirPods Pro"
) ?: "AirPods Pro"
)
)
val uiState: StateFlow<AirPodsUiState> = _uiState
private var isDemoMode = false
val demoActivated = MutableSharedFlow<Unit>()
private val listeners =
mutableMapOf<ControlCommandIdentifiers, AACPManager.ControlCommandListener>()
private val xposedRemotePref = XposedRemotePrefProvider.create()
private lateinit var broadcastReceiver: BroadcastReceiver
private val _cameraAction = MutableStateFlow(
sharedPreferences.getString("camera_action", null)
?.let { value -> AACPManager.Companion.StemPressType.entries.find { it.name == value } })
val cameraAction: StateFlow<AACPManager.Companion.StemPressType?> = _cameraAction
fun setCameraAction(action: AACPManager.Companion.StemPressType?) {
sharedPreferences.edit {
if (action == null) remove("camera_action")
else putString("camera_action", action.name)
}
_cameraAction.value = action
}
init {
observeBroadcasts()
loadName()
loadInstance()
loadSharedPreferences()
setupControlObservers()
observeBilling()
loadControlList()
observeATT()
if (isDemoMode) activateDemoMode()
}
override fun onCleared() {
listeners.forEach { (id, listener) ->
controlRepo.remove(id, listener)
}
appContext.unregisterReceiver(broadcastReceiver)
super.onCleared()
}
private fun loadName() {
val name = sharedPreferences.getString("name", "AirPods Pro")!!
_uiState.update { it.copy(deviceName = name) }
}
private fun observeBilling() {
if (isDemoMode) return
viewModelScope.launch {
// if (!BuildConfig.PLAY_BUILD) billingFirstCollectDone = true // FOSS doesn't send multiple events
BillingManager.provider.isPremium.collect { premium ->
// if (!billingFirstCollectDone) {
// billingFirstCollectDone = true
// return@collect
// }
if (!premium) {
setControlCommandBoolean(
ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
false
)
setHeadGesturesEnabled(false)
}
_uiState.update { it.copy(isPremium = premium) }
}
}
}
private fun observeBroadcasts() {
broadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val action = intent?.action ?: return
if (!isDemoMode) when (action) {
AirPodsNotifications.AIRPODS_L2CAP_CONNECTED -> {
_uiState.update {
it.copy(isLocallyConnected = true)
}
}
AirPodsNotifications.AIRPODS_DISCONNECTED -> {
_uiState.update {
it.copy(isLocallyConnected = false)
}
}
AirPodsNotifications.BATTERY_DATA -> {
_uiState.update {
it.copy(battery = service.getBattery())
}
}
AirPodsNotifications.EQ_DATA -> {
val data = intent.getFloatArrayExtra("eqData") ?: floatArrayOf()
_uiState.update {
it.copy(eqData = data)
}
}
AirPodsNotifications.AIRPODS_INFORMATION_UPDATED -> {
loadInstance()
}
}
}
}
val filter = IntentFilter().apply {
addAction(AirPodsNotifications.AIRPODS_CONNECTED)
addAction(AirPodsNotifications.AIRPODS_DISCONNECTED)
addAction(AirPodsNotifications.BATTERY_DATA)
addAction(AirPodsNotifications.EQ_DATA)
addAction(AirPodsNotifications.AIRPODS_INFORMATION_UPDATED)
}
appContext.registerReceiver(
broadcastReceiver, filter, Context.RECEIVER_NOT_EXPORTED
)
}
fun setControlCommandValue(
identifier: ControlCommandIdentifiers, value: ByteArray
) {
if (!isDemoMode) controlRepo.setValue(identifier, value)
_uiState.update {
it.copy(
controlStates = it.controlStates + (identifier to value)
)
}
}
fun setControlCommandBoolean(
identifier: ControlCommandIdentifiers, enabled: Boolean
) {
setControlCommandValue(
identifier, if (enabled) byteArrayOf(0x01) else byteArrayOf(0x02)
)
}
fun setControlCommandInt(
identifier: ControlCommandIdentifiers, value: Int
) {
setControlCommandValue(identifier, byteArrayOf(value.toByte()))
}
fun setControlCommandByte(
identifier: ControlCommandIdentifiers, value: Byte
) {
setControlCommandValue(identifier, byteArrayOf(value))
}
fun observeControl(identifier: ControlCommandIdentifiers) {
val listener = controlRepo.observe(identifier) { value ->
_uiState.update { state ->
val current = state.controlStates[identifier]
if (current?.contentEquals(value) == true) return@update state
state.copy(
controlStates = state.controlStates + (identifier to value)
)
}
}
listeners[identifier] = listener
}
// I'm lazy, sorry.
fun setupControlObservers() {
val identifiersList = listOf(
ControlCommandIdentifiers.MIC_MODE,
ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
ControlCommandIdentifiers.ONE_BUD_ANC_MODE,
ControlCommandIdentifiers.LISTENING_MODE,
ControlCommandIdentifiers.AUTO_ANSWER_MODE,
ControlCommandIdentifiers.CHIME_VOLUME,
ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
ControlCommandIdentifiers.VOLUME_SWIPE_MODE,
ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG,
ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
ControlCommandIdentifiers.HEARING_AID,
ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
ControlCommandIdentifiers.HPS_GAIN_SWIPE,
ControlCommandIdentifiers.HEARING_ASSIST_CONFIG,
ControlCommandIdentifiers.ALLOW_OFF_OPTION,
ControlCommandIdentifiers.STEM_CONFIG,
ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG,
ControlCommandIdentifiers.ALLOW_AUTO_CONNECT,
ControlCommandIdentifiers.EAR_DETECTION_CONFIG,
ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG,
ControlCommandIdentifiers.OWNS_CONNECTION,
ControlCommandIdentifiers.PPE_TOGGLE_CONFIG,
)
for (identifier in identifiersList) {
observeControl(identifier)
}
}
fun refreshInitialData() {
if (isDemoMode) return
service.let { service ->
_uiState.update {
it.copy(
isLocallyConnected = service.isConnected(), battery = service.getBattery()
)
}
}
}
private fun loadSharedPreferences() {
val offListeningModeEnabled = sharedPreferences.getBoolean("off_listening_mode", true)
val automaticEarDetectionEnabled =
sharedPreferences.getBoolean("automatic_ear_detection", true)
val automaticConnectionEnabled =
sharedPreferences.getBoolean("automatic_connection_ctrl_cmd", true)
val headGesturesEnabled = sharedPreferences.getBoolean("head_gestures", true)
val leftAction = StemAction.valueOf(
sharedPreferences.getString(
"left_long_press_action",
"CYCLE_NOISE_CONTROL_MODES"
) ?: "CYCLE_NOISE_CONTROL_MODES"
)
val rightAction = StemAction.valueOf(
sharedPreferences.getString(
"right_long_press_action",
"CYCLE_NOISE_CONTROL_MODES"
) ?: "CYCLE_NOISE_CONTROL_MODES"
)
val vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false)
_uiState.update {
it.copy(
offListeningMode = offListeningModeEnabled,
automaticEarDetectionEnabled = automaticEarDetectionEnabled,
automaticConnectionEnabled = automaticConnectionEnabled,
headGesturesEnabled = headGesturesEnabled,
leftAction = leftAction,
rightAction = rightAction,
vendorIdHook = vendorIdHook
)
}
}
fun setOffListeningMode(enabled: Boolean) {
sharedPreferences.edit { putBoolean("off_listening_mode", enabled) }
setControlCommandBoolean(ControlCommandIdentifiers.ALLOW_OFF_OPTION, enabled)
_uiState.update {
it.copy(offListeningMode = enabled)
}
}
fun setHeadGesturesEnabled(enabled: Boolean) {
sharedPreferences.edit { putBoolean("head_gestures", enabled) }
_uiState.update {
it.copy(headGesturesEnabled = enabled)
}
}
private fun loadControlList() {
_uiState.update {
it.copy(
controlStates = controlRepo.getMap()
)
}
}
private fun loadInstance() {
val instance = service.airpodsInstance ?: AirPodsInstance(
name = "AirPods",
model = AirPodsModels.getModelByModelNumber("A3049")!!,
actualModelNumber = "A3049",
serialNumber = null,
leftSerialNumber = null,
rightSerialNumber = null,
version1 = null,
version2 = null,
version3 = null,
)
_uiState.update {
it.copy(
capabilities = instance.model.capabilities,
instance = instance,
modelName = instance.model.displayName,
actualModel = instance.actualModelNumber,
serialNumbers = listOf(
instance.serialNumber ?: "",
instance.leftSerialNumber ?: "",
instance.rightSerialNumber ?: ""
),
version1 = instance.version1 ?: "",
version2 = instance.version2 ?: "",
version3 = instance.version3 ?: ""
)
}
}
fun reconnectFromSavedMac() {
service.reconnectFromSavedMac()
}
fun setName(name: String) {
service.setName(name)
}
fun startHeadTracking() {
service.startHeadTracking()
_uiState.update { it.copy(headTrackingActive = true) }
}
fun stopHeadTracking() {
service.stopHeadTracking()
_uiState.update { it.copy(headTrackingActive = false) }
}
fun setATTCharacteristicValue(handle: ATTHandles, value: ByteArray) {
if (handle == ATTHandles.LOUD_SOUND_REDUCTION) {
_uiState.update { it.copy(loudSoundReductionEnabled = value[0].toInt() == 0x01) }
}
viewModelScope.launch(Dispatchers.IO) {
try {
service.attManager?.connect()
while (service.attManager?.socket?.isConnected != true) {
delay(250)
}
service.attManager?.write(handle, value)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
fun refreshATT() {
viewModelScope.launch(Dispatchers.IO) {
val loudSoundReduction =
runCatching { service.attManager?.read(ATTHandles.LOUD_SOUND_REDUCTION) }.getOrNull()
val transparencyData =
runCatching { service.attManager?.read(ATTHandles.TRANSPARENCY) }.getOrNull()?: byteArrayOf()
val hearingAid =
runCatching { service.attManager?.read(ATTHandles.HEARING_AID) }.getOrNull()?: byteArrayOf()
_uiState.value = _uiState.value.copy(
loudSoundReductionEnabled = loudSoundReduction?.get(0)?.toInt() == 0x01,
transparencyData = transparencyData,
hearingAidData = hearingAid
)
}
}
fun observeATT() {
viewModelScope.launch(Dispatchers.IO) {
service.attManager?.connect()
while (service.attManager?.socket?.isConnected != true) {
delay(1000)
}
service.attManager?.enableNotifications(ATTHandles.LOUD_SOUND_REDUCTION)
service.attManager?.enableNotifications(ATTHandles.TRANSPARENCY)
service.attManager?.enableNotifications(ATTHandles.HEARING_AID)
while (true) {
refreshATT()
delay(15000)
}
}
}
fun setAutomaticEarDetectionEnabled(enabled: Boolean) {
sharedPreferences.edit { putBoolean("automatic_ear_detection", enabled) }
setControlCommandBoolean(ControlCommandIdentifiers.EAR_DETECTION_CONFIG, enabled)
_uiState.update {
it.copy(
automaticEarDetectionEnabled = enabled
)
}
}
fun setAutomaticConnectionEnabled(enabled: Boolean) {
sharedPreferences.edit { putBoolean("automatic_connection_ctrl_cmd", enabled) }
setControlCommandBoolean(ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG, enabled)
_uiState.update {
it.copy(
automaticConnectionEnabled = enabled
)
}
}
fun activateDemoMode() {
isDemoMode = true
viewModelScope.launch {
demoActivated.emit(Unit)
}
val fakeInstance = AirPodsInstance(
name = "AirPods Pro (Demo)",
model = AirPodsModels.getModelByModelNumber("A3049")!!,
actualModelNumber = "A3049",
serialNumber = "DEMO123",
leftSerialNumber = "L-DEMO",
rightSerialNumber = "R-DEMO",
version1 = "1.0",
version2 = "1.0",
version3 = "1.0",
)
_uiState.update {
it.copy(
isLocallyConnected = true,
instance = fakeInstance,
capabilities = fakeInstance.model.capabilities,
battery = listOf(
Battery(BatteryComponent.LEFT, 85, BatteryStatus.CHARGING),
Battery(BatteryComponent.RIGHT, 25, BatteryStatus.NOT_CHARGING),
Battery(BatteryComponent.CASE, 85, BatteryStatus.CHARGING),
),
modelName = fakeInstance.model.displayName,
actualModel = fakeInstance.actualModelNumber,
serialNumbers = listOf("DEMO", "DEMO", "DEMO"),
version3 = "Demo Firmware",
isPremium = true
)
}
}
fun sendPhoneMediaEQ(eq: FloatArray, phoneByte: Byte, mediaByte: Byte) {
service.aacpManager.sendPhoneMediaEQ(eq, phoneByte, mediaByte)
}
fun disconnect() {
service.disconnectAirPods()
if (appContext.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") != PackageManager.PERMISSION_GRANTED) {
Toast.makeText(appContext, "App has disconnected, disconnect from Android Settings.",
Toast.LENGTH_LONG).show()
}
}
}

View File

@@ -0,0 +1,185 @@
package me.kavishdevar.librepods.presentation.viewmodel
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
import me.kavishdevar.librepods.utils.NativeBridge
import me.kavishdevar.librepods.utils.XposedState
import kotlin.math.roundToInt
data class AppSettingsUiState(
val showPhoneBatteryInWidget: Boolean = false,
val conversationalAwarenessPauseMusicEnabled: Boolean = false,
val relativeConversationalAwarenessVolumeEnabled: Boolean = true,
val disconnectWhenNotWearing: Boolean = false,
val takeoverWhenDisconnected: Boolean = false,
val takeoverWhenIdle: Boolean = false,
val takeoverWhenMusic: Boolean = false,
val takeoverWhenCall: Boolean = false,
val takeoverWhenRingingCall: Boolean = false,
val takeoverWhenMediaStart: Boolean = false,
val useAlternateHeadTrackingPackets: Boolean = true,
val conversationalAwarenessVolume: Float = 43f,
val showCameraDialog: Boolean = false,
val cameraPackageValue: String = "",
val cameraPackageError: String? = null,
val vendorIdHook: Boolean = false,
val isPremium: Boolean = false,
val connectionSuccessful: Boolean = false
)
class AppSettingsViewModel(application: Application) : AndroidViewModel(application) {
private val sharedPreferences = application.getSharedPreferences("settings", Context.MODE_PRIVATE)
private val _uiState = MutableStateFlow(AppSettingsUiState())
val uiState = _uiState.asStateFlow()
private val xposedRemotePref = XposedRemotePrefProvider.create()
val sharedPrefListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPref, key ->
if (key == "connection_successful") {
_uiState.update { it.copy(connectionSuccessful = sharedPref.getBoolean(key, false)) }
}
}
init {
loadSettings()
observeBilling()
sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPrefListener)
}
override fun onCleared() {
sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPrefListener)
super.onCleared()
}
private fun observeBilling() {
viewModelScope.launch {
BillingManager.provider.isPremium.collect { premium ->
_uiState.update { it.copy(isPremium = premium) }
}
}
}
private fun loadSettings() {
_uiState.update { currentState ->
currentState.copy(
showPhoneBatteryInWidget = sharedPreferences.getBoolean("show_phone_battery_in_widget", false),
conversationalAwarenessPauseMusicEnabled = sharedPreferences.getBoolean("conversational_awareness_pause_music", false),
relativeConversationalAwarenessVolumeEnabled = sharedPreferences.getBoolean("relative_conversational_awareness_volume", true),
disconnectWhenNotWearing = sharedPreferences.getBoolean("disconnect_when_not_wearing", false),
takeoverWhenDisconnected = sharedPreferences.getBoolean("takeover_when_disconnected", false),
takeoverWhenIdle = sharedPreferences.getBoolean("takeover_when_idle", false),
takeoverWhenMusic = sharedPreferences.getBoolean("takeover_when_music", false),
takeoverWhenCall = sharedPreferences.getBoolean("takeover_when_call", false),
takeoverWhenRingingCall = sharedPreferences.getBoolean("takeover_when_ringing_call", false),
takeoverWhenMediaStart = sharedPreferences.getBoolean("takeover_when_media_start", false),
useAlternateHeadTrackingPackets = sharedPreferences.getBoolean("use_alternate_head_tracking_packets", true),
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43).toFloat(),
cameraPackageValue = sharedPreferences.getString("custom_camera_package", "") ?: "",
vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false),
connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false)
)
}
if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) {
NativeBridge.setSdpHook(_uiState.value.vendorIdHook)
}
}
fun setShowPhoneBatteryInWidget(enabled: Boolean) {
sharedPreferences.edit { putBoolean("show_phone_battery_in_widget", enabled) }
_uiState.update { it.copy(showPhoneBatteryInWidget = enabled) }
}
fun setConversationalAwarenessPauseMusicEnabled(enabled: Boolean) {
sharedPreferences.edit { putBoolean("conversational_awareness_pause_music", enabled) }
_uiState.update { it.copy(conversationalAwarenessPauseMusicEnabled = enabled) }
}
fun setRelativeConversationalAwarenessVolumeEnabled(enabled: Boolean) {
sharedPreferences.edit { putBoolean("relative_conversational_awareness_volume", enabled) }
_uiState.update { it.copy(relativeConversationalAwarenessVolumeEnabled = enabled) }
}
fun setDisconnectWhenNotWearing(enabled: Boolean) {
sharedPreferences.edit { putBoolean("disconnect_when_not_wearing", enabled) }
_uiState.update { it.copy(disconnectWhenNotWearing = enabled) }
}
fun setTakeoverWhenDisconnected(enabled: Boolean) {
sharedPreferences.edit { putBoolean("takeover_when_disconnected", enabled) }
_uiState.update { it.copy(takeoverWhenDisconnected = enabled) }
}
fun setTakeoverWhenIdle(enabled: Boolean) {
sharedPreferences.edit { putBoolean("takeover_when_idle", enabled) }
_uiState.update { it.copy(takeoverWhenIdle = enabled) }
}
fun setTakeoverWhenMusic(enabled: Boolean) {
sharedPreferences.edit { putBoolean("takeover_when_music", enabled) }
_uiState.update { it.copy(takeoverWhenMusic = enabled) }
}
fun setTakeoverWhenCall(enabled: Boolean) {
sharedPreferences.edit { putBoolean("takeover_when_call", enabled) }
_uiState.update { it.copy(takeoverWhenCall = enabled) }
}
fun setTakeoverWhenRingingCall(enabled: Boolean) {
sharedPreferences.edit { putBoolean("takeover_when_ringing_call", enabled) }
_uiState.update { it.copy(takeoverWhenRingingCall = enabled) }
}
fun setTakeoverWhenMediaStart(enabled: Boolean) {
sharedPreferences.edit { putBoolean("takeover_when_media_start", enabled) }
_uiState.update { it.copy(takeoverWhenMediaStart = enabled) }
}
fun setUseAlternateHeadTrackingPackets(enabled: Boolean) {
sharedPreferences.edit { putBoolean("use_alternate_head_tracking_packets", enabled) }
_uiState.update { it.copy(useAlternateHeadTrackingPackets = enabled) }
}
fun setConversationalAwarenessVolume(volume: Float) {
sharedPreferences.edit { putInt("conversational_awareness_volume", volume.roundToInt()) }
_uiState.update { it.copy(conversationalAwarenessVolume = volume) }
}
fun setShowCameraDialog(show: Boolean) {
_uiState.update { it.copy(showCameraDialog = show) }
}
fun setCameraPackageValue(value: String) {
_uiState.update { it.copy(cameraPackageValue = value) }
}
fun setCameraPackageError(error: String?) {
_uiState.update { it.copy(cameraPackageError = error) }
}
fun saveCameraPackage() {
if (_uiState.value.cameraPackageValue.isBlank()) {
sharedPreferences.edit { remove("custom_camera_package") }
} else {
sharedPreferences.edit { putString("custom_camera_package", _uiState.value.cameraPackageValue) }
}
setShowCameraDialog(false)
}
fun setVendorIdHook(enabled: Boolean) {
NativeBridge.setSdpHook(enabled)
xposedRemotePref.putBoolean("vendor_id_hook", enabled)
_uiState.update { it.copy(vendorIdHook = enabled) }
}
}

View File

@@ -0,0 +1,47 @@
package me.kavishdevar.librepods.presentation.viewmodel
import android.app.Activity
import android.app.Application
import android.content.Context
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.billing.BillingManager
data class PurchaseUiState(
val isPremium: Boolean = false,
val price: String = ""
)
class PurchaseViewModel(application: Application) : AndroidViewModel(application) {
private val _uiState = MutableStateFlow(PurchaseUiState())
val uiState = _uiState.asStateFlow()
init {
observeBilling()
}
private fun observeBilling() {
viewModelScope.launch {
BillingManager.provider.isPremium.collect { premium ->
_uiState.update { it.copy(isPremium = premium) }
}
}
viewModelScope.launch {
BillingManager.provider.price.collect { price ->
_uiState.update { it.copy(price = price) }
}
}
}
fun purchase(context: Context) {
BillingManager.provider.purchase(context as Activity)
}
fun restorePurchases() {
BillingManager.provider.restorePurchases()
}
}

View File

@@ -0,0 +1,37 @@
/*
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.presentation.widgets
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi
class BatteryWidget : AppWidgetProvider() {
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
ServiceManager.getService()?.updateBattery()
}
}

View File

@@ -1,24 +1,24 @@
/* /*
* 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)
package me.kavishdevar.librepods.widgets package me.kavishdevar.librepods.presentation.widgets
import android.app.PendingIntent import android.app.PendingIntent
import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetManager
@@ -29,7 +29,7 @@ import android.util.Log
import android.widget.RemoteViews import android.widget.RemoteViews
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.bluetooth.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
class NoiseControlWidget : AppWidgetProvider() { class NoiseControlWidget : AppWidgetProvider() {

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