219 Commits

Author SHA1 Message Date
Kavish Devar
f547cc13c0 linux: fix conversational awareness check for phone MAC 2025-09-03 03:28:59 +05:30
TCH
11fa9180e2 CI: add linux ci (#196) 2025-09-03 03:23:15 +05:30
Kavish Devar
73e55a02d6 add instructions for windows for prox keys script 2025-09-03 03:21:41 +05:30
Kavish Devar
325ef1e953 add cross-platform tool for retrieving proxmity key 2025-09-03 03:17:25 +05:30
qlenlen
5e30531514 i18n: add chinese translation (#200) 2025-09-03 03:01:23 +05:30
Kavish Devar
75fa80c17e android,linux: fix random volume jumps (#192)
* android: update feature flags packet data byte to remove adaptive volume
* linux: update feature flags to prevent volume jumps
2025-08-25 21:25:29 +05:30
TCH
eb1b633aff [Linux] Let users edit Phone bluetooth MAC via GUI (#195)
initial commit
2025-08-25 17:45:36 +02:00
Naveen M K
dde5d1e808 linux: update rename function call for airPods (#191)
previously it was failing with an error that renameAirPods is not a function
This should fix that as the function is defined in airPodsTrayApp
2025-08-25 17:55:06 +05:30
TCH
598bd3d7d8 linux: update logging tag (#194)
change linux logs name to librepods
2025-08-25 17:50:29 +05:30
Kavish Devar
46071f17d7 android: remove broken volume panel hook
Removed volume panel hooking logic and related classes.
2025-08-25 17:41:53 +05:30
Kavish Devar
13ab2d1feb docs: add more control commands ('25)
auto-pause when sleeping, system siri message, set recv raw gestures, 2 eq settings, in-case tone volume, allow auto-connect, disable button input
2025-08-14 10:33:52 +05:30
Raspberrynani
72a7637863 linux: fixed build instructions for ubuntu (#183)
Fixed build instructions

Ubuntu/Debian should use libssl-dev as libssl-devel does not exist
2025-07-15 17:30:30 +05:30
Kavish Devar
24686da1f3 android: add ability to launch digital assistant on long press (#180)
* Initial plan

* Implement BLE-only mode toggle and basic functionality

Co-authored-by: kavishdevar <46088622+kavishdevar@users.noreply.github.com>

* Fix BLE-only mode compatibility issues and enhance MAC address handling

Co-authored-by: kavishdevar <46088622+kavishdevar@users.noreply.github.com>

* Address BLE-only mode feedback: hide renaming, add ear detection warning, ensure default is false

Co-authored-by: kavishdevar <46088622+kavishdevar@users.noreply.github.com>

* android: add support for invoking digital assistant on long press

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2025-07-13 19:51:37 +05:30
Copilot
d9359cd81a android: add option for alternate head tracking packets (#176)
* Initial plan

* Add option for alternate head tracking packets

Co-authored-by: kavishdevar <46088622+kavishdevar@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: kavishdevar <46088622+kavishdevar@users.noreply.github.com>
2025-07-11 10:11:55 +05:30
Mario Borna Mjertan
db563fa75f Fixes and improvements to the Linux readme (#168)
* Add qt development header list for Fedora

* Add openssl development header dependency to Linux readme

* PHONE_MAC_ADDRESS is now an environment variable, not a main.h constant

* The build target is librepods, not applinux
2025-07-05 00:15:21 +05:30
Tim Gromeyer
fb3c8c73a4 [Linux] Start/Stop BLE scan when going to sleep 2025-06-24 13:30:46 +02:00
Tim Gromeyer
05c0a7c88b [Linux] Rename to Librepods 2025-06-16 11:46:50 +02:00
Tim Gromeyer
96ee2410e8 [Linux] Autostart im hidden mode 2025-06-15 12:03:23 +02:00
Tim Gromeyer
c0d915666b [Linux] Remember battery state (closes #149) 2025-06-09 10:54:02 +02:00
Tim Gromeyer
91ffaaa972 [Linux] DBus fixes 2025-06-08 22:06:58 +02:00
Tim Gromeyer
48ae249405 [Linux] Don't use playerctl for current media state 2025-06-08 18:47:47 +02:00
Tim Gromeyer
aaf82c9738 [Linux] Play/Pause via DBus 2025-06-08 18:47:47 +02:00
Tim Gromeyer
38d6f8ceae [Linux] Use DBus for following media playback change 2025-06-08 18:47:47 +02:00
Tim Gromeyer
5754dbfb16 [Linux] New ear detection implementation (#145)
* New ear detection implementation

* [Linux] Improved case battery detection when not connected
2025-06-07 09:19:14 +02:00
Kavish Devar
3b20540c34 android: fix duplicate screenshot in readme 2025-06-05 18:25:43 +05:30
Kavish Devar
595797c703 android: hook libbluetooth_qti.so too 2025-06-05 18:03:09 +05:30
Tim Gromeyer
2e782ba051 [Linux] Fix noise control mode cycling 2025-06-05 10:43:50 +02:00
Tim Gromeyer
3023c706bf [Linux] Fix Adaptive mode not working 2025-06-05 10:43:50 +02:00
Kavish Devar
0d582d890b android: add irk and encryption key from a qr 2025-06-05 13:13:49 +05:30
Tim Gromeyer
9b907fdec4 [Linux] Fix battery sometimes showing 127% (#143) 2025-06-05 09:16:04 +02:00
Tim Gromeyer
43d703423a [Linux] Add Qr-Code to sync irk and enc key (#142) 2025-06-05 09:03:29 +02:00
Kavish Devar
dcb25e2e52 [ImgBot] Optimize images 2025-06-04 17:15:18 +05:30
ImgBotApp
31397f055e [ImgBot] Optimize images
*Total -- 1,009.27kb -> 800.74kb (20.66%)

/imgs/banner.png -- 266.07kb -> 199.11kb (25.17%)
/android/imgs/debug.png -- 174.22kb -> 131.86kb (24.31%)
/android/imgs/customizations-2.png -- 211.58kb -> 162.48kb (23.2%)
/android/imgs/customizations-1.png -- 209.27kb -> 162.84kb (22.19%)
/android/app/src/main/res/drawable/pro_2_right.png -- 36.47kb -> 34.80kb (4.58%)
/android/app/src/main/res/drawable/pro_2_left.png -- 34.88kb -> 33.36kb (4.38%)
/linux/assets/airpods.png -- 76.78kb -> 76.30kb (0.63%)

Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>
2025-06-04 11:34:04 +00:00
Tim Gromeyer
070713540a [Linux] Fix build 2025-06-04 13:32:53 +02:00
Tim Gromeyer
6574e52195 [Linux] Compress images 2025-06-04 13:32:53 +02:00
Tim Gromeyer
c4633d6871 [Linux] Read AirPods state from BLE broadcast when not connected (#138)
* Fix possible build error

* [Linux] Read AirPods state from BLE broadcast when not connected

* SImplify

* Remove old code

* Remove old code

* Maintain charging state when state is unknown

* Simplify

* Remove unused var
2025-06-04 13:10:55 +02:00
Kavish Devar
5dc7e512ae update readme 2025-06-03 20:35:29 +05:30
Kavish Devar
b8e9765aff merge linux changes with local 2025-06-03 20:12:47 +05:30
Kavish Devar
62aabe80c1 android: add support for settings hook on A16 2025-06-03 20:12:25 +05:30
Kavish Devar
dc0b06a369 android: remove volume panel hook
I've moved away from AOSP and I can't maintain the hook for pixel/AOSP ROMs
2025-06-03 20:11:08 +05:30
Kavish Devar
96baebee28 Update name in linux README 2025-06-03 14:22:57 +05:30
Tim Gromeyer
c05a37bcca [Linux] Fix UI not working (#137)
* Move mac adress to deviceinfo

* Missing changes
2025-06-03 10:31:19 +02:00
Tim Gromeyer
8a69dbe173 [Linux] Move all device related properties to new class (#135)
* Clean up code

* Move all device releated properties to new class
2025-06-03 09:07:30 +02:00
Kavish Devar
b783b86b7a android: add update config for root module 2025-05-30 17:33:47 +05:30
Kavish Devar
445c999208 android: start head gestures after auto-connect 2025-05-30 17:30:30 +05:30
Kavish Devar
96e63cf35e android: fix head gestures not working 2025-05-21 21:52:41 +05:30
Kavish Devar
5472e09293 android: fix island not closing 2025-05-20 22:31:53 +05:30
Kavish Devar
e852182b48 android: use encrypted data from BLE broadcast for accurate battery levels when not connected over AACP 2025-05-20 14:52:05 +05:30
Kavish Devar
5eb13ace0c android: improve ble-based autoconnection 2025-05-20 09:54:18 +05:30
Kavish Devar
2b1fb5b71e android: use broadcasted battery data if not connected via l2cap for popup 2025-05-19 18:26:44 +05:30
Kavish Devar
c95a619465 android: bump version 2025-05-19 17:39:55 +05:30
Kavish Devar
c4bc47c48a merge the a11 fix with local 2025-05-19 17:28:30 +05:30
Kavish Devar
6a026ebab0 android: refactor AACP and add autoconnect based on BLE broadcasts 2025-05-19 17:24:41 +05:30
Kavish Devar
f3ed3bbc70 [Linux] Add One Bud ANC Mode setting (#128) 2025-05-16 18:10:20 +05:30
Tim Gromeyer
5fe123f544 [Linux] Add One Bud ANC Mode setting 2025-05-16 14:08:42 +02:00
Tim Gromeyer
09e1aa1530 [Linux] Reset tray icon when airpods disconnect 2025-05-16 14:08:20 +02:00
Kavish Devar
fd917d3fd0 [Linux] Add more control commands (#127) 2025-05-16 16:51:09 +05:30
Tim Gromeyer
84891a0bdf Remove VoiceTrigger and InCaseTone 2025-05-16 12:10:40 +02:00
Tim Gromeyer
4b3cc92e56 Make the copilot reviewer happy 2025-05-16 08:41:29 +02:00
Kavish Devar
b89d6d9dc2 android: fix support for A11 and lower 2025-05-16 04:46:25 +00:00
Kavish Devar
6985aa4a7b fix typo in AAP docs 2025-05-15 22:54:12 +05:30
Tim Gromeyer
9161f8b294 [Linux] Add more control commands (4c0381968f) 2025-05-15 12:00:01 +02:00
Kavish Devar
4c0381968f docs: create control_commands.md 2025-05-15 02:16:02 +05:30
Kavish Devar
69439257ce android: bump version 2025-05-12 17:16:47 +05:30
Kavish Devar
810a3c90e4 android: add troubleshooter for easier log access 2025-05-12 16:50:26 +05:30
Kavish Devar
0611509782 android: fix the socket error notification showing up even when it connection suceeds 2025-05-11 21:04:42 +05:30
Kavish Devar
116f7dda92 android: separated actual battery notifications from persistent service notif; better error handling when socket isn't connected 2025-05-11 20:42:54 +05:30
Kavish Devar
51ca4c12d1 android: add app description 2025-05-11 20:41:34 +05:30
Kavish Devar
8e670c2481 android: fix last commit; update copyright notice to "LibrePods Contributors" 2025-05-11 19:59:56 +05:30
Kavish Devar
aec9c7192e android: make customizations screen and head tracking screen scrollable 2025-05-11 19:46:43 +05:30
Kavish Devar
01432ce9c7 andoid: add option to not disconnect airpods when none are worn 2025-05-11 19:40:57 +05:30
Kavish Devar
9baa3c9b60 android: update haze uses 2025-05-11 19:38:55 +05:30
Kavish Devar
364a6f4b64 android: fix ear detection when none are in use and either or both are worn
Music would start playing when neither are in ear, but even one is worn. This happens even when the music was not playing when they were removed (or, connected first)
2025-05-11 18:52:33 +05:30
Kavish Devar
9b96218fa9 android: fix mediacontroller fallback volume for conversational awareness 2025-05-10 08:15:00 +05:30
Kavish Devar
98aef13395 android: add sharedpreference listeners to service 2025-05-10 08:13:56 +05:30
Kavish Devar
42e0f48b8b android: fix sharedpreference listener for conversational awareness customizations 2025-05-10 07:55:14 +05:30
Kavish Devar
4c73200f35 android: improve conversational awareness (fixes #122) 2025-05-09 22:37:39 +05:30
Kavish Devar
06de276dca android: initialize shared pref keys on first launch 2025-05-09 22:37:03 +05:30
Kavish Devar
7ffcd68ad9 android: listen for battery in the connected popup window (fix #117) 2025-05-09 09:47:54 +05:30
Kavish Devar
295c49fdc6 android: listen for airpods connection in UI (fix #118) 2025-05-09 09:41:26 +05:30
Kavish Devar
b95962d722 android: rephrase text when requesting permissions 2025-05-09 09:19:02 +05:30
Kavish Devar
45ed8a3a88 android: listen for intents to set anc mode 2025-05-09 08:56:10 +05:30
Kavish Devar
d381adaa09 Merge remote-tracking branch 'refs/remotes/origin/main' 2025-05-08 23:51:03 +05:30
Kavish Devar
58dfed97b3 android: fix the xposed module
skip unecessary parsing the argument for debugging, just return true and hope that it works
2025-05-08 23:50:30 +05:30
Kavish Devar
48e2899564 [Linux] Use Qt 6.4 for compatibility with Debian Stable (#116) 2025-05-04 07:28:28 +05:30
E. S
7f7b439746 linux: Add Debian requirements to the README 2025-05-04 01:20:06 +03:00
E. S
0b4030dd9f linux: Use Qt 6.4 to support Debian 12 2025-05-04 01:18:17 +03:00
Kavish Devar
91675de891 update linux section in readme 2025-05-03 21:58:56 +05:30
Kavish Devar
53433809aa update readme 2025-05-03 21:48:27 +05:30
Kavish Devar
2bd0a3a20c a few small changes 2025-05-03 21:44:45 +05:30
Kavish Devar
7eafb7f013 Merge pull request #111 from jazzysoggy/main
[Linux] Add seperators to tray, add tray option to reopen app and settings, prevent duplicate instances of app from being opened
2025-04-30 07:27:13 +05:30
Tim Gromeyer
96e7a81e46 [Linux] Dynamically find device sink name (#108)
* [Linux] Dynamically find device sink name

* Logging

* WIP

* List cards instead
2025-04-25 19:49:36 +02:00
Henry Cheng
a8f87f37f6 Proper handling of direct kill 2025-04-24 20:04:27 -04:00
Henry Cheng
6376240ce0 Add environmental variable check for phone MAC Address 2025-04-24 19:51:29 -04:00
Henry Cheng
a51efe35dc [Linux] Add reopen to tray options, enhance app tray, add ability to detect duplicate app instances, prevent duplicate app instances, and allow for original instance to be brought to front using the sockets 2025-04-24 19:16:24 -04:00
Tim Gromeyer
1571c6d300 Rewrite Linux section in README 2025-04-24 12:14:44 +02:00
Tim Gromeyer
924efc4faa Delete linux.old directory 2025-04-24 12:14:44 +02:00
Kavish Devar
893bcc97bc fix name in Linux readme 2025-04-24 15:38:19 +05:30
jdholtz
c320b4e27d [Linux] Add CLI flag to hide the window on startup
I like to start this program when connecting to my AirPods and it isn't
necessary for me to have the window pop up. Therefore, using the --hide
flag when starting the program will hide the window on startup.

This could also be a configuration option (e.g. 'Start minimized') but I
went with the CLI flag for simplicity for now. However, if this is a
more viable option (and it may be better in the future), I can implement
this as an option in the settings instead.
2025-04-23 22:46:47 +02:00
Tim Gromeyer
ed0de4d9fa [Linux] Make settings page scrollable 2025-04-22 15:11:30 +02:00
Tim Gromeyer
db763d7290 [Linux] Add setting to change bluetooth retry attempts 2025-04-22 15:11:30 +02:00
Tim Gromeyer
913e1a5aff [Linux] Add setting to disable notifications 2025-04-22 15:11:30 +02:00
Tim Gromeyer
ec1b0c47ca [Linux] Add autostart setting 2025-04-22 15:11:30 +02:00
Tim Gromeyer
1c7bdf987c [Linux] Add cross device setting 2025-04-22 15:11:30 +02:00
Tim Gromeyer
816992fd8a [Linux] Add ear detection behavior setting 2025-04-22 15:11:30 +02:00
Tim Gromeyer
3aeff4d986 [Linux] Add settings page (empty) 2025-04-22 15:11:30 +02:00
Kavish Devar
eacd862ef3 add FUNDING.yml
thought i already added it, apparently not ig
2025-04-22 08:23:31 +05:30
Tim Gromeyer
0846c3eb48 [Linux] Allow setting ear detection behaviour 2025-04-18 18:08:02 +02:00
Tim Gromeyer
c2db0afdf1 [Linux] Activate AirPods as output device when connecting 2025-04-18 18:08:02 +02:00
Tim Gromeyer
f75419748b Remove unused code 2025-04-17 13:05:24 +02:00
Tim Gromeyer
2bb2b0e697 [Linux] Add disconnected indicator 2025-04-17 13:05:24 +02:00
Tim Gromeyer
22e511acf2 [Linux] Battery indicator: automatic color adjustment 2025-04-17 13:05:24 +02:00
Tim Gromeyer
6ad36560a8 Split into multible files (dedup code) 2025-04-17 13:05:24 +02:00
Tim Gromeyer
2fe9724da5 [Linux] Improve connection stability (#98) 2025-04-17 08:30:31 +02:00
Kavish Devar
114c2c7210 Add metadata and BLE doc (#96)
Merge pull request #96 from tim-gromeyer/documentation
2025-04-17 07:52:24 +05:30
Kavish Devar
fa63b9a774 linux: improve disconnection behaviour (#97)
Merge pull request #97 from tim-gromeyer/linux-disconnect-behaviour
2025-04-16 14:27:22 +05:30
Tim Gromeyer
c94295ae1c Reset GUI when airpods disconnect, show notification 2025-04-15 19:55:40 +02:00
Tim Gromeyer
ecab6a9858 [Linux] Pause music when airpods disconnect 2025-04-15 19:37:39 +02:00
Tim Gromeyer
e3a7624d3e Add metadata parsing documentation 2025-04-15 10:41:02 +02:00
Tim Gromeyer
5182befac6 Add documentation for Conversational Awareness state packet format 2025-04-15 10:21:13 +02:00
Tim Gromeyer
7a8b41dfa0 Add Proximity Pairing Message documentation 2025-04-15 09:47:08 +02:00
Tim Gromeyer
e384840bcc [Linux] Implement proper disconnection detection (#95)
* [Linux] Implement proper disconection detection

* Only trigger on airpod devices

* Remove unused code

* [Linux] Fix SegmentedControl text not shown in release build (but debug builds showed text)???
2025-04-14 23:25:02 +02:00
Tim Gromeyer
b1811770a3 [Linux] CA state parsing, robuster handshake, persistent window (#94)
* [Linux] Don't quit app when closing window

* Add magic pairing functionality

* BLE: Allow selecting text

* Parse CA state from airpods

* Add ability to disable cross-device

* More robust handshake/notification request
2025-04-14 12:58:55 +02:00
Kavish Devar
42f91c4c46 android: improve debugging 2025-04-08 08:38:09 +05:30
Kavish Devar
33ba7a2f2d android: ignore values that are unlikely to represent actual head data 2025-04-08 08:38:09 +05:30
Kavish Devar
a41c24a21c Change radare2 download URL to GitHub releases
Merge pull request #93 from kavishdevar/move-radare2-from-hetzner-to-github-releases
2025-04-07 15:19:21 +05:30
Paul
acaf6f2edb Change radare2 download URL to GitHub releases
Was stored on Hetzner beforehand which is not future-proof.
2025-04-07 10:37:57 +02:00
Tim Gromeyer
e0624ce084 [Linux] Use segment control for setting noise control mode (#92)
* [Linux] Enhance GUI with icons

* Improve visibility

* Smarter hiding of battery values

* Add simple opacity based ear detection indication

* Hide disconnected devices

* Add airpods 3 icon

* Support more devices

* Better icons

* Add documentation

* Add segmented control

* Support keyboard navigation

* Fix ear detection when primary pod changes

* Support up to 9 modes with the keyboard

* Redisign

* Use id

* Use correct images

* Remove duplicates

* Use correct image

* Remove more merge conflicts

* Remove unused code

* Remove unused code

* Make all text readbale
2025-04-04 09:40:32 +02:00
Kavish Devar
fb3f948250 Merge pull request #91 from kavishdevar/imgbot
[ImgBot] Optimize images
2025-04-01 08:28:44 +05:45
ImgBotApp
1c745c8e08 [ImgBot] Optimize images
*Total -- 1,568.48kb -> 1,396.36kb (10.97%)

/android/imgs/notification-and-qs.png -- 558.78kb -> 457.84kb (18.07%)
/android/imgs/head-tracking-and-gestures.png -- 155.67kb -> 131.06kb (15.81%)
/linux/assets/pod3.png -- 854.02kb -> 807.46kb (5.45%)

Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>
2025-03-31 20:58:46 +00:00
Tim Gromeyer
e3dab8feb2 [Linux] Enhance GUI with icons (#90)
* [Linux] Enhance GUI with icons

* Improve visibility

* Smarter hiding of battery values

* Add simple opacity based ear detection indication

* Hide disconnected devices

* Add airpods 3 icon

* Support more devices

* Better icons

* Add documentation
2025-03-31 22:57:12 +02:00
Tim Gromeyer
4e72f6573e [Linux] Add battery indicator (#89)
* [Linux] Expose battery info to QML

* [Linux] Add battery indicator

* [Linux] Dynamically hide case battery level if we have no data for it

* Reduce animation speed
2025-03-30 12:00:13 +02:00
Tim Gromeyer
543362da69 [Linux] Implement renaming airpods (#88)
* [Linux] Implement rename airpods

* [Linux] Get airpods name from airpods metadata

* [Linux] Rename AirPods: Ui improvements

---------

Co-authored-by: Tim Gromeyer <tim.gromeyer@trans4mation.de>
2025-03-28 07:04:17 +01:00
Kavish Devar
5a71d9630d CI: update CI to better format custom update messages 2025-03-27 11:11:25 +05:30
Kavish Devar
09daaaedb2 Merge pull request #85 from tim-gromeyer/linux-adaptive-noise-level
[Linux] Implement adaptive audio noise
2025-03-27 10:59:02 +05:30
Kavish Devar
3b1e91bf05 Merge pull request #86 from tim-gromeyer/linux-detect-primary
[Linux] Detect which pod is the primary
2025-03-27 10:58:06 +05:30
Kavish Devar
d79ce0237d Merge pull request #87 from tim-gromeyer/linux-bug-fixes
[Linux] Fix noise control switches multiple times
2025-03-27 10:57:55 +05:30
Kavish Devar
1cf84bb4a5 CI: add custom update messages when manually run 2025-03-27 10:55:39 +05:30
Kavish Devar
05ff64f4b2 android: improve hooking
set desired flow control mode to basic because for android checking once isn't enough. even after calling l2c_fcr_chk_chan, it again compares the desired and the peer's supported channel modes.
2025-03-27 10:54:12 +05:30
Tim Gromeyer
96c5bd089f [Linux] Fix noise controll switches multiple times 2025-03-26 21:55:43 +01:00
Kavish Devar
0a13ba263b android: add a few instructions to the xposed-based onboarding 2025-03-27 00:35:00 +05:30
Kavish Devar
a206e04ba2 android [experimental]: add xposed based hooking 2025-03-27 00:01:53 +05:30
Kavish Devar
13340485b1 remove tests 2025-03-27 00:01:53 +05:30
Kavish Devar
0463b7901b "clean up" ai comments - will redocument later 2025-03-27 00:01:53 +05:30
Tim Gromeyer
55ba67190d [Linux] Implement adaptive audio noice 2025-03-26 15:06:45 +01:00
Tim Gromeyer
ce3c12f3b2 [Linux] Detect which pod is the primary 2025-03-26 14:59:56 +01:00
Kavish Devar
d004d12bb1 Merge pull request #84 from tim-gromeyer/linux-battery-class
[Linux] Add Battery Class and Fix Battery Level Assignment
2025-03-26 16:17:15 +05:30
Tim Gromeyer
53960417b6 Add battery class 2025-03-25 22:11:43 +01:00
Tim Gromeyer
a6dbbd4f0c Simple code cleanup 2025-03-25 21:33:31 +01:00
Kavish Devar
9ee0f733bc Merge pull request #80 from tim-gromeyer/linux-ble-monitor-app
[Linux] BLE monitor app
2025-03-24 20:26:56 +05:30
Tim Gromeyer
4d07cf4c16 Improved lid state detection 2025-03-24 08:29:18 +01:00
Tim Gromeyer
c74054cc98 Fix charging states 2025-03-23 22:15:52 +01:00
Tim Gromeyer
033e0be08d Fix case battery level 2025-03-23 22:15:40 +01:00
Tim Gromeyer
06f7b6bdb8 Add connection state 2025-03-23 21:49:27 +01:00
Kavish Devar
d5a96f8f5e [CI] Download radare2 module
Merge pull request #83 from tim-gromeyer/patch-1
2025-03-24 01:34:48 +05:30
Tim Gromeyer
654e4adad6 [CI] Download radare2 module 2025-03-23 21:00:46 +01:00
Kavish Devar
51f5a66a0e android: add other function signatures for newer updates of android 2025-03-24 01:28:52 +05:30
Kavish Devar
cd5b69e78c remove the huge precompiled radare2; download it when building module 2025-03-20 09:01:21 +05:30
Kavish Devar
254e6c1fb4 remove backup font file 2025-03-20 08:48:39 +05:30
Kavish Devar
d49a5df2e2 android: fix the font issue introduced when switching to a smaller font file 2025-03-20 08:47:12 +05:30
Kavish Devar
1cc09e203a remove ai’s incorrect stuff 2025-03-19 08:30:45 +05:30
Kavish Devar
ff3c33d1b2 android:add packet parsing to debug screen 2025-03-18 09:58:18 +05:30
Kavish Devar
256a42f2d2 fix link to linux folder 2025-03-18 09:43:01 +05:30
Kavish Devar
705354430a android: remove build files 2025-03-18 09:20:32 +05:30
Kavish Devar
bc8b1cc2b7 merge notification and qs tile screenshot 2025-03-18 09:18:34 +05:30
Kavish Devar
b6d18132fa add head tracking/gestures screenshot 2025-03-18 09:12:50 +05:30
Kavish Devar
446fde56d7 update head tracking readme 2025-03-18 07:10:09 +05:30
Kavish Devar
1babdad9a2 android: prevent a crash 2025-03-18 07:10:09 +05:30
Kavish Devar
6e95d2fea3 android: update transitions 2025-03-18 07:10:09 +05:30
Kavish Devar
56be361829 upload cd demo to github's cdn instead of the repo 2025-03-18 06:20:32 +05:30
Kavish Devar
753f012d89 start head tracking for the first 5 seconds to calibrate 2025-03-17 14:30:44 +05:30
Tim Gromeyer
cb625d0889 Remove old devices 2025-03-15 11:46:50 +01:00
Tim Gromeyer
9e40e6e3fd Show more info 2025-03-15 11:21:11 +01:00
Tim Gromeyer
5b5a62f156 Update 2025-03-14 22:39:21 +01:00
Tim Gromeyer
a6eb62bb77 Add more colors 2025-03-14 21:43:56 +01:00
Tim Gromeyer
4bb19a87c5 Fix device model bytes switched 2025-03-14 20:31:21 +01:00
Tim Gromeyer
2e52eb3d7d Fix AirPods model id 2025-03-14 20:22:57 +01:00
Kavish Devar
be629c16ab Update README.md 2025-03-14 17:40:34 +05:30
Kavish Devar
28b0eac8a9 Merge remote-tracking branch 'refs/remotes/origin/main' 2025-03-14 17:33:47 +05:30
Kavish Devar
22d5ae60b6 android+linux: add head tracking and gestures 2025-03-14 17:32:47 +05:30
Kavish Devar
84b548af14 [Linux] Simplify code, implement tray manager (#81)
Merge pull request #81 from tim-gromeyer/linux-simplify-code
2025-03-10 23:21:29 +05:30
Tim Gromeyer
1946857ca5 [Linux] Simplify code, implement tray manager
Implement tray manager

Bug fixes

Bug fixes
2025-03-10 18:09:06 +01:00
Tim Gromeyer
6fa8b5d611 [Linux] ble - fix charging state was inverted 2025-03-09 20:38:44 +01:00
Kavish Devar
e72b4a116e Merge pull request #79 from tim-gromeyer/organize-mediacontroller
[Linux] Organize code, implement media controller
2025-03-09 16:54:13 +05:30
Tim Gromeyer
4438cdae6f [Linux] BLE monitor app 2025-03-09 11:13:21 +01:00
Tim Gromeyer
7522292c8b [Linux] Organize code, implement media controller 2025-03-08 22:09:13 +01:00
Kavish Devar
1583f35a1e [Linux] Move bt package definitions to extra file
Merge pull request #77 from tim-gromeyer/packet-definitions
2025-03-06 10:27:30 +05:30
Tim Gromeyer
adfa11c660 Move bt package definitions to extra file 2025-03-04 21:55:03 +01:00
Kavish Devar
55d06d2f65 linux: fix the merge 2025-02-28 10:12:10 +05:30
Kavish Devar
96170fc9ce Merge pull request #64 from jay-tau/imgbot
[ImgBot] Optimize images
2025-02-17 00:46:36 +05:30
Kavish Devar
16586981a7 Merge pull request #51 from jay-tau/update-gitignore
feat: Better gitignore
2025-02-14 23:40:00 +05:30
Kavish Devar
ac053a99b9 Merge branch 'main' into update-gitignore 2025-02-14 23:38:55 +05:30
Kavish Devar
d9bf99de8b Merge pull request #53 from jay-tau/linux-readme
feat(linux): init README for Linux
2025-02-14 23:36:08 +05:30
Kavish Devar
9107a43c39 suggestions in android studio -_- 2025-02-10 09:31:10 +05:30
Kavish Devar
6940f9c9e3 fix imports for the last change 2025-02-09 23:00:08 +05:30
Kavish Devar
5908683540 Merge remote-tracking branch 'refs/remotes/origin/main' 2025-02-09 22:46:15 +05:30
Kavish Devar
918f6cfe6c implement #57 2025-02-09 22:43:48 +05:30
Kavish Devar
9ab6a20de2 Merge pull request #56 from kavishdevar/android-remove-ble-stuff
android: remove unused ble broadcast stuff
2025-02-05 21:09:07 +05:30
Kavish Devar
1d3dff175f android: remove unused ble broadcast stuff 2025-02-05 21:08:31 +05:30
Kavish Devar
f5df7e2bd3 add manual root module to gitignore 2025-02-04 17:09:28 +05:30
Kavish Devar
6bfbe0904a Merge pull request #47 from tim-gromeyer/linux-noise-cotrol-enum
[Linux] Use a enum to control the noise control mode
2025-02-02 20:01:04 +05:30
Tim Gromeyer
c493a5b29f [Linux] Use enum to represnet noise control mode 2025-02-02 11:38:29 +01:00
Joel Tony
f11680aaad feat(linux): add README for AirPods Linux Native application with setup and usage instructions 2025-02-02 02:05:53 -07:00
Joel Tony
951180251e feat: update .gitignore to include additional patterns for various IDEs and languages 2025-02-02 01:34:24 -07:00
ImgBotApp
f074462489 [ImgBot] Optimize images
*Total -- 8,888.17kb -> 7,319.00kb (17.65%)

/linux.old/imgs/daemon-log.png -- 648.49kb -> 401.67kb (38.06%)
/android/app/src/main/res/drawable/adaptive.png -- 6.49kb -> 4.16kb (35.94%)
/android/app/src/main/res/drawable/transparency.png -- 13.36kb -> 9.00kb (32.59%)
/android/imgs/long-press.png -- 86.66kb -> 60.00kb (30.76%)
/android/app/src/main/res/drawable/noise_cancellation.png -- 12.20kb -> 8.48kb (30.54%)
/linux.old/imgs/tray-icon-menu.png -- 26.32kb -> 18.62kb (29.23%)
/android/imgs/popup.png -- 1,702.28kb -> 1,223.98kb (28.1%)
/android/imgs/qstile.png -- 225.58kb -> 168.59kb (25.26%)
/android/imgs/settings-2.png -- 200.20kb -> 153.60kb (23.27%)
/android/app/src/main/res/drawable/pro_2.png -- 81.83kb -> 63.39kb (22.53%)
/head-tracking/Extracted Data/1 - 13 to 16.png -- 67.26kb -> 52.79kb (21.51%)
/head-tracking/Extracted Data/1 - 33 to 36.png -- 43.47kb -> 34.22kb (21.28%)
/android/imgs/customizations.png -- 85.53kb -> 68.31kb (20.14%)
/linux.old/icon.png -- 752.47kb -> 604.81kb (19.62%)
/android/app/src/main/res/drawable/pro_2_buds.png -- 752.47kb -> 604.81kb (19.62%)
/android/imgs/settings-1.png -- 212.21kb -> 172.09kb (18.91%)
/head-tracking/Extracted Data/1 - 41 to 44.png -- 55.37kb -> 45.25kb (18.27%)
/linux.old/imgs/read-data.png -- 329.61kb -> 283.91kb (13.86%)
/linux.old/imgs/tray-icon-hover.png -- 12.87kb -> 11.15kb (13.37%)
/android/imgs/debug.png -- 95.95kb -> 83.59kb (12.88%)
/linux.old/imgs/ear-detection.png -- 216.06kb -> 189.74kb (12.18%)
/head-tracking/Extracted Data/1 - 5 to 8.png -- 96.45kb -> 84.82kb (12.06%)
/head-tracking/Extracted Data/1 - 29 to 32.png -- 191.07kb -> 168.13kb (12.01%)
/head-tracking/Extracted Data/1 - 17 to 20.png -- 131.15kb -> 115.45kb (11.97%)
/head-tracking/Extracted Data/1 - 9 to 12.png -- 110.16kb -> 97.15kb (11.81%)
/head-tracking/Extracted Data/1 - 1 to 4.png -- 104.38kb -> 93.01kb (10.89%)
/head-tracking/Extracted Data/1 - 21 to 24.png -- 164.50kb -> 146.61kb (10.88%)
/head-tracking/Extracted Data/1 - 25 to 28.png -- 109.33kb -> 97.44kb (10.87%)
/head-tracking/Extracted Data/1 - 37 to 40.png -- 171.62kb -> 155.91kb (9.16%)
/linux.old/imgs/set-anc.png -- 71.08kb -> 64.87kb (8.74%)
/android/imgs/notification.png -- 450.50kb -> 415.45kb (7.78%)
/android/app/src/main/res/drawable-nodpi/example_appwidget_preview.png -- 3.44kb -> 3.28kb (4.51%)
/android/imgs/widget.png -- 1,447.69kb -> 1,406.83kb (2.82%)
/android/imgs/audio-connected-island.png -- 69.71kb -> 68.89kb (1.17%)
/android/imgs/cd-connected-remotely-island.png -- 69.96kb -> 69.22kb (1.07%)
/android/imgs/cd-moved-to-phone-island.png -- 70.44kb -> 69.78kb (0.94%)

Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>
2025-02-02 08:31:45 +00:00
Kavish Devar
bda572823a request advertising permissions for crossdevice services on first launch
merge pull request #50 from kavishdevar/android-fix-advertise-permissions
2025-02-02 13:15:10 +05:30
Kavish Devar
2195be741c send only after user completed sliding
merge pull request #49 from kavishdevar/android-fix-tone-volume-slider-packets
2025-02-02 13:14:57 +05:30
Kavish Devar
471bf7ca3b send only after user completed sliding 2025-02-02 13:13:32 +05:30
Kavish Devar
8d6e8d7df7 request advertising permissions for crossdevice services on first launch 2025-02-02 13:12:22 +05:30
Kavish Devar
ed26c4fec5 Merge pull request #46 from tim-gromeyer/linux-airpods-battery-level
[Linux] Show correct battery level when 1 Airpod is disconnected
2025-02-02 10:32:09 +05:30
Tim Gromeyer
46d6cab930 [Linux] Show correct battery level when 1 Airpod is disconnected
It would show 0 previously
2025-02-01 12:47:50 +01:00
Kavish Devar
5efbfa7ab7 Merge pull request #45 from tim-gromeyer/ear-detection-status-in.case
[Linux] Add "in case" as ear detection status
2025-02-01 16:34:25 +05:30
Tim Gromeyer
6cb29e26d0 [Linux] Add "in case" as ear detection status 2025-02-01 11:59:09 +01:00
Kavish Devar
321a3bd3bf finally done with most of the crossdevice stuff! 2025-02-01 03:53:37 +05:30
Kavish Devar
5cee33a354 improve screenshots layout in cd 2025-01-31 03:58:08 +05:30
Kavish Devar
055db073da update video for transitions in android app 2025-01-31 03:55:37 +05:30
Kavish Devar
c7ef31cba6 add cross device stuff (screenshots) in readme 2025-01-31 03:37:02 +05:30
Kavish Devar
de53e840ed for @maxwofford! :) 2025-01-31 01:59:21 +05:30
Kavish Devar
c84195aec8 add info button for when remotely connected 2025-01-30 04:17:07 +05:30
278 changed files with 25178 additions and 9744 deletions

View File

@@ -11,6 +11,10 @@ on:
required: true
type: boolean
default: false
custom_notes:
description: 'Custom updates to add to What''s Changed section'
required: false
type: string
workflow_call:
jobs:
@@ -43,7 +47,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
- name: Export APK_NAME for later use
run: echo "APK_NAME=ALN-$(echo ${{ github.sha }} | cut -c1-7).apk" >> $GITHUB_ENV
run: echo "APK_NAME=LibrePods-$(echo ${{ github.sha }} | cut -c1-7).apk" >> $GITHUB_ENV
- name: Rename .apk file
run: mv "./Debug APK/debug/"*.apk "./$APK_NAME"
- name: Decode keystore file
@@ -63,12 +67,32 @@ jobs:
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
if [ "${{ github.event_name }}" == "workflow_dispatch" ] && [ -n "${{ github.event.inputs.custom_notes }}" ]; then
CUSTOM_NOTES="${{ github.event.inputs.custom_notes }}"
# Check if custom notes already have bullet points or GitHub-style formatting
if echo "$CUSTOM_NOTES" | grep -q "^\*\|^- \|http.*commit\|in #[0-9]\+"; then
# Already formatted, use as is
echo "$CUSTOM_NOTES" > "$NOTES_FILE"
else
# Add bullet point formatting
echo "- $CUSTOM_NOTES" > "$NOTES_FILE"
fi
fi
echo "notes_file=$NOTES_FILE" >> $GITHUB_OUTPUT
- name: Zip root-module directory
run: zip -r -0 ../btl2capfix.zip * --verbose
working-directory: root-module
run: sh ./build-magisk-module.sh
- name: Delete release if exist then create release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release view "nightly" && gh release delete "nightly" -y --cleanup-tag
gh release create "nightly" "./$APK_NAME" "./btl2capfix.zip" -p -t "Nightly Release" -n "${{ steps.get-commits.outputs.commits }}" --generate-notes
gh release create "nightly" "./$APK_NAME" "./btl2capfix.zip" -p -t "Nightly Release" --notes-file "${{ steps.release-notes.outputs.notes_file }}" --generate-notes

36
.github/workflows/ci-linux.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: Build LibrePods Linux
on:
push:
branches:
- '*'
jobs:
build-linux:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y build-essential cmake ninja-build \
qt6-base-dev qt6-declarative-dev qt6-svg-dev \
qt6-tools-dev qt6-tools-dev-tools qt6-connectivity-dev \
libxkbcommon-dev
- name: Build project
working-directory: linux
run: |
mkdir build
cd build
cmake .. -G Ninja
ninja
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: librepods-linux
path: linux/build/librepods

469
.gitignore vendored
View File

@@ -1,13 +1,17 @@
root-module/radare2-5.9.9-android-aarch64.tar.gz
wak.toml
log.txt
btl2capfix.zip
root-module-manual
.vscode
testing.py
.DS_Store
CMakeLists.txt.user*
# Android Template
### Android ###
# Gradle files
.gradle/
build/
@@ -42,19 +46,284 @@ google-services.json
# Android Profiling
*.hprof
# Python Template
### Android Patch ###
gen-external-apklibs
# Replacement of .externalNativeBuild directories introduced
# with Android Studio 3.5.
### C++ ###
# Prerequisites
*.d
# Compiled Object files
*.slo
*.lo
*.o
*.obj
# Precompiled Headers
*.gch
*.pch
# Compiled Dynamic libraries
*.so
*.dylib
*.dll
# Fortran module files
*.mod
*.smod
# Compiled Static libraries
*.lai
*.la
*.a
*.lib
# Executables
*.exe
*.out
*.app
### CLion ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
### CLion Patch ###
# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
# *.iml
# modules.xml
# .idea/misc.xml
# *.ipr
# Sonarlint plugin
# https://plugins.jetbrains.com/plugin/7973-sonarlint
.idea/**/sonarlint/
# SonarQube Plugin
# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
.idea/**/sonarIssues.xml
# Markdown Navigator plugin
# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
.idea/**/markdown-navigator.xml
.idea/**/markdown-navigator-enh.xml
.idea/**/markdown-navigator/
# Cache file creation bug
# See https://youtrack.jetbrains.com/issue/JBR-2257
.idea/$CACHE_FILE$
# CodeStream plugin
# https://plugins.jetbrains.com/plugin/12206-codestream
.idea/codestream.xml
# Azure Toolkit for IntelliJ plugin
# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij
.idea/**/azureSettings.xml
### Kotlin ###
# Compiled class file
*.class
# Log file
# BlueJ files
*.ctxt
# Mobile Tools for Java (J2ME)
.mtj.tmp/
# Package Files #
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
replay_pid*
### Linux ###
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
### PyCharm ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
# AWS User-specific
# Generated files
# Sensitive or high-churn files
# Gradle
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
# Mongo Explorer plugin
# File-based project format
# IntelliJ
# mpeltonen/sbt-idea plugin
# JIRA plugin
# Cursive Clojure plugin
# SonarLint plugin
# Crashlytics plugin (for Android Studio and IntelliJ)
# Editor-based Rest Client
# Android studio 3.1+ serialized cache file
### PyCharm Patch ###
# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
# *.iml
# modules.xml
# .idea/misc.xml
# *.ipr
# Sonarlint plugin
# https://plugins.jetbrains.com/plugin/7973-sonarlint
# SonarQube Plugin
# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
# Markdown Navigator plugin
# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
# Cache file creation bug
# See https://youtrack.jetbrains.com/issue/JBR-2257
# CodeStream plugin
# https://plugins.jetbrains.com/plugin/12206-codestream
# Azure Toolkit for IntelliJ plugin
# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij
### Python ###
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
# *.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
@@ -102,7 +371,6 @@ cover/
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
@@ -152,10 +420,8 @@ ipython_config.py
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
@@ -206,3 +472,190 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
### Python Patch ###
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
poetry.toml
# ruff
.ruff_cache/
# LSP config files
pyrightconfig.json
### Qt ###
# C++ objects and libs
*.so.*
# Qt-es
object_script.*.Release
object_script.*.Debug
*_plugin_import.cpp
/.qmake.cache
/.qmake.stash
*.pro.user
*.pro.user.*
*.qbs.user
*.qbs.user.*
*.moc
moc_*.cpp
moc_*.h
qrc_*.cpp
ui_*.h
*.qmlc
*.jsc
Makefile*
*build-*
*.qm
*.prl
# Qt unit tests
target_wrapper.*
# QtCreator
*.autosave
# QtCreator Qml
*.qmlproject.user
*.qmlproject.user.*
# QtCreator CMake
CMakeLists.txt.user*
# QtCreator 4.8< compilation database
compile_commands.json
# QtCreator local machine specific files for imported projects
*creator.user*
*_qmlcache.qrc
### VisualStudioCode ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
### VisualStudioCode Patch ###
# Ignore all local history of files
.history
.ionide
### AndroidStudio ###
# Covers files to be ignored for android development using Android Studio.
# Built application files
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
# Generated files
bin/
gen/
# Gradle files
.gradle
# Signing files
.signing/
# Local configuration file (sdk path, etc)
# Proguard folder generated by Eclipse
proguard/
# Log Files
# Android Studio
/*/build/
/*/local.properties
/*/out
/*/*/build
/*/*/production
.navigation/
*.ipr
*.swp
# Keystore files
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Android Patch
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
# NDK
obj/
# IntelliJ IDEA
/out/
# User-specific configurations
.idea/caches/
.idea/libraries/
.idea/shelf/
.idea/workspace.xml
.idea/tasks.xml
.idea/.name
.idea/compiler.xml
.idea/copyright/profiles_settings.xml
.idea/encodings.xml
.idea/misc.xml
.idea/modules.xml
.idea/scopes/scope_settings.xml
.idea/dictionaries
.idea/vcs.xml
.idea/jsLibraryMappings.xml
.idea/datasources.xml
.idea/dataSources.ids
.idea/sqlDataSources.xml
.idea/dynamic.xml
.idea/uiDesigner.xml
.idea/assetWizardSettings.xml
.idea/gradle.xml
.idea/jarRepositories.xml
.idea/navEditor.xml
# Legacy Eclipse project files
.classpath
.project
.cproject
.settings/
# Mobile Tools for Java (J2ME)
# Package Files #
# virtual machine crash logs (Reference: http://www.java.com/en/download/help/error_hotspot.xml)
## Plugin-specific files:
# mpeltonen/sbt-idea plugin
# JIRA plugin
# Mongo Explorer plugin
.idea/mongoSettings.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
### AndroidStudio Patch ###
!/gradle/wrapper/gradle-wrapper.jar
# End of https://www.toptal.com/developers/gitignore/api/qt,c++,clion,kotlin,python,android,pycharm,androidstudio,visualstudiocode,linux
linux/.qmlls.ini

View File

@@ -134,7 +134,57 @@ AirPods send conversational awareness packets when the person wearing them start
| 03 | Person Stopped Speaking; increase volume back to normal |
| Intermediate values | Intermediate volume levels |
| 08/09 | Normal Volume |
### Reading Conversational Awareness State
After requesting notifications, the AirPods send a packet indicating the current state of Conversational Awareness (CA). This packet is only sent once after notifications are requested, not when the CA state is changed.
The packet format is:
```plaintext
04 00 04 00 09 00 28 [status] 00 00 00
```
- `[status]` is a single byte at offset 7 (zero-based), immediately after the header.
- `0x01` — Conversational Awareness is **enabled**
- `0x02` — Conversational Awareness is **disabled**
- Any other value — Unknown/undetermined state
**Example:**
```plaintext
04 00 04 00 09 00 28 01 00 00 00
```
Here, `01` at the 8th byte (offset 7) means CA is enabled.
## Metadata
This packet contains device information like name, model number, etc. The packet format is:
```plaintext
04 00 04 00 1d [strings...]
```
The strings are null-terminated UTF-8 strings in the following order:
1. Bluetooth advertising name (varies in length)
2. Model number
3. Manufacturer
4. Serial number
5. Firmware version
6. Firmware version 2 (the exact same as before??)
7. Software version (1.0.0 why would we need it?)
8. App identifier (com.apple.accessory.updater.app.71 what?)
9. Serial number 1
10. Serial number 2
11. Unknown numeric value
12. Encrypted data
13. Additional encrypted data
Example packet:
```plaintext
040004001d0002d5000400416972506f64732050726f004133303438004170706c6520496e632e0051584e524848595850360036312e313836383034303030323030303030302e323731330036312e313836383034303030323030303030302e3237313300312e302e3000636f6d2e6170706c652e6163636573736f72792e757064617465722e6170702e3731004859394c5432454632364a59004833504c5748444a32364b3000363335373533360089312a6567a5400f84a3ca234947efd40b90d78436ae5946748d70273e66066a2589300035333935303630363400```
The packet contains device identification and version information followed by some encrypted data whose format is not known.
```
# Writing to the AirPods
@@ -348,32 +398,39 @@ The packets sent (based on the previous states) are as follows:
> *i do hate apple for not hardcoding these, like there are literally only 4^2 - ${\binom{4}{1}}$ - $\binom{4}{2}$*
# Miscellaneous/Unknown
# Head Tracking
## Request something (Probably Head Positions)
## Start Tracking
This packet initiates head tracking. When sent, the AirPods begin streaming head tracking data (e.g. orientation and acceleration) for live plotting and analysis.
```plaintext
<!-- 04 00 04 00 17 00 00 00 10 00 11 00 08 7C 10 02 42 0B 08 4E 10 02 1A 05 01 40 9C 00 00 -->
OR
04 00 04 00 17 00 00 00 10 00 10 00 08 A1 02 42 0B 08 0E 10 02 1A 05 01 40 9C 00 00
```
Example packet
## Stop Tracking
```plaintext
04 00 04 00 17 00 00 00 10 00 43 00 08 ec 07 10 01 1a 3c 0e 00 01 90 95 5d af 86 19 00 00 03 04 43 94 04 9e 6b 01 00 00 00 d5 a2 06 13 eb 13 03 00 f0 ff 01 00 67 83 67 83 67 83 fe ff fd ff 07 00 b3 01 9c 03 65 00 48 74 2c 37 fd 1e 00 00
```
## Stop whatever was requested
This packet stops the head tracking data stream.
```plaintext
04 00 04 00 17 00 00 00 10 00 11 00 08 7E 10 02 42 0B 08 4E 10 02 1A 05 01 00 00 00 00
```
## Received Head Tracking Sensor Data
Once tracking is active, the AirPods stream sensor packets with the following common structure:
| Field | Offset | Length (bytes) |
|--------------------------|--------|----------------|
| orientation 1 | 43 | 2 |
| orientation 2 | 45 | 2 |
| orientation 3 | 47 | 2 |
| Horizontal Acceleration | 51 | 2 |
| Vertical Acceleration | 53 | 2 |
# LICENSE
AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
Copyright (C) 2024 Kavish Devar
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
@@ -385,4 +442,4 @@ 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/>.
along with this program. If not, see <https://www.gnu.org/licenses/>.

View File

@@ -1,4 +1,2 @@
## btl2capfix v0.0.3
- ([#34](https://github.com/kavishdevar/aln/pull/34)) @devnoname120 Add on-device libbluetooth patcher using a Magisk/KernelSU module (arm64-only)
_[See more here](https://github.com/kavishdevar/aln/releases)_
## LibrePods root module changelog
_[See here](https://github.com/kavishdevar/librepods/releases)_

View File

@@ -1,6 +1,6 @@
# Welcome to AirPods Like Normal contributing guide <!-- omit in toc -->
# Welcome to LibrePods contributing guide <!-- omit in toc -->
Thank you for considering a contribution to AirPods Like Normal! Your support helps bring Apple-exclusive AirPods features to Linux and Android.
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.
@@ -25,11 +25,11 @@ To develop for the Android App, Android Studio is the preferred IDE. And you can
#### 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/aln/issues). If no relevant issue exists, open a new one and fill in the details.
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/aln/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.
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
@@ -37,7 +37,7 @@ Browse our [issues list](https://github.com/kavishdevar/aln/issues) to find an i
1. Fork the repository and clone it to your local environment.
```
git clone https://github.com/kavishdevar/aln.git
git clone https://github.com/kavishdevar/librepods.git
cd AirPods-Like-Normal
```
2. Create a working branch to start your changes.
@@ -67,4 +67,4 @@ Once your PR is open, a team member will review it. They may ask questions or re
### Your PR is merged!
Congratulations! :tada: Once merged, your contributions will be publicly available in AirPodsLikeNormal.
Congratulations! :tada: Once merged, your contributions will be publicly available in LibrePods.

1
FUNDING.yml Normal file
View File

@@ -0,0 +1 @@
github: kavishdevar

View File

@@ -0,0 +1,164 @@
# 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).

156
README.md
View File

@@ -1,85 +1,143 @@
# ALN - AirPodsLikeNormal
*Bringing AirPods' Apple-exclusive features on linux and android!*
![LibrePods Banner](/imgs/banner.png)
## [XDAForums Thread](https://xdaforums.com/t/app-root-for-now-airpodslikenormal-unlock-apple-exclusive-airpods-features-on-android.4707585/)
## Tested device(s)
- AirPods Pro 2
Other devices might work too. Features like ear detection and battery should be available for any AirPods! Although the app will show unsupported features/settings. I will not be able test any other devices than the ones I already have (i.e. the AirPods Pro 2).
## Features
Check the [pinned issue](https://github.com/kavishdevar/aln/issues/20) for a list.
[![XDA Thread](https://img.shields.io/badge/XDA_Forums-Thread-orange)](https://xdaforums.com/t/app-root-for-now-airpodslikenormal-unlock-apple-exclusive-airpods-features-on-android.4707585/)
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/kavishdevar/librepods)](https://github.com/kavishdevar/librepods/releases/latest)
[![GitHub all releases](https://img.shields.io/github/downloads/kavishdevar/librepods/total)](https://github.com/kavishdevar/librepods/releases)
[![GitHub stars](https://img.shields.io/github/stars/kavishdevar/librepods)](https://github.com/kavishdevar/librepods/stargazers)
[![GitHub issues](https://img.shields.io/github/issues/kavishdevar/librepods)](https://github.com/kavishdevar/librepods/issues)
[![GitHub license](https://img.shields.io/github/license/kavishdevar/librepods)](https://github.com/kavishdevar/librepods/blob/main/LICENSE)
[![GitHub contributors](https://img.shields.io/github/contributors/kavishdevar/librepods)](https://github.com/kavishdevar/librepods/graphs/contributors)
## Linux — Deprecated, awaiting a rewrite!
ANY ISSUES ABOUT THE LINUX VERSION WILL BE CLOSED.
Check out the README file in [linux](/linux) folder for more info.
## What is LibrePods?
This tray app communicates with a daemon with the help of a UNIX socket. The daemon is responsible for the actual communication with the AirPods. The tray app is just a frontend for the daemon, that does ear-detection, conversational awareness, setting the noise-cancellation mode, and more.
LibrePods unlocks Apple's exclusive AirPods features on non-Apple devices. Get access to noise control modes, adaptive transparency, ear detection, battery status, and more - all the premium features you paid for but Apple locked to their ecosystem.
![Tray Battery App](/linux.old/imgs/tray-icon-hover.png)
![Tray Noise Control Mode Menu](/linux.old/imgs/tray-icon-menu.png)
## Device Compatibility
## Android
| Status | Device | Features |
|--------|--------|----------|
| ✅ | AirPods Pro (2nd Gen) | Fully supported and tested |
| ⚠️ | Other AirPods models | Basic features (battery status, ear detection) should work |
> Can I use aln without root?
Most features should work with any AirPods. Currently, testing is only performed with AirPods Pro 2.
**No, it's not possible to use aln without root.** You will have to root your device if you want to use aln, and there is no way around it. **No exceptions.**
## Key Features
### Screenshots
- **Noise Control Modes**: Easily switch between noise control modes without having to reach out to your AirPods to long press
- **Ear Detection**: Controls your music automatically when you put your AirPods in or take them out, and switch to phone speaker when you take them out
- **Battery Status**: Accurate battery levels
- **Head Gestures**: Answer calls just by nodding your head
- **Conversational Awareness**: Volume automatically lowers when you speak
- **Other customizations**:
- Rename your AirPods
- Customize long-press actions
- Few accessibility features
- And more!
Check out the new animations and transitions in the app!
See our [pinned issue](https://github.com/kavishdevar/librepods/issues/20) for a complete feature list and roadmap.
https://github.com/user-attachments/assets/eb7eebc2-fecf-410d-a363-0a5fd3a7af30
## Platform Support
### Linux
The Linux version runs as a system tray app. Connect your AirPods and enjoy:
- Battery monitoring
- Automatic Ear detection
- Conversational Awareness
- Switching Noise Control modes
- Device renaming
> [!NOTE]
> Work in progress, but core functionality is stable and usable.
For installation and detailed info, see the [Linux README](/linux/README.md).
### Android
#### Screenshots
| | | |
|-------------------|-------------------|-------------------|
| ![Settings 1](/android/imgs/settings-1.png) | ![Settings 2](/android/imgs/settings-2.png) | ![Debug Screen](/android/imgs/debug.png) |
| ![Battery Notification](/android/imgs/notification.png) | ![Popup](/android/imgs/popup.png) | ![QuickSetting Tile](/android/imgs/qstile.png) |
| ![Long Press Configuration](/android/imgs/long-press.png) | ![Widget](/android/imgs/widget.png) | ![Customizations](/android/imgs/customizations.png) |
| ![Battery Notification and QS Tile for NC Mode](/android/imgs/notification-and-qs.png) | ![Popup](/android/imgs/popup.png) | ![Head Tracking and Gestures](/android/imgs/head-tracking-and-gestures.png) |
| ![Long Press Configuration](/android/imgs/long-press.png) | ![Widget](/android/imgs/widget.png) | ![Customizations 1](/android/imgs/customizations-1.png) |
| ![Customizations 2](/android/imgs/customizations-2.png) | ![audio-popup](/android/imgs/audio-connected-island.png) | |
### Installation
Currently, there's a [bug in the Android Bluetooth stack](https://issuetracker.google.com/issues/371713238) that prevents the app from working (upvote the issue - click the '+1' icon on the top right corner of IssueTracker).
#### Root Requirement
> [!CAUTION]
> Until Google merges the fix **you will only be able to use aln if you are rooted**. There are **no exceptions**, don't ask about it.
> **You must have a rooted device to use LibrePods on Android.** This is due to a [bug in the Android Bluetooth stack](https://issuetracker.google.com/issues/371713238). Please upvote the issue by clicking the '+1' icon on the IssueTracker page.
>
> There are **no exceptions** to the root requirement until Google merges the fix.
In order to use aln you will have to install the module using your favorite root manager in OverlayFS mode (KernelSU, Apatch, or Magisk). If you don't know what this means, no support is provided: you will have to search by yourself on Google or ask in some Android rooting communities on Telegram.
#### Installation Methods
The module to install is available in the releases section under the name `btl2capfix.zip`.
##### Method 1: Xposed Module (Recommended)
This method is less intrusive and should be tried first:
### Android features
1. Install LSPosed, or another Xposed provider on your rooted device
2. Download the LibrePods app from the releases section, and install it.
3. Enable the Xposed module for the bluetooth app in your Xposed manager
4. Follow the instructions in the app to set up the module.
5. Open the app and connect your AirPods
#### Renaming the Airpods
When you rename the Airpods using the app, you'll need to re-pair it with your phone. Currently, user-level apps cannot directly rename a Bluetooth device. After re-pairing, your phone will display the updated name!
##### Method 2: Root Module (Backup Option)
If the Xposed method doesn't work for you:
#### Noise Control Modes
1. Download the `btl2capfix.zip` module from the releases section
2. Install it using your preferred root manager (KernelSU, Apatch, or Magisk).
3. Reboot your device
4. Connect your AirPods
- Active Noise Cancellation (ANC): Blocks external sounds using microphones and advanced algorithms for an immersive audio experience; ideal for noisy environments.
- Transparency Mode: Allows external sounds to blend with audio for situational awareness; best for environments where you need to stay alert.
- Off Mode: Disables noise control for a natural listening experience, conserving battery in quiet settings.
- Adaptive Transparency: Dynamically reduces sudden loud noises while maintaining environmental awareness, adjusting seamlessly to fluctuating noise levels.
##### Method 3: Patching it yourself
If you prefer to patch the Bluetooth stack yourself, follow these steps:
1. Look for the library in use by running `lsof | grep libbluetooth`
2. Find the library path (e.g., `/system/lib64/libbluetooth_jni.so`)
3. Find the `l2c_fcr_chk_chan_modes` function in the library
4. Patch the function to always return `1` (true)
5. Repack the library and push it back to the device. You can do this by creating a root module yourself.
6. Reboot your device
If you're unfamiliar with these steps, search for tutorials online or ask in Android rooting communities.
#### A few notes
- 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.
- When renaming your AirPods through the app, you'll need to re-pair them with your phone for the name change to take effect. This is a limitation of how Bluetooth device naming works on Android.
## Development Resources
For developers interested in the protocol details, check out the [AAP Definitions](/AAP%20Definitions.md) documentation.
## CrossDevice Stuff
> [!IMPORTANT]
> 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!
> This feature is still in early development and might not work as expected. No support is provided for this feature yet.
#### Conversational Awareness
### Features in Development
Automatically lowers audio volume and enhances voices when you start speaking, making it easier to engage in conversations without removing your AirPods.
- **Battery Status Sync**: Get battery status on any device when you connect your AirPods to one of them
- **Cross-device Controls**: Control your AirPods from either device when connected to one
- **Automatic Device Switching**: Seamlessly switch between Linux and Android devices based on active audio sources
#### Automatic Ear Detection
Check out the demo below:
Recognizes when the AirPods are in your ears to automatically play or pause audio and adjust functionality accordingly.
https://github.com/user-attachments/assets/d08f8a51-cd52-458b-8e55-9b44f4d5f3ab
## Check out the packet definitions at [AAP Definitions](/AAP%20Definitions.md)
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=kavishdevar/librepods&type=Date)](https://star-history.com/#kavishdevar/librepods&Date)
# License
AirPodsLikeNormal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
Copyright (C) 2024 Kavish Devar
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
@@ -92,3 +150,5 @@ 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 over [here](/LICENSE). 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.

View File

@@ -6,17 +6,15 @@ plugins {
}
android {
namespace = "me.kavishdevar.aln"
namespace = "me.kavishdevar.librepods"
compileSdk = 35
defaultConfig {
applicationId = "me.kavishdevar.aln"
applicationId = "me.kavishdevar.librepods"
minSdk = 28
targetSdk = 35
versionCode = 3
versionName = "0.0.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
versionCode = 7
versionName = "0.1.0-rc.4"
}
buildTypes {
@@ -39,6 +37,12 @@ android {
compose = true
viewBinding = true
}
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}
}
dependencies {
@@ -57,11 +61,6 @@ dependencies {
implementation(libs.androidx.constraintlayout)
implementation(libs.haze)
implementation(libs.haze.materials)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}
implementation(libs.androidx.dynamicanimation)
compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
}

Binary file not shown.

View File

@@ -1,37 +0,0 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "me.kavishdevar.aln",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 1,
"versionName": "1.0",
"outputFile": "app-release.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/app-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/app-release.dm"
]
}
],
"minSdkVersionForDexing": 28
}

View File

@@ -1,24 +0,0 @@
package me.kavishdevar.aln
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("me.kavishdevar.aln", appContext.packageName)
}
}

View File

@@ -2,21 +2,22 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature
android:name="android.hardware.telephony"
android:required="false" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<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.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission
android:name="android.permission.BLUETOOTH_PRIVILEGED"
tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="android.permission.BATTERY_STATS"
tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="android.permission.UPDATE_DEVICE_STATS"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
@@ -24,6 +25,16 @@
tools:ignore="UnusedAttribute" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
<protected-broadcast android:name="batterywidget.impl.action.update_bluetooth_data" />
<application
android:allowBackup="true"
@@ -34,7 +45,8 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.ALN"
android:theme="@style/Theme.LibrePods"
android:description="@string/app_description"
tools:ignore="UnusedAttribute"
tools:targetApi="31">
<receiver
@@ -64,7 +76,7 @@
android:name=".CustomDevice"
android:exported="true"
android:label="@string/title_activity_custom_device"
android:theme="@style/Theme.ALN">
android:theme="@style/Theme.LibrePods">
<intent-filter>
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
@@ -72,14 +84,30 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.ALN">
android:theme="@style/Theme.LibrePods">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="librepods"
android:host="add-magic-keys" />
</intent-filter>
</activity>
<activity
android:name=".QuickSettingsDialogActivity"
android:exported="false"
android:theme="@style/Theme.TransparentDialog"
android:launchMode="singleTask"
android:excludeFromRecents="true"
android:taskAffinity=""
/>
<service
android:name=".services.AirPodsService"
android:enabled="true"
@@ -107,6 +135,16 @@
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@@ -0,0 +1,12 @@
cmake_minimum_required(VERSION 3.22.1)
project("l2c_fcr_hook")
set(CMAKE_CXX_STANDARD 23)
add_library(${CMAKE_PROJECT_NAME} SHARED
l2c_fcr_hook.cpp
l2c_fcr_hook.h)
target_link_libraries(${CMAKE_PROJECT_NAME}
android
log)

View File

@@ -0,0 +1,423 @@
/*
* 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"
#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;
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;
}
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();
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");
}
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

@@ -0,0 +1,28 @@
#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);

View File

@@ -1,328 +0,0 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
*
* Copyright (C) 2024 Kavish Devar
*
* 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.aln
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Context.MODE_PRIVATE
import android.content.Context.RECEIVER_EXPORTED
import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection
import android.content.SharedPreferences
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
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.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
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.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import me.kavishdevar.aln.screens.AirPodsSettingsScreen
import me.kavishdevar.aln.screens.AppSettingsScreen
import me.kavishdevar.aln.screens.DebugScreen
import me.kavishdevar.aln.screens.LongPress
import me.kavishdevar.aln.screens.RenameScreen
import me.kavishdevar.aln.services.AirPodsService
import me.kavishdevar.aln.ui.theme.ALNTheme
import me.kavishdevar.aln.utils.AirPodsNotifications
import me.kavishdevar.aln.utils.CrossDevice
lateinit var serviceConnection: ServiceConnection
lateinit var connectionStatusReceiver: BroadcastReceiver
@ExperimentalMaterial3Api
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ALNTheme {
getSharedPreferences("settings", MODE_PRIVATE).edit().putLong("textColor",
MaterialTheme.colorScheme.onSurface.toArgb().toLong()).apply()
Main()
startService(Intent(this, AirPodsService::class.java))
}
}
}
override fun onDestroy() {
try {
unbindService(serviceConnection)
Log.d("MainActivity", "Unbound service")
} catch (e: Exception) {
Log.e("MainActivity", "Error while unbinding service: $e")
}
try {
unregisterReceiver(connectionStatusReceiver)
Log.d("MainActivity", "Unregistered receiver")
} catch (e: Exception) {
Log.e("MainActivity", "Error while unregistering receiver: $e")
}
sendBroadcast(Intent(AirPodsNotifications.DISCONNECT_RECEIVERS))
super.onDestroy()
}
override fun onStop() {
try {
unbindService(serviceConnection)
Log.d("MainActivity", "Unbound service")
} catch (e: Exception) {
Log.e("MainActivity", "Error while unbinding service: $e")
}
try {
unregisterReceiver(connectionStatusReceiver)
Log.d("MainActivity", "Unregistered receiver")
} catch (e: Exception) {
Log.e("MainActivity", "Error while unregistering receiver: $e")
}
super.onStop()
}
}
@SuppressLint("MissingPermission", "InlinedApi", "UnspecifiedRegisterReceiverFlag")
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun Main() {
val isConnected = remember { mutableStateOf(false) }
val isRemotelyConnected = remember { mutableStateOf(false) }
val permissionState = rememberMultiplePermissionsState(
permissions = listOf(
"android.permission.BLUETOOTH_CONNECT",
"android.permission.BLUETOOTH_SCAN",
"android.permission.POST_NOTIFICATIONS",
"android.permission.READ_PHONE_STATE"
)
)
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
if (permissionState.allPermissionsGranted) {
val context = LocalContext.current
val navController = rememberNavController()
connectionStatusReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == AirPodsNotifications.AIRPODS_CONNECTED) {
Log.d("MainActivity", "AirPods Connected intent received")
isConnected.value = true
}
else if (intent.action == AirPodsNotifications.AIRPODS_DISCONNECTED) {
Log.d("MainActivity", "AirPods Disconnected intent received")
isRemotelyConnected.value = CrossDevice.isAvailable
isConnected.value = false
}
else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
Log.d("MainActivity", "Disconnect Receivers intent received")
try {
context.unregisterReceiver(this)
}
catch (e: Exception) {
Log.e("MainActivity", "Error while unregistering receiver: $e")
}
}
}
}
val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
val isAvailableChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == "CrossDeviceIsAvailable") {
Log.d("MainActivity", "CrossDeviceIsAvailable changed")
isRemotelyConnected.value = sharedPreferences.getBoolean("CrossDeviceIsAvailable", false)
}
}
sharedPreferences.registerOnSharedPreferenceChangeListener(isAvailableChangeListener)
Log.d("MainActivity", "CrossDeviceIsAvailable: ${sharedPreferences.getBoolean("CrossDeviceIsAvailable", false)} | isAvailable: ${CrossDevice.isAvailable}")
isRemotelyConnected.value = sharedPreferences.getBoolean("CrossDeviceIsAvailable", false) || CrossDevice.isAvailable
Log.d("MainActivity", "isRemotelyConnected: ${isRemotelyConnected.value}")
val filter = IntentFilter().apply {
addAction(AirPodsNotifications.AIRPODS_CONNECTED)
addAction(AirPodsNotifications.AIRPODS_DISCONNECTED)
addAction(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
}
Log.d("MainActivity", "Registering Receiver")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
context.registerReceiver(connectionStatusReceiver, filter, RECEIVER_EXPORTED)
} else {
context.registerReceiver(connectionStatusReceiver, filter)
}
Log.d("MainActivity", "Registered Receiver")
Box (
modifier = Modifier
.padding(0.dp)
.fillMaxSize()
.background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7))
) {
NavHost(
navController = navController,
startDestination = "settings",
enterTransition = {
slideInHorizontally(
initialOffsetX = { it },
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessLow
)
) + scaleIn(
initialScale = 0.85f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessLow
)
)
},
exitTransition = {
slideOutHorizontally(
targetOffsetX = { -it },
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessLow
)
) + scaleOut(
targetScale = 0.85f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessLow
)
)
},
popEnterTransition = {
slideInHorizontally(
initialOffsetX = { -it },
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessLow
)
) + scaleIn(
initialScale = 0.85f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessLow
)
)
},
popExitTransition = {
slideOutHorizontally(
targetOffsetX = { it },
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessLow
)
) + scaleOut(
targetScale = 0.85f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessLow
)
)
}
) {
composable("settings") {
if (airPodsService.value != null) {
AirPodsSettingsScreen(
dev = airPodsService.value?.device,
service = airPodsService.value!!,
navController = navController,
isConnected = isConnected.value,
isRemotelyConnected = isRemotelyConnected.value
)
}
}
composable("debug") {
DebugScreen(navController = navController)
}
composable("long_press/{bud}") { navBackStackEntry ->
LongPress(
navController = navController,
name = navBackStackEntry.arguments?.getString("bud")!!
)
}
composable("rename") { navBackStackEntry ->
RenameScreen(navController)
}
composable("app_settings") {
AppSettingsScreen(navController)
}
}
}
serviceConnection = remember {
object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val binder = service as AirPodsService.LocalBinder
airPodsService.value = binder.getService()
}
override fun onServiceDisconnected(name: ComponentName?) {
airPodsService.value = null
}
}
}
context.bindService(Intent(context, AirPodsService::class.java), serviceConnection, Context.BIND_AUTO_CREATE)
if (airPodsService.value?.isConnectedLocally == true) {
isConnected.value = true
}
} else {
Column (
modifier = Modifier.padding(24.dp),
){
val textToShow = if (permissionState.shouldShowRationale) {
// If the user has denied the permission but not permanently, explain why it's needed.
"Please enable Bluetooth and Notification permissions to use the app. The Nearby Devices is required to connect to your AirPods, and the notification is required to show the AirPods battery status."
} else {
// If the user has permanently denied the permission, inform them to enable it in settings.
"Please enable Bluetooth and Notification permissions in the app settings to use the app."
}
Text(textToShow)
Button(onClick = { permissionState.launchMultiplePermissionRequest() }) {
Text("Request permission")
}
}
}
}

View File

@@ -1,126 +0,0 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
*
* Copyright (C) 2024 Kavish Devar
*
* 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.aln.composables
import android.content.SharedPreferences
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.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.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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.aln.services.AirPodsService
@Composable
fun LoudSoundReductionSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
var loudSoundReductionEnabled by remember {
mutableStateOf(
sharedPreferences.getBoolean("loud_sound_reduction", true)
)
}
fun updateLoudSoundReduction(enabled: Boolean) {
loudSoundReductionEnabled = enabled
sharedPreferences.edit().putBoolean("loud_sound_reduction", enabled).apply()
service.setLoudSoundReduction(enabled)
}
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val isPressed = remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.background(
shape = RoundedCornerShape(14.dp),
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
)
.padding(horizontal = 12.dp, vertical = 12.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed.value = true
tryAwaitRelease()
isPressed.value = false
}
)
}
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
updateLoudSoundReduction(!loudSoundReductionEnabled)
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = "Loud Sound Reduction",
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Reduces loud sounds you are exposed to.",
fontSize = 12.sp,
color = textColor.copy(0.6f),
lineHeight = 14.sp,
)
}
StyledSwitch(
checked = loudSoundReductionEnabled,
onCheckedChange = {
updateLoudSoundReduction(it)
},
)
}
}
@Preview
@Composable
fun LoudSoundReductionSwitchPreview() {
LoudSoundReductionSwitch(AirPodsService(), LocalContext.current.getSharedPreferences("preview", 0))
}

View File

@@ -1,126 +0,0 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
*
* Copyright (C) 2024 Kavish Devar
*
* 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.aln.composables
import android.content.SharedPreferences
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.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.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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.aln.services.AirPodsService
@Composable
fun PersonalizedVolumeSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
var personalizedVolumeEnabled by remember {
mutableStateOf(
sharedPreferences.getBoolean("personalized_volume", true)
)
}
fun updatePersonalizedVolume(enabled: Boolean) {
personalizedVolumeEnabled = enabled
sharedPreferences.edit().putBoolean("personalized_volume", enabled).apply()
service.setPVEnabled(enabled)
}
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val isPressed = remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.background(
shape = RoundedCornerShape(14.dp),
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
)
.padding(horizontal = 12.dp, vertical = 12.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed.value = true
tryAwaitRelease()
isPressed.value = false
}
)
}
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
updatePersonalizedVolume(!personalizedVolumeEnabled)
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = "Personalized Volume",
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Adjusts the volume of media in response to your environment.",
fontSize = 12.sp,
color = textColor.copy(0.6f),
lineHeight = 14.sp,
)
}
StyledSwitch(
checked = personalizedVolumeEnabled,
onCheckedChange = {
updatePersonalizedVolume(it)
},
)
}
}
@Preview
@Composable
fun PersonalizedVolumeSwitchPreview() {
PersonalizedVolumeSwitch(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0))
}

View File

@@ -1,270 +0,0 @@
package me.kavishdevar.aln.composables
import android.content.SharedPreferences
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.shadow
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.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.aln.R
import me.kavishdevar.aln.services.AirPodsService
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TransparencySettings(service: AirPodsService, sharedPreferences: SharedPreferences) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
var transparencyModeCustomizationEnabled by remember { mutableStateOf(sharedPreferences.getBoolean("transparency_mode_customization", false)) }
var amplification by remember { mutableIntStateOf(sharedPreferences.getInt("transparency_amplification", 0)) }
var balance by remember { mutableIntStateOf(sharedPreferences.getInt("transparency_balance", 0)) }
var tone by remember { mutableIntStateOf(sharedPreferences.getInt("transparency_tone", 0)) }
var ambientNoise by remember { mutableIntStateOf(sharedPreferences.getInt("transparency_ambient_noise", 0)) }
var conversationBoostEnabled by remember { mutableStateOf(sharedPreferences.getBoolean("transparency_conversation_boost", false)) }
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 12.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
transparencyModeCustomizationEnabled = !transparencyModeCustomizationEnabled
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = "Transparency Mode",
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "You can customize Transparency mode for your AirPods Pro.",
fontSize = 12.sp,
color = textColor.copy(0.6f),
lineHeight = 14.sp,
)
}
StyledSwitch(
checked = transparencyModeCustomizationEnabled,
onCheckedChange = {
transparencyModeCustomizationEnabled = it
},
)
}
if (transparencyModeCustomizationEnabled) {
Spacer(modifier = Modifier.height(8.dp))
SliderRow(
label = "Amplification",
value = amplification,
onValueChange = {
amplification = it
sharedPreferences.edit().putInt("transparency_amplification", it).apply()
},
isDarkTheme = isDarkTheme
)
Spacer(modifier = Modifier.height(8.dp))
SliderRow(
label = "Balance",
value = balance,
onValueChange = {
balance = it
sharedPreferences.edit().putInt("transparency_balance", it).apply()
},
isDarkTheme = isDarkTheme
)
Spacer(modifier = Modifier.height(8.dp))
SliderRow(
label = "Tone",
value = tone,
onValueChange = {
tone = it
sharedPreferences.edit().putInt("transparency_tone", it).apply()
},
isDarkTheme = isDarkTheme
)
Spacer(modifier = Modifier.height(8.dp))
SliderRow(
label = "Ambient Noise",
value = ambientNoise,
onValueChange = {
ambientNoise = it
sharedPreferences.edit().putInt("transparency_ambient_noise", it).apply()
},
isDarkTheme = isDarkTheme
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
conversationBoostEnabled = !conversationBoostEnabled
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = "Conversation Boost",
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Conversation Boost focuses your AirPods on the person in front of you, making it easier to hear in a face-to-face conversation.",
fontSize = 12.sp,
color = textColor.copy(0.6f),
lineHeight = 14.sp,
)
}
StyledSwitch(
checked = conversationBoostEnabled,
onCheckedChange = {
conversationBoostEnabled = it
},
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SliderRow(
label: String,
value: Int,
onValueChange: (Int) -> Unit,
isDarkTheme: Boolean
) {
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
val thumbColor = Color(0xFFFFFFFF)
val labelTextColor = if (isDarkTheme) Color.White else Color.Black
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
style = TextStyle(
fontSize = 16.sp,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = "\uDBC0\uDEA1",
style = TextStyle(
fontSize = 16.sp,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(start = 4.dp)
)
Slider(
value = value.toFloat(),
onValueChange = {
onValueChange(it.toInt())
},
valueRange = 0f..100f,
onValueChangeFinished = {
onValueChange(value)
},
modifier = Modifier
.weight(1f)
.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(value.toFloat() / 100)
.height(4.dp)
.background(activeTrackColor, RoundedCornerShape(4.dp))
)
}
}
)
Text(
text = "\uDBC0\uDEA9",
style = TextStyle(
fontSize = 16.sp,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(end = 4.dp)
)
}
}

View File

@@ -1,314 +0,0 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
*
* Copyright (C) 2024 Kavish Devar
*
* 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.aln.screens
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.content.Context.MODE_PRIVATE
import android.content.Intent
import android.content.SharedPreferences
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.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
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.drawBehind
import androidx.compose.ui.geometry.Offset
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.TextFieldValue
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 androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.haze
import dev.chrisbanes.haze.hazeChild
import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.aln.R
import me.kavishdevar.aln.composables.AccessibilitySettings
import me.kavishdevar.aln.composables.AudioSettings
import me.kavishdevar.aln.composables.BatteryView
import me.kavishdevar.aln.composables.IndependentToggle
import me.kavishdevar.aln.composables.NameField
import me.kavishdevar.aln.composables.NavigationButton
import me.kavishdevar.aln.composables.NoiseControlSettings
import me.kavishdevar.aln.composables.PressAndHoldSettings
import me.kavishdevar.aln.services.AirPodsService
import me.kavishdevar.aln.ui.theme.ALNTheme
import me.kavishdevar.aln.utils.AirPodsNotifications
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@SuppressLint("MissingPermission")
@Composable
fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
navController: NavController, isConnected: Boolean, isRemotelyConnected: Boolean) {
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
var device by remember { mutableStateOf(dev) }
var deviceName by remember {
mutableStateOf(
TextFieldValue(
sharedPreferences.getString("name", device?.name ?: "AirPods Pro").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 verticalScrollState = rememberScrollState()
val hazeState = remember { HazeState() }
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
Scaffold(
containerColor = if (isSystemInDarkTheme()) Color(
0xFF000000
) else Color(
0xFFF2F2F7
),
topBar = {
val darkMode = isSystemInDarkTheme()
val mDensity = remember { mutableFloatStateOf(1f) }
CenterAlignedTopAppBar(
title = {
Text(
text = deviceName.text,
style = TextStyle(
fontSize = 20.sp,
fontWeight = FontWeight.Medium,
color = if (darkMode) Color.White else Color.Black,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
},
modifier = Modifier
.hazeChild(
state = hazeState,
style = CupertinoMaterials.thick(),
block = {
alpha =
if (verticalScrollState.value > 55.dp.value * mDensity.floatValue) 1f else 0f
}
)
.drawBehind {
mDensity.floatValue = density
val strokeWidth = 0.7.dp.value * density
val y = size.height - strokeWidth / 2
if (verticalScrollState.value > 55.dp.value * density) {
drawLine(
if (darkMode) Color.DarkGray else Color.LightGray,
Offset(0f, y),
Offset(size.width, y),
strokeWidth
)
}
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent
),
actions = {
IconButton(
onClick = {
navController.navigate("app_settings")
},
colors = IconButtonDefaults.iconButtonColors(
containerColor = Color.Transparent,
contentColor = if (isSystemInDarkTheme()) Color.White else Color.Black
)
) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = "Settings",
)
}
}
)
}
) { paddingValues ->
if (isConnected == true || isRemotelyConnected == true) {
Column(
modifier = Modifier
.haze(hazeState)
.fillMaxSize()
.padding(horizontal = 16.dp)
.verticalScroll(
state = verticalScrollState,
enabled = true,
)
) {
Spacer(Modifier.height(75.dp))
LaunchedEffect(service) {
service.let {
it.sendBroadcast(Intent(AirPodsNotifications.Companion.BATTERY_DATA).apply {
putParcelableArrayListExtra("data", ArrayList(it.getBattery()))
})
it.sendBroadcast(Intent(AirPodsNotifications.Companion.ANC_DATA).apply {
putExtra("data", it.getANC())
})
}
}
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
Spacer(modifier = Modifier.height(64.dp))
BatteryView(service = service)
Spacer(modifier = Modifier.height(32.dp))
NameField(
name = stringResource(R.string.name),
value = deviceName.text,
navController = navController
)
Spacer(modifier = Modifier.height(32.dp))
NoiseControlSettings(service = service)
Spacer(modifier = Modifier.height(16.dp))
PressAndHoldSettings(navController = navController)
Spacer(modifier = Modifier.height(16.dp))
AudioSettings(service = service, sharedPreferences = sharedPreferences)
Spacer(modifier = Modifier.height(16.dp))
IndependentToggle(
name = "Automatic Ear Detection",
service = service,
functionName = "setEarDetection",
sharedPreferences = sharedPreferences,
true
)
Spacer(modifier = Modifier.height(16.dp))
IndependentToggle(
name = "Off Listening Mode",
service = service,
functionName = "setOffListeningMode",
sharedPreferences = sharedPreferences,
false
)
Spacer(modifier = Modifier.height(16.dp))
AccessibilitySettings(service = service, sharedPreferences = sharedPreferences)
Spacer(modifier = Modifier.height(16.dp))
NavigationButton("debug", "Debug", navController)
Spacer(Modifier.height(24.dp))
}
}
else {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 8.dp)
.verticalScroll(
state = verticalScrollState,
enabled = true,
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "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 = "Please connect your AirPods to access settings.",
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()
)
}
}
}
}
@Preview
@Composable
fun AirPodsSettingsScreenPreview() {
Column (
modifier = Modifier.height(2000.dp)
) {
ALNTheme (
darkTheme = true
) {
AirPodsSettingsScreen(dev = null, service = AirPodsService(), navController = rememberNavController(), isConnected = true, isRemotelyConnected = false)
}
}
}

View File

@@ -1,363 +0,0 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
*
* Copyright (C) 2024 Kavish Devar
*
* 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.aln.screens
import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
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.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
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.scale
import androidx.compose.ui.draw.shadow
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import me.kavishdevar.aln.R
import me.kavishdevar.aln.composables.IndependentToggle
import me.kavishdevar.aln.composables.StyledSwitch
import me.kavishdevar.aln.services.ServiceManager
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppSettingsScreen(navController: NavController) {
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
val name = remember { mutableStateOf(sharedPreferences.getString("name", "") ?: "") }
val isDarkTheme = isSystemInDarkTheme()
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = {
Text(
text = stringResource(R.string.app_settings),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
},
navigationIcon = {
TextButton(
onClick = {
navController.popBackStack()
},
shape = RoundedCornerShape(8.dp),
) {
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowLeft,
contentDescription = "Back",
tint = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
modifier = Modifier.scale(1.5f)
)
Text(
text = name.value,
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
color = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
)
)
},
containerColor = if (isSystemInDarkTheme()) Color(0xFF000000)
else Color(0xFFF2F2F7),
) { paddingValues ->
Column (
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 12.dp)
) {
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
IndependentToggle("Show phone battery in widget", ServiceManager.getService()!!, "setPhoneBatteryInWidget", sharedPreferences)
Column (
modifier = Modifier
.fillMaxWidth()
.height(275.sp.value.dp)
.background(
backgroundColor,
RoundedCornerShape(14.dp)
)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
val sliderValue = remember { mutableFloatStateOf(0f) }
LaunchedEffect(sliderValue) {
if (sharedPreferences.contains("conversational_awareness_volume")) {
sliderValue.floatValue = sharedPreferences.getInt("conversational_awareness_volume", 0).toFloat()
}
}
LaunchedEffect(sliderValue.floatValue) {
sharedPreferences.edit().putInt("conversational_awareness_volume", sliderValue.floatValue.toInt()).apply()
}
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFFD9D9D9)
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
val labelTextColor = if (isDarkTheme) Color.White else Color.Black
Text(
text = stringResource(R.string.conversational_awareness_customization),
style = TextStyle(
fontSize = 20.sp,
color = textColor
),
modifier = Modifier
.padding(top = 12.dp, bottom = 4.dp)
)
var conversationalAwarenessPauseMusicEnabled by remember {
mutableStateOf(
sharedPreferences.getBoolean("conversational_awareness_pause_music", true)
)
}
fun updateConversationalAwarenessPauseMusic(enabled: Boolean) {
conversationalAwarenessPauseMusicEnabled = enabled
sharedPreferences.edit().putBoolean("conversational_awareness_pause_music", enabled).apply()
}
var relativeConversationalAwarenessVolumeEnabled by remember {
mutableStateOf(
sharedPreferences.getBoolean("relative_conversational_awareness_volume", true)
)
}
fun updateRelativeConversationalAwarenessVolume(enabled: Boolean) {
relativeConversationalAwarenessVolumeEnabled = enabled
sharedPreferences.edit().putBoolean("relative_conversational_awareness_volume", enabled).apply()
}
Row(
modifier = Modifier
.fillMaxWidth()
.height(85.sp.value.dp)
.background(
shape = RoundedCornerShape(14.dp),
color = Color.Transparent
)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
updateConversationalAwarenessPauseMusic(!conversationalAwarenessPauseMusicEnabled)
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = stringResource(R.string.conversational_awareness_pause_music),
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.conversational_awareness_pause_music_description),
fontSize = 14.sp,
color = textColor.copy(0.6f),
lineHeight = 16.sp,
)
}
StyledSwitch(
checked = conversationalAwarenessPauseMusicEnabled,
onCheckedChange = {
updateConversationalAwarenessPauseMusic(it)
},
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.height(85.sp.value.dp)
.background(
shape = RoundedCornerShape(14.dp),
color = Color.Transparent
)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
updateRelativeConversationalAwarenessVolume(!relativeConversationalAwarenessVolumeEnabled)
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = stringResource(R.string.relative_conversational_awareness_volume),
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.relative_conversational_awareness_volume_description),
fontSize = 14.sp,
color = textColor.copy(0.6f),
lineHeight = 16.sp,
)
}
StyledSwitch(
checked = relativeConversationalAwarenessVolumeEnabled,
onCheckedChange = {
updateRelativeConversationalAwarenessVolume(it)
}
)
}
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
Slider(
value = sliderValue.floatValue,
onValueChange = {
sliderValue.floatValue = it
},
valueRange = 10f..85f,
onValueChangeFinished = {
sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat()
},
modifier = Modifier
.weight(1f)
.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(((sliderValue.floatValue - 10) * 100) /7500)
.height(4.dp)
.background(if (conversationalAwarenessPauseMusicEnabled) trackColor else activeTrackColor, RoundedCornerShape(4.dp))
)
}
}
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "10%",
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = labelTextColor
),
modifier = Modifier.padding(start = 4.dp)
)
Text(
text = "85%",
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = labelTextColor
),
modifier = Modifier.padding(end = 4.dp)
)
}
}
}
}
}
@Preview
@Composable
fun AppSettingsScreenPreview() {
AppSettingsScreen(navController = NavController(LocalContext.current))
}

View File

@@ -1,264 +0,0 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
*
* Copyright (C) 2024 Kavish Devar
*
* 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(ExperimentalHazeMaterialsApi::class)
package me.kavishdevar.aln.screens
import android.annotation.SuppressLint
import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
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.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
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.scale
import androidx.compose.ui.graphics.Color
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.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.haze
import dev.chrisbanes.haze.hazeChild
import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.flow.MutableStateFlow
import me.kavishdevar.aln.R
import me.kavishdevar.aln.services.ServiceManager
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter", "UnspecifiedRegisterReceiverFlag")
@Composable
fun DebugScreen(navController: NavController) {
val hazeState = remember { HazeState() }
val context = LocalContext.current
val listState = rememberLazyListState()
val scrollOffset by remember { derivedStateOf { listState.firstVisibleItemScrollOffset } }
val packetLogsFlow = remember { MutableStateFlow(emptySet<String>()) }
val expandedItems = remember { mutableStateOf(setOf<Int>()) }
LaunchedEffect(Unit) {
ServiceManager.getService()?.packetLogsFlow?.collect { packetLogsFlow.value = it }
}
val packetLogs = packetLogsFlow.collectAsState(setOf()).value
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("Debug") },
navigationIcon = {
TextButton(
onClick = {
navController.popBackStack()
},
shape = RoundedCornerShape(8.dp),
) {
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowLeft,
contentDescription = "Back",
tint = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5),
modifier = Modifier.scale(1.5f)
)
Text(
sharedPreferences.getString("name", "AirPods")!!,
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
color = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
)
}
},
modifier = Modifier
.hazeChild(
state = hazeState,
style = CupertinoMaterials.thick(),
block = {
alpha = if (scrollOffset > 0) {
1f
} else {
0f
}
}
),
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
),
)
},
containerColor = if (isSystemInDarkTheme()) Color(0xFF000000)
else Color(0xFFF2F2F7),
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.imePadding()
.haze(hazeState)
.padding(top = paddingValues.calculateTopPadding())
) {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
content = {
items(packetLogs.size) { index ->
val message = packetLogs.elementAt(index)
val isSent = message.startsWith("Sent")
val isExpanded = expandedItems.value.contains(index)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp, horizontal = 4.dp)
.clickable {
expandedItems.value = if (isExpanded) {
expandedItems.value - index
} else {
expandedItems.value + index
}
},
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
shape = RoundedCornerShape(4.dp),
colors = CardDefaults.cardColors(
containerColor = Color.Transparent
)
) {
Column(modifier = Modifier.padding(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = if (isSent) Icons.AutoMirrored.Filled.KeyboardArrowLeft else Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = if (isSent) Color.Green else Color.Red,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Column {
Text(
text =
if (isSent) message.substring(5).take(60) + (if (message.substring(5).length > 60) "..." else "")
else message.substring(9).take(60) + (if (message.substring(9).length > 60) "..." else ""),
style = MaterialTheme.typography.bodySmall,
)
if (isExpanded) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = message.substring(if (isSent) 5 else 9),
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
)
}
}
}
}
}
}
}
)
Spacer(modifier = Modifier.height(8.dp))
val airPodsService = ServiceManager.getService()?.let { mutableStateOf(it) }
HorizontalDivider()
Row(
modifier = Modifier
.fillMaxWidth()
.background(if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7)),
verticalAlignment = Alignment.CenterVertically
) {
val packet = remember { mutableStateOf(TextFieldValue("")) }
TextField(
value = packet.value,
onValueChange = { packet.value = it },
label = { Text("Packet") },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
.padding(bottom = 5.dp),
trailingIcon = {
IconButton(
onClick = {
airPodsService?.value?.sendPacket(packet.value.text)
packet.value = TextFieldValue("")
}
) {
@Suppress("DEPRECATION")
Icon(Icons.Filled.Send, contentDescription = "Send")
}
},
colors = TextFieldDefaults.colors(
focusedContainerColor = if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7),
unfocusedContainerColor = if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7),
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedTextColor = if (isSystemInDarkTheme()) Color.White else Color.Black,
unfocusedTextColor = if (isSystemInDarkTheme()) Color.White else Color.Black.copy(alpha = 0.6f),
focusedLabelColor = if (isSystemInDarkTheme()) Color.White.copy(alpha = 0.6f) else Color.Black,
unfocusedLabelColor = if (isSystemInDarkTheme()) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f),
),
shape = RoundedCornerShape(12.dp)
)
}
}
}
}

View File

@@ -1,297 +0,0 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
*
* Copyright (C) 2024 Kavish Devar
*
* 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.aln.screens
import android.content.Context
import android.util.Log
import androidx.compose.animation.animateColorAsState
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.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
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.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.imageResource
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.aln.R
import me.kavishdevar.aln.services.ServiceManager
@Composable()
fun RightDivider() {
HorizontalDivider(
thickness = 1.5.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(start = 72.dp)
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LongPress(navController: NavController, name: String) {
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
val offChecked = remember { mutableStateOf(sharedPreferences.getBoolean("long_press_off", false)) }
val ncChecked = remember { mutableStateOf(sharedPreferences.getBoolean("long_press_nc", false)) }
val transparencyChecked = remember { mutableStateOf(sharedPreferences.getBoolean("long_press_transparency", false)) }
val adaptiveChecked = remember { mutableStateOf(sharedPreferences.getBoolean("long_press_adaptive", false)) }
Log.d("LongPress", "offChecked: ${offChecked.value}, ncChecked: ${ncChecked.value}, transparencyChecked: ${transparencyChecked.value}, adaptiveChecked: ${adaptiveChecked.value}")
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = {
Text(
name,
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
},
navigationIcon = {
TextButton(
onClick = {
navController.popBackStack()
},
shape = RoundedCornerShape(8.dp),
) {
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowLeft,
contentDescription = "Back",
tint = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
modifier = Modifier.scale(1.5f)
)
Text(
sharedPreferences.getString("name", "AirPods")!!,
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
color = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
)
)
},
containerColor = if (isSystemInDarkTheme()) Color(0xFF000000)
else Color(0xFFF2F2F7),
) { paddingValues ->
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Column (
modifier = Modifier
.fillMaxSize()
.padding(paddingValues = paddingValues)
.padding(horizontal = 16.dp)
.padding(top = 8.dp)
) {
Text(
text = "NOISE CONTROL",
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
),
fontFamily = FontFamily(Font(R.font.sf_pro)),
modifier = Modifier
.padding(8.dp, bottom = 4.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp)),
horizontalAlignment = Alignment.CenterHorizontally
) {
val offListeningMode = sharedPreferences.getBoolean("off_listening_mode", false)
LongPressElement("Off", offChecked, "long_press_off", offListeningMode, R.drawable.noise_cancellation, isFirst = true)
if (offListeningMode) RightDivider()
LongPressElement("Transparency", transparencyChecked, "long_press_transparency", resourceId = R.drawable.transparency, isFirst = !offListeningMode)
RightDivider()
LongPressElement("Adaptive", adaptiveChecked, "long_press_adaptive", resourceId = R.drawable.adaptive)
RightDivider()
LongPressElement("Noise Cancellation", ncChecked, "long_press_nc", resourceId = R.drawable.noise_cancellation, isLast = true)
}
Text(
"Press and hold the stem to cycle between the selected noise control modes.",
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor.copy(alpha = 0.6f),
modifier = Modifier
.padding(start = 16.dp, top = 4.dp)
)
}
}
}
@Composable
fun LongPressElement(name: String, checked: MutableState<Boolean>, id: String, enabled: Boolean = true, resourceId: Int, isFirst: Boolean = false, isLast: Boolean = false) {
val sharedPreferences =
LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
val offListeningMode = sharedPreferences.getBoolean("off_listening_mode", false)
val darkMode = isSystemInDarkTheme()
val textColor = if (darkMode) Color.White else Color.Black
val desc = when (name) {
"Off" -> "Turns off noise management"
"Noise Cancellation" -> "Blocks out external sounds"
"Transparency" -> "Lets in external sounds"
"Adaptive" -> "Dynamically adjust external noise"
else -> ""
}
fun valueChanged(value: Boolean = !checked.value) {
val originalLongPressArray = booleanArrayOf(
sharedPreferences.getBoolean("long_press_off", false),
sharedPreferences.getBoolean("long_press_nc", false),
sharedPreferences.getBoolean("long_press_transparency", false),
sharedPreferences.getBoolean("long_press_adaptive", false)
)
if (!value && originalLongPressArray.count { it } <= 2) {
return
}
checked.value = value
with(sharedPreferences.edit()) {
putBoolean(id, checked.value)
apply()
}
val newLongPressArray = booleanArrayOf(
sharedPreferences.getBoolean("long_press_off", false),
sharedPreferences.getBoolean("long_press_nc", false),
sharedPreferences.getBoolean("long_press_transparency", false),
sharedPreferences.getBoolean("long_press_adaptive", false)
)
ServiceManager.getService()
?.updateLongPress(originalLongPressArray, newLongPressArray, offListeningMode)
}
val shape = when {
isFirst -> RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)
isLast -> RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp)
else -> RoundedCornerShape(0.dp)
}
var backgroundColor by remember { mutableStateOf(if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
if (!enabled) {
valueChanged(false)
} else {
Row(
modifier = Modifier
.height(72.dp)
.background(animatedBackgroundColor, shape)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
backgroundColor = if (darkMode) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor = if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
},
onTap = { valueChanged() }
)
}
.padding(horizontal = 16.dp, vertical = 0.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Icon(
bitmap = ImageBitmap.imageResource(resourceId),
contentDescription = "Icon",
tint = Color(0xFF007AFF),
modifier = Modifier
.height(48.dp)
.wrapContentWidth()
)
Column (
modifier = Modifier
.weight(1f)
.padding(vertical = 2.dp)
.padding(start = 8.dp)
)
{
Text(
name,
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
Text (
desc,
fontSize = 14.sp,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
}
Checkbox(
checked = checked.value,
onCheckedChange = { valueChanged() },
colors = CheckboxDefaults.colors().copy(
checkedCheckmarkColor = Color(0xFF007AFF),
uncheckedCheckmarkColor = Color.Transparent,
checkedBoxColor = Color.Transparent,
uncheckedBoxColor = Color.Transparent,
checkedBorderColor = Color.Transparent,
uncheckedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent,
disabledCheckedBoxColor = Color.Transparent,
disabledUncheckedBoxColor = Color.Transparent,
disabledUncheckedBorderColor = Color.Transparent
),
modifier = Modifier
.height(24.dp)
.scale(1.5f),
)
}
}
}

View File

@@ -1,142 +0,0 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
*
* Copyright (C) 2024 Kavish Devar
*
* 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.aln.services
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 android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import android.util.Log
import me.kavishdevar.aln.utils.AirPodsNotifications
import me.kavishdevar.aln.utils.NoiseControlMode
class AirPodsQSService: TileService() {
private val ancModes = listOf(NoiseControlMode.NOISE_CANCELLATION.name, NoiseControlMode.TRANSPARENCY.name, NoiseControlMode.ADAPTIVE.name)
private var currentModeIndex = 2
private lateinit var ancStatusReceiver: BroadcastReceiver
private lateinit var availabilityReceiver: BroadcastReceiver
@SuppressLint("InlinedApi", "UnspecifiedRegisterReceiverFlag")
override fun onStartListening() {
super.onStartListening()
currentModeIndex = (ServiceManager.getService()?.getANC()?.minus(1)) ?: -1
if (currentModeIndex == -1) {
currentModeIndex = 2
}
if (ServiceManager.getService() == null) {
qsTile.state = Tile.STATE_UNAVAILABLE
qsTile.updateTile()
}
if (ServiceManager.getService()?.isConnectedLocally == true) {
qsTile.state = Tile.STATE_ACTIVE
qsTile.updateTile()
}
else {
qsTile.state = Tile.STATE_UNAVAILABLE
qsTile.updateTile()
}
ancStatusReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val ancStatus = intent.getIntExtra("data", 4)
currentModeIndex = if (ancStatus == 2) 0 else if (ancStatus == 3) 1 else if (ancStatus == 4) 2 else 2
updateTile()
}
}
availabilityReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == AirPodsNotifications.Companion.AIRPODS_CONNECTED) {
qsTile.state = Tile.STATE_ACTIVE
qsTile.updateTile()
}
else if (intent.action == AirPodsNotifications.Companion.AIRPODS_DISCONNECTED) {
qsTile.state = Tile.STATE_UNAVAILABLE
qsTile.updateTile()
}
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(
ancStatusReceiver,
IntentFilter(AirPodsNotifications.Companion.ANC_DATA), RECEIVER_EXPORTED
)
} else {
registerReceiver(
ancStatusReceiver,
IntentFilter(AirPodsNotifications.Companion.ANC_DATA)
)
}
qsTile.state = if (ServiceManager.getService()?.isConnectedLocally == true) Tile.STATE_ACTIVE else Tile.STATE_UNAVAILABLE
val ancIndex = ServiceManager.getService()?.getANC()
currentModeIndex = if (ancIndex != null) { if (ancIndex == 2) 0 else if (ancIndex == 3) 1 else if (ancIndex == 4) 2 else 2 } else 0
updateTile()
}
override fun onStopListening() {
super.onStopListening()
try {
unregisterReceiver(ancStatusReceiver)
}
catch (
_: IllegalArgumentException
)
{
Log.e("QuickSettingTileService", "Receiver not registered")
}
try {
unregisterReceiver(availabilityReceiver)
}
catch (
_: IllegalArgumentException
)
{
Log.e("QuickSettingTileService", "Receiver not registered")
}
}
override fun onClick() {
super.onClick()
Log.d("QuickSettingTileService", "ANC tile clicked")
currentModeIndex = (currentModeIndex + 1) % ancModes.size
Log.d("QuickSettingTileService", "New mode index: $currentModeIndex, would be set to ${currentModeIndex + 1}")
switchAncMode()
}
private fun updateTile() {
val currentMode = ancModes[currentModeIndex % ancModes.size]
qsTile.label = currentMode.replace("_", " ").lowercase().replaceFirstChar { it.uppercase() }
qsTile.state = Tile.STATE_ACTIVE
qsTile.updateTile()
}
private fun switchAncMode() {
val airPodsService = ServiceManager.getService()
Log.d("QuickSettingTileService", "Setting ANC mode to ${currentModeIndex + 2}")
airPodsService?.setANCMode(currentModeIndex + 2)
Log.d("QuickSettingTileService", "ANC mode set to ${currentModeIndex + 2}")
updateTile()
}
}

View File

@@ -1,148 +0,0 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
*
* Copyright (C) 2024 Kavish Devar
*
* 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.aln.utils
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ObjectAnimator
import android.animation.PropertyValuesHolder
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Resources
import android.graphics.PixelFormat
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log.e
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
import android.view.animation.AnticipateOvershootInterpolator
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.VideoView
import androidx.core.content.ContextCompat.getString
import me.kavishdevar.aln.R
import me.kavishdevar.aln.services.ServiceManager
class IslandWindow(context: Context) {
private val windowManager: WindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
@SuppressLint("InflateParams")
private val islandView: View = LayoutInflater.from(context).inflate(R.layout.island_window, null)
private var isClosing = false
val isVisible: Boolean
get() = islandView.parent != null && islandView.visibility == View.VISIBLE
@SuppressLint("SetTextI18n")
fun show(name: String, batteryPercentage: Int, context: Context, takingOver: Boolean) {
if (ServiceManager.getService()?.islandOpen == true) return
else ServiceManager.getService()?.islandOpen = true
val displayMetrics = Resources.getSystem().displayMetrics
val width = (displayMetrics.widthPixels * 0.95).toInt()
val params = WindowManager.LayoutParams(
width,
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
PixelFormat.TRANSLUCENT
).apply {
gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL
}
islandView.visibility = View.VISIBLE
islandView.findViewById<TextView>(R.id.island_battery_text).text = "$batteryPercentage%"
islandView.findViewById<TextView>(R.id.island_device_name).text = name
islandView.setOnClickListener {
ServiceManager.getService()?.startMainActivity()
close()
}
if (takingOver) {
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_taking_over_text)
} else if (CrossDevice.isAvailable) {
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_connected_remote_text)
} else {
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_connected_text)
}
val batteryProgressBar = islandView.findViewById<ProgressBar>(R.id.island_battery_progress)
batteryProgressBar.progress = batteryPercentage
batteryProgressBar.isIndeterminate = false
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
val videoUri = Uri.parse("android.resource://me.kavishdevar.aln/${R.raw.island}")
videoView.setVideoURI(videoUri)
videoView.setOnPreparedListener { mediaPlayer ->
mediaPlayer.isLooping = true
videoView.start()
}
windowManager.addView(islandView, params)
val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 0.5f, 1f)
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 0.5f, 1f)
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, -200f, 0f)
ObjectAnimator.ofPropertyValuesHolder(islandView, scaleX, scaleY, translationY).apply {
duration = 700
interpolator = AnticipateOvershootInterpolator()
start()
}
Handler(Looper.getMainLooper()).postDelayed({
close()
}, 4500)
}
fun close() {
try {
if (isClosing) return
isClosing = true
ServiceManager.getService()?.islandOpen = false
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
videoView.stopPlayback()
val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 1f, 0.5f)
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 1f, 0.5f)
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, 0f, -200f)
ObjectAnimator.ofPropertyValuesHolder(islandView, scaleX, scaleY, translationY).apply {
duration = 700
interpolator = AnticipateOvershootInterpolator()
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
islandView.visibility = View.GONE
try {
windowManager.removeView(islandView)
} catch (e: Exception) {
e("IslandWindow", "Error removing view: $e")
}
isClosing = false
}
})
start()
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}

View File

@@ -1,287 +0,0 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
*
* Copyright (C) 2024 Kavish Devar
*
* 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:Suppress("unused")
package me.kavishdevar.aln.utils
import android.os.Parcelable
import android.util.Log
import kotlinx.parcelize.Parcelize
enum class Enums(val value: ByteArray) {
NOISE_CANCELLATION(Capabilities.NOISE_CANCELLATION),
CONVERSATION_AWARENESS(Capabilities.CONVERSATION_AWARENESS),
CUSTOMIZABLE_ADAPTIVE_TRANSPARENCY(Capabilities.CUSTOMIZABLE_ADAPTIVE_TRANSPARENCY),
PREFIX(byteArrayOf(0x04, 0x00, 0x04, 0x00)),
SETTINGS(byteArrayOf(0x09, 0x00)),
SUFFIX(byteArrayOf(0x00, 0x00, 0x00)),
NOTIFICATION_FILTER(byteArrayOf(0x0f)),
HANDSHAKE(byteArrayOf(0x00, 0x00, 0x04, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
SPECIFIC_FEATURES(byteArrayOf(0x4d)),
SET_SPECIFIC_FEATURES(PREFIX.value + SPECIFIC_FEATURES.value + byteArrayOf(0x00,
0xff.toByte(), 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
REQUEST_NOTIFICATIONS(PREFIX.value + NOTIFICATION_FILTER.value + byteArrayOf(0x00, 0xff.toByte(), 0xff.toByte(), 0xff.toByte(), 0xff.toByte())),
NOISE_CANCELLATION_PREFIX(PREFIX.value + SETTINGS.value + NOISE_CANCELLATION.value),
NOISE_CANCELLATION_OFF(NOISE_CANCELLATION_PREFIX.value + Capabilities.NoiseCancellation.OFF.value + SUFFIX.value),
NOISE_CANCELLATION_ON(NOISE_CANCELLATION_PREFIX.value + Capabilities.NoiseCancellation.ON.value + SUFFIX.value),
NOISE_CANCELLATION_TRANSPARENCY(NOISE_CANCELLATION_PREFIX.value + Capabilities.NoiseCancellation.TRANSPARENCY.value + SUFFIX.value),
NOISE_CANCELLATION_ADAPTIVE(NOISE_CANCELLATION_PREFIX.value + Capabilities.NoiseCancellation.ADAPTIVE.value + SUFFIX.value),
SET_CONVERSATION_AWARENESS_OFF(PREFIX.value + SETTINGS.value + CONVERSATION_AWARENESS.value + Capabilities.ConversationAwareness.OFF.value + SUFFIX.value),
SET_CONVERSATION_AWARENESS_ON(PREFIX.value + SETTINGS.value + CONVERSATION_AWARENESS.value + Capabilities.ConversationAwareness.ON.value + SUFFIX.value),
CONVERSATION_AWARENESS_RECEIVE_PREFIX(PREFIX.value + byteArrayOf(0x4b, 0x00, 0x02, 0x00));
}
object BatteryComponent {
const val LEFT = 4
const val RIGHT = 2
const val CASE = 8
}
object BatteryStatus {
const val CHARGING = 1
const val NOT_CHARGING = 2
const val DISCONNECTED = 4
}
@Parcelize
data class Battery(val component: Int, val level: Int, val status: Int) : Parcelable {
fun getComponentName(): String? {
return when (component) {
BatteryComponent.LEFT -> "LEFT"
BatteryComponent.RIGHT -> "RIGHT"
BatteryComponent.CASE -> "CASE"
else -> null
}
}
fun getStatusName(): String? {
return when (status) {
BatteryStatus.CHARGING -> "CHARGING"
BatteryStatus.NOT_CHARGING -> "NOT_CHARGING"
BatteryStatus.DISCONNECTED -> "DISCONNECTED"
else -> null
}
}
}
enum class NoiseControlMode {
OFF, NOISE_CANCELLATION, TRANSPARENCY, ADAPTIVE
}
class AirPodsNotifications {
companion object {
const val AIRPODS_CONNECTED = "me.kavishdevar.aln.AIRPODS_CONNECTED"
const val AIRPODS_DATA = "me.kavishdevar.aln.AIRPODS_DATA"
const val EAR_DETECTION_DATA = "me.kavishdevar.aln.EAR_DETECTION_DATA"
const val ANC_DATA = "me.kavishdevar.aln.ANC_DATA"
const val BATTERY_DATA = "me.kavishdevar.aln.BATTERY_DATA"
const val CA_DATA = "me.kavishdevar.aln.CA_DATA"
const val AIRPODS_DISCONNECTED = "me.kavishdevar.aln.AIRPODS_DISCONNECTED"
const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.aln.AIRPODS_CONNECTION_DETECTED"
const val DISCONNECT_RECEIVERS = "me.kavishdevar.aln.DISCONNECT_RECEIVERS"
}
class EarDetection {
private val notificationBit = Capabilities.EAR_DETECTION
private val notificationPrefix = Enums.PREFIX.value + notificationBit
var status: List<Byte> = listOf(0x01, 0x01)
fun setStatus(data: ByteArray) {
status = listOf(data[6], data[7])
}
fun isEarDetectionData(data: ByteArray): Boolean {
if (data.size != 8) {
return false
}
val prefixHex = notificationPrefix.joinToString("") { "%02x".format(it) }
val dataHex = data.joinToString("") { "%02x".format(it) }
return dataHex.startsWith(prefixHex)
}
}
class ANC {
private val notificationPrefix = Enums.NOISE_CANCELLATION_PREFIX.value
var status: Int = 1
private set
fun isANCData(data: ByteArray): Boolean {
if (data.size != 11) {
return false
}
val prefixHex = notificationPrefix.joinToString("") { "%02x".format(it) }
val dataHex = data.joinToString("") { "%02x".format(it) }
return dataHex.startsWith(prefixHex)
}
fun setStatus(data: ByteArray) {
if (data.size != 11) {
return
}
status = data[7].toInt()
}
val name: String =
when (status) {
1 -> "OFF"
2 -> "ON"
3 -> "TRANSPARENCY"
4 -> "ADAPTIVE"
else -> "UNKNOWN"
}
}
class BatteryNotification {
private var first: Battery = Battery(BatteryComponent.LEFT, 0, BatteryStatus.DISCONNECTED)
private var second: Battery = Battery(BatteryComponent.RIGHT, 0, BatteryStatus.DISCONNECTED)
private var case: Battery = Battery(BatteryComponent.CASE, 0, BatteryStatus.DISCONNECTED)
fun isBatteryData(data: ByteArray): Boolean {
if (data.joinToString("") { "%02x".format(it) }.startsWith("040004000400")) {
Log.d("BatteryNotification", "Battery data starts with 040004000400. Most likely is a battery packet.")
} else {
return false
}
if (data.size != 22) {
Log.d("BatteryNotification", "Battery data size is not 22, probably being used with Airpods with fewer or more battery count.")
return false
}
Log.d("BatteryNotification", data.joinToString("") { "%02x".format(it) }.startsWith("040004000400").toString())
return data.joinToString("") { "%02x".format(it) }.startsWith("040004000400")
}
fun setBattery(data: ByteArray) {
if (data.size != 22) {
return
}
first = if (data[10].toInt() == BatteryStatus.DISCONNECTED) {
Battery(first.component, first.level, data[10].toInt())
} else {
Battery(data[7].toInt(), data[9].toInt(), data[10].toInt())
}
second = if (data[15].toInt() == BatteryStatus.DISCONNECTED) {
Battery(second.component, second.level, data[15].toInt())
} else {
Battery(data[12].toInt(), data[14].toInt(), data[15].toInt())
}
case = if (data[20].toInt() == BatteryStatus.DISCONNECTED && case.status != BatteryStatus.DISCONNECTED) {
Battery(case.component, case.level, data[20].toInt())
} else {
Battery(data[17].toInt(), data[19].toInt(), data[20].toInt())
}
}
fun getBattery(): List<Battery> {
val left = if (first.component == BatteryComponent.LEFT) first else second
val right = if (first.component == BatteryComponent.LEFT) second else first
return listOf(left, right, case)
}
}
class ConversationalAwarenessNotification {
@Suppress("PrivatePropertyName")
private val NOTIFICATION_PREFIX = Enums.CONVERSATION_AWARENESS_RECEIVE_PREFIX.value
var status: Byte = 0
private set
fun isConversationalAwarenessData(data: ByteArray): Boolean {
if (data.size != 10) {
return false
}
val prefixHex = NOTIFICATION_PREFIX.joinToString("") { "%02x".format(it) }
val dataHex = data.joinToString("") { "%02x".format(it) }
return dataHex.startsWith(prefixHex)
}
fun setData(data: ByteArray) {
status = data[9]
}
}
}
class Capabilities {
companion object {
val NOISE_CANCELLATION = byteArrayOf(0x0d)
val CONVERSATION_AWARENESS = byteArrayOf(0x28)
val CUSTOMIZABLE_ADAPTIVE_TRANSPARENCY = byteArrayOf(0x01, 0x02)
val EAR_DETECTION = byteArrayOf(0x06)
}
enum class NoiseCancellation(val value: ByteArray) {
OFF(byteArrayOf(0x01)),
ON(byteArrayOf(0x02)),
TRANSPARENCY(byteArrayOf(0x03)),
ADAPTIVE(byteArrayOf(0x04));
}
enum class ConversationAwareness(val value: ByteArray) {
OFF(byteArrayOf(0x02)),
ON(byteArrayOf(0x01));
}
}
enum class LongPressPackets(val value: ByteArray) {
ENABLE_EVERYTHING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0F, 0x00, 0x00, 0x00)),
DISABLE_OFF_FROM_EVERYTHING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0e, 0x00, 0x00, 0x00)),
DISABLE_OFF_FROM_TRANSPARENCY_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0c, 0x00, 0x00, 0x00)),
DISABLE_OFF_FROM_TRANSPARENCY_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x06, 0x00, 0x00, 0x00)),
DISABLE_OFF_FROM_ADAPTIVE_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0a, 0x00, 0x00, 0x00)),
ENABLE_OFF_FROM_TRANSPARENCY_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x07, 0x00, 0x00, 0x00)),
ENABLE_OFF_FROM_ADAPTIVE_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0b, 0x00, 0x00, 0x00)),
ENABLE_OFF_FROM_TRANSPARENCY_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0d, 0x00, 0x00, 0x00)),
DISABLE_TRANSPARENCY_FROM_EVERYTHING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0b, 0x00, 0x00, 0x00)),
DISABLE_TRANSPARENCY_FROM_OFF_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x03, 0x00, 0x00, 0x00)),
DISABLE_TRANSPARENCY_FROM_ADAPTIVE_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0a, 0x00, 0x00, 0x00)),
DISABLE_TRANSPARENCY_FROM_OFF_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x09, 0x00, 0x00, 0x00)),
ENABLE_TRANSPARENCY_FROM_OFF_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x07, 0x00, 0x00, 0x00)),
ENABLE_TRANSPARENCY_FROM_ADAPTIVE_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0e, 0x00, 0x00, 0x00)),
ENABLE_TRANSPARENCY_FROM_OFF_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0d, 0x00, 0x00, 0x00)),
DISABLE_ANC_FROM_EVERYTHING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0D, 0x00, 0x00, 0x00)),
DISABLE_ANC_FROM_OFF_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x05, 0x00, 0x00, 0x00)),
DISABLE_ANC_FROM_ADAPTIVE_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0c, 0x00, 0x00, 0x00)),
DISABLE_ANC_FROM_OFF_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x09, 0x00, 0x00, 0x00)),
ENABLE_ANC_FROM_OFF_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x07, 0x00, 0x00, 0x00)),
ENABLE_ANC_FROM_ADAPTIVE_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0e, 0x00, 0x00, 0x00)),
ENABLE_ANC_FROM_OFF_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0b, 0x00, 0x00, 0x00)),
DISABLE_ADAPTIVE_FROM_EVERYTHING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x07, 0x00, 0x00, 0x00)),
DISABLE_ADAPTIVE_FROM_OFF_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x05, 0x00, 0x00, 0x00)),
DISABLE_ADAPTIVE_FROM_TRANSPARENCY_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x06, 0x00, 0x00, 0x00)),
DISABLE_ADAPTIVE_FROM_OFF_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x03, 0x00, 0x00, 0x00)),
ENABLE_ADAPTIVE_FROM_OFF_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0d, 0x00, 0x00, 0x00)),
ENABLE_ADAPTIVE_FROM_TRANSPARENCY_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0e, 0x00, 0x00, 0x00)),
ENABLE_ADAPTIVE_FROM_OFF_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0b, 0x00, 0x00, 0x00)),
ENABLE_EVERYTHING_OFF_DISABLED(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0E, 0x00, 0x00, 0x00)),
DISABLE_TRANSPARENCY_OFF_DISABLED(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0A, 0x00, 0x00, 0x00)),
DISABLE_ADAPTIVE_OFF_DISABLED(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x06, 0x00, 0x00, 0x00)),
DISABLE_ANC_OFF_DISABLED(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0C, 0x00, 0x00, 0x00)),
}

View File

@@ -1,184 +0,0 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
*
* Copyright (C) 2024 Kavish Devar
*
* 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.aln.utils
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ObjectAnimator
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.PixelFormat
import android.util.Log
import android.view.Gravity
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.WindowManager
import android.view.animation.AccelerateInterpolator
import android.view.animation.DecelerateInterpolator
import android.widget.ImageButton
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.VideoView
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.aln.R
@SuppressLint("InflateParams", "ClickableViewAccessibility")
class PopupWindow(context: Context) {
private val mView: View
@Suppress("DEPRECATION")
private val mParams: WindowManager.LayoutParams = WindowManager.LayoutParams().apply {
height = WindowManager.LayoutParams.WRAP_CONTENT
width = WindowManager.LayoutParams.MATCH_PARENT
type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
format = PixelFormat.TRANSLUCENT
gravity = Gravity.BOTTOM
dimAmount = 0.3f
flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
WindowManager.LayoutParams.FLAG_FULLSCREEN or
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_DIM_BEHIND or
WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
}
private val mWindowManager: WindowManager
init {
val layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
mView = layoutInflater.inflate(R.layout.popup_window, null)
mParams.x = 0
mParams.y = 0
mParams.gravity = Gravity.BOTTOM
mView.setOnClickListener {
close()
}
mView.findViewById<ImageButton>(R.id.close_button).setOnClickListener {
close()
}
val ll = mView.findViewById<LinearLayout>(R.id.linear_layout)
ll.setOnClickListener {
close()
}
@Suppress("DEPRECATION")
mView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
mView.setOnTouchListener { _, event ->
if (event.action == MotionEvent.ACTION_DOWN) {
val touchY = event.rawY
val popupTop = mView.top
if (touchY < popupTop) {
close()
true
} else {
false
}
} else {
false
}
}
mWindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
}
@SuppressLint("InlinedApi", "SetTextI18n")
fun open(name: String = "AirPods Pro", batteryNotification: AirPodsNotifications.BatteryNotification) {
try {
if (mView.windowToken == null) {
if (mView.parent == null) {
mWindowManager.addView(mView, mParams)
mView.findViewById<TextView>(R.id.name).text = name
val vid = mView.findViewById<VideoView>(R.id.video)
vid.setVideoPath("android.resource://me.kavishdevar.aln/" + R.raw.connected)
vid.resolveAdjustedSize(vid.width, vid.height)
vid.start()
vid.setOnCompletionListener {
vid.start()
}
val batteryStatus = batteryNotification.getBattery()
val batteryLeftText = mView.findViewById<TextView>(R.id.left_battery)
val batteryRightText = mView.findViewById<TextView>(R.id.right_battery)
val batteryCaseText = mView.findViewById<TextView>(R.id.case_battery)
batteryLeftText.text = batteryStatus.find { it.component == BatteryComponent.LEFT }?.let {
"\uDBC3\uDC8E ${it.level}%"
} ?: ""
batteryRightText.text = batteryStatus.find { it.component == BatteryComponent.RIGHT }?.let {
"\uDBC3\uDC8D ${it.level}%"
} ?: ""
batteryCaseText.text = batteryStatus.find { it.component == BatteryComponent.CASE }?.let {
"\uDBC3\uDE6C ${it.level}%"
} ?: ""
val displayMetrics = mView.context.resources.displayMetrics
val screenHeight = displayMetrics.heightPixels
mView.translationY = screenHeight.toFloat()
ObjectAnimator.ofFloat(mView, "translationY", 0f).apply {
duration = 500
interpolator = DecelerateInterpolator()
start()
}
CoroutineScope(MainScope().coroutineContext).launch {
delay(12000)
close()
}
}
}
} catch (e: Exception) {
Log.d("PopupService", e.toString())
}
}
fun close() {
try {
ObjectAnimator.ofFloat(mView, "translationY", mView.height.toFloat()).apply {
duration = 500
interpolator = AccelerateInterpolator()
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
try {
mWindowManager.removeView(mView)
} catch (e: Exception) {
Log.d("PopupService", e.toString())
}
}
})
start()
}
} catch (e: Exception) {
Log.d("PopupService", e.toString())
}
}
}

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2024 Kavish Devar
* 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
@@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.aln
package me.kavishdevar.librepods
import android.Manifest
import android.annotation.SuppressLint
@@ -47,7 +47,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import me.kavishdevar.aln.ui.theme.ALNTheme
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
import org.lsposed.hiddenapibypass.HiddenApiBypass
import java.util.UUID
@@ -57,7 +57,7 @@ class CustomDevice : ComponentActivity() {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ALNTheme {
LibrePodsTheme {
val connect = remember { mutableStateOf(false) }
Scaffold(
modifier = Modifier.fillMaxSize(),

View File

@@ -0,0 +1,734 @@
/*
* 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
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Context.MODE_PRIVATE
import android.content.Intent
import android.content.ServiceConnection
import android.content.SharedPreferences
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.provider.Settings
import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
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.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.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Phone
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.vectorResource
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.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.MultiplePermissionsState
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.screens.AirPodsSettingsScreen
import me.kavishdevar.librepods.screens.AppSettingsScreen
import me.kavishdevar.librepods.screens.DebugScreen
import me.kavishdevar.librepods.screens.HeadTrackingScreen
import me.kavishdevar.librepods.screens.LongPress
import me.kavishdevar.librepods.screens.Onboarding
import me.kavishdevar.librepods.screens.RenameScreen
import me.kavishdevar.librepods.screens.TroubleshootingScreen
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
import me.kavishdevar.librepods.utils.CrossDevice
import me.kavishdevar.librepods.utils.RadareOffsetFinder
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
lateinit var serviceConnection: ServiceConnection
lateinit var connectionStatusReceiver: BroadcastReceiver
@ExperimentalMaterial3Api
class MainActivity : ComponentActivity() {
companion object {
init {
System.loadLibrary("l2c_fcr_hook")
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
LibrePodsTheme {
getSharedPreferences("settings", MODE_PRIVATE).edit().putLong("textColor",
MaterialTheme.colorScheme.onSurface.toArgb().toLong()).apply()
Main()
}
}
handleIncomingIntent(intent)
}
override fun onDestroy() {
try {
unbindService(serviceConnection)
Log.d("MainActivity", "Unbound service")
} catch (e: Exception) {
Log.e("MainActivity", "Error while unbinding service: $e")
}
try {
unregisterReceiver(connectionStatusReceiver)
Log.d("MainActivity", "Unregistered receiver")
} catch (e: Exception) {
Log.e("MainActivity", "Error while unregistering receiver: $e")
}
sendBroadcast(Intent(AirPodsNotifications.DISCONNECT_RECEIVERS))
super.onDestroy()
}
override fun onStop() {
try {
unbindService(serviceConnection)
Log.d("MainActivity", "Unbound service")
} catch (e: Exception) {
Log.e("MainActivity", "Error while unbinding service: $e")
}
try {
unregisterReceiver(connectionStatusReceiver)
Log.d("MainActivity", "Unregistered receiver")
} catch (e: Exception) {
Log.e("MainActivity", "Error while unregistering receiver: $e")
}
super.onStop()
}
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" -> {
// Extract query parameters
val queryParams = data.queryParameterNames
queryParams.forEach { param ->
val value = data.getQueryParameter(param)
// Handle your parameters here
Log.d("LibrePods", "Parameter: $param = $value")
}
// Process the magic keys addition
handleAddMagicKeys(data)
}
}
}
}
private fun handleAddMagicKeys(uri: Uri) {
val context = this
val sharedPreferences = getSharedPreferences("settings", Context.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).apply()
}
if (encKeyHex != null && validateHexInput(encKeyHex)) {
val encKeyBytes = hexStringToByteArray(encKeyHex)
val encKeyBase64 = Base64.encode(encKeyBytes)
sharedPreferences.edit().putString("ENC_KEY", encKeyBase64).apply()
}
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
}
}
@SuppressLint("MissingPermission", "InlinedApi", "UnspecifiedRegisterReceiverFlag")
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun Main() {
val isConnected = remember { mutableStateOf(false) }
val isRemotelyConnected = remember { mutableStateOf(false) }
val hookAvailable = RadareOffsetFinder(LocalContext.current).isHookOffsetAvailable()
val context = LocalContext.current
var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) }
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) {
listOf(
"android.permission.BLUETOOTH_CONNECT",
"android.permission.BLUETOOTH_SCAN",
"android.permission.BLUETOOTH",
"android.permission.BLUETOOTH_ADMIN",
"android.permission.BLUETOOTH_ADVERTISE"
)
} else {
listOf(
"android.permission.BLUETOOTH",
"android.permission.BLUETOOTH_ADMIN",
"android.permission.ACCESS_FINE_LOCATION"
)
}
val otherPermissions = listOf(
"android.permission.POST_NOTIFICATIONS",
"android.permission.READ_PHONE_STATE",
"android.permission.ANSWER_PHONE_CALLS"
)
val allPermissions = bluetoothPermissions + otherPermissions
val permissionState = rememberMultiplePermissionsState(
permissions = allPermissions
)
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
LaunchedEffect(Unit) {
canDrawOverlays = Settings.canDrawOverlays(context)
}
if (permissionState.allPermissionsGranted && (canDrawOverlays || overlaySkipped.value)) {
val context = LocalContext.current
context.startService(Intent(context, AirPodsService::class.java))
val navController = rememberNavController()
val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
val isAvailableChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == "CrossDeviceIsAvailable") {
Log.d("MainActivity", "CrossDeviceIsAvailable changed")
isRemotelyConnected.value = sharedPreferences.getBoolean("CrossDeviceIsAvailable", false)
}
}
sharedPreferences.registerOnSharedPreferenceChangeListener(isAvailableChangeListener)
Log.d("MainActivity", "CrossDeviceIsAvailable: ${sharedPreferences.getBoolean("CrossDeviceIsAvailable", false)} | isAvailable: ${CrossDevice.isAvailable}")
isRemotelyConnected.value = sharedPreferences.getBoolean("CrossDeviceIsAvailable", false) || CrossDevice.isAvailable
Log.d("MainActivity", "isRemotelyConnected: ${isRemotelyConnected.value}")
Box (
modifier = Modifier
.padding(0.dp)
.fillMaxSize()
.background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7))
) {
NavHost(
navController = navController,
startDestination = if (hookAvailable) "settings" else "onboarding",
enterTransition = {
slideInHorizontally(
initialOffsetX = { it },
animationSpec = tween(durationMillis = 300)
) + fadeIn(animationSpec = tween(durationMillis = 300))
},
exitTransition = {
slideOutHorizontally(
targetOffsetX = { -it/4 },
animationSpec = tween(durationMillis = 300)
) + fadeOut(animationSpec = tween(durationMillis = 150))
},
popEnterTransition = {
slideInHorizontally(
initialOffsetX = { -it/4 },
animationSpec = tween(durationMillis = 300)
) + fadeIn(animationSpec = tween(durationMillis = 300))
},
popExitTransition = {
slideOutHorizontally(
targetOffsetX = { it },
animationSpec = tween(durationMillis = 300)
) + fadeOut(animationSpec = tween(durationMillis = 150))
}
) {
composable("settings") {
if (airPodsService.value != null) {
AirPodsSettingsScreen(
dev = airPodsService.value?.device,
service = airPodsService.value!!,
navController = navController,
isConnected = isConnected.value,
isRemotelyConnected = isRemotelyConnected.value
)
}
}
composable("debug") {
DebugScreen(navController = navController)
}
composable("long_press/{bud}") { navBackStackEntry ->
LongPress(
navController = navController,
name = navBackStackEntry.arguments?.getString("bud")!!
)
}
composable("rename") { navBackStackEntry ->
RenameScreen(navController)
}
composable("app_settings") {
AppSettingsScreen(navController)
}
composable("troubleshooting") {
TroubleshootingScreen(navController)
}
composable("head_tracking") {
HeadTrackingScreen(navController)
}
composable("onboarding") {
Onboarding(navController, context)
}
}
}
serviceConnection = remember {
object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
val binder = service as AirPodsService.LocalBinder
airPodsService.value = binder.getService()
}
override fun onServiceDisconnected(name: ComponentName?) {
airPodsService.value = null
}
}
}
context.bindService(Intent(context, AirPodsService::class.java), serviceConnection, Context.BIND_AUTO_CREATE)
if (airPodsService.value?.isConnectedLocally == true) {
isConnected.value = true
}
} else {
PermissionsScreen(
permissionState = permissionState,
canDrawOverlays = canDrawOverlays,
onOverlaySettingsReturn = { canDrawOverlays = Settings.canDrawOverlays(context) }
)
}
}
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
@Composable
fun PermissionsScreen(
permissionState: MultiplePermissionsState,
canDrawOverlays: Boolean,
onOverlaySettingsReturn: () -> Unit
) {
val context = LocalContext.current
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White
val textColor = if (isDarkTheme) Color.White else Color.Black
val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
val scrollState = rememberScrollState()
val basicPermissionsGranted = permissionState.permissions.all { it.status.isGranted }
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
val pulseScale by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 1.05f,
animationSpec = infiniteRepeatable(
animation = tween(1000),
repeatMode = RepeatMode.Reverse
),
label = "pulse scale"
)
Column(
modifier = Modifier
.fillMaxSize()
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
.padding(16.dp)
.verticalScroll(scrollState),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(180.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "\uDBC2\uDEB7",
style = TextStyle(
fontSize = 48.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor,
textAlign = TextAlign.Center
)
)
Canvas(
modifier = Modifier
.size(120.dp)
.scale(pulseScale)
) {
val radius = size.minDimension / 2.2f
val centerX = size.width / 2
val centerY = size.height / 2
rotate(degrees = 45f) {
drawCircle(
color = accentColor.copy(alpha = 0.1f),
radius = radius * 1.3f,
center = Offset(centerX, centerY)
)
drawCircle(
color = accentColor.copy(alpha = 0.2f),
radius = radius * 1.1f,
center = Offset(centerX, centerY)
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Permission Required",
style = TextStyle(
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor,
textAlign = TextAlign.Center
),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "The following permissions are required to use the app. Please grant them to continue.",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor.copy(alpha = 0.7f),
textAlign = TextAlign.Center
),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(32.dp))
PermissionCard(
title = "Bluetooth Permissions",
description = "Required to communicate with your AirPods",
icon = ImageVector.vectorResource(id = R.drawable.ic_bluetooth),
isGranted = permissionState.permissions.filter {
it.permission.contains("BLUETOOTH")
}.all { it.status.isGranted },
backgroundColor = backgroundColor,
textColor = textColor,
accentColor = accentColor
)
PermissionCard(
title = "Notification Permission",
description = "To show battery status",
icon = Icons.Default.Notifications,
isGranted = permissionState.permissions.find {
it.permission == "android.permission.POST_NOTIFICATIONS"
}?.status?.isGranted == true,
backgroundColor = backgroundColor,
textColor = textColor,
accentColor = accentColor
)
PermissionCard(
title = "Phone Permissions",
description = "For answering calls with Head Gestures",
icon = Icons.Default.Phone,
isGranted = permissionState.permissions.filter {
it.permission.contains("PHONE") || it.permission.contains("CALLS")
}.all { it.status.isGranted },
backgroundColor = backgroundColor,
textColor = textColor,
accentColor = accentColor
)
PermissionCard(
title = "Display Over Other Apps",
description = "For popup animations when AirPods connect",
icon = ImageVector.vectorResource(id = R.drawable.ic_layers),
isGranted = canDrawOverlays,
backgroundColor = backgroundColor,
textColor = textColor,
accentColor = accentColor
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = { permissionState.launchMultiplePermissionRequest() },
modifier = Modifier
.fillMaxWidth()
.height(55.dp),
colors = ButtonDefaults.buttonColors(
containerColor = accentColor
),
shape = RoundedCornerShape(8.dp)
) {
Text(
"Ask for regular permissions",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
)
}
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = {
val intent = Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:${context.packageName}")
)
context.startActivity(intent)
onOverlaySettingsReturn()
},
modifier = Modifier
.fillMaxWidth()
.height(55.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (canDrawOverlays) Color.Gray else accentColor
),
enabled = !canDrawOverlays,
shape = RoundedCornerShape(8.dp)
) {
Text(
if (canDrawOverlays) "Overlay Permission Granted" else "Grant Overlay Permission",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
)
}
if (!canDrawOverlays && basicPermissionsGranted) {
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = {
val editor = context.getSharedPreferences("settings", MODE_PRIVATE).edit()
editor.putBoolean("overlay_permission_skipped", true)
editor.apply()
val intent = Intent(context, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
context.startActivity(intent)
},
modifier = Modifier
.fillMaxWidth()
.height(55.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF757575)
),
shape = RoundedCornerShape(8.dp)
) {
Text(
"Continue without overlay",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
)
}
}
}
}
@Composable
fun PermissionCard(
title: String,
description: String,
icon: ImageVector,
isGranted: Boolean,
backgroundColor: Color,
textColor: Color,
accentColor: Color
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
colors = CardDefaults.cardColors(
containerColor = backgroundColor
),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(8.dp))
.background(if (isGranted) accentColor.copy(alpha = 0.15f) else Color.Gray.copy(alpha = 0.15f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = title,
tint = if (isGranted) accentColor else Color.Gray,
modifier = Modifier.size(24.dp)
)
}
Column(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp)
) {
Text(
text = title,
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = description,
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor.copy(alpha = 0.6f)
)
)
}
Box(
modifier = Modifier
.size(24.dp)
.clip(RoundedCornerShape(12.dp))
.background(if (isGranted) Color(0xFF4CAF50) else Color.Gray),
contentAlignment = Alignment.Center
) {
Text(
text = if (isGranted) "" else "!",
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
)
}
}
}
}

View File

@@ -0,0 +1,621 @@
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection
import android.media.AudioManager
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.util.Log
import android.view.Gravity
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.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.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.composables.AdaptiveRainbowBrush
import me.kavishdevar.librepods.composables.ControlCenterNoiseControlSegmentedButton
import me.kavishdevar.librepods.composables.IconAreaSize
import me.kavishdevar.librepods.composables.VerticalVolumeSlider
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.constants.NoiseControlMode
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs
class QuickSettingsDialogActivity : ComponentActivity() {
private var airPodsService: AirPodsService? = null
private var isBound = false
private var isNoiseControlExpandedState by mutableStateOf(false)
private val connection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, service: IBinder) {
val binder = service as AirPodsService.LocalBinder
airPodsService = binder.getService()
isBound = true
Log.d("QSActivity", "Service bound")
setContent {
LibrePodsTheme {
DraggableDismissBox(
onDismiss = { finish() },
onlyCollapseWhenClicked = {
if (isNoiseControlExpandedState) {
isNoiseControlExpandedState = false
true
} else {
false
}
}
) {
if (isBound && airPodsService != null) {
NewControlCenterDialogContent(
service = airPodsService,
isNoiseControlExpanded = isNoiseControlExpandedState,
onNoiseControlExpandedChange = { isNoiseControlExpandedState = it }
)
}
}
}
}
}
override fun onServiceDisconnected(arg0: ComponentName) {
isBound = false
airPodsService = null
Log.d("QSActivity", "Service unbound")
finish()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL)
window.addFlags(WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH)
window.addFlags(WindowManager.LayoutParams.FLAG_BLUR_BEHIND)
window.setGravity(Gravity.BOTTOM)
Intent(this, AirPodsService::class.java).also { intent ->
bindService(intent, connection, Context.BIND_AUTO_CREATE)
}
setContent {
LibrePodsTheme {
DraggableDismissBox(
onDismiss = { finish() },
onlyCollapseWhenClicked = {
if (isNoiseControlExpandedState) {
isNoiseControlExpandedState = false
true
} else {
false
}
}
) {
if (isBound && airPodsService != null) {
NewControlCenterDialogContent(
service = airPodsService,
isNoiseControlExpanded = isNoiseControlExpandedState,
onNoiseControlExpandedChange = { isNoiseControlExpandedState = it }
)
}
}
}
}
}
override fun onDestroy() {
super.onDestroy()
if (isBound) {
unbindService(connection)
isBound = false
}
}
}
@Composable
fun DraggableDismissBox(
onDismiss: () -> Unit,
onlyCollapseWhenClicked: () -> Boolean,
content: @Composable () -> Unit
) {
val coroutineScope = rememberCoroutineScope()
var dragOffset by remember { mutableFloatStateOf(0f) }
var isDragging by remember { mutableStateOf(false) }
val dismissThreshold = 400f
val animatedOffset = remember { Animatable(0f) }
val animatedScale = remember { Animatable(1f) }
val animatedAlpha = remember { Animatable(1f) }
val backgroundAlpha by animateFloatAsState(
targetValue = if (isDragging) {
val dragProgress = (abs(dragOffset) / 800f).coerceIn(0f, 0.8f)
1f - dragProgress
} else 1f,
label = "BackgroundFade"
)
LaunchedEffect(isDragging) {
if (!isDragging) {
if (abs(dragOffset) < dismissThreshold) {
val springSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessHigh,
visibilityThreshold = 0.1f
)
launch { animatedOffset.animateTo(0f, springSpec) }
launch { animatedScale.animateTo(1f, springSpec) }
launch { animatedAlpha.animateTo(1f, tween(100)) }
dragOffset = 0f
}
}
}
LaunchedEffect(dragOffset, isDragging) {
if (isDragging) {
val dragProgress = (abs(dragOffset) / 1000f).coerceIn(0f, 0.5f)
animatedOffset.snapTo(dragOffset)
animatedScale.snapTo(1f - dragProgress * 0.3f)
animatedAlpha.snapTo(1f - dragProgress * 0.7f)
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.5f * backgroundAlpha))
.pointerInput(Unit) {
detectVerticalDragGestures(
onDragStart = { isDragging = true },
onDragEnd = {
isDragging = false
if (abs(dragOffset) > dismissThreshold) {
coroutineScope.launch {
val direction = if (dragOffset > 0) 1f else -1f
launch {
animatedOffset.animateTo(
direction * 1500f,
tween(350, easing = FastOutSlowInEasing)
)
}
launch { animatedScale.animateTo(0.7f, tween(350)) }
launch { animatedAlpha.animateTo(0f, tween(250)) }
kotlinx.coroutines.delay(350)
onDismiss()
}
}
},
onDragCancel = { isDragging = false },
onVerticalDrag = { change, dragAmount ->
change.consume()
dragOffset += dragAmount
}
)
}
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
onlyCollapseWhenClicked()
},
contentAlignment = Alignment.BottomCenter
) {
Box(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer(
translationY = animatedOffset.value,
scaleX = animatedScale.value,
scaleY = animatedScale.value,
alpha = animatedAlpha.value
),
contentAlignment = Alignment.BottomCenter
) {
content()
}
}
}
@SuppressLint("UnspecifiedRegisterReceiverFlag")
@Composable
fun NewControlCenterDialogContent(
service: AirPodsService?,
isNoiseControlExpanded: Boolean,
onNoiseControlExpandedChange: (Boolean) -> Unit
) {
val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val textColor = Color.White
var currentAncMode by remember { mutableStateOf(NoiseControlMode.TRANSPARENCY) }
var isConvAwarenessEnabled by remember { mutableStateOf(false) }
val isOffModeEnabled = remember { sharedPreferences.getBoolean("off_listening_mode", true) }
val availableModes = remember(isOffModeEnabled) {
mutableListOf(
NoiseControlMode.TRANSPARENCY,
NoiseControlMode.ADAPTIVE,
NoiseControlMode.NOISE_CANCELLATION
).apply {
if (isOffModeEnabled) {
add(0, NoiseControlMode.OFF)
}
}
}
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) }
var currentVolumeInt by remember { mutableIntStateOf(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)) }
val animatedVolumeFraction by animateFloatAsState(
targetValue = currentVolumeInt.toFloat() / maxVolume.toFloat(),
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMediumLow
),
label = "VolumeAnimation"
)
var liveDragFraction by remember { mutableFloatStateOf(animatedVolumeFraction) }
var isDraggingVolume by remember { mutableStateOf(false) }
LaunchedEffect(animatedVolumeFraction, isDraggingVolume) {
if (!isDraggingVolume) {
liveDragFraction = animatedVolumeFraction
}
}
DisposableEffect(service, availableModes) {
val ancReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == AirPodsNotifications.ANC_DATA && service != null) {
val newModeOrdinal = intent.getIntExtra("data", NoiseControlMode.TRANSPARENCY.ordinal + 1) - 1
val newMode = NoiseControlMode.entries.getOrElse(newModeOrdinal) { NoiseControlMode.TRANSPARENCY }
if (availableModes.contains(newMode)) {
currentAncMode = newMode
} else if (newMode == NoiseControlMode.OFF && !isOffModeEnabled) {
currentAncMode = NoiseControlMode.TRANSPARENCY
}
Log.d("QSActivity", "ANC Receiver updated mode to: $currentAncMode (available: ${availableModes.joinToString()})")
}
}
}
val filter = IntentFilter(AirPodsNotifications.ANC_DATA)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(ancReceiver, filter, Context.RECEIVER_EXPORTED)
} else {
context.registerReceiver(ancReceiver, filter)
}
service?.let {
val initialModeOrdinal = it.getANC().minus(1)
var initialMode = NoiseControlMode.entries.getOrElse(initialModeOrdinal) { NoiseControlMode.TRANSPARENCY }
if (!availableModes.contains(initialMode)) {
initialMode = NoiseControlMode.TRANSPARENCY
}
currentAncMode = initialMode
isConvAwarenessEnabled = sharedPreferences.getBoolean("conversational_awareness", true)
Log.d("QSActivity", "Initial ANC: $currentAncMode, ConvAware: $isConvAwarenessEnabled")
}
onDispose {
context.unregisterReceiver(ancReceiver)
}
}
DisposableEffect(Unit) {
val volumeReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == "android.media.VOLUME_CHANGED_ACTION") {
val newVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
if (newVolume != currentVolumeInt) {
currentVolumeInt = newVolume
Log.d("QSActivity", "Volume Receiver updated volume to: $currentVolumeInt")
}
}
}
}
val filter = IntentFilter("android.media.VOLUME_CHANGED_ACTION")
context.registerReceiver(volumeReceiver, filter)
onDispose {
context.unregisterReceiver(volumeReceiver)
}
}
val deviceName = remember { sharedPreferences.getString("name", "AirPods") ?: "AirPods" }
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.Transparent)
.padding(horizontal = 24.dp)
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
awaitPointerEvent()
}
}
},
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween
) {
if (service != null) {
Spacer(modifier = Modifier.weight(1f))
Column(
modifier = Modifier
.weight(2f)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
painter = painterResource(id = R.drawable.airpods),
contentDescription = "Device Icon",
tint = textColor.copy(alpha = 0.8f),
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = deviceName,
color = textColor,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(32.dp))
VerticalVolumeSlider(
displayFraction = animatedVolumeFraction,
maxVolume = maxVolume,
onVolumeChange = { newVolume ->
currentVolumeInt = newVolume
try {
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, newVolume, 0)
} catch (e: Exception) { Log.e("QSActivity", "Failed to set volume", e) }
},
initialFraction = animatedVolumeFraction,
onDragStateChange = { dragging -> isDraggingVolume = dragging },
baseSliderHeight = 400.dp,
baseSliderWidth = 145.dp,
baseCornerRadius = 48.dp,
maxStretchFactor = 1.15f,
minCompressionFactor = 0.875f,
stretchSensitivity = 0.3f,
compressionSensitivity = 0.3f,
cornerRadiusChangeFactor = -0.5f,
directionalStretchRatio = 0.75f,
modifier = Modifier
.width(145.dp)
.padding(vertical = 8.dp)
)
}
Spacer(modifier = Modifier.weight(1f))
Box(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 72.dp)
.animateContentSize(
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
)
),
contentAlignment = Alignment.Center
) {
Crossfade(
targetState = isNoiseControlExpanded,
animationSpec = tween(durationMillis = 300),
label = "NoiseControlCrossfade"
) { expanded ->
if (expanded) {
ControlCenterNoiseControlSegmentedButton(
availableModes = availableModes,
selectedMode = currentAncMode,
onModeSelected = { newMode ->
service.aacpManager.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
value = newMode.ordinal + 1
)
currentAncMode = newMode
},
modifier = Modifier.fillMaxWidth(0.8f)
)
} else {
Row(
modifier = Modifier.fillMaxWidth(0.85f),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
val noiseControlButtonBrush = if (currentAncMode == NoiseControlMode.ADAPTIVE) {
AdaptiveRainbowBrush
} else {
null
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.weight(1f)
) {
Box(
modifier = Modifier
.size(IconAreaSize)
.clip(CircleShape)
.background(
brush = noiseControlButtonBrush ?:
Brush.linearGradient(colors = listOf(Color(0xFF0A84FF), Color(0xFF0A84FF)))
)
.clickable(
onClick = { onNoiseControlExpandedChange(true) },
indication = null,
interactionSource = remember { MutableInteractionSource() }
),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(id = getModeIconRes(currentAncMode)),
contentDescription = getModeLabel(currentAncMode),
tint = Color.White,
modifier = Modifier.size(32.dp)
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = getModeLabel(currentAncMode),
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
}
Spacer(modifier = Modifier.width(24.dp))
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.weight(1f)
) {
Box(
modifier = Modifier
.size(IconAreaSize)
.clip(CircleShape)
.background(
Brush.linearGradient(
colors = listOf(
if (isConvAwarenessEnabled) Color(0xFF0A84FF) else Color(0x593C3C3E),
if (isConvAwarenessEnabled) Color(0xFF0A84FF) else Color(0x593C3C3E)
)
)
)
.clickable(
onClick = {
val newState = !isConvAwarenessEnabled
service.aacpManager.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG.value,
value = newState
)
isConvAwarenessEnabled = newState
},
indication = null,
interactionSource = remember { MutableInteractionSource() }
),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(id = R.drawable.airpods),
contentDescription = "Conversational Awareness",
tint = Color.White,
modifier = Modifier.size(32.dp)
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Conversational\nAwareness",
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
lineHeight = 14.sp
)
}
}
}
}
}
} else {
Spacer(modifier = Modifier.weight(1f))
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text("Loading...", color = textColor)
}
Spacer(modifier = Modifier.weight(1f))
}
}
}
private fun getModeIconRes(mode: NoiseControlMode): Int {
return when (mode) {
NoiseControlMode.OFF -> R.drawable.noise_cancellation
NoiseControlMode.TRANSPARENCY -> R.drawable.transparency
NoiseControlMode.ADAPTIVE -> R.drawable.adaptive
NoiseControlMode.NOISE_CANCELLATION -> R.drawable.noise_cancellation
}
}
private fun getModeLabel(mode: NoiseControlMode): String {
return when (mode) {
NoiseControlMode.OFF -> "Off"
NoiseControlMode.TRANSPARENCY -> "Transparency"
NoiseControlMode.ADAPTIVE -> "Adaptive"
NoiseControlMode.NOISE_CANCELLATION -> "Noise Cancel"
}
}

View File

@@ -1,25 +1,25 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
*
* Copyright (C) 2024 Kavish Devar
*
* 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.aln.composables
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.content.Context
import android.content.SharedPreferences
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
@@ -38,22 +38,23 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.aln.R
import me.kavishdevar.aln.services.AirPodsService
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun AccessibilitySettings(service: AirPodsService, sharedPreferences: SharedPreferences) {
fun AccessibilitySettings() {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val service = ServiceManager.getService()!!
Text(
text = stringResource(R.string.accessibility).uppercase(),
style = TextStyle(
@@ -87,51 +88,75 @@ fun AccessibilitySettings(service: AirPodsService, sharedPreferences: SharedPref
)
)
ToneVolumeSlider(service = service, sharedPreferences = sharedPreferences)
ToneVolumeSlider()
}
val pressSpeedOptions = listOf("Default", "Slower", "Slowest")
var selectedPressSpeed by remember { mutableStateOf(pressSpeedOptions[0]) }
val pressSpeedOptions = mapOf(
0.toByte() to "Default",
1.toByte() to "Slower",
2.toByte() to "Slowest"
)
val selectedPressSpeedValue = service.aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL }?.value?.takeIf { it.isNotEmpty() }?.get(0)
var selectedPressSpeed by remember { mutableStateOf(pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]) }
DropdownMenuComponent(
label = "Press Speed",
options = pressSpeedOptions,
selectedOption = selectedPressSpeed,
onOptionSelected = {
selectedPressSpeed = it
service.setPressSpeed(pressSpeedOptions.indexOf(it))
options = pressSpeedOptions.values.toList(),
selectedOption = selectedPressSpeed.toString(),
onOptionSelected = { newValue ->
selectedPressSpeed = newValue
service.aacpManager.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value,
value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 0.toByte()
)
},
textColor = textColor
)
val pressAndHoldDurationOptions = listOf("Default", "Slower", "Slowest")
var selectedPressAndHoldDuration by remember { mutableStateOf(pressAndHoldDurationOptions[0]) }
val pressAndHoldDurationOptions = mapOf(
0.toByte() to "Default",
1.toByte() to "Slower",
2.toByte() to "Slowest"
)
val selectedPressAndHoldDurationValue = service.aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL }?.value?.takeIf { it.isNotEmpty() }?.get(0)
var selectedPressAndHoldDuration by remember { mutableStateOf(pressAndHoldDurationOptions[selectedPressAndHoldDurationValue] ?: pressAndHoldDurationOptions[0]) }
DropdownMenuComponent(
label = "Press and Hold Duration",
options = pressAndHoldDurationOptions,
selectedOption = selectedPressAndHoldDuration,
onOptionSelected = {
selectedPressAndHoldDuration = it
service.setPressAndHoldDuration(pressAndHoldDurationOptions.indexOf(it))
options = pressAndHoldDurationOptions.values.toList(),
selectedOption = selectedPressAndHoldDuration.toString(),
onOptionSelected = { newValue ->
selectedPressAndHoldDuration = newValue
service.aacpManager.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value,
value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 0.toByte()
)
},
textColor = textColor
)
val volumeSwipeSpeedOptions = listOf("Default", "Longer", "Longest")
var selectedVolumeSwipeSpeed by remember { mutableStateOf(volumeSwipeSpeedOptions[0]) }
val volumeSwipeSpeedOptions = mapOf<Byte, String>(
1.toByte() to "Default",
2.toByte() to "Longer",
3.toByte() to "Longest"
)
val selectedVolumeSwipeSpeedValue = service.aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL }?.value?.takeIf { it.isNotEmpty() }?.get(0)
var selectedVolumeSwipeSpeed by remember { mutableStateOf(volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue] ?: volumeSwipeSpeedOptions[1]) }
DropdownMenuComponent(
label = "Volume Swipe Speed",
options = volumeSwipeSpeedOptions,
selectedOption = selectedVolumeSwipeSpeed,
onOptionSelected = {
selectedVolumeSwipeSpeed = it
service.setVolumeSwipeSpeed(volumeSwipeSpeedOptions.indexOf(it))
options = volumeSwipeSpeedOptions.values.toList(),
selectedOption = selectedVolumeSwipeSpeed.toString(),
onOptionSelected = { newValue ->
selectedVolumeSwipeSpeed = newValue
service.aacpManager.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value,
value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 1.toByte()
)
},
textColor = textColor
)
SinglePodANCSwitch(service = service, sharedPreferences = sharedPreferences)
VolumeControlSwitch(service = service, sharedPreferences = sharedPreferences)
// TransparencySettings(service = service, sharedPreferences = sharedPreferences)
SinglePodANCSwitch()
VolumeControlSwitch()
}
}
@@ -192,5 +217,5 @@ fun DropdownMenuComponent(
@Preview
@Composable
fun AccessibilitySettingsPreview() {
AccessibilitySettings(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", Context.MODE_PRIVATE))
AccessibilitySettings()
}

View File

@@ -1,25 +1,25 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
*
* Copyright (C) 2024 Kavish Devar
*
* 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.aln.composables
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.content.Context
import android.content.SharedPreferences
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
@@ -44,26 +44,26 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
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 me.kavishdevar.aln.services.AirPodsService
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AdaptiveStrengthSlider(service: AirPodsService, sharedPreferences: SharedPreferences) {
fun AdaptiveStrengthSlider() {
val sliderValue = remember { mutableFloatStateOf(0f) }
val service = ServiceManager.getService()!!
LaunchedEffect(sliderValue) {
if (sharedPreferences.contains("adaptive_strength")) {
sliderValue.floatValue = sharedPreferences.getInt("adaptive_strength", 0).toFloat()
}
}
LaunchedEffect(sliderValue.floatValue) {
sharedPreferences.edit().putInt("adaptive_strength", sliderValue.floatValue.toInt()).apply()
val sliderValueFromAACP = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
sliderValueFromAACP?.toFloat()?.let { sliderValue.floatValue = (100 - it) }
}
val isDarkTheme = isSystemInDarkTheme()
@@ -86,7 +86,10 @@ fun AdaptiveStrengthSlider(service: AirPodsService, sharedPreferences: SharedPre
valueRange = 0f..100f,
onValueChangeFinished = {
sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat()
service.setAdaptiveStrength(100 - sliderValue.floatValue.toInt())
service.aacpManager.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH.value,
value = (100 - sliderValue.floatValue).toInt()
)
},
modifier = Modifier
.fillMaxWidth()
@@ -151,5 +154,5 @@ fun AdaptiveStrengthSlider(service: AirPodsService, sharedPreferences: SharedPre
@Preview
@Composable
fun AdaptiveStrengthSliderPreview() {
AdaptiveStrengthSlider(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", Context.MODE_PRIVATE))
}
AdaptiveStrengthSlider()
}

View File

@@ -1,25 +1,25 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
*
* Copyright (C) 2024 Kavish Devar
*
* 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.aln.composables
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.content.Context
import android.content.SharedPreferences
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
@@ -30,18 +30,17 @@ import androidx.compose.material3.Text
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.text.TextStyle
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 me.kavishdevar.aln.R
import me.kavishdevar.aln.services.AirPodsService
import me.kavishdevar.librepods.R
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences) {
fun AudioSettings() {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
@@ -64,9 +63,7 @@ fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences)
.padding(top = 2.dp)
) {
PersonalizedVolumeSwitch(service = service, sharedPreferences = sharedPreferences)
ConversationalAwarenessSwitch(service = service, sharedPreferences = sharedPreferences)
LoudSoundReductionSwitch(service = service, sharedPreferences = sharedPreferences)
ConversationalAwarenessSwitch()
Column(
modifier = Modifier
@@ -95,7 +92,7 @@ fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences)
)
)
AdaptiveStrengthSlider(service = service, sharedPreferences = sharedPreferences)
AdaptiveStrengthSlider()
}
}
}
@@ -103,5 +100,5 @@ fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences)
@Preview
@Composable
fun AudioSettingsPreview() {
AudioSettings(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", Context.MODE_PRIVATE))
AudioSettings()
}

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2024 Kavish Devar
* 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
@@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.aln.composables
package me.kavishdevar.librepods.composables
import androidx.compose.animation.core.animateFloatAsState
@@ -48,7 +48,7 @@ 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.aln.R
import me.kavishdevar.librepods.R
@Composable
fun BatteryIndicator(batteryPercentage: Int, charging: Boolean = false) {

View File

@@ -1,22 +1,24 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
*
* Copyright (C) 2024 Kavish Devar
*
* 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.aln.composables
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.content.BroadcastReceiver
import android.content.Context
@@ -44,12 +46,13 @@ 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.aln.R
import me.kavishdevar.aln.services.AirPodsService
import me.kavishdevar.aln.utils.AirPodsNotifications
import me.kavishdevar.aln.utils.Battery
import me.kavishdevar.aln.utils.BatteryComponent
import me.kavishdevar.aln.utils.BatteryStatus
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) {

View File

@@ -0,0 +1,104 @@
/*
* 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.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
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.dp
import androidx.compose.ui.unit.sp
private val SelectedColorBlue = Color(0xFF0A84FF)
private val UnselectedColor = Color(0x593C3C3E)
private val TextColor = Color.White
private val IconTint = Color.White
@Composable
fun ControlCenterButton(
label: String,
icon: Painter,
onClick: () -> Unit,
modifier: Modifier = Modifier,
iconAreaSize: Dp,
isSelected: Boolean,
backgroundBrush: Brush? = null
) {
val targetBackgroundColor = if (isSelected) SelectedColorBlue else UnselectedColor
val backgroundColor by animateColorAsState(
targetValue = targetBackgroundColor,
label = "ButtonBackground"
)
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier
.size(iconAreaSize)
.clip(CircleShape)
.background(backgroundBrush ?: Brush.linearGradient(colors=listOf(backgroundColor, backgroundColor)))
.clickable(
onClick = onClick,
indication = null,
interactionSource = remember { MutableInteractionSource() }
),
contentAlignment = Alignment.Center
) {
Icon(
painter = icon,
contentDescription = null,
tint = IconTint,
modifier = Modifier.size(32.dp)
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = label,
color = TextColor,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
maxLines = 2
)
}
}

View File

@@ -0,0 +1,242 @@
/*
* 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.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
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.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.NoiseControlMode
private val ContainerColor = Color(0x593C3C3E)
private val SelectedIndicatorColorGray = Color(0xFF6C6C6E)
private val SelectedIndicatorColorBlue = Color(0xFF0A84FF)
private val TextColor = Color.White
private val IconTintUnselected = Color.White
private val IconTintSelected = Color.White
internal val AdaptiveRainbowBrush = Brush.sweepGradient(
colors = listOf(
Color(0xFFB03A2F), Color(0xFFB07A2F), Color(0xFFB0A22F), Color(0xFF6AB02F),
Color(0xFF2FAAB0), Color(0xFF2F5EB0), Color(0xFF7D2FB0), Color(0xFFB02F7D),
Color(0xFFB03A2F)
)
)
internal val IconAreaSize = 72.dp
private val IconSize = 42.dp
private val IconRowHeight = IconAreaSize + 12.dp
private val TextRowHeight = 24.dp
private val TextSize = 12.sp
@Composable
fun ControlCenterNoiseControlSegmentedButton(
modifier: Modifier = Modifier,
availableModes: List<NoiseControlMode>,
selectedMode: NoiseControlMode,
onModeSelected: (NoiseControlMode) -> Unit
) {
val selectedIndex = availableModes.indexOf(selectedMode).coerceAtLeast(0)
val density = LocalDensity.current
var iconRowWidthPx by remember { mutableFloatStateOf(0f) }
val itemCount = availableModes.size
val itemSlotWidthPx = remember(iconRowWidthPx, itemCount) {
if (itemCount > 0 && iconRowWidthPx > 0) {
iconRowWidthPx / itemCount
} else {
0f
}
}
val itemSlotWidthDp = remember(itemSlotWidthPx) { with(density) { itemSlotWidthPx.toDp() } }
val iconAreaSizePx = remember { with(density) { IconAreaSize.toPx() } }
val targetIndicatorStartPx = remember(selectedIndex, itemSlotWidthPx, iconAreaSizePx) {
if (itemSlotWidthPx > 0) {
val slotCenterPx = (selectedIndex + 0.5f) * itemSlotWidthPx
slotCenterPx - (iconAreaSizePx / 2f)
} else {
0f
}
}
val indicatorOffset: Dp by animateDpAsState(
targetValue = with(density) { targetIndicatorStartPx.toDp() },
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMedium
),
label = "IndicatorOffset"
)
val indicatorBackground = remember(selectedMode) {
when (selectedMode) {
NoiseControlMode.ADAPTIVE -> AdaptiveRainbowBrush
NoiseControlMode.OFF -> Brush.linearGradient(colors=listOf(SelectedIndicatorColorGray, SelectedIndicatorColorGray))
NoiseControlMode.TRANSPARENCY,
NoiseControlMode.NOISE_CANCELLATION -> Brush.linearGradient(colors=listOf(SelectedIndicatorColorBlue, SelectedIndicatorColorBlue))
}
}
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(IconRowHeight)
.clip(CircleShape)
.background(ContainerColor)
.onSizeChanged { iconRowWidthPx = it.width.toFloat() },
contentAlignment = Alignment.Center
) {
Box(
Modifier
.align(Alignment.CenterStart)
.offset(x = indicatorOffset)
.size(IconAreaSize)
.clip(CircleShape)
.background(indicatorBackground)
)
Row(
modifier = Modifier.fillMaxWidth().align(Alignment.Center),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceAround
) {
availableModes.forEach { mode ->
val isSelected = selectedMode == mode
NoiseControlIconItem(
modifier = Modifier.size(IconAreaSize),
mode = mode,
isSelected = isSelected,
onClick = { onModeSelected(mode) }
)
}
}
}
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.height(TextRowHeight),
horizontalArrangement = Arrangement.SpaceAround,
verticalAlignment = Alignment.CenterVertically
) {
availableModes.forEach { mode ->
val isSelected = selectedMode == mode
Text(
text = getModeLabel(mode),
color = TextColor,
fontSize = TextSize,
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
textAlign = TextAlign.Center,
modifier = Modifier.width(itemSlotWidthDp.coerceAtLeast(1.dp))
)
}
}
}
}
@Composable
private fun NoiseControlIconItem(
modifier: Modifier = Modifier,
mode: NoiseControlMode,
isSelected: Boolean,
onClick: () -> Unit
) {
val iconRes = remember(mode) { getModeIconRes(mode) }
val tint = IconTintUnselected
Box(
modifier = modifier
.clip(CircleShape)
.clickable(
onClick = onClick,
indication = null,
interactionSource = remember { MutableInteractionSource() }
),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(id = iconRes),
contentDescription = getModeLabel(mode),
tint = if (isSelected && mode == NoiseControlMode.ADAPTIVE) IconTintSelected else tint,
modifier = Modifier.size(IconSize)
)
}
}
private fun getModeIconRes(mode: NoiseControlMode): Int {
return when (mode) {
NoiseControlMode.OFF -> R.drawable.noise_cancellation
NoiseControlMode.TRANSPARENCY -> R.drawable.transparency
NoiseControlMode.ADAPTIVE -> R.drawable.adaptive
NoiseControlMode.NOISE_CANCELLATION -> R.drawable.noise_cancellation
}
}
private fun getModeLabel(mode: NoiseControlMode): String {
return when (mode) {
NoiseControlMode.OFF -> "Off"
NoiseControlMode.TRANSPARENCY -> "Transparency"
NoiseControlMode.ADAPTIVE -> "Adaptive"
NoiseControlMode.NOISE_CANCELLATION -> "Noise Cancellation"
}
}

View File

@@ -1,24 +1,25 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
*
* Copyright (C) 2024 Kavish Devar
*
* 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.aln.composables
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.content.SharedPreferences
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
@@ -41,24 +42,31 @@ 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.aln.services.AirPodsService
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun ConversationalAwarenessSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
fun ConversationalAwarenessSwitch() {
val service = ServiceManager.getService()!!
val conversationEnabledValue = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
var conversationalAwarenessEnabled by remember {
mutableStateOf(
sharedPreferences.getBoolean("conversational_awareness", true)
conversationEnabledValue == 1.toByte()
)
}
fun updateConversationalAwareness(enabled: Boolean) {
conversationalAwarenessEnabled = enabled
sharedPreferences.edit().putBoolean("conversational_awareness", enabled).apply()
service.setCAEnabled(enabled)
service.aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG.value,
enabled
)
}
val isDarkTheme = isSystemInDarkTheme()
@@ -121,5 +129,5 @@ fun ConversationalAwarenessSwitch(service: AirPodsService, sharedPreferences: Sh
@Preview
@Composable
fun ConversationalAwarenessSwitchPreview() {
ConversationalAwarenessSwitch(AirPodsService(), LocalContext.current.getSharedPreferences("preview", 0))
}
ConversationalAwarenessSwitch()
}

View File

@@ -0,0 +1,182 @@
/*
* 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.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
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.setValue
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.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import me.kavishdevar.librepods.R
class DropdownItem(val name: String, val onSelect: () -> Unit) {
fun select() {
onSelect()
}
}
@Composable
fun CustomDropdown(name: String, description: String = "", items: List<DropdownItem>) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
var expanded by remember { mutableStateOf(false) }
var offset by remember { mutableStateOf(IntOffset.Zero) }
var popupHeight by remember { mutableStateOf(0.dp) }
val animatedHeight by animateDpAsState(
targetValue = if (expanded) popupHeight else 0.dp,
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow)
)
val animatedScale by animateFloatAsState(
targetValue = if (expanded) 1f else 0f,
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow)
)
Row(
modifier = Modifier
.fillMaxWidth()
.background(
shape = RoundedCornerShape(14.dp),
color = Color.Transparent
)
.padding(horizontal = 12.dp, vertical = 12.dp)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
expanded = true
}
.onGloballyPositioned { coordinates ->
val windowPosition = coordinates.localToWindow(Offset.Zero)
offset = IntOffset(windowPosition.x.toInt(), windowPosition.y.toInt() + coordinates.size.height)
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = name,
fontSize = 16.sp,
color = textColor,
maxLines = 1
)
if (description.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = description,
fontSize = 12.sp,
color = textColor.copy(0.6f),
lineHeight = 14.sp,
maxLines = 1
)
}
}
Text(
text = "\uDBC0\uDD8F",
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
}
if (expanded) {
Popup(
alignment = Alignment.TopStart,
offset = offset ,
properties = PopupProperties(focusable = true),
onDismissRequest = { expanded = false }
) {
val density = LocalDensity.current
Column(
modifier = Modifier
.background(backgroundColor, RoundedCornerShape(8.dp))
.padding(8.dp)
.widthIn(max = 50.dp)
.height(animatedHeight)
.scale(animatedScale)
.onGloballyPositioned { coordinates ->
popupHeight = with(density) { coordinates.size.height.toDp() }
}
) {
items.forEach { item ->
Text(
text = item.name,
modifier = Modifier
.fillMaxWidth()
.clickable {
item.select()
expanded = false
}
.padding(8.dp),
color = textColor
)
}
}
}
}
}
@Preview
@Composable
fun CustomDropdownPreview() {
CustomDropdown(
name = "Volume Swipe Speed",
items = listOf(
DropdownItem("Always On") { },
DropdownItem("Off") { },
DropdownItem("Only when speaking") { }
)
)
}

View File

@@ -1,22 +1,24 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
*
* Copyright (C) 2024 Kavish Devar
*
* 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.aln.composables
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.content.SharedPreferences
import androidx.compose.animation.animateColorAsState
@@ -45,18 +47,41 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.aln.services.AirPodsService
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun IndependentToggle(name: String, service: AirPodsService, functionName: String, sharedPreferences: SharedPreferences, default: Boolean = false) {
fun IndependentToggle(name: String, service: AirPodsService? = null, functionName: String? = null, sharedPreferences: SharedPreferences, default: Boolean = false, controlCommandIdentifier: AACPManager.Companion.ControlCommandIdentifiers? = null) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val snakeCasedName = name.replace(Regex("[\\W\\s]+"), "_").lowercase()
val snakeCasedName =
controlCommandIdentifier?.name ?: name.replace(Regex("[\\W\\s]+"), "_").lowercase()
var checked by remember { mutableStateOf(default) }
if (controlCommandIdentifier != null) {
checked = service!!.aacpManager.controlCommandStatusList.find {
it.identifier == controlCommandIdentifier
}?.value?.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte()
}
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
fun cb() {
if (controlCommandIdentifier == null) {
sharedPreferences.edit().putBoolean(snakeCasedName, checked).apply()
}
if (functionName != null && service != null) {
val method =
service::class.java.getMethod(functionName, Boolean::class.java)
method.invoke(service, checked)
}
if (controlCommandIdentifier != null) {
service?.aacpManager?.sendControlCommand(identifier = controlCommandIdentifier.value, value = checked)
}
}
LaunchedEffect(sharedPreferences) {
checked = sharedPreferences.getBoolean(snakeCasedName, true)
}
@@ -73,13 +98,7 @@ fun IndependentToggle(name: String, service: AirPodsService, functionName: Strin
},
onTap = {
checked = !checked
sharedPreferences
.edit()
.putBoolean(snakeCasedName, checked)
.apply()
val method = service::class.java.getMethod(functionName, Boolean::class.java)
method.invoke(service, checked)
cb()
}
)
},
@@ -97,9 +116,7 @@ fun IndependentToggle(name: String, service: AirPodsService, functionName: Strin
checked = checked,
onCheckedChange = {
checked = it
sharedPreferences.edit().putBoolean(snakeCasedName, it).apply()
val method = service::class.java.getMethod(functionName, Boolean::class.java)
method.invoke(service, it)
cb()
},
)
}

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2024 Kavish Devar
* 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
@@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.aln.composables
package me.kavishdevar.librepods.composables
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2024 Kavish Devar
* 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
@@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.aln.composables
package me.kavishdevar.librepods.composables
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2024 Kavish Devar
* 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
@@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.aln.composables
package me.kavishdevar.librepods.composables
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -35,7 +35,7 @@ import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import me.kavishdevar.aln.R
import me.kavishdevar.librepods.R
@Composable
fun NoiseControlButton(

View File

@@ -1,29 +1,30 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
*
* Copyright (C) 2024 Kavish Devar
*
* 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.aln.composables
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.os.Build
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.Spring
@@ -50,7 +51,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
@@ -72,34 +72,36 @@ import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import me.kavishdevar.aln.R
import me.kavishdevar.aln.services.AirPodsService
import me.kavishdevar.aln.utils.AirPodsNotifications
import me.kavishdevar.aln.utils.NoiseControlMode
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications
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.math.roundToInt
@SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope")
@Composable
fun NoiseControlSettings(service: AirPodsService) {
fun NoiseControlSettings(
service: AirPodsService,
) {
val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val offListeningMode = remember { mutableStateOf(sharedPreferences.getBoolean("off_listening_mode", true)) }
val preferenceChangeListener = remember {
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == "off_listening_mode") {
offListeningMode.value = sharedPreferences.getBoolean("off_listening_mode", true)
}
}
}
DisposableEffect(Unit) {
sharedPreferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener)
onDispose {
sharedPreferences.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener)
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 backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8)
val textColor = if (isDarkTheme) Color.White else Color.Black
@@ -113,12 +115,20 @@ fun NoiseControlSettings(service: AirPodsService) {
val d3a = remember { mutableFloatStateOf(0f) }
fun onModeSelected(mode: NoiseControlMode, received: Boolean = false) {
if (!received && !offListeningMode.value && mode == NoiseControlMode.OFF) {
noiseControlMode.value = NoiseControlMode.ADAPTIVE
val previousMode = noiseControlMode.value
val targetMode = if (!offListeningMode.value && mode == NoiseControlMode.OFF) {
NoiseControlMode.TRANSPARENCY
} else {
noiseControlMode.value = mode
mode
}
if (!received) service.setANCMode(mode.ordinal + 1)
noiseControlMode.value = targetMode
if (!received && targetMode != previousMode) {
service.aacpManager.sendControlCommand(identifier = AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, value = targetMode.ordinal + 1)
}
when (noiseControlMode.value) {
NoiseControlMode.NOISE_CANCELLATION -> {
d1a.floatValue = 1f
@@ -312,9 +322,10 @@ fun NoiseControlSettings(service: AirPodsService) {
1 -> if (offListeningMode.value) NoiseControlMode.TRANSPARENCY else NoiseControlMode.ADAPTIVE
2 -> if (offListeningMode.value) NoiseControlMode.ADAPTIVE else NoiseControlMode.NOISE_CANCELLATION
3 -> NoiseControlMode.NOISE_CANCELLATION
else -> null
else -> noiseControlMode.value // Keep current if index is invalid
}
newMode?.let { onModeSelected(it) }
// Call onModeSelected which now handles service call but not callback
onModeSelected(newMode)
}
)
) {
@@ -430,4 +441,4 @@ fun NoiseControlSettings(service: AirPodsService) {
@Composable
fun NoiseControlSettingsPreview() {
NoiseControlSettings(AirPodsService())
}
}

View File

@@ -1,23 +1,24 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
*
* Copyright (C) 2024 Kavish Devar
*
* 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.aln.composables
package me.kavishdevar.librepods.composables
import android.content.Context
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
@@ -56,7 +57,8 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import me.kavishdevar.aln.R
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.StemAction
@Composable
fun PressAndHoldSettings(navController: NavController) {
@@ -70,6 +72,24 @@ fun PressAndHoldSettings(navController: NavController) {
val animatedLeftBackgroundColor by animateColorAsState(targetValue = leftBackgroundColor, animationSpec = animationSpec)
val animatedRightBackgroundColor by animateColorAsState(targetValue = rightBackgroundColor, animationSpec = animationSpec)
val context = LocalContext.current
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.DIGITAL_ASSISTANT -> "Digital Assistant"
else -> "INVALID!!"
}
val rightActionText = when (StemAction.valueOf(rightAction ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) {
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
else -> "INVALID!!"
}
Text(
text = stringResource(R.string.press_and_hold_airpods).uppercase(),
style = TextStyle(
@@ -81,6 +101,8 @@ fun PressAndHoldSettings(navController: NavController) {
modifier = Modifier.padding(8.dp, bottom = 2.dp)
)
Spacer(modifier = Modifier.height(1.dp))
Column(
modifier = Modifier
.fillMaxWidth()
@@ -120,7 +142,7 @@ fun PressAndHoldSettings(navController: NavController) {
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = stringResource(R.string.noise_control),
text = leftActionText,
style = TextStyle(
fontSize = 18.sp,
color = textColor.copy(alpha = 0.6f),
@@ -180,7 +202,7 @@ fun PressAndHoldSettings(navController: NavController) {
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = stringResource(R.string.noise_control),
text = rightActionText,
style = TextStyle(
fontSize = 18.sp,
color = textColor.copy(alpha = 0.6f),

View File

@@ -1,24 +1,25 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
*
* Copyright (C) 2024 Kavish Devar
*
* 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.aln.composables
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.content.SharedPreferences
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
@@ -41,24 +42,31 @@ 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.aln.services.AirPodsService
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun SinglePodANCSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
fun SinglePodANCSwitch() {
val service = ServiceManager.getService()!!
val singleANCEnabledValue = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
var singleANCEnabled by remember {
mutableStateOf(
sharedPreferences.getBoolean("single_anc", true)
singleANCEnabledValue == 1.toByte()
)
}
fun updateSingleEnabled(enabled: Boolean) {
singleANCEnabled = enabled
sharedPreferences.edit().putBoolean("single_anc", enabled).apply()
service.setNoiseCancellationWithOnePod(enabled)
service.aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE.value,
enabled
)
}
val isDarkTheme = isSystemInDarkTheme()
@@ -121,5 +129,5 @@ fun SinglePodANCSwitch(service: AirPodsService, sharedPreferences: SharedPrefere
@Preview
@Composable
fun SinglePodANCSwitchPreview() {
SinglePodANCSwitch(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0))
}
SinglePodANCSwitch()
}

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2024 Kavish Devar
* 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
@@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.aln.composables
package me.kavishdevar.librepods.composables
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background

View File

@@ -1,24 +1,26 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
*
* Copyright (C) 2024 Kavish Devar
*
* 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.aln.composables
@file:OptIn(ExperimentalEncodingApi::class)
import android.content.SharedPreferences
package me.kavishdevar.librepods.composables
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
@@ -35,14 +37,12 @@ import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
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
@@ -50,22 +50,23 @@ 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 me.kavishdevar.aln.services.AirPodsService
import me.kavishdevar.aln.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.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ToneVolumeSlider(service: AirPodsService, sharedPreferences: SharedPreferences) {
val sliderValue = remember { mutableFloatStateOf(0f) }
LaunchedEffect(sliderValue) {
if (sharedPreferences.contains("tone_volume")) {
sliderValue.floatValue = sharedPreferences.getInt("tone_volume", 0).toFloat()
}
}
LaunchedEffect(sliderValue.floatValue) {
sharedPreferences.edit().putInt("tone_volume", sliderValue.floatValue.toInt()).apply()
}
fun ToneVolumeSlider() {
val service = ServiceManager.getService()!!
val sliderValueFromAACP = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
val sliderValue = remember { mutableFloatStateOf(
sliderValueFromAACP?.toFloat() ?: -1f
) }
Log.d("ToneVolumeSlider", "Slider value: ${sliderValue.floatValue}")
val isDarkTheme = isSystemInDarkTheme()
@@ -74,7 +75,6 @@ fun ToneVolumeSlider(service: AirPodsService, sharedPreferences: SharedPreferenc
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
val labelTextColor = if (isDarkTheme) Color.White else Color.Black
Row(
modifier = Modifier
.fillMaxWidth(),
@@ -95,11 +95,16 @@ fun ToneVolumeSlider(service: AirPodsService, sharedPreferences: SharedPreferenc
value = sliderValue.floatValue,
onValueChange = {
sliderValue.floatValue = it
service.setToneVolume(volume = it.toInt())
},
valueRange = 0f..100f,
onValueChangeFinished = {
sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat()
service.aacpManager.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME.value,
value = byteArrayOf(sliderValue.floatValue.toInt().toByte(),
0x50.toByte()
)
)
},
modifier = Modifier
.weight(1f)
@@ -156,5 +161,5 @@ fun ToneVolumeSlider(service: AirPodsService, sharedPreferences: SharedPreferenc
@Preview
@Composable
fun ToneVolumeSliderPreview() {
ToneVolumeSlider(AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0))
}
ToneVolumeSlider()
}

View File

@@ -0,0 +1,190 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2025 LibrePods Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU 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.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
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.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.sign
@Composable
fun VerticalVolumeSlider(
displayFraction: Float,
maxVolume: Int,
onVolumeChange: (Int) -> Unit,
initialFraction: Float,
onDragStateChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
baseSliderHeight: Dp = 400.dp,
baseSliderWidth: Dp = 145.dp,
baseCornerRadius: Dp = 45.dp,
maxStretchFactor: Float = 1.15f,
minCompressionFactor: Float = 0.875f,
stretchSensitivity: Float = 1.0f,
compressionSensitivity: Float = 1.0f,
cornerRadiusChangeFactor: Float = 0.2f,
directionalStretchRatio: Float = 0.75f
) {
val trackColor = Color(0x593C3C3E)
val progressColor = Color.White
var dragFraction by remember { mutableFloatStateOf(initialFraction) }
var isDragging by remember { mutableStateOf(false) }
var rawDragPosition by remember { mutableFloatStateOf(initialFraction) }
var overscrollAmount by remember { mutableFloatStateOf(0f) }
val baseHeightPx = with(LocalDensity.current) { baseSliderHeight.toPx() }
val animatedProgress by animateFloatAsState(
targetValue = dragFraction.coerceIn(0f, 1f),
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMedium
),
label = "ProgressAnimation"
)
val animatedOverscroll by animateFloatAsState(
targetValue = overscrollAmount,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMediumLow
),
label = "OverscrollAnimation"
)
val maxOverscrollEffect = (maxStretchFactor - 1f).coerceAtLeast(0f)
val stretchMultiplier = stretchSensitivity
val compressionMultiplier = compressionSensitivity
val overscrollDirection = sign(animatedOverscroll)
val totalStretchAmount = (min(maxOverscrollEffect, abs(animatedOverscroll) * stretchMultiplier) * baseSliderHeight.value).dp
val offsetY = if (abs(animatedOverscroll) > 0.001f) {
val asymmetricOffset = totalStretchAmount * (directionalStretchRatio - 0.5f)
(-overscrollDirection * asymmetricOffset.value).dp
} else {
0.dp
}
val heightStretch = baseSliderHeight + totalStretchAmount
val widthCompression = baseSliderWidth * max(
minCompressionFactor,
1f - min(1f - minCompressionFactor, abs(animatedOverscroll) * compressionMultiplier)
)
val dynamicCornerRadius = baseCornerRadius * (1f - min(cornerRadiusChangeFactor, abs(animatedOverscroll) * cornerRadiusChangeFactor * 2f))
Box(
modifier = modifier,
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.height(heightStretch)
.width(widthCompression)
.offset(y = offsetY)
.clip(RoundedCornerShape(dynamicCornerRadius))
.background(trackColor)
.pointerInput(Unit) {
detectTapGestures { offset ->
val newFraction = 1f - (offset.y / size.height).coerceIn(0f, 1f)
dragFraction = newFraction
rawDragPosition = newFraction
overscrollAmount = 0f
val newVolume = (newFraction * maxVolume).roundToInt()
onVolumeChange(newVolume)
}
}
.draggable(
orientation = Orientation.Vertical,
state = rememberDraggableState { delta ->
rawDragPosition -= (delta / baseHeightPx)
dragFraction = rawDragPosition.coerceIn(0f, 1f)
overscrollAmount = when {
rawDragPosition > 1f -> min(1.0f, (rawDragPosition - 1f) * 2.0f)
rawDragPosition < 0f -> max(-1.0f, rawDragPosition * 2.0f)
else -> 0f
}
val newVolume = (dragFraction * maxVolume).roundToInt()
onVolumeChange(newVolume)
},
onDragStarted = {
isDragging = true
dragFraction = displayFraction
rawDragPosition = displayFraction
overscrollAmount = 0f
onDragStateChange(true)
},
onDragStopped = {
isDragging = false
overscrollAmount = 0f
rawDragPosition = dragFraction
onDragStateChange(false)
}
),
contentAlignment = Alignment.BottomCenter
) {
Box(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(animatedProgress)
.background(progressColor)
)
}
}
}

View File

@@ -1,24 +1,25 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
*
* Copyright (C) 2024 Kavish Devar
*
* 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.aln.composables
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.content.SharedPreferences
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
@@ -41,23 +42,30 @@ 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.aln.services.AirPodsService
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun VolumeControlSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
fun VolumeControlSwitch() {
val service = ServiceManager.getService()!!
val volumeControlEnabledValue = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
var volumeControlEnabled by remember {
mutableStateOf(
sharedPreferences.getBoolean("volume_control", true)
volumeControlEnabledValue == 1.toByte()
)
}
fun updateVolumeControlEnabled(enabled: Boolean) {
volumeControlEnabled = enabled
sharedPreferences.edit().putBoolean("volume_control", enabled).apply()
service.setVolumeControl(enabled)
service.aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE.value,
enabled
)
}
val isDarkTheme = isSystemInDarkTheme()
@@ -120,5 +128,5 @@ fun VolumeControlSwitch(service: AirPodsService, sharedPreferences: SharedPrefer
@Preview
@Composable
fun VolumeControlSwitchPreview() {
VolumeControlSwitch(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0))
}
VolumeControlSwitch()
}

View File

@@ -0,0 +1,255 @@
/*
* 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 android.os.Parcelable
import android.util.Log
import kotlinx.parcelize.Parcelize
enum class Enums(val value: ByteArray) {
NOISE_CANCELLATION(Capabilities.NOISE_CANCELLATION),
PREFIX(byteArrayOf(0x04, 0x00, 0x04, 0x00)),
SETTINGS(byteArrayOf(0x09, 0x00)),
NOISE_CANCELLATION_PREFIX(PREFIX.value + SETTINGS.value + NOISE_CANCELLATION.value),
CONVERSATION_AWARENESS_RECEIVE_PREFIX(PREFIX.value + byteArrayOf(0x4b, 0x00, 0x02, 0x00)),
}
object BatteryComponent {
const val LEFT = 4
const val RIGHT = 2
const val CASE = 8
}
object BatteryStatus {
const val CHARGING = 1
const val NOT_CHARGING = 2
const val DISCONNECTED = 4
}
@Parcelize
data class Battery(val component: Int, val level: Int, val status: Int) : Parcelable {
fun getComponentName(): String? {
return when (component) {
BatteryComponent.LEFT -> "LEFT"
BatteryComponent.RIGHT -> "RIGHT"
BatteryComponent.CASE -> "CASE"
else -> null
}
}
fun getStatusName(): String? {
return when (status) {
BatteryStatus.CHARGING -> "CHARGING"
BatteryStatus.NOT_CHARGING -> "NOT_CHARGING"
BatteryStatus.DISCONNECTED -> "DISCONNECTED"
else -> null
}
}
}
enum class NoiseControlMode {
OFF, NOISE_CANCELLATION, TRANSPARENCY, ADAPTIVE
}
class AirPodsNotifications {
companion object {
const val AIRPODS_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED"
const val AIRPODS_DATA = "me.kavishdevar.librepods.AIRPODS_DATA"
const val EAR_DETECTION_DATA = "me.kavishdevar.librepods.EAR_DETECTION_DATA"
const val ANC_DATA = "me.kavishdevar.librepods.ANC_DATA"
const val BATTERY_DATA = "me.kavishdevar.librepods.BATTERY_DATA"
const val CA_DATA = "me.kavishdevar.librepods.CA_DATA"
const val AIRPODS_DISCONNECTED = "me.kavishdevar.librepods.AIRPODS_DISCONNECTED"
const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTION_DETECTED"
const val DISCONNECT_RECEIVERS = "me.kavishdevar.librepods.DISCONNECT_RECEIVERS"
}
class EarDetection {
private val notificationBit = Capabilities.EAR_DETECTION
private val notificationPrefix = Enums.PREFIX.value + notificationBit
var status: List<Byte> = listOf(0x01, 0x01)
fun setStatus(data: ByteArray) {
status = listOf(data[6], data[7])
}
fun isEarDetectionData(data: ByteArray): Boolean {
if (data.size != 8) {
return false
}
val prefixHex = notificationPrefix.joinToString("") { "%02x".format(it) }
val dataHex = data.joinToString("") { "%02x".format(it) }
return dataHex.startsWith(prefixHex)
}
}
class ANC {
private val notificationPrefix = Enums.NOISE_CANCELLATION_PREFIX.value
var status: Int = 1
private set
fun isANCData(data: ByteArray): Boolean {
if (data.size != 11) {
return false
}
val prefixHex = notificationPrefix.joinToString("") { "%02x".format(it) }
val dataHex = data.joinToString("") { "%02x".format(it) }
return dataHex.startsWith(prefixHex)
}
fun setStatus(data: ByteArray) {
when (data.size) {
// if the whole packet is given
11 -> {
status = data[7].toInt()
}
// if only the data is given
1 -> {
status = data[0].toInt()
}
// if the value of control command is given
4 -> {
status = data[0].toInt()
}
else -> {
Log.d("ANC", "Invalid ANC data size: ${data.size}")
}
}
}
val name: String =
when (status) {
1 -> "OFF"
2 -> "ON"
3 -> "TRANSPARENCY"
4 -> "ADAPTIVE"
else -> "UNKNOWN"
}
}
class BatteryNotification {
private var first: Battery = Battery(BatteryComponent.LEFT, 0, BatteryStatus.DISCONNECTED)
private var second: Battery = Battery(BatteryComponent.RIGHT, 0, BatteryStatus.DISCONNECTED)
private var case: Battery = Battery(BatteryComponent.CASE, 0, BatteryStatus.DISCONNECTED)
fun isBatteryData(data: ByteArray): Boolean {
if (data.joinToString("") { "%02x".format(it) }.startsWith("040004000400")) {
Log.d("BatteryNotification", "Battery data starts with 040004000400. Most likely is a battery packet.")
} else {
return false
}
if (data.size != 22) {
Log.d("BatteryNotification", "Battery data size is not 22, probably being used with Airpods with fewer or more battery count.")
return false
}
Log.d("BatteryNotification", data.joinToString("") { "%02x".format(it) }.startsWith("040004000400").toString())
return data.joinToString("") { "%02x".format(it) }.startsWith("040004000400")
}
fun setBatteryDirect(
leftLevel: Int,
leftCharging: Boolean,
rightLevel: Int,
rightCharging: Boolean,
caseLevel: Int,
caseCharging: Boolean
) {
first = Battery(BatteryComponent.LEFT, leftLevel, if (leftCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING)
second = Battery(BatteryComponent.RIGHT, rightLevel, if (rightCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING)
case = Battery(BatteryComponent.CASE, caseLevel, if (caseCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING)
}
fun setBattery(data: ByteArray) {
if (data.size != 22) {
return
}
first = if (data[10].toInt() == BatteryStatus.DISCONNECTED) {
Battery(first.component, first.level, data[10].toInt())
} else {
Battery(data[7].toInt(), data[9].toInt(), data[10].toInt())
}
second = if (data[15].toInt() == BatteryStatus.DISCONNECTED) {
Battery(second.component, second.level, data[15].toInt())
} else {
Battery(data[12].toInt(), data[14].toInt(), data[15].toInt())
}
case = if (data[20].toInt() == BatteryStatus.DISCONNECTED && case.status != BatteryStatus.DISCONNECTED) {
Battery(case.component, case.level, data[20].toInt())
} else {
Battery(data[17].toInt(), data[19].toInt(), data[20].toInt())
}
}
fun getBattery(): List<Battery> {
val left = if (first.component == BatteryComponent.LEFT) first else second
val right = if (first.component == BatteryComponent.LEFT) second else first
return listOf(left, right, case)
}
}
class ConversationalAwarenessNotification {
@Suppress("PrivatePropertyName")
private val NOTIFICATION_PREFIX = Enums.CONVERSATION_AWARENESS_RECEIVE_PREFIX.value
var status: Byte = 0
private set
fun isConversationalAwarenessData(data: ByteArray): Boolean {
if (data.size != 10) {
return false
}
val prefixHex = NOTIFICATION_PREFIX.joinToString("") { "%02x".format(it) }
val dataHex = data.joinToString("") { "%02x".format(it) }
return dataHex.startsWith(prefixHex)
}
fun setData(data: ByteArray) {
status = data[9]
}
}
}
class Capabilities {
companion object {
val NOISE_CANCELLATION = byteArrayOf(0x0d)
val EAR_DETECTION = byteArrayOf(0x06)
}
}
fun isHeadTrackingData(data: ByteArray): Boolean {
if (data.size <= 60) return false
val prefixPattern = byteArrayOf(
0x04, 0x00, 0x04, 0x00, 0x17, 0x00, 0x00, 0x00,
0x10, 0x00
)
for (i in prefixPattern.indices) {
if (data[i] != prefixPattern[i].toByte()) return false
}
if (data[10] != 0x44.toByte() && data[10] != 0x45.toByte()) return false
if (data[11] != 0x00.toByte()) return false
return true
}

View File

@@ -0,0 +1,42 @@
/*
* 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.constants.StemAction.entries
import me.kavishdevar.librepods.utils.AACPManager
enum class StemAction {
PLAY_PAUSE,
PREVIOUS_TRACK,
NEXT_TRACK,
CAMERA_SHUTTER,
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,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2024 Kavish Devar
* 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
@@ -16,12 +16,15 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.aln.receivers
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.receivers
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import me.kavishdevar.aln.services.AirPodsService
import kotlin.io.encoding.ExperimentalEncodingApi
import me.kavishdevar.librepods.services.AirPodsService
class BootReceiver: BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {

View File

@@ -0,0 +1,484 @@
/*
* 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.screens
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Context.MODE_PRIVATE
import android.content.Context.RECEIVER_EXPORTED
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.os.Build
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.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
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.TextFieldValue
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 androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import dev.chrisbanes.haze.HazeEffectScope
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.AccessibilitySettings
import me.kavishdevar.librepods.composables.AudioSettings
import me.kavishdevar.librepods.composables.BatteryView
import me.kavishdevar.librepods.composables.IndependentToggle
import me.kavishdevar.librepods.composables.NameField
import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.composables.NoiseControlSettings
import me.kavishdevar.librepods.composables.PressAndHoldSettings
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
@Composable
fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
navController: NavController, isConnected: Boolean, isRemotelyConnected: Boolean) {
var isLocallyConnected by remember { mutableStateOf(isConnected) }
var isRemotelyConnected by remember { mutableStateOf(isRemotelyConnected) }
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
val bleOnlyMode = sharedPreferences.getBoolean("ble_only_mode", false)
var device by remember { mutableStateOf(dev) }
var deviceName by remember {
mutableStateOf(
TextFieldValue(
sharedPreferences.getString("name", device?.name ?: "AirPods Pro").toString()
)
)
}
LaunchedEffect(service) {
isLocallyConnected = service.isConnectedLocally
}
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 verticalScrollState = rememberScrollState()
val hazeState = remember { HazeState() }
val snackbarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope()
fun handleRemoteConnection(connected: Boolean) {
isRemotelyConnected = connected
}
fun showSnackbar(message: String) {
coroutineScope.launch {
snackbarHostState.showSnackbar(message)
}
}
val context = LocalContext.current
val connectionReceiver = remember {
object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
"me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY" -> {
coroutineScope.launch {
handleRemoteConnection(true)
}
}
"me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY" -> {
coroutineScope.launch {
handleRemoteConnection(false)
}
}
AirPodsNotifications.AIRPODS_CONNECTED -> {
coroutineScope.launch {
isLocallyConnected = true
}
}
AirPodsNotifications.AIRPODS_DISCONNECTED -> {
coroutineScope.launch {
isLocallyConnected = false
}
}
AirPodsNotifications.DISCONNECT_RECEIVERS -> {
try {
context?.unregisterReceiver(this)
} catch (e: IllegalArgumentException) {
e.printStackTrace()
}
}
}
}
}
}
DisposableEffect(Unit) {
val filter = IntentFilter().apply {
addAction("me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY")
addAction("me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY")
addAction(AirPodsNotifications.AIRPODS_CONNECTED)
addAction(AirPodsNotifications.AIRPODS_DISCONNECTED)
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(connectionReceiver, filter, RECEIVER_EXPORTED)
} else {
context.registerReceiver(connectionReceiver, filter)
}
onDispose {
try {
context.unregisterReceiver(connectionReceiver)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
Scaffold(
containerColor = if (isSystemInDarkTheme()) Color(
0xFF000000
) else Color(
0xFFF2F2F7
),
topBar = {
val darkMode = isSystemInDarkTheme()
val mDensity = remember { mutableFloatStateOf(1f) }
CenterAlignedTopAppBar(
title = {
Text(
text = deviceName.text,
style = TextStyle(
fontSize = 20.sp,
fontWeight = FontWeight.Medium,
color = if (darkMode) Color.White else Color.Black,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
},
modifier = Modifier
.hazeEffect(
state = hazeState,
style = CupertinoMaterials.thick(),
block = fun HazeEffectScope.() {
alpha =
if (verticalScrollState.value > 60.dp.value * mDensity.floatValue) 1f else 0f
})
.drawBehind {
mDensity.floatValue = density
val strokeWidth = 0.7.dp.value * density
val y = size.height - strokeWidth / 2
if (verticalScrollState.value > 60.dp.value * density) {
drawLine(
if (darkMode) Color.DarkGray else Color.LightGray,
Offset(0f, y),
Offset(size.width, y),
strokeWidth
)
}
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent
),
actions = {
if (isRemotelyConnected) {
IconButton(
onClick = {
showSnackbar("Connected remotely to AirPods via Linux.")
},
colors = IconButtonDefaults.iconButtonColors(
containerColor = Color.Transparent,
contentColor = if (isSystemInDarkTheme()) Color.White else Color.Black
)
) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = "Info",
)
}
}
IconButton(
onClick = {
navController.navigate("app_settings")
},
colors = IconButtonDefaults.iconButtonColors(
containerColor = Color.Transparent,
contentColor = if (isSystemInDarkTheme()) Color.White else Color.Black
)
) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = "Settings",
)
}
}
)
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { paddingValues ->
if (isLocallyConnected || isRemotelyConnected) {
Column(
modifier = Modifier
.hazeSource(hazeState)
.fillMaxSize()
.padding(horizontal = 16.dp)
.verticalScroll(
state = verticalScrollState,
enabled = true,
)
) {
Spacer(Modifier.height(75.dp))
LaunchedEffect(service) {
service.let {
it.sendBroadcast(Intent(AirPodsNotifications.Companion.BATTERY_DATA).apply {
putParcelableArrayListExtra("data", ArrayList(it.getBattery()))
})
it.sendBroadcast(Intent(AirPodsNotifications.Companion.ANC_DATA).apply {
putExtra("data", it.getANC())
})
}
}
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
Spacer(modifier = Modifier.height(64.dp))
BatteryView(service = service)
Spacer(modifier = Modifier.height(32.dp))
// Show BLE-only mode indicator
if (bleOnlyMode) {
Text(
text = "BLE-only mode - advanced features disabled",
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(8.dp, bottom = 16.dp)
)
}
// Only show name field when not in BLE-only mode
if (!bleOnlyMode) {
NameField(
name = stringResource(R.string.name),
value = deviceName.text,
navController = navController
)
}
// Only show L2CAP-dependent features when not in BLE-only mode
if (!bleOnlyMode) {
Spacer(modifier = Modifier.height(32.dp))
NoiseControlSettings(service = service)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.head_gestures).uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(8.dp, bottom = 2.dp)
)
Spacer(modifier = Modifier.height(2.dp))
NavigationButton(to = "head_tracking", "Head Tracking", navController)
Spacer(modifier = Modifier.height(16.dp))
PressAndHoldSettings(navController = navController)
Spacer(modifier = Modifier.height(16.dp))
AudioSettings()
Spacer(modifier = Modifier.height(16.dp))
IndependentToggle(
name = "Off Listening Mode",
service = service,
sharedPreferences = sharedPreferences,
default = false,
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
)
Spacer(modifier = Modifier.height(16.dp))
AccessibilitySettings()
}
Spacer(modifier = Modifier.height(16.dp))
IndependentToggle(
name = "Automatic Ear Detection",
service = service,
functionName = "setEarDetection",
sharedPreferences = sharedPreferences,
default = true,
)
// Only show debug when not in BLE-only mode
if (!bleOnlyMode) {
Spacer(modifier = Modifier.height(16.dp))
NavigationButton("debug", "Debug", navController)
}
Spacer(Modifier.height(24.dp))
}
}
else {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 8.dp)
.verticalScroll(
state = verticalScrollState,
enabled = true,
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "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 = "Please connect your AirPods to access settings.",
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))
Button(
onClick = { navController.navigate("troubleshooting") },
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFF2F2F7),
contentColor = if (isSystemInDarkTheme()) Color.White else Color.Black,
)
) {
Text(
text = "Troubleshoot Connection",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
}
}
@Preview
@Composable
fun AirPodsSettingsScreenPreview() {
Column (
modifier = Modifier.height(2000.dp)
) {
LibrePodsTheme (
darkTheme = true
) {
AirPodsSettingsScreen(dev = null, service = AirPodsService(), navController = rememberNavController(), isConnected = true, isRemotelyConnected = false)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,667 @@
/*
* 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(ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.screens
import android.annotation.SuppressLint
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Build
import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
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.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
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.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import dev.chrisbanes.haze.HazeEffectScope
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.BatteryStatus
import me.kavishdevar.librepods.constants.isHeadTrackingData
import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi
data class PacketInfo(
val type: String,
val description: String,
val rawData: String,
val parsedData: Map<String, String> = emptyMap(),
val isUnknown: Boolean = false
)
fun parsePacket(message: String): PacketInfo {
val rawData = if (message.startsWith("Sent")) message.substring(5) else message.substring(9)
val bytes = rawData.split(" ").mapNotNull {
it.takeIf { it.isNotEmpty() }?.toIntOrNull(16)?.toByte()
}.toByteArray()
val airPodsService = ServiceManager.getService()
if (airPodsService != null) {
return when {
message.startsWith("Sent") -> parseOutgoingPacket(bytes, rawData)
airPodsService.batteryNotification.isBatteryData(bytes) -> {
val batteryInfo = mutableMapOf<String, String>()
airPodsService.batteryNotification.setBattery(bytes)
val batteries = airPodsService.batteryNotification.getBattery()
val batteryInfoString = batteries.joinToString(", ") { battery ->
"${battery.getComponentName() ?: "Unknown"}: ${battery.level}% ${if (battery.status == BatteryStatus.CHARGING) "(Charging)" else ""}"
}
batteries.forEach { battery ->
if (battery.status != BatteryStatus.DISCONNECTED) {
batteryInfo[battery.getComponentName() ?: "Unknown"] =
"${battery.level}% ${if (battery.status == BatteryStatus.CHARGING) "(Charging)" else ""}"
}
}
PacketInfo(
"Battery",
batteryInfoString,
rawData,
batteryInfo
)
}
airPodsService.ancNotification.isANCData(bytes) -> {
airPodsService.ancNotification.setStatus(bytes)
val mode = when (airPodsService.ancNotification.status) {
1 -> "Off"
2 -> "Noise Cancellation"
3 -> "Transparency"
4 -> "Adaptive"
else -> "Unknown"
}
PacketInfo(
"Noise Control",
"Mode: $mode",
rawData,
mapOf("Mode" to mode)
)
}
airPodsService.earDetectionNotification.isEarDetectionData(bytes) -> {
airPodsService.earDetectionNotification.setStatus(bytes)
val status = airPodsService.earDetectionNotification.status
val primaryStatus = if (status[0] == 0.toByte()) "In ear" else "Out of ear"
val secondaryStatus = if (status[1] == 0.toByte()) "In ear" else "Out of ear"
PacketInfo(
"Ear Detection",
"Primary: $primaryStatus, Secondary: $secondaryStatus",
rawData,
mapOf("Primary" to primaryStatus, "Secondary" to secondaryStatus)
)
}
airPodsService.conversationAwarenessNotification.isConversationalAwarenessData(bytes) -> {
airPodsService.conversationAwarenessNotification.setData(bytes)
val statusMap = mapOf(
1.toByte() to "Started speaking",
2.toByte() to "Speaking",
8.toByte() to "Stopped speaking",
9.toByte() to "Not speaking"
)
val status = statusMap[airPodsService.conversationAwarenessNotification.status] ?:
"Unknown (${airPodsService.conversationAwarenessNotification.status})"
PacketInfo(
"Conversation Awareness",
"Status: $status",
rawData,
mapOf("Status" to status)
)
}
isHeadTrackingData(bytes) -> {
val horizontal = if (bytes.size >= 53)
"${bytes[51].toInt() and 0xFF or (bytes[52].toInt() shl 8)}" else "Unknown"
val vertical = if (bytes.size >= 55)
"${bytes[53].toInt() and 0xFF or (bytes[54].toInt() shl 8)}" else "Unknown"
PacketInfo(
"Head Tracking",
"Position data",
rawData,
mapOf("Horizontal" to horizontal, "Vertical" to vertical)
)
}
else -> PacketInfo("Unknown", "Unknown packet format", rawData, emptyMap(), true)
}
} else {
return if (message.startsWith("Sent")) {
parseOutgoingPacket(bytes, rawData)
} else {
PacketInfo("Unknown", "Unknown packet format", rawData, emptyMap(), true)
}
}
}
fun parseOutgoingPacket(bytes: ByteArray, rawData: String): PacketInfo {
if (bytes.size < 7) {
return PacketInfo("Unknown", "Unknown outgoing packet", rawData, emptyMap(), true)
}
return when {
bytes.size >= 16 &&
bytes[0] == 0x00.toByte() &&
bytes[1] == 0x00.toByte() &&
bytes[2] == 0x04.toByte() &&
bytes[3] == 0x00.toByte() -> {
PacketInfo("Handshake", "Initial handshake with AirPods", rawData)
}
bytes.size >= 11 &&
bytes[0] == 0x04.toByte() &&
bytes[1] == 0x00.toByte() &&
bytes[2] == 0x04.toByte() &&
bytes[3] == 0x00.toByte() &&
bytes[4] == 0x09.toByte() &&
bytes[5] == 0x00.toByte() &&
bytes[6] == 0x0d.toByte() -> {
val mode = when (bytes[7].toInt()) {
1 -> "Off"
2 -> "Noise Cancellation"
3 -> "Transparency"
4 -> "Adaptive"
else -> "Unknown"
}
PacketInfo("Noise Control", "Set mode to $mode", rawData, mapOf("Mode" to mode))
}
bytes.size >= 11 &&
bytes[0] == 0x04.toByte() &&
bytes[1] == 0x00.toByte() &&
bytes[2] == 0x04.toByte() &&
bytes[3] == 0x00.toByte() &&
bytes[4] == 0x09.toByte() &&
bytes[5] == 0x00.toByte() &&
bytes[6] == 0x28.toByte() -> {
val mode = if (bytes[7].toInt() == 1) "On" else "Off"
PacketInfo("Conversation Awareness", "Set mode to $mode", rawData, mapOf("Mode" to mode))
}
bytes.size > 10 &&
bytes[0] == 0x04.toByte() &&
bytes[1] == 0x00.toByte() &&
bytes[2] == 0x04.toByte() &&
bytes[3] == 0x00.toByte() &&
bytes[4] == 0x17.toByte() -> {
val action = if (bytes.joinToString(" ") { "%02X".format(it) }.contains("A1 02")) "Start" else "Stop"
PacketInfo("Head Tracking", "$action head tracking", rawData)
}
bytes.size >= 11 &&
bytes[0] == 0x04.toByte() &&
bytes[1] == 0x00.toByte() &&
bytes[2] == 0x04.toByte() &&
bytes[3] == 0x00.toByte() &&
bytes[4] == 0x09.toByte() &&
bytes[5] == 0x00.toByte() &&
bytes[6] == 0x1A.toByte() -> {
PacketInfo("Long Press Config", "Change long press modes", rawData)
}
bytes.size >= 9 &&
bytes[0] == 0x04.toByte() &&
bytes[1] == 0x00.toByte() &&
bytes[2] == 0x04.toByte() &&
bytes[3] == 0x00.toByte() &&
bytes[4] == 0x4d.toByte() -> {
PacketInfo("Feature Request", "Set specific features", rawData)
}
bytes.size >= 9 &&
bytes[0] == 0x04.toByte() &&
bytes[1] == 0x00.toByte() &&
bytes[2] == 0x04.toByte() &&
bytes[3] == 0x00.toByte() &&
bytes[4] == 0x0f.toByte() -> {
PacketInfo("Notifications", "Request notifications", rawData)
}
else -> PacketInfo("Unknown", "Unknown outgoing packet", rawData, emptyMap(), true)
}
}
@Composable
fun IOSCheckbox(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.size(24.dp)
.clickable { onCheckedChange(!checked) },
contentAlignment = Alignment.Center
) {
if (checked) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Checked",
tint = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5),
modifier = Modifier.size(20.dp)
)
}
}
}
@RequiresApi(Build.VERSION_CODES.Q)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class, ExperimentalFoundationApi::class)
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter", "UnspecifiedRegisterReceiverFlag")
@Composable
fun DebugScreen(navController: NavController) {
val hazeState = remember { HazeState() }
val context = LocalContext.current
val listState = rememberLazyListState()
val scrollOffset by remember { derivedStateOf { listState.firstVisibleItemScrollOffset } }
val focusManager = LocalFocusManager.current
val coroutineScope = rememberCoroutineScope()
val showMenu = remember { mutableStateOf(false) }
val airPodsService = remember { ServiceManager.getService() }
val packetLogs = airPodsService?.packetLogsFlow?.collectAsState(emptySet())?.value ?: emptySet()
val shouldScrollToBottom = remember { mutableStateOf(true) }
val refreshTrigger = remember { mutableStateOf(0) }
LaunchedEffect(refreshTrigger.value) {
while(true) {
delay(1000)
refreshTrigger.value = refreshTrigger.value + 1
}
}
val expandedItems = remember { mutableStateOf(setOf<Int>()) }
fun copyToClipboard(text: String) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Packet Data", text)
clipboard.setPrimaryClip(clip)
Toast.makeText(context, "Packet copied to clipboard", Toast.LENGTH_SHORT).show()
}
LaunchedEffect(packetLogs.size, refreshTrigger.value) {
if (shouldScrollToBottom.value && packetLogs.isNotEmpty()) {
listState.animateScrollToItem(packetLogs.size - 1)
}
}
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("Debug") },
navigationIcon = {
TextButton(
onClick = { navController.popBackStack() },
shape = RoundedCornerShape(8.dp),
) {
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowLeft,
contentDescription = "Back",
tint = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5),
modifier = Modifier.scale(1.5f)
)
Text(
sharedPreferences.getString("name", "AirPods")!!,
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
color = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
)
}
},
actions = {
Box {
IconButton(onClick = { showMenu.value = true }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "More Options",
tint = if (isSystemInDarkTheme()) Color.White else Color.Black
)
}
DropdownMenu(
expanded = showMenu.value,
onDismissRequest = { showMenu.value = false },
modifier = Modifier
.width(250.dp)
.background(
if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7)
)
.padding(vertical = 4.dp)
) {
DropdownMenuItem(
text = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
"Auto-scroll",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal
)
)
Spacer(modifier = Modifier.weight(1f))
IOSCheckbox(
checked = shouldScrollToBottom.value,
onCheckedChange = { shouldScrollToBottom.value = it }
)
}
},
onClick = {
shouldScrollToBottom.value = !shouldScrollToBottom.value
showMenu.value = false
}
)
HorizontalDivider(
color = if (isSystemInDarkTheme()) Color(0xFF3A3A3C) else Color(0xFFE5E5EA),
thickness = 0.5.dp
)
DropdownMenuItem(
text = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
"Clear logs",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal
)
)
Spacer(modifier = Modifier.weight(1f))
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Clear logs",
tint = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5)
)
}
},
onClick = {
ServiceManager.getService()?.clearLogs()
expandedItems.value = emptySet()
showMenu.value = false
}
)
}
}
},
modifier = Modifier.hazeEffect(
state = hazeState,
style = CupertinoMaterials.thick(),
block = fun HazeEffectScope.() {
alpha = if (scrollOffset > 0) 1f else 0f
}),
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
)
},
containerColor = if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7),
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.hazeSource(hazeState)
.padding(top = paddingValues.calculateTopPadding())
.navigationBarsPadding()
) {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
content = {
items(packetLogs.size) { index ->
val message = packetLogs.elementAt(index)
val isSent = message.startsWith("Sent")
val isExpanded = expandedItems.value.contains(index)
val packetInfo = parsePacket(message)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp, horizontal = 4.dp)
.combinedClickable(
onClick = {
expandedItems.value = if (isExpanded) {
expandedItems.value - index
} else {
expandedItems.value + index
}
},
onLongClick = {
copyToClipboard(packetInfo.rawData)
}
),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
shape = RoundedCornerShape(4.dp),
colors = CardDefaults.cardColors(
containerColor = if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7),
)
) {
Column(modifier = Modifier.padding(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = if (isSent) Icons.AutoMirrored.Filled.KeyboardArrowLeft else Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = if (isSent) Color.Green else Color.Red,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Column {
Text(
text = if (packetInfo.isUnknown) {
val shortenedData = packetInfo.rawData.take(60) +
(if (packetInfo.rawData.length > 60) "..." else "")
shortenedData
} else {
"${packetInfo.type}: ${packetInfo.description}"
},
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.hack))
)
)
if (isExpanded) {
Spacer(modifier = Modifier.height(4.dp))
if (packetInfo.parsedData.isNotEmpty()) {
packetInfo.parsedData.forEach { (key, value) ->
Row {
Text(
text = "$key: ",
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily(Font(R.font.hack))
),
color = Color.Gray
)
Text(
text = value,
style = TextStyle(
fontSize = 12.sp,
fontFamily = FontFamily(Font(R.font.hack))
),
color = Color.Gray
)
}
}
Spacer(modifier = Modifier.height(4.dp))
}
Text(
text = "Raw: ${packetInfo.rawData}",
style = TextStyle(
fontSize = 12.sp,
fontFamily = FontFamily(Font(R.font.hack))
),
color = Color.Gray
)
}
}
}
}
}
}
}
)
Spacer(modifier = Modifier.height(8.dp))
val airPodsService = ServiceManager.getService()?.let { mutableStateOf(it) }
HorizontalDivider()
Row(
modifier = Modifier
.fillMaxWidth()
.background(if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7)),
verticalAlignment = Alignment.CenterVertically
) {
val packet = remember { mutableStateOf(TextFieldValue("")) }
TextField(
value = packet.value,
onValueChange = { packet.value = it },
label = { Text("Packet") },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
.padding(bottom = 5.dp),
trailingIcon = {
IconButton(
onClick = {
if (packet.value.text.isNotBlank()) {
airPodsService?.value?.aacpManager?.sendPacket(
packet.value.text
.split(" ")
.map { it.toInt(16).toByte() }
.toByteArray()
)
packet.value = TextFieldValue("")
focusManager.clearFocus()
if (shouldScrollToBottom.value && packetLogs.isNotEmpty()) {
coroutineScope.launch {
try {
delay(100)
listState.animateScrollToItem(
index = (packetLogs.size - 1).coerceAtLeast(0),
scrollOffset = 0
)
} catch (e: Exception) {
e.printStackTrace()
listState.scrollToItem(
index = (packetLogs.size - 1).coerceAtLeast(0)
)
}
}
}
}
}
) {
@Suppress("DEPRECATION")
Icon(Icons.Filled.Send, contentDescription = "Send")
}
},
colors = TextFieldDefaults.colors(
focusedContainerColor = if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7),
unfocusedContainerColor = if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7),
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedTextColor = if (isSystemInDarkTheme()) Color.White else Color.Black,
unfocusedTextColor = if (isSystemInDarkTheme()) Color.White else Color.Black.copy(alpha = 0.6f),
focusedLabelColor = if (isSystemInDarkTheme()) Color.White.copy(alpha = 0.6f) else Color.Black,
unfocusedLabelColor = if (isSystemInDarkTheme()) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f),
),
shape = RoundedCornerShape(12.dp)
)
}
}
}
}

View File

@@ -0,0 +1,859 @@
/*
* 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.screens
import android.content.Context
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Canvas
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.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
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.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.asAndroidPath
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
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.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import dev.chrisbanes.haze.HazeEffectScope
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.IndependentToggle
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.HeadTracking
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.sin
import kotlin.random.Random
@ExperimentalHazeMaterialsApi
@RequiresApi(Build.VERSION_CODES.Q)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
@Composable
fun HeadTrackingScreen(navController: NavController) {
DisposableEffect(Unit) {
ServiceManager.getService()?.startHeadTracking()
onDispose {
ServiceManager.getService()?.stopHeadTracking()
}
}
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val scrollState = rememberScrollState()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val hazeState = remember { HazeState() }
var mDensity by remember { mutableFloatStateOf(0f) }
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
CenterAlignedTopAppBar(
modifier = Modifier.hazeEffect(
state = hazeState,
style = CupertinoMaterials.thick(),
block = fun HazeEffectScope.() {
alpha =
if (scrollState.value > 60.dp.value * mDensity) 1f else 0f
})
.drawBehind {
mDensity = density
val strokeWidth = 0.7.dp.value * density
val y = size.height - strokeWidth / 2
if (scrollState.value > 60.dp.value * density) {
drawLine(
if (isDarkTheme) Color.DarkGray else Color.LightGray,
Offset(0f, y),
Offset(size.width, y),
strokeWidth
)
}
},
title = {
Text(
stringResource(R.string.head_tracking),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
},
navigationIcon = {
TextButton(
onClick = {
navController.popBackStack()
if (ServiceManager.getService()?.isHeadTrackingActive == true) ServiceManager.getService()?.stopHeadTracking()
},
shape = RoundedCornerShape(8.dp),
modifier = Modifier.width(180.dp)
) {
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowLeft,
contentDescription = "Back",
tint = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
modifier = Modifier.scale(1.5f)
)
Text(
sharedPreferences.getString("name", "AirPods")!!,
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
color = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
}
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent
),
actions = {
var isActive by remember { mutableStateOf(ServiceManager.getService()?.isHeadTrackingActive == true) }
IconButton(
onClick = {
if (ServiceManager.getService()?.isHeadTrackingActive == false) {
ServiceManager.getService()?.startHeadTracking()
Log.d("HeadTrackingScreen", "Head tracking started")
isActive = true
} else {
ServiceManager.getService()?.stopHeadTracking()
Log.d("HeadTrackingScreen", "Head tracking stopped")
isActive = false
}
},
) {
Icon(
if (isActive) {
ImageVector.Builder(
name = "Pause",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
path(
fill = SolidColor(Color.Black),
pathBuilder = {
moveTo(6f, 5f)
lineTo(10f, 5f)
lineTo(10f, 19f)
lineTo(6f, 19f)
lineTo(6f, 5f)
moveTo(14f, 5f)
lineTo(18f, 5f)
lineTo(18f, 19f)
lineTo(14f, 19f)
lineTo(14f, 5f)
}
)
}.build()
} else Icons.Filled.PlayArrow,
contentDescription = "Start",
tint = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
modifier = Modifier.scale(1.5f)
)
}
},
scrollBehavior = scrollBehavior
)
},
containerColor = if (isSystemInDarkTheme()) Color(0xFF000000)
else Color(0xFFF2F2F7),
) { paddingValues ->
Column (
modifier = Modifier
.fillMaxSize()
.padding(paddingValues = paddingValues)
.padding(horizontal = 16.dp)
.padding(top = 8.dp)
.verticalScroll(scrollState)
.hazeSource(state = hazeState)
) {
val sharedPreferences =
LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
var gestureText by remember { mutableStateOf("") }
val coroutineScope = rememberCoroutineScope()
IndependentToggle(name = "Head Gestures", sharedPreferences = sharedPreferences)
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))
Text(
"Head Orientation",
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
),
modifier = Modifier.padding(start = 4.dp, bottom = 8.dp, top = 8.dp)
)
HeadVisualization()
Spacer(modifier = Modifier.height(16.dp))
Text(
"Acceleration",
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
),
modifier = Modifier.padding(start = 4.dp, bottom = 8.dp, top = 8.dp)
)
AccelerationPlot()
Spacer(modifier = Modifier.height(16.dp))
Button (
onClick = {
gestureText = "Shake your head or nod!"
coroutineScope.launch {
val accepted = ServiceManager.getService()?.testHeadGestures() ?: false
gestureText = if (accepted) "\"Yes\" gesture detected." else "\"No\" gesture detected."
}
},
modifier = Modifier
.fillMaxWidth()
.height(55.dp),
colors = ButtonDefaults.buttonColors(
containerColor = backgroundColor
),
shape = RoundedCornerShape(8.dp)
) {
Text(
"Test Head Gestures",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
),
)
}
var lastClickTime by remember { mutableLongStateOf(0L) }
var shouldExplode by remember { mutableStateOf(false) }
LaunchedEffect(gestureText) {
if (gestureText.isNotEmpty()) {
lastClickTime = System.currentTimeMillis()
delay(3000)
if (System.currentTimeMillis() - lastClickTime >= 3000) {
shouldExplode = true
}
}
}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.padding(top = 12.dp, bottom = 24.dp)
) {
AnimatedContent(
targetState = gestureText,
transitionSpec = {
(fadeIn(
animationSpec = tween(300)
) + slideInVertically(
initialOffsetY = { 40 },
animationSpec = tween(300)
)).togetherWith(fadeOut(animationSpec = tween(150)))
}
) { text ->
if (shouldExplode) {
LaunchedEffect(Unit) {
CoroutineScope(coroutineScope.coroutineContext).launch {
delay(750)
gestureText = ""
}
}
ParticleText(
text = text,
style = TextStyle(
fontSize = 20.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor,
textAlign = TextAlign.Center
),
onAnimationComplete = {
shouldExplode = false
},
)
} else {
Text(
text = text,
style = TextStyle(
fontSize = 20.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor,
textAlign = TextAlign.Center
),
modifier = Modifier
.fillMaxWidth()
)
}
}
}
}
}
}
private data class Particle(
val initialPosition: Offset,
val velocity: Offset,
var alpha: Float = 1f
)
@Composable
private fun ParticleText(
text: String,
style: TextStyle,
onAnimationComplete: () -> Unit,
) {
val particles = remember { mutableStateListOf<Particle>() }
val textMeasurer = rememberTextMeasurer()
var isAnimating by remember { mutableStateOf(true) }
var textVisible by remember { mutableStateOf(true) }
Canvas(modifier = Modifier.fillMaxWidth()) {
val textLayoutResult = textMeasurer.measure(text, style)
val textBounds = textLayoutResult.size
val centerX = (size.width - textBounds.width) / 2
val centerY = size.height / 2
if (textVisible && particles.isEmpty()) {
drawText(
textMeasurer = textMeasurer,
text = text,
style = style,
topLeft = Offset(centerX, centerY - textBounds.height / 2)
)
}
if (particles.isEmpty()) {
val random = Random(System.currentTimeMillis())
for (i in 0..100) {
val x = centerX + random.nextFloat() * textBounds.width
val y = centerY - textBounds.height / 2 + random.nextFloat() * textBounds.height
val vx = (random.nextFloat() - 0.5f) * 20
val vy = (random.nextFloat() - 0.5f) * 20
particles.add(Particle(Offset(x, y), Offset(vx, vy)))
}
textVisible = false
}
particles.forEach { particle ->
drawCircle(
color = style.color.copy(alpha = particle.alpha),
radius = 0.5.dp.toPx(),
center = particle.initialPosition
)
}
}
LaunchedEffect(text) {
while (isAnimating) {
delay(16)
particles.forEachIndexed { index, particle ->
particles[index] = particle.copy(
initialPosition = particle.initialPosition + particle.velocity,
alpha = (particle.alpha - 0.02f).coerceAtLeast(0f)
)
}
if (particles.all { it.alpha <= 0f }) {
isAnimating = false
onAnimationComplete()
}
}
}
}
@Composable
private fun HeadVisualization() {
val orientation by HeadTracking.orientation.collectAsState()
val darkTheme = isSystemInDarkTheme()
val backgroundColor = if (darkTheme) Color(0xFF1C1C1E) else Color.White
val strokeColor = if (darkTheme) Color.White else Color.Black
Card(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(2f),
colors = CardDefaults.cardColors(
containerColor = backgroundColor
)
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Canvas(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
val width = size.width
val height = size.height
val center = Offset(width / 2, height / 2)
val faceRadius = height * 0.35f
val pitch = Math.toRadians(orientation.pitch.toDouble())
val yaw = Math.toRadians(orientation.yaw.toDouble())
val cosY = cos(yaw).toFloat()
val sinY = sin(yaw).toFloat()
val cosP = cos(pitch).toFloat()
val sinP = sin(pitch).toFloat()
fun rotate3D(point: Triple<Float, Float, Float>): Triple<Float, Float, Float> {
val (x, y, z) = point
val x1 = x * cosY - z * sinY
val y1 = y
val z1 = x * sinY + z * cosY
val x2 = x1
val y2 = y1 * cosP - z1 * sinP
val z2 = y1 * sinP + z1 * cosP
return Triple(x2, y2, z2)
}
fun project(point: Triple<Float, Float, Float>): Pair<Float, Float> {
val (x, y, z) = point
val scale = 1f + (z / width)
return Pair(center.x + x * scale, center.y + y * scale)
}
val earWidth = height * 0.08f
val earHeight = height * 0.2f
val earOffsetX = height * 0.4f
val earOffsetY = 0f
val earZ = 0f
for (xSign in listOf(-1f, 1f)) {
val rotated = rotate3D(Triple(earOffsetX * xSign, earOffsetY, earZ))
val (earX, earY) = project(rotated)
drawRoundRect(
color = strokeColor,
topLeft = Offset(earX - earWidth/2, earY - earHeight/2),
size = Size(earWidth, earHeight),
cornerRadius = CornerRadius(earWidth/2),
style = Stroke(width = 4.dp.toPx())
)
}
val spherePath = Path()
val firstPoint = project(rotate3D(Triple(faceRadius, 0f, 0f)))
spherePath.moveTo(firstPoint.first, firstPoint.second)
for (i in 1..32) {
val angle = (i * 2 * Math.PI / 32).toFloat()
val point = project(rotate3D(Triple(
cos(angle) * faceRadius,
sin(angle) * faceRadius,
0f
)))
spherePath.lineTo(point.first, point.second)
}
spherePath.close()
drawContext.canvas.nativeCanvas.apply {
val paint = android.graphics.Paint().apply {
style = android.graphics.Paint.Style.FILL
shader = android.graphics.RadialGradient(
center.x + sinY * faceRadius * 0.3f,
center.y - sinP * faceRadius * 0.3f,
faceRadius * 1.4f,
intArrayOf(
backgroundColor.copy(alpha = 1f).toArgb(),
backgroundColor.copy(alpha = 0.95f).toArgb(),
backgroundColor.copy(alpha = 0.9f).toArgb(),
backgroundColor.copy(alpha = 0.8f).toArgb(),
backgroundColor.copy(alpha = 0.7f).toArgb()
),
floatArrayOf(0.3f, 0.5f, 0.7f, 0.8f, 1f),
android.graphics.Shader.TileMode.CLAMP
)
}
drawPath(spherePath.asAndroidPath(), paint)
val highlightPaint = android.graphics.Paint().apply {
style = android.graphics.Paint.Style.FILL
shader = android.graphics.RadialGradient(
center.x - faceRadius * 0.4f - sinY * faceRadius * 0.5f,
center.y - faceRadius * 0.4f - sinP * faceRadius * 0.5f,
faceRadius * 0.9f,
intArrayOf(
android.graphics.Color.WHITE,
android.graphics.Color.argb(100, 255, 255, 255),
android.graphics.Color.TRANSPARENT
),
floatArrayOf(0f, 0.3f, 1f),
android.graphics.Shader.TileMode.CLAMP
)
alpha = if (darkTheme) 30 else 60
}
drawPath(spherePath.asAndroidPath(), highlightPaint)
val secondaryHighlightPaint = android.graphics.Paint().apply {
style = android.graphics.Paint.Style.FILL
shader = android.graphics.RadialGradient(
center.x + faceRadius * 0.3f + sinY * faceRadius * 0.3f,
center.y + faceRadius * 0.3f - sinP * faceRadius * 0.3f,
faceRadius * 0.7f,
intArrayOf(
android.graphics.Color.WHITE,
android.graphics.Color.TRANSPARENT
),
floatArrayOf(0f, 1f),
android.graphics.Shader.TileMode.CLAMP
)
alpha = if (darkTheme) 15 else 30
}
drawPath(spherePath.asAndroidPath(), secondaryHighlightPaint)
val shadowPaint = android.graphics.Paint().apply {
style = android.graphics.Paint.Style.FILL
shader = android.graphics.RadialGradient(
center.x + sinY * faceRadius * 0.5f,
center.y - sinP * faceRadius * 0.5f,
faceRadius * 1.1f,
intArrayOf(
android.graphics.Color.TRANSPARENT,
android.graphics.Color.BLACK
),
floatArrayOf(0.7f, 1f),
android.graphics.Shader.TileMode.CLAMP
)
alpha = if (darkTheme) 40 else 20
}
drawPath(spherePath.asAndroidPath(), shadowPaint)
}
drawPath(
path = spherePath,
color = strokeColor,
style = Stroke(width = 4.dp.toPx())
)
val smileRadius = faceRadius * 0.5f
val smileStartAngle = -340f
val smileSweepAngle = 140f
val smileOffsetY = faceRadius * 0.1f
val smilePath = Path()
for (i in 0..32) {
val angle = Math.toRadians(smileStartAngle + (smileSweepAngle * i / 32.0))
val x = cos(angle.toFloat()) * smileRadius
val y = sin(angle.toFloat()) * smileRadius + smileOffsetY
val rotated = rotate3D(Triple(x, y, 0f))
val projected = project(rotated)
if (i == 0) {
smilePath.moveTo(projected.first, projected.second)
} else {
smilePath.lineTo(projected.first, projected.second)
}
}
drawPath(
path = smilePath,
color = strokeColor,
style = Stroke(
width = 4.dp.toPx(),
cap = StrokeCap.Round
)
)
val eyeOffsetX = height * 0.15f
val eyeOffsetY = height * 0.1f
val eyeLength = height * 0.08f
for (xSign in listOf(-1f, 1f)) {
val rotated = rotate3D(Triple(eyeOffsetX * xSign, -eyeOffsetY, 0f))
val (eyeX, eyeY) = project(rotated)
drawLine(
color = strokeColor,
start = Offset(eyeX, eyeY - eyeLength/2),
end = Offset(eyeX, eyeY + eyeLength/2),
strokeWidth = 4.dp.toPx(),
cap = StrokeCap.Round
)
}
drawContext.canvas.nativeCanvas.apply {
val paint = android.graphics.Paint().apply {
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
textSize = 12.sp.toPx()
textAlign = android.graphics.Paint.Align.RIGHT
typeface = android.graphics.Typeface.create(
"SF Pro",
android.graphics.Typeface.NORMAL
)
}
val pitch = orientation.pitch.toInt()
val yaw = orientation.yaw.toInt()
val text = "Pitch: ${pitch}° Yaw: ${yaw}°"
drawText(
text,
width - 8.dp.toPx(),
height - 8.dp.toPx(),
paint
)
}
}
}
}
}
@Composable
private fun AccelerationPlot() {
val acceleration by HeadTracking.acceleration.collectAsState()
val maxPoints = 100
val points = remember { mutableStateListOf<Pair<Float, Float>>() }
val darkTheme = isSystemInDarkTheme()
var maxAbs by remember { mutableFloatStateOf(1000f) }
LaunchedEffect(acceleration) {
points.add(Pair(acceleration.horizontal, acceleration.vertical))
if (points.size > maxPoints) {
points.removeAt(0)
}
val currentMax = points.maxOf { maxOf(abs(it.first), abs(it.second)) }
maxAbs = maxOf(currentMax * 1.2f, 1000f)
}
Card(
modifier = Modifier
.fillMaxWidth()
.height(300.dp),
colors = CardDefaults.cardColors(
containerColor = if (darkTheme) Color(0xFF1C1C1E) else Color.White
)
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Canvas(
modifier = Modifier.fillMaxSize()
) {
val width = size.width
val height = size.height
val xScale = width / maxPoints
val yScale = (height - 40.dp.toPx()) / (maxAbs * 2)
val zeroY = height / 2
val gridColor = if (darkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.1f)
for (i in 0..maxPoints step 10) {
val x = i * xScale
drawLine(
color = gridColor,
start = Offset(x, 0f),
end = Offset(x, height),
strokeWidth = 1.dp.toPx()
)
}
val gridStep = maxAbs / 4
for (value in (-maxAbs.toInt()..maxAbs.toInt()) step gridStep.toInt()) {
val y = zeroY - value * yScale
drawLine(
color = gridColor,
start = Offset(0f, y),
end = Offset(width, y),
strokeWidth = 1.dp.toPx()
)
}
drawLine(
color = if (darkTheme) Color.White.copy(alpha = 0.3f) else Color.Black.copy(alpha = 0.3f),
start = Offset(0f, zeroY),
end = Offset(width, zeroY),
strokeWidth = 1.5f.dp.toPx()
)
if (points.size > 1) {
for (i in 0 until points.size - 1) {
val x1 = i * xScale
val x2 = (i + 1) * xScale
drawLine(
color = Color(0xFF007AFF),
start = Offset(x1, zeroY - points[i].first * yScale),
end = Offset(x2, zeroY - points[i + 1].first * yScale),
strokeWidth = 2.dp.toPx()
)
drawLine(
color = Color(0xFFFF3B30),
start = Offset(x1, zeroY - points[i].second * yScale),
end = Offset(x2, zeroY - points[i + 1].second * yScale),
strokeWidth = 2.dp.toPx()
)
}
}
drawContext.canvas.nativeCanvas.apply {
val paint = android.graphics.Paint().apply {
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
textSize = 12.sp.toPx()
textAlign = android.graphics.Paint.Align.RIGHT
}
drawText("${maxAbs.toInt()}", 30.dp.toPx(), 20.dp.toPx(), paint)
drawText("0", 30.dp.toPx(), height/2, paint)
drawText("-${maxAbs.toInt()}", 30.dp.toPx(), height - 10.dp.toPx(), paint)
}
val legendY = 15.dp.toPx()
val textOffsetY = legendY + 5.dp.toPx() / 2
drawCircle(Color(0xFF007AFF), 5.dp.toPx(), Offset(width - 150.dp.toPx(), legendY))
drawContext.canvas.nativeCanvas.apply {
val paint = android.graphics.Paint().apply {
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
textSize = 12.sp.toPx()
textAlign = android.graphics.Paint.Align.LEFT
}
drawText("Horizontal", width - 140.dp.toPx(), textOffsetY, paint)
}
drawCircle(Color(0xFFFF3B30), 5.dp.toPx(), Offset(width - 70.dp.toPx(), legendY))
drawContext.canvas.nativeCanvas.apply {
val paint = android.graphics.Paint().apply {
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
textSize = 12.sp.toPx()
textAlign = android.graphics.Paint.Align.LEFT
}
drawText("Vertical", width - 60.dp.toPx(), textOffsetY, paint)
}
}
}
}
}
@ExperimentalHazeMaterialsApi
@RequiresApi(Build.VERSION_CODES.Q)
@Preview
@Composable
fun HeadTrackingScreenPreview() {
HeadTrackingScreen(navController = NavController(LocalContext.current))
}

View File

@@ -0,0 +1,670 @@
/*
* 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.screens
import android.content.Context
import android.util.Log
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
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.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
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.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
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.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.utils.RadareOffsetFinder
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Onboarding(navController: NavController, activityContext: Context) {
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White
val textColor = if (isDarkTheme) Color.White else Color.Black
val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
val radareOffsetFinder = remember { RadareOffsetFinder(activityContext) }
val progressState by radareOffsetFinder.progressState.collectAsState()
var isComplete by remember { mutableStateOf(false) }
var hasStarted by remember { mutableStateOf(false) }
var rootCheckPassed by remember { mutableStateOf(false) }
var checkingRoot by remember { mutableStateOf(false) }
var rootCheckFailed by remember { mutableStateOf(false) }
var moduleEnabled by remember { mutableStateOf(false) }
var bluetoothToggled by remember { mutableStateOf(false) }
var showMenu by remember { mutableStateOf(false) }
var showSkipDialog by remember { mutableStateOf(false) }
fun checkRootAccess() {
checkingRoot = true
rootCheckFailed = false
kotlinx.coroutines.MainScope().launch {
withContext(Dispatchers.IO) {
try {
val process = Runtime.getRuntime().exec("su -c id")
val exitValue = process.waitFor()
withContext(Dispatchers.Main) {
rootCheckPassed = (exitValue == 0)
rootCheckFailed = (exitValue != 0)
checkingRoot = false
}
} catch (e: Exception) {
Log.e("Onboarding", "Root check failed", e)
withContext(Dispatchers.Main) {
rootCheckPassed = false
rootCheckFailed = true
checkingRoot = false
}
}
}
}
}
LaunchedEffect(hasStarted) {
if (hasStarted && rootCheckPassed) {
Log.d("Onboarding", "Checking if hook offset is available...")
val isHookReady = radareOffsetFinder.isHookOffsetAvailable()
Log.d("Onboarding", "Hook offset ready: $isHookReady")
if (isHookReady) {
Log.d("Onboarding", "Hook is ready")
isComplete = true
} else {
Log.d("Onboarding", "Hook not ready, starting setup process...")
withContext(Dispatchers.IO) {
radareOffsetFinder.setupAndFindOffset()
}
}
}
}
LaunchedEffect(progressState) {
if (progressState is RadareOffsetFinder.ProgressState.Success) {
isComplete = true
}
}
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = {
Text(
"Setting Up",
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
),
actions = {
Box {
IconButton(onClick = { showMenu = true }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "More Options"
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
text = { Text("Skip Setup") },
onClick = {
showMenu = false
showSkipDialog = true
}
)
}
}
}
)
},
containerColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(16.dp))
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = backgroundColor),
shape = RoundedCornerShape(12.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
if (!rootCheckPassed && !hasStarted) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = "Root Access",
tint = accentColor,
modifier = Modifier.size(50.dp)
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Root Access Required",
style = TextStyle(
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "This app needs root access to hook onto the Bluetooth library",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
textAlign = TextAlign.Center,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor.copy(alpha = 0.7f)
)
)
if (rootCheckFailed) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Root access was denied. Please grant root permissions.",
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Normal,
textAlign = TextAlign.Center,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color(0xFFFF453A)
)
)
}
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = { checkRootAccess() },
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
colors = ButtonDefaults.buttonColors(
containerColor = accentColor
),
shape = RoundedCornerShape(8.dp),
enabled = !checkingRoot
) {
if (checkingRoot) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Text(
"Check Root Access",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
)
}
}
} else {
StatusIcon(if (hasStarted) progressState else RadareOffsetFinder.ProgressState.Idle, isDarkTheme)
Spacer(modifier = Modifier.height(24.dp))
AnimatedContent(
targetState = if (hasStarted) getStatusTitle(progressState, isComplete, moduleEnabled, bluetoothToggled) else "Setup Required",
transitionSpec = { fadeIn() togetherWith fadeOut() }
) { text ->
Text(
text = text,
style = TextStyle(
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
}
Spacer(modifier = Modifier.height(8.dp))
AnimatedContent(
targetState = if (hasStarted)
getStatusDescription(progressState, isComplete, moduleEnabled, bluetoothToggled)
else
"AirPods functionality requires one-time setup for hooking into Bluetooth library",
transitionSpec = { fadeIn() togetherWith fadeOut() }
) { text ->
Text(
text = text,
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
textAlign = TextAlign.Center,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor.copy(alpha = 0.7f)
)
)
}
Spacer(modifier = Modifier.height(24.dp))
if (!hasStarted) {
Button(
onClick = { hasStarted = true },
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
colors = ButtonDefaults.buttonColors(
containerColor = accentColor
),
shape = RoundedCornerShape(8.dp)
) {
Text(
"Start Setup",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
)
}
} else {
when (progressState) {
is RadareOffsetFinder.ProgressState.DownloadProgress -> {
val progress = (progressState as RadareOffsetFinder.ProgressState.DownloadProgress).progress
val animatedProgress by animateFloatAsState(
targetValue = progress,
label = "Download Progress"
)
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
LinearProgressIndicator(
progress = { animatedProgress },
modifier = Modifier
.fillMaxWidth()
.height(8.dp),
strokeCap = StrokeCap.Round,
color = accentColor
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "${(progress * 100).toInt()}%",
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor.copy(alpha = 0.6f)
)
)
}
}
is RadareOffsetFinder.ProgressState.Success -> {
if (!moduleEnabled) {
Button(
onClick = { moduleEnabled = true },
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
colors = ButtonDefaults.buttonColors(
containerColor = accentColor
),
shape = RoundedCornerShape(8.dp)
) {
Text(
"I've Enabled/Reactivated the Module",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
)
}
} else if (!bluetoothToggled) {
Button(
onClick = { bluetoothToggled = true },
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
colors = ButtonDefaults.buttonColors(
containerColor = accentColor
),
shape = RoundedCornerShape(8.dp)
) {
Text(
"I've Toggled Bluetooth",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
)
}
} else {
Button(
onClick = {
navController.navigate("settings") {
popUpTo("onboarding") { inclusive = true }
}
},
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
colors = ButtonDefaults.buttonColors(
containerColor = accentColor
),
shape = RoundedCornerShape(8.dp)
) {
Text(
"Continue to Settings",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
)
}
}
}
is RadareOffsetFinder.ProgressState.Idle,
is RadareOffsetFinder.ProgressState.Error -> {
// No specific UI for these states
}
else -> {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.height(8.dp),
strokeCap = StrokeCap.Round,
color = accentColor
)
}
}
}
}
}
}
Spacer(modifier = Modifier.weight(1f))
if (progressState is RadareOffsetFinder.ProgressState.Error && !isComplete && hasStarted) {
Button(
onClick = {
Log.d("Onboarding", "Trying to find offset again...")
kotlinx.coroutines.MainScope().launch {
withContext(Dispatchers.IO) {
radareOffsetFinder.setupAndFindOffset()
}
}
},
modifier = Modifier
.fillMaxWidth()
.height(55.dp),
colors = ButtonDefaults.buttonColors(
containerColor = accentColor
),
shape = RoundedCornerShape(8.dp)
) {
Text(
"Try Again",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
)
}
}
}
if (showSkipDialog) {
AlertDialog(
onDismissRequest = { showSkipDialog = false },
title = { Text("Skip Setup") },
text = {
Text(
"Have you installed the root module that patches the Bluetooth library directly? This option is for users who have manually patched their system instead of using the dynamic hook.",
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
},
confirmButton = {
val sharedPreferences = activityContext.getSharedPreferences("settings", Context.MODE_PRIVATE)
TextButton(
onClick = {
showSkipDialog = false
RadareOffsetFinder.clearHookOffsets()
sharedPreferences.edit().putBoolean("skip_setup", true).apply()
navController.navigate("settings") {
popUpTo("onboarding") { inclusive = true }
}
}
) {
Text(
"Yes, Skip Setup",
color = accentColor,
fontWeight = FontWeight.Bold
)
}
},
dismissButton = {
TextButton(
onClick = { showSkipDialog = false }
) {
Text("Cancel")
}
},
containerColor = backgroundColor,
textContentColor = textColor,
titleContentColor = textColor
)
}
}
}
@Composable
private fun StatusIcon(
progressState: RadareOffsetFinder.ProgressState,
isDarkTheme: Boolean
) {
val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
val errorColor = if (isDarkTheme) Color(0xFFFF453A) else Color(0xFFFF3B30)
val successColor = if (isDarkTheme) Color(0xFF30D158) else Color(0xFF34C759)
Box(
modifier = Modifier.size(80.dp),
contentAlignment = Alignment.Center
) {
when (progressState) {
is RadareOffsetFinder.ProgressState.Error -> {
Icon(
imageVector = Icons.Default.Clear,
contentDescription = "Error",
tint = errorColor,
modifier = Modifier.size(50.dp)
)
}
is RadareOffsetFinder.ProgressState.Success -> {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Success",
tint = successColor,
modifier = Modifier.size(50.dp)
)
}
is RadareOffsetFinder.ProgressState.Idle -> {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = "Settings",
tint = accentColor,
modifier = Modifier.size(50.dp)
)
}
else -> {
CircularProgressIndicator(
modifier = Modifier.size(50.dp),
color = accentColor,
strokeWidth = 4.dp
)
}
}
}
}
private fun getStatusTitle(
state: RadareOffsetFinder.ProgressState,
isComplete: Boolean,
moduleEnabled: Boolean,
bluetoothToggled: Boolean
): String {
return when (state) {
is RadareOffsetFinder.ProgressState.Success -> {
when {
!moduleEnabled -> "Enable Xposed Module"
!bluetoothToggled -> "Toggle Bluetooth"
else -> "Setup Complete"
}
}
is RadareOffsetFinder.ProgressState.Idle -> "Getting Ready"
is RadareOffsetFinder.ProgressState.CheckingExisting -> "Checking if radare2 already downloaded"
is RadareOffsetFinder.ProgressState.Downloading -> "Downloading radare2"
is RadareOffsetFinder.ProgressState.DownloadProgress -> "Downloading radare2"
is RadareOffsetFinder.ProgressState.Extracting -> "Extracting radare2"
is RadareOffsetFinder.ProgressState.MakingExecutable -> "Setting executable permissions"
is RadareOffsetFinder.ProgressState.FindingOffset -> "Finding function offset"
is RadareOffsetFinder.ProgressState.SavingOffset -> "Saving offset"
is RadareOffsetFinder.ProgressState.Cleaning -> "Cleaning Up"
is RadareOffsetFinder.ProgressState.Error -> "Setup Failed"
}
}
private fun getStatusDescription(
state: RadareOffsetFinder.ProgressState,
isComplete: Boolean,
moduleEnabled: Boolean,
bluetoothToggled: Boolean
): String {
return when (state) {
is RadareOffsetFinder.ProgressState.Success -> {
when {
!moduleEnabled -> "Please enable the LibrePods Xposed module in your Xposed manager (e.g. LSPosed). If already enabled, disable and re-enable it."
!bluetoothToggled -> "Please turn off and then turn on Bluetooth to apply the changes."
else -> "All set! You can now use your AirPods with enhanced functionality."
}
}
is RadareOffsetFinder.ProgressState.Idle -> "Preparing"
is RadareOffsetFinder.ProgressState.CheckingExisting -> "Checking if radare2 are already installed"
is RadareOffsetFinder.ProgressState.Downloading -> "Starting radare2 download"
is RadareOffsetFinder.ProgressState.DownloadProgress -> "Downloading radare2"
is RadareOffsetFinder.ProgressState.Extracting -> "Extracting radare2"
is RadareOffsetFinder.ProgressState.MakingExecutable -> "Setting executable permissions on radare2 binaries"
is RadareOffsetFinder.ProgressState.FindingOffset -> "Looking for the required Bluetooth function in system libraries"
is RadareOffsetFinder.ProgressState.SavingOffset -> "Saving the function offset"
is RadareOffsetFinder.ProgressState.Cleaning -> "Removing temporary extracted files"
is RadareOffsetFinder.ProgressState.Error -> state.message
}
}
@Preview
@Composable
fun OnboardingPreview() {
Onboarding(navController = NavController(LocalContext.current), activityContext = LocalContext.current)
}
private suspend fun delay(timeMillis: Long) {
kotlinx.coroutines.delay(timeMillis)
}

View File

@@ -0,0 +1,496 @@
/*
* 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(ExperimentalStdlibApi::class, ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.screens
import android.content.Context
import android.util.Log
import androidx.compose.animation.animateColorAsState
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.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
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.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
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 me.kavishdevar.librepods.constants.StemAction
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.experimental.and
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable()
fun RightDivider() {
HorizontalDivider(
thickness = 1.5.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(start = 72.dp)
)
}
@Composable()
fun RightDividerNoIcon() {
HorizontalDivider(
thickness = 1.5.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(start = 20.dp)
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LongPress(navController: NavController, name: String) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val modesByte = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
if (modesByte != null) {
Log.d("PressAndHoldSettingsScreen", "Current modes state: ${modesByte.toString(2)}")
Log.d("PressAndHoldSettingsScreen", "Off mode: ${(modesByte and 0x01) != 0.toByte()}")
Log.d("PressAndHoldSettingsScreen", "Transparency mode: ${(modesByte and 0x02) != 0.toByte()}")
Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x04) != 0.toByte()}")
Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}")
}
val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val deviceName = sharedPreferences.getString("name", "AirPods Pro")
val prefKey = if (name.lowercase() == "left") "left_long_press_action" else "right_long_press_action"
val longPressActionPref = sharedPreferences.getString(prefKey, StemAction.CYCLE_NOISE_CONTROL_MODES.name)
Log.d("PressAndHoldSettingsScreen", "Long press action preference ($prefKey): $longPressActionPref")
var longPressAction by remember { mutableStateOf(StemAction.valueOf(longPressActionPref ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) }
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = {
Text(
name,
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
},
navigationIcon = {
TextButton(
onClick = {
navController.popBackStack()
},
shape = RoundedCornerShape(8.dp),
) {
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowLeft,
contentDescription = "Back",
tint = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
modifier = Modifier.scale(1.5f)
)
Text(
deviceName?: "AirPods Pro",
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
color = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
)
)
},
containerColor = if (isSystemInDarkTheme()) Color(0xFF000000)
else Color(0xFFF2F2F7),
) { paddingValues ->
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Column (
modifier = Modifier
.fillMaxSize()
.padding(paddingValues = paddingValues)
.padding(horizontal = 16.dp)
.padding(top = 8.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp)),
horizontalAlignment = Alignment.CenterHorizontally
) {
LongPressActionElement(
name = "Noise Control",
selected = longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES,
onClick = {
longPressAction = StemAction.CYCLE_NOISE_CONTROL_MODES
sharedPreferences.edit().putString(prefKey, StemAction.CYCLE_NOISE_CONTROL_MODES.name).apply()
},
isFirst = true,
isLast = false
)
RightDividerNoIcon()
LongPressActionElement(
name = "Digital Assistant",
selected = longPressAction == StemAction.DIGITAL_ASSISTANT,
onClick = {
longPressAction = StemAction.DIGITAL_ASSISTANT
sharedPreferences.edit().putString(prefKey, StemAction.DIGITAL_ASSISTANT.name).apply()
},
isFirst = false,
isLast = true
)
}
if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) {
Text(
text = "NOISE CONTROL",
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
),
fontFamily = FontFamily(Font(R.font.sf_pro)),
modifier = Modifier
.padding(top = 32.dp, bottom = 4.dp)
.padding(horizontal = 8.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp)),
horizontalAlignment = Alignment.CenterHorizontally
) {
val offListeningModeValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
val offListeningMode = offListeningModeValue == 1.toByte()
LongPressElement(
name = "Off",
enabled = offListeningMode,
resourceId = R.drawable.noise_cancellation,
isFirst = true)
if (offListeningMode) RightDivider()
LongPressElement(
name = "Transparency",
resourceId = R.drawable.transparency,
isFirst = !offListeningMode)
RightDivider()
LongPressElement(
name = "Adaptive",
resourceId = R.drawable.adaptive)
RightDivider()
LongPressElement(
name = "Noise Cancellation",
resourceId = R.drawable.noise_cancellation,
isLast = true)
}
Text(
"Press and hold the stem to cycle between the selected noise control modes.",
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor.copy(alpha = 0.6f),
modifier = Modifier
.padding(start = 16.dp, top = 4.dp)
)
}
}
}
Log.d("PressAndHoldSettingsScreen", "Current byte: ${ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
}?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toString(2)}")
}
@Composable
fun LongPressElement(name: String, enabled: Boolean = true, resourceId: Int, isFirst: Boolean = false, isLast: Boolean = false) {
val bit = when (name) {
"Off" -> 0x01
"Transparency" -> 0x02
"Noise Cancellation" -> 0x04
"Adaptive" -> 0x08
else -> -1
}
val context = LocalContext.current
val currentByteValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
val savedByte = context.getSharedPreferences("settings", Context.MODE_PRIVATE).getInt("long_press_byte", 0b0101.toInt())
val byteValue = currentByteValue ?: (savedByte and 0xFF).toByte()
val isChecked = (byteValue.toInt() and bit) != 0
val checked = remember { mutableStateOf(isChecked) }
Log.d("PressAndHoldSettingsScreen", "LongPressElement: $name, checked: ${checked.value}, byteValue: ${byteValue.toInt()}, in bits: ${byteValue.toInt().toString(2)}")
val darkMode = isSystemInDarkTheme()
val textColor = if (darkMode) Color.White else Color.Black
val desc = when (name) {
"Off" -> "Turns off noise management"
"Noise Cancellation" -> "Blocks out external sounds"
"Transparency" -> "Lets in external sounds"
"Adaptive" -> "Dynamically adjust external noise"
else -> ""
}
fun countEnabledModes(byteValue: Int): Int {
var count = 0
if ((byteValue and 0x01) != 0) count++
if ((byteValue and 0x02) != 0) count++
if ((byteValue and 0x04) != 0) count++
if ((byteValue and 0x08) != 0) count++
Log.d("PressAndHoldSettingsScreen", "Byte: ${byteValue.toString(2)} Enabled modes: $count")
return count
}
fun valueChanged(value: Boolean = !checked.value) {
val latestByteValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
val currentValue = (latestByteValue?.toInt() ?: byteValue.toInt()) and 0xFF
Log.d("PressAndHoldSettingsScreen", "Current value: $currentValue (binary: ${Integer.toBinaryString(currentValue)}), bit: $bit, value: $value")
if (!value) {
val newValue = currentValue and bit.inv()
Log.d("PressAndHoldSettingsScreen", "Bit to disable: $bit, inverted: ${bit.inv()}, after AND: ${Integer.toBinaryString(newValue)}")
val modeCount = countEnabledModes(newValue)
Log.d("PressAndHoldSettingsScreen", "After disabling, enabled modes count: $modeCount")
if (modeCount < 2) {
Log.d("PressAndHoldSettingsScreen", "Cannot disable $name mode - need at least 2 modes enabled")
return
}
val updatedByte = newValue.toByte()
Log.d("PressAndHoldSettingsScreen", "Sending updated byte: ${updatedByte.toInt() and 0xFF} (binary: ${Integer.toBinaryString(updatedByte.toInt() and 0xFF)})")
ServiceManager.getService()!!.aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
updatedByte
)
context.getSharedPreferences("settings", Context.MODE_PRIVATE).edit()
.putInt("long_press_byte", newValue).apply()
checked.value = false
Log.d("PressAndHoldSettingsScreen", "Updated: $name, enabled: false, byte: ${updatedByte.toInt() and 0xFF}, bits: ${Integer.toBinaryString(updatedByte.toInt() and 0xFF)}")
} else {
val newValue = currentValue or bit
val updatedByte = newValue.toByte()
ServiceManager.getService()!!.aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
updatedByte
)
context.getSharedPreferences("settings", Context.MODE_PRIVATE).edit()
.putInt("long_press_byte", newValue).apply()
checked.value = true
Log.d("PressAndHoldSettingsScreen", "Updated: $name, enabled: true, byte: ${updatedByte.toInt() and 0xFF}, bits: ${newValue.toString(2)}")
}
}
val shape = when {
isFirst -> RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)
isLast -> RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp)
else -> RoundedCornerShape(0.dp)
}
var backgroundColor by remember { mutableStateOf(if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
if (!enabled) {
valueChanged(false)
} else {
Row(
modifier = Modifier
.height(72.dp)
.background(animatedBackgroundColor, shape)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
backgroundColor = if (darkMode) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor = if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
valueChanged()
},
)
}
.padding(horizontal = 16.dp, vertical = 0.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Icon(
painter = painterResource(resourceId),
contentDescription = "Icon",
tint = Color(0xFF007AFF),
modifier = Modifier
.height(48.dp)
.wrapContentWidth()
)
Column (
modifier = Modifier
.weight(1f)
.padding(vertical = 2.dp)
.padding(start = 8.dp)
)
{
Text(
name,
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
Text (
desc,
fontSize = 14.sp,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
}
Checkbox(
checked = checked.value,
onCheckedChange = { valueChanged() },
colors = CheckboxDefaults.colors().copy(
checkedCheckmarkColor = Color(0xFF007AFF),
uncheckedCheckmarkColor = Color.Transparent,
checkedBoxColor = Color.Transparent,
uncheckedBoxColor = Color.Transparent,
checkedBorderColor = Color.Transparent,
uncheckedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent,
disabledCheckedBoxColor = Color.Transparent,
disabledUncheckedBoxColor = Color.Transparent,
disabledUncheckedBorderColor = Color.Transparent
),
modifier = Modifier
.height(24.dp)
.scale(1.5f),
)
}
}
}
@Composable
fun LongPressActionElement(
name: String,
selected: Boolean,
onClick: () -> Unit,
isFirst: Boolean = false,
isLast: Boolean = false
) {
val darkMode = isSystemInDarkTheme()
val shape = when {
isFirst -> RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)
isLast -> RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp)
else -> RoundedCornerShape(0.dp)
}
var backgroundColor by remember { mutableStateOf(if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
Row(
modifier = Modifier
.height(48.dp)
.background(animatedBackgroundColor, shape)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
backgroundColor = if (darkMode) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor = if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
onClick()
}
)
}
.padding(horizontal = 16.dp, vertical = 0.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
name,
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
modifier = Modifier
.weight(1f)
.padding(start = 4.dp)
)
Checkbox(
checked = selected,
onCheckedChange = { onClick() },
colors = CheckboxDefaults.colors().copy(
checkedCheckmarkColor = Color(0xFF007AFF),
uncheckedCheckmarkColor = Color.Transparent,
checkedBoxColor = Color.Transparent,
uncheckedBoxColor = Color.Transparent,
checkedBorderColor = Color.Transparent,
uncheckedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent,
disabledCheckedBoxColor = Color.Transparent,
disabledUncheckedBoxColor = Color.Transparent,
disabledUncheckedBorderColor = Color.Transparent
),
modifier = Modifier
.height(24.dp)
.scale(1.5f),
)
}
}

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2024 Kavish Devar
* 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
@@ -16,7 +16,9 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.aln.screens
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.screens
import android.content.Context
import androidx.compose.foundation.background
@@ -64,8 +66,9 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import me.kavishdevar.aln.R
import me.kavishdevar.aln.services.ServiceManager
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalMaterial3Api::class)
@@ -198,4 +201,4 @@ fun RenameScreen(navController: NavController) {
@Composable
fun RenameScreenPreview() {
RenameScreen(navController = NavController(LocalContext.current))
}
}

View File

@@ -0,0 +1,276 @@
/*
* 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.services
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.graphics.drawable.Icon
import android.os.Build
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import android.util.Log
import androidx.annotation.RequiresApi
import me.kavishdevar.librepods.QuickSettingsDialogActivity
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.constants.NoiseControlMode
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
@RequiresApi(Build.VERSION_CODES.Q)
class AirPodsQSService : TileService() {
private lateinit var sharedPreferences: SharedPreferences
private var currentAncMode: Int = NoiseControlMode.OFF.ordinal + 1
private var isAirPodsConnected: Boolean = false
private val ancStatusReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == AirPodsNotifications.ANC_DATA) {
val newMode = intent.getIntExtra("data", NoiseControlMode.OFF.ordinal + 1)
Log.d("AirPodsQSService", "Received ANC update: $newMode")
currentAncMode = newMode
updateTile()
}
}
}
private val availabilityReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
AirPodsNotifications.AIRPODS_CONNECTED -> {
Log.d("AirPodsQSService", "Received AIRPODS_CONNECTED")
isAirPodsConnected = true
currentAncMode =
ServiceManager.getService()?.getANC() ?: (NoiseControlMode.OFF.ordinal + 1)
updateTile()
}
AirPodsNotifications.AIRPODS_DISCONNECTED -> {
Log.d("AirPodsQSService", "Received AIRPODS_DISCONNECTED")
isAirPodsConnected = false
updateTile()
}
}
}
}
private val preferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == "off_listening_mode") {
Log.d("AirPodsQSService", "Preference changed: $key")
if (currentAncMode == NoiseControlMode.OFF.ordinal + 1 && !isOffModeEnabled()) {
currentAncMode = NoiseControlMode.TRANSPARENCY.ordinal + 1
}
updateTile()
}
}
override fun onCreate() {
super.onCreate()
sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE)
}
@SuppressLint("InlinedApi", "UnspecifiedRegisterReceiverFlag")
override fun onStartListening() {
super.onStartListening()
Log.d("AirPodsQSService", "onStartListening")
val service = ServiceManager.getService()
isAirPodsConnected = service?.isConnectedLocally == true
currentAncMode = service?.getANC() ?: (NoiseControlMode.OFF.ordinal + 1)
if (currentAncMode == NoiseControlMode.OFF.ordinal + 1 && !isOffModeEnabled()) {
currentAncMode = NoiseControlMode.TRANSPARENCY.ordinal + 1
}
val ancIntentFilter = IntentFilter(AirPodsNotifications.ANC_DATA)
val availabilityIntentFilter = IntentFilter().apply {
addAction(AirPodsNotifications.AIRPODS_CONNECTED)
addAction(AirPodsNotifications.AIRPODS_DISCONNECTED)
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(ancStatusReceiver, ancIntentFilter, RECEIVER_EXPORTED)
registerReceiver(availabilityReceiver, availabilityIntentFilter, RECEIVER_EXPORTED)
} else {
registerReceiver(ancStatusReceiver, ancIntentFilter)
registerReceiver(availabilityReceiver, availabilityIntentFilter)
}
sharedPreferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener)
Log.d("AirPodsQSService", "Receivers registered")
} catch (e: Exception) {
Log.e("AirPodsQSService", "Error registering receivers: $e")
}
updateTile()
}
override fun onStopListening() {
super.onStopListening()
Log.d("AirPodsQSService", "onStopListening")
try {
unregisterReceiver(ancStatusReceiver)
unregisterReceiver(availabilityReceiver)
sharedPreferences.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener)
Log.d("AirPodsQSService", "Receivers unregistered")
} catch (e: IllegalArgumentException) {
Log.e("AirPodsQSService", "Receiver not registered or already unregistered: $e")
} catch (e: Exception) {
Log.e("AirPodsQSService", "Error unregistering receivers: $e")
}
}
override fun onClick() {
super.onClick()
Log.d("AirPodsQSService", "onClick - Current state: $isAirPodsConnected, Current mode: $currentAncMode")
if (!isAirPodsConnected) {
Log.d("AirPodsQSService", "Tile clicked but AirPods not connected.")
return
}
val clickBehavior = sharedPreferences.getString("qs_click_behavior", "dialog") ?: "dialog"
if (clickBehavior == "dialog") {
launchDialogActivity()
} else {
cycleAncMode()
}
}
private fun launchDialogActivity() {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
val pendingIntent = PendingIntent.getActivity(
this,
0,
Intent(this, QuickSettingsDialogActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
startActivityAndCollapse(pendingIntent)
} else {
val intent = Intent(this, QuickSettingsDialogActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
}
@Suppress("DEPRECATION")
@SuppressLint("StartActivityAndCollapseDeprecated")
startActivityAndCollapse(intent)
}
Log.d("AirPodsQSService", "Called startActivityAndCollapse for QuickSettingsDialogActivity")
} catch (e: Exception) {
Log.e("AirPodsQSService", "Error launching QuickSettingsDialogActivity: $e")
}
}
private fun cycleAncMode() {
val service = ServiceManager.getService()
if (service == null) {
Log.d("AirPodsQSService", "Tile clicked (cycle mode) but service is null.")
return
}
val nextMode = getNextAncMode()
Log.d("AirPodsQSService", "Cycling ANC mode to: $nextMode")
service.aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
nextMode
)
}
private fun updateTile() {
val tile = qsTile ?: return
Log.d("AirPodsQSService", "updateTile - Connected: $isAirPodsConnected, Mode: $currentAncMode")
val deviceName = sharedPreferences.getString("name", "AirPods") ?: "AirPods"
if (isAirPodsConnected) {
tile.state = Tile.STATE_ACTIVE
tile.label = getModeLabel(currentAncMode)
tile.subtitle = deviceName
tile.icon = Icon.createWithResource(this, getModeIcon(currentAncMode))
} else {
tile.state = Tile.STATE_UNAVAILABLE
tile.label = "AirPods"
tile.subtitle = "Disconnected"
tile.icon = Icon.createWithResource(this, R.drawable.airpods)
}
try {
tile.updateTile()
Log.d("AirPodsQSService", "Tile updated successfully")
} catch (e: Exception) {
Log.e("AirPodsQSService", "Error updating tile: $e")
}
}
private fun isOffModeEnabled(): Boolean {
return sharedPreferences.getBoolean("off_listening_mode", true)
}
private fun getAvailableModes(): List<Int> {
val modes = mutableListOf(
NoiseControlMode.TRANSPARENCY.ordinal + 1,
NoiseControlMode.ADAPTIVE.ordinal + 1,
NoiseControlMode.NOISE_CANCELLATION.ordinal + 1
)
if (isOffModeEnabled()) {
modes.add(0, NoiseControlMode.OFF.ordinal + 1)
}
return modes
}
private fun getNextAncMode(): Int {
val availableModes = getAvailableModes()
val currentIndex = availableModes.indexOf(currentAncMode)
val nextIndex = (currentIndex + 1) % availableModes.size
return availableModes[nextIndex]
}
private fun getModeLabel(mode: Int): String {
return when (mode) {
NoiseControlMode.OFF.ordinal + 1 -> "Off"
NoiseControlMode.TRANSPARENCY.ordinal + 1 -> "Transparency"
NoiseControlMode.ADAPTIVE.ordinal + 1 -> "Adaptive"
NoiseControlMode.NOISE_CANCELLATION.ordinal + 1 -> "Noise Cancellation"
else -> "Unknown"
}
}
private fun getModeIcon(mode: Int): Int {
return when (mode) {
NoiseControlMode.OFF.ordinal + 1 -> R.drawable.noise_cancellation
NoiseControlMode.TRANSPARENCY.ordinal + 1 -> R.drawable.transparency
NoiseControlMode.ADAPTIVE.ordinal + 1 -> R.drawable.adaptive
NoiseControlMode.NOISE_CANCELLATION.ordinal + 1 -> R.drawable.noise_cancellation
else -> R.drawable.airpods
}
}
override fun onTileAdded() {
super.onTileAdded()
Log.d("AirPodsQSService", "Tile added")
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2024 Kavish Devar
* 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
@@ -17,7 +17,7 @@
*/
package me.kavishdevar.aln.ui.theme
package me.kavishdevar.librepods.ui.theme
import androidx.compose.ui.graphics.Color

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2024 Kavish Devar
* 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
@@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.aln.ui.theme
package me.kavishdevar.librepods.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
@@ -38,21 +38,11 @@ private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
@Composable
fun ALNTheme(
fun LibrePodsTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2024 Kavish Devar
* 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
@@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.aln.ui.theme
package me.kavishdevar.librepods.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle

View File

@@ -0,0 +1,554 @@
/*
* LibrePods - AirPods liberated from Apple's 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.utils
import android.util.Log
import me.kavishdevar.librepods.utils.AACPManager.Companion.ControlCommandIdentifiers.entries
import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressBudType.entries
import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType.entries
import kotlin.io.encoding.ExperimentalEncodingApi
/**
* Manager class for Apple Accessory Communication Protocol (AACP)
* This class is responsible for handling the L2CAP socket management,
* constructing and parsing packets for communication with AirPods.
*/
class AACPManager {
companion object {
private const val TAG = "AACPManager"
object Opcodes {
const val SET_FEATURE_FLAGS: Byte = 0x4d
const val REQUEST_NOTIFICATIONS: Byte = 0x0f
const val BATTERY_INFO: Byte = 0x04
const val CONTROL_COMMAND: Byte = 0x09
const val EAR_DETECTION: Byte = 0x06
const val CONVERSATION_AWARENESS: Byte = 0x4b
const val DEVICE_METADATA: Byte = 0x1d
const val RENAME: Byte = 0x1E
const val HEADTRACKING: Byte = 0x17
const val PROXIMITY_KEYS_REQ: Byte = 0x30
const val PROXIMITY_KEYS_RSP: Byte = 0x31
const val STEM_PRESS: Byte = 0x19
}
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
data class ControlCommandStatus(
val identifier: ControlCommandIdentifiers,
val value: ByteArray
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ControlCommandStatus
if (identifier != other.identifier) return false
if (!value.contentEquals(other.value)) return false
return true
}
override fun hashCode(): Int {
var result: Int = identifier.hashCode()
result = 31 * result + value.contentHashCode()
return result
}
}
// @Suppress("unused")
enum class ControlCommandIdentifiers(val value: Byte) {
MIC_MODE(0x01),
BUTTON_SEND_MODE(0x05),
VOICE_TRIGGER(0x12),
SINGLE_CLICK_MODE(0x14),
DOUBLE_CLICK_MODE(0x15),
CLICK_HOLD_MODE(0x16),
DOUBLE_CLICK_INTERVAL(0x17),
CLICK_HOLD_INTERVAL(0x18),
LISTENING_MODE_CONFIGS(0x1A),
ONE_BUD_ANC_MODE(0x1B),
CROWN_ROTATION_DIRECTION(0x1C),
LISTENING_MODE(0x0D),
AUTO_ANSWER_MODE(0x1E),
CHIME_VOLUME(0x1F),
VOLUME_SWIPE_INTERVAL(0x23),
CALL_MANAGEMENT_CONFIG(0x24),
VOLUME_SWIPE_MODE(0x25),
ADAPTIVE_VOLUME_CONFIG(0x26),
SOFTWARE_MUTE_CONFIG(0x27),
CONVERSATION_DETECT_CONFIG(0x28),
SSL(0x29),
HEARING_AID(0x2C),
AUTO_ANC_STRENGTH(0x2E),
HPS_GAIN_SWIPE(0x2F),
HRM_STATE(0x30),
IN_CASE_TONE_CONFIG(0x31),
SIRI_MULTITONE_CONFIG(0x32),
HEARING_ASSIST_CONFIG(0x33),
ALLOW_OFF_OPTION(0x34),
STEM_CONFIG(0x39);
companion object {
fun fromByte(byte: Byte): ControlCommandIdentifiers? =
entries.find { it.value == byte }
}
}
enum class ProximityKeyType(val value: Byte) {
IRK(0x01),
ENC_KEY(0x04);
companion object {
fun fromByte(byte: Byte): ProximityKeyType =
ProximityKeyType.entries.find { it.value == byte }?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte")
}
}
enum class StemPressType(val value: Byte) {
SINGLE_PRESS(0x05),
DOUBLE_PRESS(0x06),
TRIPLE_PRESS(0x07),
LONG_PRESS(0x08);
companion object {
fun fromByte(byte: Byte): StemPressType? =
entries.find { it.value == byte }
}
}
enum class StemPressBudType(val value: Byte) {
LEFT(0x01),
RIGHT(0x02);
companion object {
fun fromByte(byte: Byte): StemPressBudType? =
entries.find { it.value == byte }
}
}
}
var controlCommandStatusList: MutableList<ControlCommandStatus> = mutableListOf<ControlCommandStatus>()
var controlCommandListeners: MutableMap<ControlCommandIdentifiers, MutableList<ControlCommandListener>> = mutableMapOf()
fun getControlCommandStatus(identifier: ControlCommandIdentifiers): ControlCommandStatus? {
return controlCommandStatusList.find { it.identifier == identifier }
}
private fun setControlCommandStatusValue(identifier: ControlCommandIdentifiers, value: ByteArray) {
val existingStatus = getControlCommandStatus(identifier)
if (existingStatus == value) {
controlCommandStatusList.remove(existingStatus)
}
if (existingStatus != null) {
controlCommandStatusList.remove(existingStatus)
}
controlCommandListeners[identifier]?.forEach { listener ->
listener.onControlCommandReceived(ControlCommand(identifier.value, value))
}
controlCommandStatusList.add(ControlCommandStatus(identifier, value))
}
interface PacketCallback {
fun onBatteryInfoReceived(batteryInfo: ByteArray)
fun onEarDetectionReceived(earDetection: ByteArray)
fun onConversationAwarenessReceived(conversationAwareness: ByteArray)
fun onControlCommandReceived(controlCommand: ByteArray)
fun onDeviceMetadataReceived(deviceMetadata: ByteArray)
fun onHeadTrackingReceived(headTracking: ByteArray)
fun onUnknownPacketReceived(packet: ByteArray)
fun onProximityKeysReceived(proximityKeys: ByteArray)
fun onStemPressReceived(stemPress: ByteArray)
}
fun parseStemPressResponse(data: ByteArray): Pair<StemPressType, StemPressBudType> {
Log.d(TAG, "Parsing Stem Press Response: ${data.joinToString(" ") { "%02X".format(it) }}")
if (data.size != 8) {
throw IllegalArgumentException("Data array too short to parse Stem Press Response")
}
if (data[4] != Opcodes.STEM_PRESS) {
throw IllegalArgumentException("Data array does not start with STEM_PRESS opcode")
}
val type = StemPressType.fromByte(data[6]) ?: throw IllegalArgumentException("Unknown Stem Press Type: ${data[5]}")
val bud = StemPressBudType.fromByte(data[7]) ?: throw IllegalArgumentException("Unknown Stem Press Bud Type: ${data[6]}")
return Pair(type, bud)
}
interface ControlCommandListener {
fun onControlCommandReceived(controlCommand: ControlCommand)
}
fun registerControlCommandListener(identifier: ControlCommandIdentifiers, callback: ControlCommandListener) {
controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback)
}
private var callback: PacketCallback? = null
fun setPacketCallback(callback: PacketCallback) {
this.callback = callback
}
fun createDataPacket(data: ByteArray): ByteArray {
return HEADER_BYTES + data
}
fun createControlCommandPacket(identifier: Byte, data: ByteArray): ByteArray {
val opcode = byteArrayOf(Opcodes.CONTROL_COMMAND, 0x00)
val payload = ByteArray(7)
System.arraycopy(opcode, 0, payload, 0, 2)
payload[2] = identifier
val dataLength = minOf(data.size, 4)
System.arraycopy(data, 0, payload, 3, dataLength)
return payload
}
fun sendDataPacket(data: ByteArray): Boolean {
return sendPacket(createDataPacket(data))
}
fun sendControlCommand(identifier: Byte, value: ByteArray): Boolean {
val controlPacket = createControlCommandPacket(identifier, value)
setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
value
)
return sendDataPacket(controlPacket)
}
@OptIn(ExperimentalStdlibApi::class)
fun sendControlCommand(identifier: Byte, value: Byte): Boolean {
val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value))
setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
byteArrayOf(value)
)
return sendDataPacket(controlPacket)
}
fun sendControlCommand(identifier: Byte, value: Boolean): Boolean {
val controlPacket = createControlCommandPacket(identifier, if (value) byteArrayOf(0x01) else byteArrayOf(0x02))
setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
if (value) byteArrayOf(0x01) else byteArrayOf(0x02)
)
return sendDataPacket(controlPacket)
}
fun sendControlCommand(identifier: Byte, value: Int): Boolean {
val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value.toByte()))
setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
byteArrayOf(value.toByte())
)
return sendDataPacket(controlPacket)
}
fun parseProximityKeysResponse(data: ByteArray): Map<ProximityKeyType, ByteArray> {
Log.d(TAG, "Parsing Proximity Keys Response: ${data.joinToString(" ") { "%02X".format(it) }}")
if (data.size < 4) {
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
}
if (data[4] != Opcodes.PROXIMITY_KEYS_RSP) {
throw IllegalArgumentException("Data array does not start with PROXIMITY_KEYS_RSP opcode")
}
val keyCount = data[6].toInt()
val keys = mutableMapOf<ProximityKeyType, ByteArray>()
var offset = 7
for (i in 0 until keyCount) {
Log.d(TAG, "Parsing Proximity Key $i")
if (offset + 3 >= data.size) {
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
}
val keyType = data[offset]
val keyLength = data[offset + 2].toInt()
Log.d(TAG, "Key Type: ${keyType.toString(16)}, Key Length: $keyLength")
offset += 4
if (offset + keyLength > data.size) {
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
}
val key = ByteArray(keyLength)
System.arraycopy(data, offset, key, 0, keyLength)
keys[ProximityKeyType.fromByte(keyType)] = key
offset += keyLength
Log.d(TAG, "Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${key.joinToString(" ") { "%02X".format(it) }}")
}
return keys
}
fun sendRequestProximityKeys(type: Byte): Boolean {
Log.d(TAG, "Requesting proximity keys of type: ${type.toString(16)}")
return sendDataPacket(createRequestProximityKeysPacket(type))
}
fun createRequestProximityKeysPacket(type: Byte): ByteArray {
val opcode = byteArrayOf(Opcodes.PROXIMITY_KEYS_REQ, 0x00)
val data = byteArrayOf(type, 0x00)
return opcode + data
}
@OptIn(ExperimentalStdlibApi::class)
fun receivePacket(packet: ByteArray) {
if (!packet.toHexString().startsWith("04000400")) {
Log.w(TAG, "Received packet does not start with expected header: ${packet.joinToString(" ") { "%02X".format(it) }}")
return
}
if (packet.size < 6) {
Log.w(TAG, "Received packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}")
return
}
val opcode = packet[4]
when (opcode) {
Opcodes.BATTERY_INFO -> {
callback?.onBatteryInfoReceived(packet)
}
Opcodes.CONTROL_COMMAND -> {
val controlCommand = ControlCommand.fromByteArray(packet)
setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return,
controlCommand.value
)
Log.d(TAG, "Control command received: ${controlCommand.identifier.toHexString()} - ${controlCommand.value.joinToString(" ") { "%02X".format(it) }}")
Log.d(TAG, "Control command list is now: ${
controlCommandStatusList.joinToString(", ") { "${it.identifier.name} (${it.identifier.value.toHexString()}) - ${it.value.joinToString(" ") { "%02X".format(it) }}" }
}")
val controlCommandIdentifier = ControlCommandIdentifiers.fromByte(controlCommand.identifier)
if (controlCommandIdentifier != null) {
controlCommandListeners[controlCommandIdentifier]?.forEach { listener ->
listener.onControlCommandReceived(controlCommand)
}
} else {
Log.w(TAG, "Unknown control command identifier: ${controlCommand.identifier.toHexString()}")
}
callback?.onControlCommandReceived(packet)
}
Opcodes.EAR_DETECTION -> {
callback?.onEarDetectionReceived(packet)
}
Opcodes.CONVERSATION_AWARENESS -> {
callback?.onConversationAwarenessReceived(packet)
}
Opcodes.DEVICE_METADATA -> {
callback?.onDeviceMetadataReceived(packet)
}
Opcodes.HEADTRACKING -> {
if (packet.size < 70) {
Log.w(TAG, "Received HEADTRACKING packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}")
return
}
callback?.onHeadTrackingReceived(packet)
}
Opcodes.PROXIMITY_KEYS_RSP -> {
callback?.onProximityKeysReceived(packet)
}
Opcodes.STEM_PRESS -> {
callback?.onStemPressReceived(packet)
}
else -> {
callback?.onUnknownPacketReceived(packet)
}
}
}
fun sendNotificationRequest(): Boolean {
return sendDataPacket(createRequestNotificationPacket())
}
fun createRequestNotificationPacket(): ByteArray {
val opcode = byteArrayOf(Opcodes.REQUEST_NOTIFICATIONS, 0x00)
val data = byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte())
return opcode + data
}
fun sendSetFeatureFlagsPacket(): Boolean {
return sendDataPacket(createSetFeatureFlagsPacket())
}
fun createSetFeatureFlagsPacket(): ByteArray {
val opcode = byteArrayOf(Opcodes.SET_FEATURE_FLAGS, 0x00)
val data = byteArrayOf(0xD7.toByte(), 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
return opcode + data
}
fun createHandshakePacket(): ByteArray {
return byteArrayOf(
0x00, 0x00, 0x04, 0x00,
0x01, 0x00, 0x02, 0x00,
0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00
)
}
fun sendStartHeadTracking(): Boolean {
return sendDataPacket(createStartHeadTrackingPacket())
}
fun createStartHeadTrackingPacket(): ByteArray {
val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00)
val data = byteArrayOf(
0x00, 0x00, 0x10, 0x00, 0x10, 0x00, 0x08, 0xA1.toByte(), 0x02, 0x42, 0x0B, 0x08, 0x0E, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x40, 0x9C.toByte(), 0x00, 0x00,
)
return opcode + data
}
fun createAlternateStartHeadTrackingPacket(): ByteArray {
val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00)
val data = byteArrayOf(
0x00, 0x00, 0x10, 0x00, 0x0F, 0x00, 0x08, 0x73, 0x42, 0x0B, 0x08, 0x10, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x40, 0x9C.toByte(), 0x00, 0x00
)
return opcode + data
}
fun sendStopHeadTracking(): Boolean {
return sendDataPacket(createStopHeadTrackingPacket())
}
fun createStopHeadTrackingPacket(): ByteArray {
val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00)
val data = byteArrayOf(
0x00, 0x00, 0x10, 0x00, 0x11, 0x00, 0x08, 0x7E, 0x10, 0x02, 0x42, 0x0B, 0x08, 0x4E, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00
)
return opcode + data
}
fun createAlternateStopHeadTrackingPacket(): ByteArray {
val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00)
val data = byteArrayOf(
0x00, 0x00, 0x10, 0x00, 0x0F, 0x00, 0x08, 0x75, 0x42, 0x0B, 0x08, 0x10, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00
)
return opcode + data
}
fun sendRename(name: String): Boolean {
return sendDataPacket(createRenamePacket(name))
}
fun createRenamePacket(name: String): ByteArray {
val nameBytes = name.toByteArray()
val size = nameBytes.size
val packet = ByteArray(5 + size)
packet[0] = Opcodes.RENAME
packet[1] = 0x00
packet[2] = size.toByte()
packet[3] = 0x00
System.arraycopy(nameBytes, 0, packet, 4, size)
return packet
}
data class ControlCommand(
val identifier: Byte,
val value: ByteArray
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ControlCommand
if (identifier != other.identifier) return false
if (!value.contentEquals(other.value)) return false
return true
}
override fun hashCode(): Int {
var result: Int = identifier.toInt()
result = 31 * result + value.contentHashCode()
return result
}
companion object {
fun fromByteArray(data: ByteArray): ControlCommand {
if (data.size < 4) {
throw IllegalArgumentException("Data array too short to parse ControlCommand")
}
if (data[0] == 0x04.toByte() && data[1] == 0x00.toByte() && data[2] == 0x04.toByte() && data[3] == 0x00.toByte()) {
val newData = ByteArray(data.size - 4)
System.arraycopy(data, 4, newData, 0, data.size - 4)
return fromByteArray(newData)
}
if (data[0] != Opcodes.CONTROL_COMMAND) {
throw IllegalArgumentException("Data array does not start with CONTROL_COMMAND opcode")
}
val identifier = data[2]
val value = ByteArray(4)
System.arraycopy(data, 3, value, 0, 4)
val trimmedValue = value.takeWhile { it != 0x00.toByte() }.toByteArray()
return ControlCommand(identifier, trimmedValue)
}
}
}
@OptIn(ExperimentalStdlibApi::class)
fun sendStemConfigPacket(
singlePressCustomized: Boolean = false,
doublePressCustomized: Boolean = false,
triplePressCustomized: Boolean = false,
longPressCustomized: Boolean = false
): Boolean {
val value = ((if (singlePressCustomized) 0x01 else 0) or
(if (doublePressCustomized) 0x02 else 0) or
(if (triplePressCustomized) 0x04 else 0) or
(if (longPressCustomized) 0x08 else 0)).toByte()
Log.d(TAG, "Sending Stem Config Packet with value: ${value.toHexString()}")
return sendControlCommand(
ControlCommandIdentifiers.STEM_CONFIG.value, value
)
}
@OptIn(ExperimentalStdlibApi::class)
fun sendPacket(packet: ByteArray): Boolean {
try {
Log.d(TAG, "Sending packet: ${packet.joinToString(" ") { "%02X".format(it) }}")
if (packet[4] == Opcodes.CONTROL_COMMAND) {
val controlCommand = ControlCommand.fromByteArray(packet)
Log.d(TAG, "Control command: ${controlCommand.identifier.toHexString()} - ${controlCommand.value.joinToString(" ") { "%02X".format(it) }}")
setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return false,
controlCommand.value
)
}
val socket = BluetoothConnectionManager.getCurrentSocket()
if (socket?.isConnected == true) {
socket.outputStream?.write(packet)
socket.outputStream?.flush()
return true
} else {
Log.d(TAG, "Can't send packet: Socket not initialized or connected")
return false
}
} catch (e: Exception) {
Log.e(TAG, "Error sending packet: ${e.message}")
return false
}
}
}

View File

@@ -0,0 +1,490 @@
/*
* LibrePods - AirPods liberated from Apple's 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.utils
import android.annotation.SuppressLint
import android.bluetooth.BluetoothManager
import android.bluetooth.le.BluetoothLeScanner
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.content.SharedPreferences
import android.os.Handler
import android.os.Looper
import android.util.Log
import me.kavishdevar.librepods.services.ServiceManager
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
/**
* Manager for Bluetooth Low Energy scanning operations specifically for AirPods
*/
@OptIn(ExperimentalEncodingApi::class)
class BLEManager(private val context: Context) {
data class AirPodsStatus(
val address: String,
val lastSeen: Long = System.currentTimeMillis(),
val paired: Boolean = false,
val model: String = "Unknown",
val leftBattery: Int? = null,
val rightBattery: Int? = null,
val caseBattery: Int? = null,
val isLeftInEar: Boolean = false,
val isRightInEar: Boolean = false,
val isLeftCharging: Boolean = false,
val isRightCharging: Boolean = false,
val isCaseCharging: Boolean = false,
val lidOpen: Boolean = false,
val color: String = "Unknown",
val connectionState: String = "Unknown"
)
fun getMostRecentStatus(): AirPodsStatus? {
return deviceStatusMap.values.maxByOrNull { it.lastSeen }
}
interface AirPodsStatusListener {
fun onDeviceStatusChanged(device: AirPodsStatus, previousStatus: AirPodsStatus?)
fun onBroadcastFromNewAddress(device: AirPodsStatus)
fun onLidStateChanged(lidOpen: Boolean)
fun onEarStateChanged(device: AirPodsStatus, leftInEar: Boolean, rightInEar: Boolean)
fun onBatteryChanged(device: AirPodsStatus)
}
private var mBluetoothLeScanner: BluetoothLeScanner? = null
private var mScanCallback: ScanCallback? = null
private var airPodsStatusListener: AirPodsStatusListener? = null
private val deviceStatusMap = mutableMapOf<String, AirPodsStatus>()
private val verifiedAddresses = mutableSetOf<String>()
private val sharedPreferences: SharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
private var currentGlobalLidState: Boolean? = null
private var lastBroadcastTime: Long = 0
private val processedAddresses = mutableSetOf<String>()
private val lastValidCaseBatteryMap = mutableMapOf<String, Int>()
private val modelNames = mapOf(
0x0E20 to "AirPods Pro",
0x1420 to "AirPods Pro 2",
0x2420 to "AirPods Pro 2 (USB-C)",
0x0220 to "AirPods 1",
0x0F20 to "AirPods 2",
0x1320 to "AirPods 3",
0x1920 to "AirPods 4",
0x1B20 to "AirPods 4 (ANC)",
0x0A20 to "AirPods Max",
0x1F20 to "AirPods Max (USB-C)"
)
val colorNames = mapOf(
0x00 to "White", 0x01 to "Black", 0x02 to "Red", 0x03 to "Blue",
0x04 to "Pink", 0x05 to "Gray", 0x06 to "Silver", 0x07 to "Gold",
0x08 to "Rose Gold", 0x09 to "Space Gray", 0x0A to "Dark Blue",
0x0B to "Light Blue", 0x0C to "Yellow"
)
val connStates = mapOf(
0x00 to "Disconnected", 0x04 to "Idle", 0x05 to "Music",
0x06 to "Call", 0x07 to "Ringing", 0x09 to "Hanging Up", 0xFF to "Unknown"
)
private val cleanupHandler = Handler(Looper.getMainLooper())
private val cleanupRunnable = object : Runnable {
override fun run() {
cleanupStaleDevices()
checkLidStateTimeout()
cleanupHandler.postDelayed(this, CLEANUP_INTERVAL_MS)
}
}
fun setAirPodsStatusListener(listener: AirPodsStatusListener) {
airPodsStatusListener = listener
}
@SuppressLint("MissingPermission")
fun startScanning() {
try {
Log.d(TAG, "Starting BLE scanner")
val btManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val btAdapter = btManager.adapter
if (btAdapter == null) {
Log.d(TAG, "No Bluetooth adapter available")
return
}
if (mBluetoothLeScanner != null && mScanCallback != null) {
mBluetoothLeScanner?.stopScan(mScanCallback)
mScanCallback = null
}
if (!btAdapter.isEnabled) {
Log.d(TAG, "Bluetooth is disabled")
return
}
mBluetoothLeScanner = btAdapter.bluetoothLeScanner
val scanSettings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
.setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT)
.setReportDelay(500L)
.build()
val manufacturerData = ByteArray(27)
val manufacturerDataMask = ByteArray(27)
manufacturerData[0] = 7
manufacturerData[1] = 25
manufacturerDataMask[0] = -1
manufacturerDataMask[1] = -1
val scanFilter = ScanFilter.Builder()
.setManufacturerData(76, manufacturerData, manufacturerDataMask)
.build()
mScanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
processScanResult(result)
}
override fun onBatchScanResults(results: List<ScanResult>) {
processedAddresses.clear()
for (result in results) {
processScanResult(result)
}
}
override fun onScanFailed(errorCode: Int) {
Log.e(TAG, "BLE scan failed with error code: $errorCode")
}
}
mBluetoothLeScanner?.startScan(listOf(scanFilter), scanSettings, mScanCallback)
Log.d(TAG, "BLE scanner started successfully")
cleanupHandler.postDelayed(cleanupRunnable, CLEANUP_INTERVAL_MS)
} catch (t: Throwable) {
Log.e(TAG, "Error starting BLE scanner", t)
}
}
@SuppressLint("MissingPermission")
fun stopScanning() {
try {
if (mBluetoothLeScanner != null && mScanCallback != null) {
Log.d(TAG, "Stopping BLE scanner")
mBluetoothLeScanner?.stopScan(mScanCallback)
mScanCallback = null
}
cleanupHandler.removeCallbacks(cleanupRunnable)
} catch (t: Throwable) {
Log.e(TAG, "Error stopping BLE scanner", t)
}
}
@OptIn(ExperimentalEncodingApi::class)
private fun getEncryptionKeyFromPreferences(): ByteArray? {
val keyBase64 = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, null)
return if (keyBase64 != null) {
try {
Base64.decode(keyBase64)
} catch (e: Exception) {
Log.e(TAG, "Failed to decode encryption key", e)
null
}
} else {
null
}
}
private fun decryptLastBytes(data: ByteArray, key: ByteArray): ByteArray? {
return try {
if (data.size < 16) {
return null
}
val block = data.copyOfRange(data.size - 16, data.size)
val cipher = Cipher.getInstance("AES/ECB/NoPadding")
val secretKey = SecretKeySpec(key, "AES")
cipher.init(Cipher.DECRYPT_MODE, secretKey)
cipher.doFinal(block)
} catch (e: Exception) {
Log.e(TAG, "Error decrypting data", e)
null
}
}
private fun formatBattery(byteVal: Int): Pair<Boolean, Int> {
val charging = (byteVal and 0x80) != 0
val level = byteVal and 0x7F
return Pair(charging, level)
}
private fun processScanResult(result: ScanResult) {
try {
val scanRecord = result.scanRecord ?: return
val address = result.device.address
if (processedAddresses.contains(address)) {
return
}
val manufacturerData = scanRecord.getManufacturerSpecificData(76) ?: return
if (manufacturerData.size <= 20) return
if (!verifiedAddresses.contains(address)) {
val irk = getIrkFromPreferences()
if (irk == null || !BluetoothCryptography.verifyRPA(address, irk)) {
return
}
verifiedAddresses.add(address)
Log.d(TAG, "RPA verified and added to trusted list: $address")
}
processedAddresses.add(address)
lastBroadcastTime = System.currentTimeMillis()
val encryptionKey = getEncryptionKeyFromPreferences()
val decryptedData = if (encryptionKey != null) decryptLastBytes(manufacturerData, encryptionKey) else null
val parsedStatus = if (decryptedData != null && decryptedData.size == 16) {
parseProximityMessageWithDecryption(address, manufacturerData, decryptedData)
} else {
parseProximityMessage(address, manufacturerData)
}
val previousStatus = deviceStatusMap[address]
deviceStatusMap[address] = parsedStatus
airPodsStatusListener?.let { listener ->
if (previousStatus == null) {
listener.onBroadcastFromNewAddress(parsedStatus)
Log.d(TAG, "New AirPods device detected: $address")
if (currentGlobalLidState == null || currentGlobalLidState != parsedStatus.lidOpen) {
currentGlobalLidState = parsedStatus.lidOpen
listener.onLidStateChanged(parsedStatus.lidOpen)
Log.d(TAG, "Lid state ${if (parsedStatus.lidOpen) "opened" else "closed"} (detected from new device)")
}
} else {
if (parsedStatus != previousStatus) {
listener.onDeviceStatusChanged(parsedStatus, previousStatus)
}
if (parsedStatus.lidOpen != previousStatus.lidOpen) {
val previousGlobalState = currentGlobalLidState
currentGlobalLidState = parsedStatus.lidOpen
if (previousGlobalState != parsedStatus.lidOpen) {
listener.onLidStateChanged(parsedStatus.lidOpen)
Log.d(TAG, "Lid state changed from ${previousGlobalState} to ${parsedStatus.lidOpen}")
}
}
if (parsedStatus.isLeftInEar != previousStatus.isLeftInEar ||
parsedStatus.isRightInEar != previousStatus.isRightInEar) {
listener.onEarStateChanged(
parsedStatus,
parsedStatus.isLeftInEar,
parsedStatus.isRightInEar
)
Log.d(TAG, "Ear state changed - Left: ${parsedStatus.isLeftInEar}, Right: ${parsedStatus.isRightInEar}")
}
if (parsedStatus.leftBattery != previousStatus.leftBattery ||
parsedStatus.rightBattery != previousStatus.rightBattery ||
parsedStatus.caseBattery != previousStatus.caseBattery) {
listener.onBatteryChanged(parsedStatus)
Log.d(TAG, "Battery changed - Left: ${parsedStatus.leftBattery}, Right: ${parsedStatus.rightBattery}, Case: ${parsedStatus.caseBattery}")
}
}
}
} catch (t: Throwable) {
Log.e(TAG, "Error processing scan result", t)
}
}
private fun parseProximityMessageWithDecryption(address: String, data: ByteArray, decrypted: ByteArray): AirPodsStatus {
val paired = data[2].toInt() == 1
val modelId = ((data[3].toInt() and 0xFF) shl 8) or (data[4].toInt() and 0xFF)
val model = modelNames[modelId] ?: "Unknown ($modelId)"
val status = data[5].toInt() and 0xFF
val flagsCase = data[7].toInt() and 0xFF
val lid = data[8].toInt() and 0xFF
val color = colorNames[data[9].toInt()] ?: "Unknown"
val conn = connStates[data[10].toInt()] ?: "Unknown (${data[10].toInt()})"
val primaryLeft = ((status shr 5) and 0x01) == 1
val thisInCase = ((status shr 6) and 0x01) == 1
val xorFactor = primaryLeft xor thisInCase
val isLeftInEar = if (xorFactor) (status and 0x08) != 0 else (status and 0x02) != 0
val isRightInEar = if (xorFactor) (status and 0x02) != 0 else (status and 0x08) != 0
val isFlipped = !primaryLeft
val leftByteIndex = if (isFlipped) 2 else 1
val rightByteIndex = if (isFlipped) 1 else 2
val (isLeftCharging, leftBattery) = formatBattery(decrypted[leftByteIndex].toInt() and 0xFF)
val (isRightCharging, rightBattery) = formatBattery(decrypted[rightByteIndex].toInt() and 0xFF)
val rawCaseBatteryByte = decrypted[3].toInt() and 0xFF
val (isCaseCharging, rawCaseBattery) = formatBattery(rawCaseBatteryByte)
val caseBattery = if (rawCaseBatteryByte == 0xFF || (isCaseCharging && rawCaseBattery == 127)) {
lastValidCaseBatteryMap[address]
} else {
lastValidCaseBatteryMap[address] = rawCaseBattery
rawCaseBattery
}
val lidOpen = ((lid shr 3) and 0x01) == 0
return AirPodsStatus(
address = address,
lastSeen = System.currentTimeMillis(),
paired = paired,
model = model,
leftBattery = leftBattery,
rightBattery = rightBattery,
caseBattery = caseBattery,
isLeftInEar = isLeftInEar,
isRightInEar = isRightInEar,
isLeftCharging = isLeftCharging,
isRightCharging = isRightCharging,
isCaseCharging = isCaseCharging,
lidOpen = lidOpen,
color = color,
connectionState = conn
)
}
private fun cleanupStaleDevices() {
val now = System.currentTimeMillis()
val staleCutoff = now - STALE_DEVICE_TIMEOUT_MS
val staleDevices = deviceStatusMap.filter { it.value.lastSeen < staleCutoff }
for (device in staleDevices) {
deviceStatusMap.remove(device.key)
Log.d(TAG, "Removed stale device from tracking: ${device.key}")
}
}
private fun checkLidStateTimeout() {
val currentTime = System.currentTimeMillis()
if (currentTime - lastBroadcastTime > LID_CLOSE_TIMEOUT_MS && currentGlobalLidState == true) {
Log.d(TAG, "No broadcasts for ${LID_CLOSE_TIMEOUT_MS}ms, forcing lid state to closed")
currentGlobalLidState = false
airPodsStatusListener?.onLidStateChanged(false)
}
}
@OptIn(ExperimentalEncodingApi::class)
private fun getIrkFromPreferences(): ByteArray? {
val irkBase64 = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.IRK.name, null)
return if (irkBase64 != null) {
try {
Base64.decode(irkBase64)
} catch (e: Exception) {
Log.e(TAG, "Failed to decode IRK", e)
null
}
} else {
null
}
}
private fun parseProximityMessage(address: String, data: ByteArray): AirPodsStatus {
val paired = data[2].toInt() == 1
val modelId = ((data[3].toInt() and 0xFF) shl 8) or (data[4].toInt() and 0xFF)
val model = modelNames[modelId] ?: "Unknown ($modelId)"
val status = data[5].toInt() and 0xFF
val podsBattery = data[6].toInt() and 0xFF
val flagsCase = data[7].toInt() and 0xFF
val lid = data[8].toInt() and 0xFF
val color = colorNames[data[9].toInt()] ?: "Unknown"
val conn = connStates[data[10].toInt()] ?: "Unknown (${data[10].toInt()})"
val primaryLeft = ((status shr 5) and 0x01) == 1
val thisInCase = ((status shr 6) and 0x01) == 1
val xorFactor = primaryLeft xor thisInCase
val isLeftInEar = if (xorFactor) (status and 0x08) != 0 else (status and 0x02) != 0
val isRightInEar = if (xorFactor) (status and 0x02) != 0 else (status and 0x08) != 0
val isFlipped = !primaryLeft
val leftBatteryNibble = if (isFlipped) (podsBattery shr 4) and 0x0F else podsBattery and 0x0F
val rightBatteryNibble = if (isFlipped) podsBattery and 0x0F else (podsBattery shr 4) and 0x0F
val caseBattery = flagsCase and 0x0F
val flags = (flagsCase shr 4) and 0x0F
val isLeftCharging = if (isFlipped) (flags and 0x02) != 0 else (flags and 0x01) != 0
val isRightCharging = if (isFlipped) (flags and 0x01) != 0 else (flags and 0x02) != 0
val isCaseCharging = (flags and 0x04) != 0
val lidOpen = ((lid shr 3) and 0x01) == 0
fun decodeBattery(n: Int): Int? = when (n) {
in 0x0..0x9 -> n * 10
in 0xA..0xE -> 100
0xF -> null
else -> null
}
return AirPodsStatus(
address = address,
lastSeen = System.currentTimeMillis(),
paired = paired,
model = model,
leftBattery = decodeBattery(leftBatteryNibble),
rightBattery = decodeBattery(rightBatteryNibble),
caseBattery = decodeBattery(caseBattery),
isLeftInEar = isLeftInEar,
isRightInEar = isRightInEar,
isLeftCharging = isLeftCharging,
isRightCharging = isRightCharging,
isCaseCharging = isCaseCharging,
lidOpen = lidOpen,
color = color,
connectionState = conn
)
}
companion object {
private const val TAG = "AirPodsBLE"
private const val CLEANUP_INTERVAL_MS = 30000L
private const val STALE_DEVICE_TIMEOUT_MS = 60000L
private const val LID_CLOSE_TIMEOUT_MS = 2000L
}
}

View File

@@ -0,0 +1,40 @@
/*
* LibrePods - AirPods liberated from Apple's 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.utils
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

@@ -0,0 +1,74 @@
/*
* LibrePods - AirPods liberated from Apple's 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.utils
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
/**
* Utilities for Bluetooth cryptography operations, particularly for
* verifying Resolvable Private Addresses (RPA) used by AirPods.
*/
object BluetoothCryptography {
/**
* Verifies if the provided Bluetooth address is an RPA that matches the given Identity Resolving Key (IRK)
*
* @param addr The Bluetooth address to verify
* @param irk The Identity Resolving Key to use for verification
* @return true if the address is verified as an RPA matching the IRK
*/
fun verifyRPA(addr: String, irk: ByteArray): Boolean {
val rpa = addr.split(":").map { it.toInt(16).toByte() }.reversed().toByteArray()
val prand = rpa.copyOfRange(3, 6)
val hash = rpa.copyOfRange(0, 3)
val computedHash = ah(irk, prand)
return hash.contentEquals(computedHash)
}
/**
* Performs E function (AES-128) as specified in Bluetooth Core Specification
*
* @param key The key for encryption
* @param data The data to encrypt
* @return The encrypted data
*/
fun e(key: ByteArray, data: ByteArray): ByteArray {
val swappedKey = key.reversedArray()
val swappedData = data.reversedArray()
val cipher = Cipher.getInstance("AES/ECB/NoPadding")
val secretKey = SecretKeySpec(swappedKey, "AES")
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
return cipher.doFinal(swappedData).reversedArray()
}
/**
* Performs the ah function as specified in Bluetooth Core Specification
*
* @param k The IRK key
* @param r The random part of the address
* @return The hash part of the address
*/
fun ah(k: ByteArray, r: ByteArray): ByteArray {
val rPadded = ByteArray(16)
r.copyInto(rPadded, 0, 0, 3)
val encrypted = e(k, rPadded)
return encrypted.copyOfRange(0, 3)
}
}

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2024 Kavish Devar
* 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
@@ -16,8 +16,9 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.aln.utils
package me.kavishdevar.librepods.utils
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
@@ -35,10 +36,12 @@ import android.os.ParcelUuid
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.aln.services.ServiceManager
import me.kavishdevar.librepods.services.ServiceManager
import java.io.IOException
import java.util.UUID
import kotlin.io.encoding.ExperimentalEncodingApi
enum class CrossDevicePackets(val packet: ByteArray) {
AIRPODS_CONNECTED(byteArrayOf(0x00, 0x01, 0x00, 0x01)),
@@ -66,6 +69,7 @@ object CrossDevice {
private lateinit var sharedPreferences: SharedPreferences
private const val PACKET_LOG_KEY = "packet_log"
private var earDetectionStatus = listOf(false, false)
var disconnectionRequested = false
@SuppressLint("MissingPermission")
fun init(context: Context) {
@@ -75,7 +79,7 @@ object CrossDevice {
sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", false).apply()
this@CrossDevice.bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
this@CrossDevice.bluetoothLeAdvertiser = bluetoothAdapter.bluetoothLeAdvertiser
startAdvertising()
// startAdvertising()
startServer()
initialized = true
}
@@ -84,15 +88,25 @@ object CrossDevice {
@SuppressLint("MissingPermission")
private fun startServer() {
CoroutineScope(Dispatchers.IO).launch {
serverSocket = bluetoothAdapter.listenUsingRfcommWithServiceRecord("ALNCrossDevice", uuid)
if (!bluetoothAdapter.isEnabled) return@launch
// serverSocket = bluetoothAdapter.listenUsingRfcommWithServiceRecord("ALNCrossDevice", uuid)
Log.d("CrossDevice", "Server started")
while (serverSocket != null) {
if (!bluetoothAdapter.isEnabled) {
serverSocket?.close()
break
}
if (clientSocket != null) {
try {
clientSocket!!.close()
} catch (e: IOException) {
e.printStackTrace()
}
}
try {
val socket = serverSocket!!.accept()
handleClientConnection(socket)
} catch (e: IOException) {
e.printStackTrace()
}
} catch (e: IOException) { }
}
}
}
@@ -111,8 +125,11 @@ object CrossDevice {
.addManufacturerData(MANUFACTURER_ID, MANUFACTURER_DATA.toByteArray())
.addServiceUuid(ParcelUuid(uuid))
.build()
bluetoothLeAdvertiser.startAdvertising(settings, data, advertiseCallback)
try {
bluetoothLeAdvertiser.startAdvertising(settings, data, advertiseCallback)
} catch (e: Exception) {
Log.e("CrossDevice", "Failed to start BLE Advertising: ${e.message}")
}
Log.d("CrossDevice", "BLE Advertising started")
}
}
@@ -134,13 +151,13 @@ object CrossDevice {
clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_CONNECTED.packet)
} else {
clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_DISCONNECTED.packet)
// Reset state variables
isAvailable = true
}
}
fun sendReceivedPacket(packet: ByteArray) {
Log.d("CrossDevice", "Sending packet to remote device")
if (clientSocket == null || clientSocket!!.outputStream != null) {
Log.d("CrossDevice", "Client socket is null")
return
}
clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_DATA_HEADER.packet + packet)
@@ -157,20 +174,37 @@ object CrossDevice {
@SuppressLint("MissingPermission")
private fun handleClientConnection(socket: BluetoothSocket) {
Log.d("CrossDevice", "Client connected")
notifyAirPodsConnectedRemotely(ServiceManager.getService()?.applicationContext!!)
clientSocket = socket
val inputStream = socket.inputStream
val buffer = ByteArray(1024)
var bytes: Int
setAirPodsConnected(ServiceManager.getService()?.isConnectedLocally == true)
while (true) {
bytes = inputStream.read(buffer)
val packet = buffer.copyOf(bytes)
try {
bytes = inputStream.read(buffer)
} catch (e: IOException) {
e.printStackTrace()
notifyAirPodsDisconnectedRemotely(ServiceManager.getService()?.applicationContext!!)
val s = serverSocket?.accept()
if (s != null) {
handleClientConnection(s)
}
break
}
var packet = buffer.copyOf(bytes)
logPacket(packet, "Relay")
Log.d("CrossDevice", "Received packet: ${packet.joinToString("") { "%02x".format(it) }}")
if (bytes == -1) {
notifyAirPodsDisconnectedRemotely(ServiceManager.getService()?.applicationContext!!)
break
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet)) {
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet) || packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet + CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) {
ServiceManager.getService()?.disconnect()
disconnectionRequested = true
CoroutineScope(Dispatchers.IO).launch {
delay(1000)
disconnectionRequested = false
}
} else if (packet.contentEquals(CrossDevicePackets.AIRPODS_CONNECTED.packet)) {
isAvailable = true
sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", true).apply()
@@ -190,17 +224,23 @@ object CrossDevice {
if (packet.sliceArray(0..3).contentEquals(CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) {
isAvailable = true
sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", true).apply()
if (packet.size % 2 == 0) {
val half = packet.size / 2
if (packet.sliceArray(0 until half).contentEquals(packet.sliceArray(half until packet.size))) {
Log.d("CrossDevice", "Duplicated packet, trimming")
packet = packet.sliceArray(0 until half)
}
}
var trimmedPacket = packet.drop(CrossDevicePackets.AIRPODS_DATA_HEADER.packet.size).toByteArray()
Log.d("CrossDevice", "Received relayed packet, with ${sharedPreferences.getBoolean("CrossDeviceIsAvailable", false)} | ${ServiceManager.getService()?.earDetectionNotification?.isEarDetectionData(trimmedPacket)}")
Log.d("CrossDevice", "Received relayed packet: ${trimmedPacket.joinToString("") { "%02x".format(it) }}")
if (ServiceManager.getService()?.isConnectedLocally == true) {
val packetInHex = trimmedPacket.joinToString("") { "%02x".format(it) }
ServiceManager.getService()?.sendPacket(packetInHex)
// ServiceManager.getService()?.sendPacket(packetInHex)
} else if (ServiceManager.getService()?.batteryNotification?.isBatteryData(trimmedPacket) == true) {
batteryBytes = trimmedPacket
ServiceManager.getService()?.batteryNotification?.setBattery(trimmedPacket)
Log.d("CrossDevice", "Battery data: ${ServiceManager.getService()?.batteryNotification?.getBattery()[0]?.level}")
ServiceManager.getService()?.updateBatteryWidget()
ServiceManager.getService()?.updateBattery()
ServiceManager.getService()?.sendBatteryBroadcast()
ServiceManager.getService()?.sendBatteryNotification()
} else if (ServiceManager.getService()?.ancNotification?.isANCData(trimmedPacket) == true) {
@@ -217,7 +257,7 @@ object CrossDevice {
)
if (earDetectionStatus == listOf(false, false) && newEarDetectionStatus.contains(true)) {
ServiceManager.getService()?.applicationContext?.sendBroadcast(
Intent("me.kavishdevar.aln.cross_device_island")
Intent("me.kavishdevar.librepods.cross_device_island")
)
}
earDetectionStatus = newEarDetectionStatus
@@ -229,7 +269,6 @@ object CrossDevice {
fun sendRemotePacket(byteArray: ByteArray) {
if (clientSocket == null || clientSocket!!.outputStream == null) {
Log.d("CrossDevice", "Client socket is null")
return
}
clientSocket?.outputStream?.write(byteArray)
@@ -237,4 +276,13 @@ object CrossDevice {
logPacket(byteArray, "Sent")
Log.d("CrossDevice", "Sent packet to remote device")
}
fun notifyAirPodsConnectedRemotely(context: Context) {
val intent = Intent("me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY")
context.sendBroadcast(intent)
}
fun notifyAirPodsDisconnectedRemotely(context: Context) {
val intent = Intent("me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY")
context.sendBroadcast(intent)
}
}

View File

@@ -0,0 +1,399 @@
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.utils
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.services.ServiceManager
import java.util.Collections
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
@RequiresApi(Build.VERSION_CODES.Q)
class GestureDetector(
private val airPodsService: AirPodsService
) {
companion object {
private const val TAG = "GestureDetector"
private const val IMMEDIATE_FEEDBACK_THRESHOLD = 600
private const val DIRECTION_CHANGE_SENSITIVITY = 150
private const val FAST_MOVEMENT_THRESHOLD = 300.0
private const val MIN_REQUIRED_EXTREMES = 3
private const val MAX_REQUIRED_EXTREMES = 4
private const val MAX_VALID_ORIENTATION_VALUE = 6000
}
val audio = GestureFeedback(ServiceManager.getService()?.baseContext!!)
private val horizontalBuffer = Collections.synchronizedList(ArrayList<Double>())
private val verticalBuffer = Collections.synchronizedList(ArrayList<Double>())
private val horizontalAvgBuffer = Collections.synchronizedList(ArrayList<Double>())
private val verticalAvgBuffer = Collections.synchronizedList(ArrayList<Double>())
private var prevHorizontal: Double = 0.0
private var prevVertical: Double = 0.0
private val horizontalPeaks = CopyOnWriteArrayList<Triple<Int, Double, Long>>()
private val horizontalTroughs = CopyOnWriteArrayList<Triple<Int, Double, Long>>()
private val verticalPeaks = CopyOnWriteArrayList<Triple<Int, Double, Long>>()
private val verticalTroughs = CopyOnWriteArrayList<Triple<Int, Double, Long>>()
private var lastPeakTime: Long = 0
private val peakIntervals = Collections.synchronizedList(ArrayList<Double>())
private val movementSpeedIntervals = Collections.synchronizedList(ArrayList<Long>())
private val peakThreshold = 400
private val directionChangeThreshold = DIRECTION_CHANGE_SENSITIVITY
private val rhythmConsistencyThreshold = 0.5
private var horizontalIncreasing: Boolean? = null
private var verticalIncreasing: Boolean? = null
private val minConfidenceThreshold = 0.7
private var isRunning = false
private var detectionJob: Job? = null
private var gestureDetectedCallback: ((Boolean) -> Unit)? = null
private var significantMotion = false
private var lastSignificantMotionTime = 0L
init {
while (horizontalAvgBuffer.size < 3) horizontalAvgBuffer.add(0.0)
while (verticalAvgBuffer.size < 3) verticalAvgBuffer.add(0.0)
}
fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> Unit) {
if (isRunning) return
Log.d(TAG, "Starting gesture detection...")
isRunning = true
gestureDetectedCallback = onGestureDetected
Log.d(TAG, "started: ${airPodsService.startHeadTracking()}")
clearData()
prevHorizontal = 0.0
prevVertical = 0.0
detectionJob = CoroutineScope(Dispatchers.Default).launch {
while (isRunning) {
delay(50)
val gesture = detectGestures()
if (gesture != null) {
withContext(Dispatchers.Main) {
audio.playConfirmation(gesture)
gestureDetectedCallback?.invoke(gesture)
stopDetection(doNotStop)
}
break
}
}
}
}
fun stopDetection(doNotStop: Boolean = false) {
if (!isRunning) return
Log.d(TAG, "Stopping gesture detection")
isRunning = false
if (!doNotStop) airPodsService.stopHeadTracking()
detectionJob?.cancel()
detectionJob = null
gestureDetectedCallback = null
}
@RequiresApi(Build.VERSION_CODES.R)
fun processHeadOrientation(horizontal: Int, vertical: Int) {
if (!isRunning) return
if (abs(horizontal) > MAX_VALID_ORIENTATION_VALUE || abs(vertical) > MAX_VALID_ORIENTATION_VALUE) {
Log.d(TAG, "Ignoring likely calibration data: h=$horizontal, v=$vertical")
return
}
val horizontalDelta = horizontal - prevHorizontal
val verticalDelta = vertical - prevVertical
val significantHorizontal = abs(horizontalDelta) > IMMEDIATE_FEEDBACK_THRESHOLD
val significantVertical = abs(verticalDelta) > IMMEDIATE_FEEDBACK_THRESHOLD
if (significantHorizontal && (!significantVertical || abs(horizontalDelta) > abs(verticalDelta))) {
CoroutineScope(Dispatchers.Main).launch {
audio.playDirectional(isVertical = false, value = horizontalDelta)
}
significantMotion = true
lastSignificantMotionTime = System.currentTimeMillis()
Log.d(TAG, "Significant HORIZONTAL movement: $horizontalDelta")
}
else if (significantVertical) {
CoroutineScope(Dispatchers.Main).launch {
audio.playDirectional(isVertical = true, value = verticalDelta)
}
significantMotion = true
lastSignificantMotionTime = System.currentTimeMillis()
Log.d(TAG, "Significant VERTICAL movement: $verticalDelta")
}
else if (significantMotion &&
(System.currentTimeMillis() - lastSignificantMotionTime) > 300) {
significantMotion = false
}
prevHorizontal = horizontal.toDouble()
prevVertical = vertical.toDouble()
val smoothHorizontal = applySmoothing(horizontal.toDouble(), horizontalAvgBuffer)
val smoothVertical = applySmoothing(vertical.toDouble(), verticalAvgBuffer)
synchronized(horizontalBuffer) {
horizontalBuffer.add(smoothHorizontal)
if (horizontalBuffer.size > 100) horizontalBuffer.removeAt(0)
}
synchronized(verticalBuffer) {
verticalBuffer.add(smoothVertical)
if (verticalBuffer.size > 100) verticalBuffer.removeAt(0)
}
detectPeaksAndTroughs()
}
private fun applySmoothing(newValue: Double, buffer: MutableList<Double>): Double {
synchronized(buffer) {
buffer.add(newValue)
if (buffer.size > 3) buffer.removeAt(0)
return buffer.average()
}
}
private fun detectPeaksAndTroughs() {
if (horizontalBuffer.size < 4 || verticalBuffer.size < 4) return
val hValues = horizontalBuffer.takeLast(4)
val vValues = verticalBuffer.takeLast(4)
val hVariance = calculateVariance(hValues)
val vVariance = calculateVariance(vValues)
processDirectionChanges(
horizontalBuffer,
horizontalIncreasing,
hVariance,
horizontalPeaks,
horizontalTroughs
)?.let { horizontalIncreasing = it }
processDirectionChanges(
verticalBuffer,
verticalIncreasing,
vVariance,
verticalPeaks,
verticalTroughs
)?.let { verticalIncreasing = it }
}
private fun processDirectionChanges(
buffer: List<Double>,
isIncreasing: Boolean?,
variance: Double,
peaks: MutableList<Triple<Int, Double, Long>>,
troughs: MutableList<Triple<Int, Double, Long>>
): Boolean? {
if (buffer.size < 2) return isIncreasing
val current = buffer.last()
val prev = buffer[buffer.size - 2]
var increasing = isIncreasing ?: (current > prev)
val dynamicThreshold = max(50.0, min(directionChangeThreshold.toDouble(), variance / 3))
val now = System.currentTimeMillis()
if (increasing && current < prev - dynamicThreshold) {
if (abs(prev) > peakThreshold) {
peaks.add(Triple(buffer.size - 1, prev, now))
if (lastPeakTime > 0) {
val interval = (now - lastPeakTime) / 1000.0
val timeDiff = now - lastPeakTime
synchronized(peakIntervals) {
peakIntervals.add(interval)
if (peakIntervals.size > 5) peakIntervals.removeAt(0)
}
synchronized(movementSpeedIntervals) {
movementSpeedIntervals.add(timeDiff)
if (movementSpeedIntervals.size > 5) movementSpeedIntervals.removeAt(0)
}
}
lastPeakTime = now
}
increasing = false
} else if (!increasing && current > prev + dynamicThreshold) {
if (abs(prev) > peakThreshold) {
troughs.add(Triple(buffer.size - 1, prev, now))
if (lastPeakTime > 0) {
val interval = (now - lastPeakTime) / 1000.0
val timeDiff = now - lastPeakTime
synchronized(peakIntervals) {
peakIntervals.add(interval)
if (peakIntervals.size > 5) peakIntervals.removeAt(0)
}
synchronized(movementSpeedIntervals) {
movementSpeedIntervals.add(timeDiff)
if (movementSpeedIntervals.size > 5) movementSpeedIntervals.removeAt(0)
}
}
lastPeakTime = now
}
increasing = true
}
return increasing
}
private fun calculateVariance(values: List<Double>): Double {
if (values.size <= 1) return 0.0
val mean = values.average()
val squaredDiffs = values.map { (it - mean) * (it - mean) }
return squaredDiffs.average()
}
private fun calculateRhythmConsistency(): Double {
if (peakIntervals.size < 2) return 0.0
val meanInterval = peakIntervals.average()
if (meanInterval == 0.0) return 0.0
val variances = peakIntervals.map { (it / meanInterval - 1.0).pow(2) }
val consistency = 1.0 - min(1.0, variances.average() / rhythmConsistencyThreshold)
return max(0.0, consistency)
}
private fun calculateConfidenceScore(extremes: List<Triple<Int, Double, Long>>, isVertical: Boolean): Double {
if (extremes.size < getRequiredExtremes()) return 0.0
val sortedExtremes = extremes.sortedBy { it.first }
val recent = sortedExtremes.takeLast(getRequiredExtremes())
val avgAmplitude = recent.map { abs(it.second) }.average()
val amplitudeFactor = min(1.0, avgAmplitude / 600)
val rhythmFactor = calculateRhythmConsistency()
val signs = recent.map { if (it.second > 0) 1 else -1 }
val alternating = (1 until signs.size).all { signs[it] != signs[it - 1] }
val alternationFactor = if (alternating) 1.0 else 0.5
val isolationFactor = if (isVertical) {
val vertAmplitude = recent.map { abs(it.second) }.average()
val horizVals = horizontalBuffer.takeLast(recent.size * 2)
val horizAmplitude = horizVals.map { abs(it) }.average()
min(1.0, vertAmplitude / (horizAmplitude + 0.1) * 1.2)
} else {
val horizAmplitude = recent.map { abs(it.second) }.average()
val vertVals = verticalBuffer.takeLast(recent.size * 2)
val vertAmplitude = vertVals.map { abs(it) }.average()
min(1.0, horizAmplitude / (vertAmplitude + 0.1) * 1.2)
}
return (
amplitudeFactor * 0.4 +
rhythmFactor * 0.2 +
alternationFactor * 0.2 +
isolationFactor * 0.2
)
}
private fun getRequiredExtremes(): Int {
if (movementSpeedIntervals.isEmpty()) return MIN_REQUIRED_EXTREMES
val avgInterval = movementSpeedIntervals.average()
Log.d(TAG, "Average movement interval: $avgInterval ms")
return if (avgInterval < FAST_MOVEMENT_THRESHOLD) {
MAX_REQUIRED_EXTREMES
} else {
MIN_REQUIRED_EXTREMES
}
}
private fun detectGestures(): Boolean? {
val requiredExtremes = getRequiredExtremes()
Log.d(TAG, "Current required extremes: $requiredExtremes")
if (verticalPeaks.size + verticalTroughs.size >= requiredExtremes) {
val allExtremes = (verticalPeaks + verticalTroughs).sortedBy { it.first }
val confidence = calculateConfidenceScore(allExtremes, isVertical = true)
Log.d(TAG, "Vertical motion confidence: $confidence (need $minConfidenceThreshold)")
if (confidence >= minConfidenceThreshold) {
Log.d(TAG, "\"Yes\" Gesture Detected (confidence: $confidence, extremes: ${allExtremes.size}/$requiredExtremes)")
return true
}
}
if (horizontalPeaks.size + horizontalTroughs.size >= requiredExtremes) {
val allExtremes = (horizontalPeaks + horizontalTroughs).sortedBy { it.first }
val confidence = calculateConfidenceScore(allExtremes, isVertical = false)
Log.d(TAG, "Horizontal motion confidence: $confidence (need $minConfidenceThreshold)")
if (confidence >= minConfidenceThreshold) {
Log.d(TAG, "\"No\" Gesture Detected (confidence: $confidence, extremes: ${allExtremes.size}/$requiredExtremes)")
return false
}
}
return null
}
private fun clearData() {
horizontalBuffer.clear()
verticalBuffer.clear()
horizontalPeaks.clear()
horizontalTroughs.clear()
verticalPeaks.clear()
verticalTroughs.clear()
peakIntervals.clear()
movementSpeedIntervals.clear()
horizontalIncreasing = null
verticalIncreasing = null
lastPeakTime = 0
significantMotion = false
lastSignificantMotionTime = 0L
}
private fun Double.pow(exponent: Int): Double = this.pow(exponent.toDouble())
}

View File

@@ -0,0 +1,163 @@
@file:Suppress("PrivatePropertyName")
package me.kavishdevar.librepods.utils
import android.content.Context
import android.media.AudioAttributes
import android.media.SoundPool
import android.os.Build
import android.os.SystemClock
import android.util.Log
import androidx.annotation.RequiresApi
import me.kavishdevar.librepods.R
import java.util.concurrent.atomic.AtomicBoolean
@RequiresApi(Build.VERSION_CODES.Q)
class GestureFeedback(private val context: Context) {
private val TAG = "GestureFeedback"
private val soundsLoaded = AtomicBoolean(false)
private val soundPool = SoundPool.Builder()
.setMaxStreams(3)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setFlags(AudioAttributes.FLAG_LOW_LATENCY or
AudioAttributes.FLAG_AUDIBILITY_ENFORCED)
.build()
)
.build()
private var soundId = 0
private var confirmYesId = 0
private var confirmNoId = 0
private var lastHorizontalTime = 0L
private var lastLeftTime = 0L
private var lastRightTime = 0L
private var lastVerticalTime = 0L
private var lastUpTime = 0L
private var lastDownTime = 0L
private val MIN_TIME_BETWEEN_SOUNDS = 150L
private val MIN_TIME_BETWEEN_DIRECTION = 200L
private var currentHorizontalStreamId = 0
private var currentVerticalStreamId = 0
private val LEFT_VOLUME = Pair(1.0f, 0.0f)
private val RIGHT_VOLUME = Pair(0.0f, 1.0f)
private val VERTICAL_VOLUME = Pair(1.0f, 1.0f)
init {
soundId = soundPool.load(context, R.raw.blip_no, 1)
confirmYesId = soundPool.load(context, R.raw.confirm_yes, 1)
confirmNoId = soundPool.load(context, R.raw.confirm_no, 1)
soundPool.setOnLoadCompleteListener { _, _, _ ->
Log.d(TAG, "Sounds loaded")
soundsLoaded.set(true)
soundPool.play(soundId, 0.0f, 0.0f, 1, 0, 1.0f)
}
}
@RequiresApi(Build.VERSION_CODES.R)
fun playDirectional(isVertical: Boolean, value: Double) {
if (!soundsLoaded.get()) {
Log.d(TAG, "Sounds not yet loaded, skipping playback")
return
}
val now = SystemClock.uptimeMillis()
if (isVertical) {
val isUp = value > 0
if (now - lastVerticalTime < MIN_TIME_BETWEEN_SOUNDS) {
Log.d(TAG, "Skipping vertical sound due to general vertical debounce")
return
}
if (isUp && now - lastUpTime < MIN_TIME_BETWEEN_DIRECTION) {
Log.d(TAG, "Skipping UP sound due to direction debounce")
return
}
if (!isUp && now - lastDownTime < MIN_TIME_BETWEEN_DIRECTION) {
Log.d(TAG, "Skipping DOWN sound due to direction debounce")
return
}
if (currentVerticalStreamId > 0) {
soundPool.stop(currentVerticalStreamId)
}
val (leftVol, rightVol) = VERTICAL_VOLUME
currentVerticalStreamId = soundPool.play(soundId, leftVol, rightVol, 1, 0, 1.0f)
Log.d(TAG, "Playing VERTICAL sound: ${if (isUp) "UP" else "DOWN"} - streamID=$currentVerticalStreamId")
lastVerticalTime = now
if (isUp) {
lastUpTime = now
} else {
lastDownTime = now
}
} else {
if (now - lastHorizontalTime < MIN_TIME_BETWEEN_SOUNDS) {
Log.d(TAG, "Skipping horizontal sound due to general horizontal debounce")
return
}
val isRight = value > 0
if (isRight && now - lastRightTime < MIN_TIME_BETWEEN_DIRECTION) {
Log.d(TAG, "Skipping RIGHT sound due to direction debounce")
return
}
if (!isRight && now - lastLeftTime < MIN_TIME_BETWEEN_DIRECTION) {
Log.d(TAG, "Skipping LEFT sound due to direction debounce")
return
}
if (currentHorizontalStreamId > 0) {
soundPool.stop(currentHorizontalStreamId)
}
val (leftVol, rightVol) = if (isRight) RIGHT_VOLUME else LEFT_VOLUME
currentHorizontalStreamId = soundPool.play(soundId, leftVol, rightVol, 1, 0, 1.0f)
Log.d(TAG, "Playing HORIZONTAL sound: ${if (isRight) "RIGHT" else "LEFT"} - streamID=$currentHorizontalStreamId")
lastHorizontalTime = now
if (isRight) {
lastRightTime = now
} else {
lastLeftTime = now
}
}
}
fun playConfirmation(isYes: Boolean) {
if (currentHorizontalStreamId > 0) {
soundPool.stop(currentHorizontalStreamId)
}
if (currentVerticalStreamId > 0) {
soundPool.stop(currentVerticalStreamId)
}
val soundId = if (isYes) confirmYesId else confirmNoId
if (soundId != 0 && soundsLoaded.get()) {
val streamId = soundPool.play(soundId, 1.0f, 1.0f, 1, 0, 1.0f)
Log.d(TAG, "Playing ${if (isYes) "YES" else "NO"} confirmation - streamID=$streamId")
}
}
}

View File

@@ -0,0 +1,83 @@
package me.kavishdevar.librepods.utils
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlin.math.roundToInt
data class Orientation(val pitch: Float = 0f, val yaw: Float = 0f)
data class Acceleration(val vertical: Float = 0f, val horizontal: Float = 0f)
object HeadTracking {
private val _orientation = MutableStateFlow(Orientation())
val orientation = _orientation.asStateFlow()
private val _acceleration = MutableStateFlow(Acceleration())
val acceleration = _acceleration.asStateFlow()
private val calibrationSamples = mutableListOf<Triple<Int, Int, Int>>()
private var isCalibrated = false
private var o1Neutral = 19000
private var o2Neutral = 0
private var o3Neutral = 0
private const val CALIBRATION_SAMPLE_COUNT = 10
private const val ORIENTATION_OFFSET = 5500
fun processPacket(packet: ByteArray) {
val o1 = bytesToInt(packet[43], packet[44])
val o2 = bytesToInt(packet[45], packet[46])
val o3 = bytesToInt(packet[47], packet[48])
val horizontalAccel = bytesToInt(packet[51], packet[52]).toFloat()
val verticalAccel = bytesToInt(packet[53], packet[54]).toFloat()
if (!isCalibrated) {
calibrationSamples.add(Triple(o1, o2, o3))
if (calibrationSamples.size >= CALIBRATION_SAMPLE_COUNT) {
calibrate()
}
return
}
val orientation = calculateOrientation(o1, o2, o3)
_orientation.value = orientation
_acceleration.value = Acceleration(verticalAccel, horizontalAccel)
}
private fun calibrate() {
if (calibrationSamples.size < 3) return
// Add offset during calibration
o1Neutral = calibrationSamples.map { it.first + ORIENTATION_OFFSET }.average().roundToInt()
o2Neutral = calibrationSamples.map { it.second + ORIENTATION_OFFSET }.average().roundToInt()
o3Neutral = calibrationSamples.map { it.third + ORIENTATION_OFFSET }.average().roundToInt()
isCalibrated = true
}
@Suppress("UnusedVariable")
private fun calculateOrientation(o1: Int, o2: Int, o3: Int): Orientation {
if (!isCalibrated) return Orientation()
val o1Norm = (o1 + ORIENTATION_OFFSET) - o1Neutral
val o2Norm = (o2 + ORIENTATION_OFFSET) - o2Neutral
val o3Norm = (o3 + ORIENTATION_OFFSET) - o3Neutral
val pitch = (o2Norm + o3Norm) / 2f / 32000f * 180f
val yaw = (o2Norm - o3Norm) / 2f / 32000f * 180f
return Orientation(pitch, yaw)
}
private fun bytesToInt(b1: Byte, b2: Byte): Int {
return (b2.toInt() shl 8) or (b1.toInt() and 0xFF)
}
fun reset() {
calibrationSamples.clear()
isCalibrated = false
_orientation.value = Orientation()
_acceleration.value = Acceleration()
}
}

View File

@@ -0,0 +1,689 @@
/*
* 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.utils
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ObjectAnimator
import android.animation.PropertyValuesHolder
import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.res.Resources
import android.graphics.PixelFormat
import android.graphics.drawable.GradientDrawable
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log.e
import android.view.Gravity
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.VelocityTracker
import android.view.View
import android.view.WindowManager
import android.view.animation.AccelerateInterpolator
import android.view.animation.AnticipateOvershootInterpolator
import android.view.animation.DecelerateInterpolator
import android.view.animation.OvershootInterpolator
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.VideoView
import androidx.core.content.ContextCompat.getString
import androidx.dynamicanimation.animation.DynamicAnimation
import androidx.dynamicanimation.animation.SpringAnimation
import androidx.dynamicanimation.animation.SpringForce
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.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs
enum class IslandType {
CONNECTED,
TAKING_OVER,
MOVED_TO_REMOTE,
}
class IslandWindow(private val context: Context) {
private val windowManager: WindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
@SuppressLint("InflateParams")
private val islandView: View = LayoutInflater.from(context).inflate(R.layout.island_window, null)
private var isClosing = false
private var params: WindowManager.LayoutParams? = null
private var initialY = 0f
private var initialTouchY = 0f
private var lastTouchY = 0f
private var velocityTracker: VelocityTracker? = null
private var isBeingDragged = false
private var autoCloseHandler: Handler? = null
private var autoCloseRunnable: Runnable? = null
private var initialHeight = 0
private var screenHeight = 0
private var isDraggingDown = false
private var lastMoveTime = 0L
private var yMovement = 0f
private var dragDistance = 0f
private var initialConnectedTextY = 0f
private var initialDeviceTextY = 0f
private var initialBatteryViewY = 0f
private var initialVideoViewY = 0f
private var initialTextSeparation = 0f
private val containerView = FrameLayout(context)
private lateinit var springAnimation: SpringAnimation
private val flingAnimator = ValueAnimator()
private val batteryReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == AirPodsNotifications.BATTERY_DATA) {
val batteryList = intent.getParcelableArrayListExtra<Battery>("data")
updateBatteryDisplay(batteryList)
} else if (intent?.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
try {
context?.unregisterReceiver(this)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}
val isVisible: Boolean
get() = containerView.parent != null && containerView.visibility == View.VISIBLE
@SuppressLint("SetTextI18n")
private fun updateBatteryDisplay(batteryList: ArrayList<Battery>?) {
if (batteryList == null || batteryList.isEmpty()) return
val leftBattery = batteryList.find { it.component == BatteryComponent.LEFT }
val rightBattery = batteryList.find { it.component == BatteryComponent.RIGHT }
val leftLevel = leftBattery?.level ?: 0
val rightLevel = rightBattery?.level ?: 0
val leftStatus = leftBattery?.status ?: BatteryStatus.DISCONNECTED
val rightStatus = rightBattery?.status ?: BatteryStatus.DISCONNECTED
val batteryText = islandView.findViewById<TextView>(R.id.island_battery_text)
val batteryProgressBar = islandView.findViewById<ProgressBar>(R.id.island_battery_progress)
val displayBatteryLevel = when {
leftLevel > 0 && rightLevel > 0 -> minOf(leftLevel, rightLevel)
leftLevel > 0 -> leftLevel
rightLevel > 0 -> rightLevel
else -> null
}
if (displayBatteryLevel != null) {
batteryText.text = "$displayBatteryLevel%"
batteryProgressBar.progress = displayBatteryLevel
batteryProgressBar.isIndeterminate = false
} else {
batteryText.text = "?"
batteryProgressBar.progress = 0
batteryProgressBar.isIndeterminate = false
}
}
@SuppressLint("SetTextI18s", "ClickableViewAccessibility", "UnspecifiedRegisterReceiverFlag")
fun show(name: String, batteryPercentage: Int, context: Context, type: IslandType = IslandType.CONNECTED) {
if (ServiceManager.getService()?.islandOpen == true) return
else ServiceManager.getService()?.islandOpen = true
val displayMetrics = Resources.getSystem().displayMetrics
val width = (displayMetrics.widthPixels * 0.95).toInt()
screenHeight = displayMetrics.heightPixels
val batteryList = ServiceManager.getService()?.getBattery()
val batteryText = islandView.findViewById<TextView>(R.id.island_battery_text)
val batteryProgressBar = islandView.findViewById<ProgressBar>(R.id.island_battery_progress)
val displayBatteryLevel = if (batteryList != null) {
val leftBattery = batteryList.find { it.component == BatteryComponent.LEFT }
val rightBattery = batteryList.find { it.component == BatteryComponent.RIGHT }
when {
leftBattery?.level ?: 0 > 0 && rightBattery?.level ?: 0 > 0 ->
minOf(leftBattery!!.level, rightBattery!!.level)
leftBattery?.level ?: 0 > 0 -> leftBattery!!.level
rightBattery?.level ?: 0 > 0 -> rightBattery!!.level
batteryPercentage > 0 -> batteryPercentage
else -> null
}
} else if (batteryPercentage > 0) {
batteryPercentage
} else {
null
}
if (displayBatteryLevel != null) {
batteryText.text = "$displayBatteryLevel%"
batteryProgressBar.progress = displayBatteryLevel
} else {
batteryText.text = "?"
batteryProgressBar.progress = 0
}
batteryProgressBar.isIndeterminate = false
islandView.findViewById<TextView>(R.id.island_device_name).text = name
val batteryIntentFilter = IntentFilter(AirPodsNotifications.BATTERY_DATA)
batteryIntentFilter.addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(batteryReceiver, batteryIntentFilter, Context.RECEIVER_EXPORTED)
} else {
context.registerReceiver(batteryReceiver, batteryIntentFilter)
}
ServiceManager.getService()?.sendBatteryBroadcast()
containerView.removeAllViews()
val containerParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.WRAP_CONTENT
)
containerView.addView(islandView, containerParams)
params = WindowManager.LayoutParams(
width,
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
PixelFormat.TRANSLUCENT
).apply {
gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL
}
islandView.visibility = View.VISIBLE
containerView.visibility = View.VISIBLE
containerView.setOnTouchListener { _, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return@setOnTouchListener false)
flingAnimator.cancel()
velocityTracker?.recycle()
velocityTracker = VelocityTracker.obtain()
velocityTracker?.addMovement(event)
initialY = containerView.translationY
initialTouchY = event.rawY
lastTouchY = event.rawY
initialHeight = islandView.height
isBeingDragged = false
isDraggingDown = false
lastMoveTime = System.currentTimeMillis()
dragDistance = 0f
captureInitialPositions()
true
}
MotionEvent.ACTION_MOVE -> {
velocityTracker?.addMovement(event)
val deltaY = event.rawY - initialTouchY
val moveDelta = event.rawY - lastTouchY
dragDistance += abs(moveDelta)
isDraggingDown = moveDelta > 0
val currentTime = System.currentTimeMillis()
val timeDelta = currentTime - lastMoveTime
if (timeDelta > 0) {
yMovement = moveDelta / timeDelta * 10
}
lastMoveTime = currentTime
if (abs(deltaY) > 5 || isBeingDragged) {
isBeingDragged = true
// Cancel auto close timer when dragging starts
autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return@setOnTouchListener false)
val dampedDeltaY = if (deltaY > 0) {
initialY + (deltaY * 0.6f)
} else {
initialY + (deltaY * 0.9f)
}
containerView.translationY = dampedDeltaY
if (isDraggingDown && deltaY > 0) {
val stretchAmount = (deltaY * 0.5f).coerceAtMost(200f)
applyCustomStretchEffect(stretchAmount, deltaY)
}
}
lastTouchY = event.rawY
true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
velocityTracker?.addMovement(event)
velocityTracker?.computeCurrentVelocity(1000)
val yVelocity = velocityTracker?.yVelocity ?: 0f
if (isBeingDragged) {
val currentTranslationY = containerView.translationY
val significantVelocity = abs(yVelocity) > 800
val significantDrag = abs(dragDistance) > 80
when {
yVelocity < -1200 || (currentTranslationY < -80 && !isDraggingDown) -> {
animateDismissWithInertia(yVelocity)
}
yVelocity > 1200 || (isDraggingDown && significantDrag) -> {
animateExpandWithStretch(yVelocity)
}
else -> {
springBackWithInertia(yVelocity)
}
}
} else if (dragDistance < 10) {
resetAutoCloseTimer()
}
velocityTracker?.recycle()
velocityTracker = null
isBeingDragged = false
true
}
else -> false
}
}
when (type) {
IslandType.CONNECTED -> {
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_connected_text)
}
IslandType.TAKING_OVER -> {
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_taking_over_text)
}
IslandType.MOVED_TO_REMOTE -> {
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_moved_to_remote_text)
}
}
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
val videoUri = Uri.parse("android.resource://me.kavishdevar.librepods/${R.raw.island}")
videoView.setVideoURI(videoUri)
videoView.setOnPreparedListener { mediaPlayer ->
mediaPlayer.isLooping = true
videoView.start()
}
windowManager.addView(containerView, params)
islandView.post {
initialHeight = islandView.height
captureInitialPositions()
}
springAnimation = SpringAnimation(containerView, DynamicAnimation.TRANSLATION_Y, 0f).apply {
spring = SpringForce(0f)
.setDampingRatio(SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY)
.setStiffness(SpringForce.STIFFNESS_MEDIUM)
}
val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 0.5f, 1f)
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 0.5f, 1f)
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, -200f, 0f)
ObjectAnimator.ofPropertyValuesHolder(containerView, scaleX, scaleY, translationY).apply {
duration = 700
interpolator = AnticipateOvershootInterpolator()
start()
}
resetAutoCloseTimer()
}
private fun captureInitialPositions() {
val connectedText = islandView.findViewById<TextView>(R.id.island_connected_text)
val deviceText = islandView.findViewById<TextView>(R.id.island_device_name)
val batteryView = islandView.findViewById<FrameLayout>(R.id.island_battery_container)
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
connectedText.post {
initialConnectedTextY = connectedText.y
initialDeviceTextY = deviceText.y
initialTextSeparation = deviceText.y - (connectedText.y + connectedText.height)
if (batteryView != null) initialBatteryViewY = batteryView.y
initialVideoViewY = videoView.y
}
}
private fun applyCustomStretchEffect(stretchAmount: Float, dragY: Float) {
try {
val mainLayout = islandView.findViewById<LinearLayout>(R.id.island_window_layout)
val connectedText = islandView.findViewById<TextView>(R.id.island_connected_text)
val deviceText = islandView.findViewById<TextView>(R.id.island_device_name)
val batteryView = islandView.findViewById<FrameLayout>(R.id.island_battery_container)
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
val stretchFactor = 1f + (stretchAmount / 300f).coerceAtMost(4.0f)
val newMinHeight = (initialHeight * stretchFactor).toInt()
mainLayout.minimumHeight = newMinHeight
val textMarginIncrease = (stretchAmount * 0.8f).toInt()
val deviceTextParams = deviceText.layoutParams as LinearLayout.LayoutParams
deviceTextParams.topMargin = textMarginIncrease
deviceText.layoutParams = deviceTextParams
val background = mainLayout.background
if (background is GradientDrawable) {
val cornerRadius = 56f
background.cornerRadius = cornerRadius
}
if (params != null) {
params!!.height = screenHeight
val containerParams = containerView.layoutParams
containerParams.height = screenHeight
containerView.layoutParams = containerParams
try {
windowManager.updateViewLayout(containerView, params)
} catch (e: Exception) {
e.printStackTrace()
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun resetAutoCloseTimer() {
autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return)
autoCloseHandler = Handler(Looper.getMainLooper())
autoCloseRunnable = Runnable { close() }
autoCloseHandler?.postDelayed(autoCloseRunnable!!, 4500)
}
private fun springBackWithInertia(velocity: Float) {
springAnimation.cancel()
flingAnimator.cancel()
springAnimation.setStartVelocity(velocity)
val baseStiffness = SpringForce.STIFFNESS_MEDIUM
val dynamicStiffness = baseStiffness * (1f + (abs(velocity) / 3000f))
springAnimation.spring = SpringForce(0f)
.setDampingRatio(SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY)
.setStiffness(dynamicStiffness)
resetStretchEffects(velocity)
if (params != null) {
params!!.height = WindowManager.LayoutParams.WRAP_CONTENT
try {
windowManager.updateViewLayout(containerView, params)
} catch (e: Exception) {
e.printStackTrace()
}
}
springAnimation.start()
}
private fun resetStretchEffects(velocity: Float) {
try {
val mainLayout = islandView.findViewById<LinearLayout>(R.id.island_window_layout)
val deviceText = islandView.findViewById<TextView>(R.id.island_device_name)
val heightAnimator = ValueAnimator.ofInt(mainLayout.minimumHeight, initialHeight)
heightAnimator.duration = 300
heightAnimator.interpolator = OvershootInterpolator(1.5f)
heightAnimator.addUpdateListener { animation ->
mainLayout.minimumHeight = animation.animatedValue as Int
}
val deviceTextParams = deviceText.layoutParams as LinearLayout.LayoutParams
val textMarginAnimator = ValueAnimator.ofInt(deviceTextParams.topMargin, 0)
textMarginAnimator.duration = 300
textMarginAnimator.interpolator = OvershootInterpolator(1.5f)
textMarginAnimator.addUpdateListener { animation ->
deviceTextParams.topMargin = animation.animatedValue as Int
deviceText.layoutParams = deviceTextParams
}
heightAnimator.start()
textMarginAnimator.start()
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun animateDismissWithInertia(velocity: Float) {
springAnimation.cancel()
flingAnimator.cancel()
val baseDistance = -screenHeight
val velocityFactor = (abs(velocity) / 2000f).coerceIn(0.5f, 2.0f)
val targetDistance = baseDistance * velocityFactor
val baseDuration = 400L
val velocityDurationFactor = (1500f / (abs(velocity) + 1500f))
val duration = (baseDuration * velocityDurationFactor).toLong().coerceIn(200L, 500L)
flingAnimator.setFloatValues(containerView.translationY, targetDistance)
flingAnimator.duration = duration
flingAnimator.addUpdateListener { animation ->
containerView.translationY = animation.animatedValue as Float
val progress = animation.animatedFraction
containerView.scaleX = 1f - (progress * 0.5f)
containerView.scaleY = 1f - (progress * 0.5f)
containerView.alpha = 1f - progress
}
flingAnimator.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
forceClose()
}
})
flingAnimator.interpolator = DecelerateInterpolator(1.2f)
flingAnimator.start()
}
private fun animateExpandWithStretch(velocity: Float) {
springAnimation.cancel()
flingAnimator.cancel()
val baseDuration = 600L
val velocityFactor = (1800f / (abs(velocity) + 1800f)).coerceIn(0.5f, 1.5f)
val expandDuration = (baseDuration * velocityFactor).toLong().coerceIn(300L, 700L)
if (params != null) {
params!!.height = screenHeight
try {
windowManager.updateViewLayout(containerView, params)
} catch (e: Exception) {
e.printStackTrace()
}
}
val containerAnimator = ValueAnimator.ofFloat(containerView.translationY, screenHeight * 0.6f)
containerAnimator.duration = expandDuration
containerAnimator.interpolator = DecelerateInterpolator(0.8f)
containerAnimator.addUpdateListener { animation ->
containerView.translationY = animation.animatedValue as Float
}
val stretchAnimator = ValueAnimator.ofFloat(0f, 1f)
stretchAnimator.duration = expandDuration
stretchAnimator.interpolator = OvershootInterpolator(0.5f)
stretchAnimator.addUpdateListener { animation ->
val progress = animation.animatedValue as Float
animateCustomStretch(progress, expandDuration)
}
val normalizeAnimator = ValueAnimator.ofFloat(1.0f, 0.0f)
normalizeAnimator.duration = 300
normalizeAnimator.startDelay = expandDuration - 150
normalizeAnimator.interpolator = AccelerateInterpolator(1.2f)
normalizeAnimator.addUpdateListener { animation ->
val progress = animation.animatedValue as Float
containerView.alpha = progress
if (progress < 0.7f) {
islandView.findViewById<VideoView>(R.id.island_video_view).visibility = View.GONE
}
}
normalizeAnimator.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
ServiceManager.getService()?.startMainActivity()
forceClose()
}
})
containerAnimator.start()
stretchAnimator.start()
normalizeAnimator.start()
}
private fun animateCustomStretch(progress: Float, duration: Long) {
try {
val mainLayout = islandView.findViewById<LinearLayout>(R.id.island_window_layout)
val connectedText = islandView.findViewById<TextView>(R.id.island_connected_text)
val deviceText = islandView.findViewById<TextView>(R.id.island_device_name)
val targetHeight = (screenHeight * 0.7f).toInt()
val currentHeight = initialHeight + ((targetHeight - initialHeight) * progress)
mainLayout.minimumHeight = currentHeight.toInt()
val mainLayoutParams = mainLayout.layoutParams
mainLayoutParams.height = LinearLayout.LayoutParams.MATCH_PARENT
mainLayout.layoutParams = mainLayoutParams
val targetMargin = (400 * progress).toInt()
val deviceTextParams = deviceText.layoutParams as LinearLayout.LayoutParams
deviceTextParams.topMargin = targetMargin
deviceText.layoutParams = deviceTextParams
val baseTextSize = 24f
deviceText.textSize = baseTextSize + (progress * 8f)
val baseSubTextSize = 16f
connectedText.textSize = baseSubTextSize + (progress * 4f)
} catch (e: Exception) {
e.printStackTrace()
}
}
fun close() {
try {
if (isClosing) return
isClosing = true
try {
context.unregisterReceiver(batteryReceiver)
} catch (e: Exception) {
e.printStackTrace()
}
ServiceManager.getService()?.islandOpen = false
autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return)
resetStretchEffects(0f)
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
try {
videoView.stopPlayback()
} catch (e: Exception) {
e.printStackTrace()
}
val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, containerView.scaleX, 0.5f)
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, containerView.scaleY, 0.5f)
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, containerView.translationY, -200f)
ObjectAnimator.ofPropertyValuesHolder(containerView, scaleX, scaleY, translationY).apply {
duration = 700
interpolator = AnticipateOvershootInterpolator()
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
cleanupAndRemoveView()
}
})
start()
}
} catch (e: Exception) {
e.printStackTrace()
// Even if animation fails, ensure we cleanup
cleanupAndRemoveView()
}
}
private fun cleanupAndRemoveView() {
containerView.visibility = View.GONE
try {
if (containerView.parent != null) {
windowManager.removeView(containerView)
}
} catch (e: Exception) {
e("IslandWindow", "Error removing view: $e")
}
isClosing = false
// Make sure all animations are canceled
springAnimation.cancel()
flingAnimator.cancel()
}
fun forceClose() {
try {
if (isClosing) return
isClosing = true
try {
context.unregisterReceiver(batteryReceiver)
} catch (e: Exception) {
// Silent catch - receiver might already be unregistered
}
ServiceManager.getService()?.islandOpen = false
autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return)
// Cancel all ongoing animations
springAnimation.cancel()
flingAnimator.cancel()
// Immediately remove the view without animations
cleanupAndRemoveView()
} catch (e: Exception) {
e.printStackTrace()
isClosing = false
}
}
}

View File

@@ -0,0 +1,761 @@
package me.kavishdevar.librepods.utils
import android.content.Context
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.os.ParcelUuid
import android.util.Log
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateInterpolator
import android.view.animation.DecelerateInterpolator
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import io.github.libxposed.api.XposedInterface
import io.github.libxposed.api.XposedInterface.AfterHookCallback
import io.github.libxposed.api.XposedModule
import io.github.libxposed.api.XposedModuleInterface
import io.github.libxposed.api.XposedModuleInterface.ModuleLoadedParam
import io.github.libxposed.api.annotations.AfterInvocation
import io.github.libxposed.api.annotations.XposedHooker
private const val TAG = "AirPodsHook"
private lateinit var module: KotlinModule
class KotlinModule(base: XposedInterface, param: ModuleLoadedParam): XposedModule(base, param) {
init {
Log.i(TAG, "AirPodsHook module initialized at :: ${param.processName}")
module = this
}
override fun onPackageLoaded(param: XposedModuleInterface.PackageLoadedParam) {
super.onPackageLoaded(param)
Log.i(TAG, "onPackageLoaded :: ${param.packageName}")
if (param.packageName == "com.google.android.bluetooth" || param.packageName == "com.android.bluetooth") {
Log.i(TAG, "Bluetooth app detected, hooking l2c_fcr_chk_chan_modes")
try {
if (param.isFirstPackage) {
Log.i(TAG, "Loading native library for Bluetooth hook")
System.loadLibrary("l2c_fcr_hook")
Log.i(TAG, "Native library loaded successfully")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to load native library: ${e.message}", e)
}
}
if (param.packageName == "com.google.android.settings") {
Log.i(TAG, "Settings app detected, hooking Bluetooth icon handling")
try {
val headerControllerClass = param.classLoader.loadClass(
"com.google.android.settings.bluetooth.AdvancedBluetoothDetailsHeaderController")
val updateIconMethod = headerControllerClass.getDeclaredMethod(
"updateIcon",
android.widget.ImageView::class.java,
String::class.java)
hook(updateIconMethod, BluetoothIconHooker::class.java)
Log.i(TAG, "Successfully hooked updateIcon method in Bluetooth settings")
try {
val displayPreferenceMethod = headerControllerClass.getDeclaredMethod(
"displayPreference",
param.classLoader.loadClass("androidx.preference.PreferenceScreen"))
hook(displayPreferenceMethod, BluetoothSettingsAirPodsHooker::class.java)
Log.i(TAG, "Successfully hooked displayPreference for AirPods button injection")
} catch (e: Exception) {
Log.e(TAG, "Failed to hook displayPreference: ${e.message}", e)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to hook Bluetooth icon handler: ${e.message}", e)
}
}
if (param.packageName == "com.android.settings") {
Log.i(TAG, "Settings app detected, hooking Bluetooth icon handling")
try {
val headerControllerClass = param.classLoader.loadClass(
"com.android.settings.bluetooth.AdvancedBluetoothDetailsHeaderController")
val updateIconMethod = headerControllerClass.getDeclaredMethod(
"updateIcon",
android.widget.ImageView::class.java,
String::class.java)
hook(updateIconMethod, BluetoothIconHooker::class.java)
Log.i(TAG, "Successfully hooked updateIcon method in Bluetooth settings")
try {
val displayPreferenceMethod = headerControllerClass.getDeclaredMethod(
"displayPreference",
param.classLoader.loadClass("androidx.preference.PreferenceScreen"))
hook(displayPreferenceMethod, BluetoothSettingsAirPodsHooker::class.java)
Log.i(TAG, "Successfully hooked displayPreference for AirPods button injection")
} catch (e: Exception) {
Log.e(TAG, "Failed to hook displayPreference: ${e.message}", e)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to hook Bluetooth icon handler: ${e.message}", e)
}
}
}
@XposedHooker
class BluetoothSettingsAirPodsHooker : XposedInterface.Hooker {
companion object {
private const val AIRPODS_UUID = "74ec2172-0bad-4d01-8f77-997b2be0722a"
private const val LIBREPODS_PREFERENCE_KEY = "librepods_open_preference"
private const val ACTION_SET_ANC_MODE = "me.kavishdevar.librepods.SET_ANC_MODE"
private const val EXTRA_ANC_MODE = "anc_mode"
private const val ANC_MODE_OFF = 1
private const val ANC_MODE_NOISE_CANCELLATION = 2
private const val ANC_MODE_TRANSPARENCY = 3
private const val ANC_MODE_ADAPTIVE = 4
private var currentAncMode = ANC_MODE_NOISE_CANCELLATION
@JvmStatic
@AfterInvocation
fun afterDisplayPreference(callback: AfterHookCallback) {
try {
val controller = callback.thisObject!!
val preferenceScreen = callback.args[0]!!
val context = preferenceScreen.javaClass.getMethod("getContext").invoke(preferenceScreen) as Context
val deviceField = controller.javaClass.getDeclaredField("mCachedDevice")
deviceField.isAccessible = true
val cachedDevice = deviceField.get(controller) ?: return
val getDeviceMethod = cachedDevice.javaClass.getMethod("getDevice")
val bluetoothDevice = getDeviceMethod.invoke(cachedDevice) ?: return
val uuidsMethod = bluetoothDevice.javaClass.getMethod("getUuids")
val uuids = uuidsMethod.invoke(bluetoothDevice) as? Array<ParcelUuid>
if (uuids != null) {
val isAirPods = uuids.any { it.uuid.toString() == AIRPODS_UUID }
if (isAirPods) {
Log.i(TAG, "AirPods device detected in settings, injecting controls")
val findPreferenceMethod = preferenceScreen.javaClass.getMethod("findPreference", CharSequence::class.java)
val existingPref = findPreferenceMethod.invoke(preferenceScreen, LIBREPODS_PREFERENCE_KEY)
if (existingPref != null) {
Log.i(TAG, "LIBREPODS button already exists, skipping")
return
}
val preferenceClass = preferenceScreen.javaClass.classLoader.loadClass("androidx.preference.Preference")
val preference = preferenceClass.getConstructor(Context::class.java).newInstance(context)
val setKeyMethod = preferenceClass.getMethod("setKey", String::class.java)
setKeyMethod.invoke(preference, LIBREPODS_PREFERENCE_KEY)
val setTitleMethod = preferenceClass.getMethod("setTitle", CharSequence::class.java)
setTitleMethod.invoke(preference, "Open LibrePods")
val setSummaryMethod = preferenceClass.getMethod("setSummary", CharSequence::class.java)
setSummaryMethod.invoke(preference, "Control AirPods features")
val setIconMethod = preferenceClass.getMethod("setIcon", Int::class.java)
setIconMethod.invoke(preference, android.R.drawable.ic_menu_manage)
val setOrderMethod = preferenceClass.getMethod("setOrder", Int::class.java)
setOrderMethod.invoke(preference, 1000)
val intent = Intent().apply {
setClassName("me.kavishdevar.librepods", "me.kavishdevar.librepods.MainActivity")
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
val setIntentMethod = preferenceClass.getMethod("setIntent", Intent::class.java)
setIntentMethod.invoke(preference, intent)
val addPreferenceMethod = preferenceScreen.javaClass.getMethod("addPreference", preferenceClass)
addPreferenceMethod.invoke(preferenceScreen, preference)
Log.i(TAG, "Successfully added Open LIBREPODS button to AirPods settings")
}
}
} catch (e: Exception) {
Log.e(TAG, "Error in BluetoothSettingsAirPodsHooker: ${e.message}", e)
e.printStackTrace()
}
}
}
}
@XposedHooker
class BluetoothIconHooker : XposedInterface.Hooker {
companion object {
@JvmStatic
@AfterInvocation
fun afterUpdateIcon(callback: AfterHookCallback) {
Log.i(TAG, "BluetoothIconHooker called with args: ${callback.args.joinToString(", ")}")
try {
val imageView = callback.args[0] as ImageView
val iconUri = callback.args[1] as String
val uri = android.net.Uri.parse(iconUri)
if (uri.toString().startsWith("android.resource://me.kavishdevar.librepods")) {
Log.i(TAG, "Handling AirPods icon URI: $uri")
try {
val context = imageView.context
android.os.Handler(android.os.Looper.getMainLooper()).post {
try {
val packageName = uri.authority
val packageContext = context.createPackageContext(
packageName,
Context.CONTEXT_IGNORE_SECURITY
)
val resPath = uri.pathSegments
if (resPath.size >= 2 && resPath[0] == "drawable") {
val resourceName = resPath[1]
val resourceId = packageContext.resources.getIdentifier(
resourceName, "drawable", packageName
)
if (resourceId != 0) {
val drawable = packageContext.resources.getDrawable(
resourceId, packageContext.theme
)
imageView.setImageDrawable(drawable)
imageView.alpha = 1.0f
callback.result = null
Log.i(TAG, "Successfully loaded icon from resource: $resourceName")
} else {
Log.e(TAG, "Resource not found: $resourceName")
}
}
} catch (e: Exception) {
Log.e(TAG, "Error loading resource from URI $uri: ${e.message}")
}
}
} catch (e: Exception) {
Log.e(TAG, "Error accessing context: ${e.message}")
}
}
} catch (e: Exception) {
Log.e(TAG, "Error in BluetoothIconHooker: ${e.message}")
e.printStackTrace()
}
}
}
}
override fun getApplicationInfo(): ApplicationInfo {
return super.applicationInfo
}
companion object {
private const val ANC_MODE_OFF = 1
private const val ANC_MODE_NOISE_CANCELLATION = 2
private const val ANC_MODE_TRANSPARENCY = 3
private const val ANC_MODE_ADAPTIVE = 4
private var currentANCMode = ANC_MODE_NOISE_CANCELLATION
private const val ACTION_SET_ANC_MODE = "me.kavishdevar.librepods.SET_ANC_MODE"
private const val EXTRA_ANC_MODE = "anc_mode"
private const val ANIMATION_DURATION = 250L
private fun addAirPodsControlsToDialog(volumeDialog: Any) {
try {
val contextField = volumeDialog.javaClass.getDeclaredField("mContext")
contextField.isAccessible = true
val context = contextField.get(volumeDialog) as Context
val dialogViewField = volumeDialog.javaClass.getDeclaredField("mDialogView")
dialogViewField.isAccessible = true
val dialogView = dialogViewField.get(volumeDialog) as ViewGroup
val dialogRowsViewField = volumeDialog.javaClass.getDeclaredField("mDialogRowsView")
dialogRowsViewField.isAccessible = true
val dialogRowsView = dialogRowsViewField.get(volumeDialog) as ViewGroup
Log.d(TAG, "Found dialogRowsView: ${dialogRowsView.javaClass.name}")
val existingContainer = dialogView.findViewWithTag<View>("airpods_container")
if (existingContainer != null) {
Log.d(TAG, "AirPods container already exists, ensuring visibility state")
val drawer = existingContainer.findViewWithTag<View>("airpods_drawer_container")
drawer?.visibility = View.GONE
drawer?.alpha = 0f
drawer?.translationY = 0f
val button = existingContainer.findViewWithTag<ImageButton>("airpods_button")
button?.visibility = View.VISIBLE
button?.alpha = 1f
if (button != null) {
updateMainButtonIcon(context, button, currentANCMode)
}
return
}
val newAirPodsButton = ImageButton(context).apply {
tag = "airpods_button"
try {
val airPodsPackage = context.createPackageContext(
"me.kavishdevar.librepods",
Context.CONTEXT_IGNORE_SECURITY
)
val airPodsIconRes = airPodsPackage.resources.getIdentifier(
"airpods", "drawable", "me.kavishdevar.librepods")
if (airPodsIconRes != 0) {
val airPodsDrawable = airPodsPackage.resources.getDrawable(
airPodsIconRes, airPodsPackage.theme)
setImageDrawable(airPodsDrawable)
} else {
setImageResource(android.R.drawable.ic_media_play)
Log.d(TAG, "Using fallback icon because airpods icon resource not found")
}
} catch (e: Exception) {
setImageResource(android.R.drawable.ic_media_play)
Log.e(TAG, "Failed to load AirPods icon: ${e.message}")
}
val shape = GradientDrawable()
shape.shape = GradientDrawable.RECTANGLE
shape.setColor(Color.BLACK)
background = shape
imageTintList = ColorStateList.valueOf(Color.WHITE)
scaleType = ImageView.ScaleType.CENTER_INSIDE
setPadding(24, 24, 24, 24)
val params = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
90
)
params.gravity = Gravity.CENTER
params.setMargins(0, 0, 0, 0)
layoutParams = params
setOnClickListener {
Log.d(TAG, "AirPods button clicked, toggling drawer")
val container = findAirPodsContainer(this)
val drawerContainer = container?.findViewWithTag<View>("airpods_drawer_container")
if (drawerContainer != null && container != null) {
if (drawerContainer.visibility == View.VISIBLE) {
hideAirPodsDrawer(container, this, drawerContainer)
} else {
showAirPodsDrawer(container, this, drawerContainer)
}
} else {
Log.e(TAG, "Could not find container or drawer for toggle")
}
}
contentDescription = "AirPods Settings"
}
val airPodsContainer = FrameLayout(context).apply {
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
tag = "airpods_container"
}
newAirPodsButton.setOnLongClickListener {
Log.d(TAG, "AirPods button long-pressed, opening QuickSettingsDialogActivity")
val intent = Intent().apply {
setClassName("me.kavishdevar.librepods", "me.kavishdevar.librepods.QuickSettingsDialogActivity")
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
context.startActivity(intent)
try {
val dismissMethod = volumeDialog.javaClass.getMethod("dismissH")
dismissMethod.invoke(volumeDialog)
} catch (e: Exception) {
Log.w(TAG, "Could not dismiss volume dialog: ${e.message}")
}
true
}
val airPodsDrawer = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.WRAP_CONTENT
).apply {
gravity = Gravity.TOP
}
tag = "airpods_drawer_container"
visibility = View.GONE
alpha = 0f
val drawerShape = GradientDrawable()
drawerShape.shape = GradientDrawable.RECTANGLE
drawerShape.setColor(Color.BLACK)
background = drawerShape
setPadding(16, 8, 16, 8)
}
val buttonContainer = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.WRAP_CONTENT
).apply {
gravity = Gravity.TOP
}
tag = "airpods_button_container"
}
val modes = listOf(ANC_MODE_OFF, ANC_MODE_TRANSPARENCY, ANC_MODE_ADAPTIVE, ANC_MODE_NOISE_CANCELLATION)
for (mode in modes) {
val modeOption = createAncModeOption(context, mode, mode == currentANCMode, newAirPodsButton)
airPodsDrawer.addView(modeOption)
}
buttonContainer.addView(newAirPodsButton)
airPodsContainer.addView(airPodsDrawer)
airPodsContainer.addView(buttonContainer)
val settingsViewField = try {
val field = volumeDialog.javaClass.getDeclaredField("mSettingsView")
field.isAccessible = true
field.get(volumeDialog) as? View
} catch (e: Exception) {
Log.e(TAG, "Failed to get settings view field: ${e.message}")
null
}
if (settingsViewField != null && settingsViewField.parent is ViewGroup) {
val settingsParent = settingsViewField.parent as ViewGroup
val settingsIndex = findViewIndexInParent(settingsParent, settingsViewField)
if (settingsIndex >= 0) {
settingsParent.addView(airPodsContainer, settingsIndex)
Log.i(TAG, "Added AirPods controls before settings button")
} else {
settingsParent.addView(airPodsContainer)
Log.i(TAG, "Added AirPods controls to the end of settings parent")
}
} else {
dialogView.addView(airPodsContainer)
Log.i(TAG, "Fallback: Added AirPods controls to dialog view")
}
updateMainButtonIcon(context, newAirPodsButton, currentANCMode)
Log.i(TAG, "Successfully added AirPods button and drawer to volume dialog")
} catch (e: Exception) {
Log.e(TAG, "Error adding AirPods button to volume panel: ${e.message}")
e.printStackTrace()
}
}
private fun findViewIndexInParent(parent: ViewGroup, view: View): Int {
for (i in 0 until parent.childCount) {
if (parent.getChildAt(i) == view) {
return i
}
}
return -1
}
private fun updateMainButtonIcon(context: Context, button: ImageButton, mode: Int) {
try {
val pkgContext = context.createPackageContext(
"me.kavishdevar.librepods",
Context.CONTEXT_IGNORE_SECURITY
)
val resName = when (mode) {
ANC_MODE_OFF -> "noise_cancellation"
ANC_MODE_TRANSPARENCY -> "transparency"
ANC_MODE_ADAPTIVE -> "adaptive"
ANC_MODE_NOISE_CANCELLATION -> "noise_cancellation"
else -> "noise_cancellation"
}
val resId = pkgContext.resources.getIdentifier(
resName, "drawable", "me.kavishdevar.librepods"
)
if (resId != 0) {
val drawable = pkgContext.resources.getDrawable(resId, pkgContext.theme)
button.setImageDrawable(drawable)
button.setColorFilter(Color.WHITE)
} else {
button.setImageResource(getIconResourceForMode(mode))
button.setColorFilter(Color.WHITE)
}
} catch (e: Exception) {
button.setImageResource(getIconResourceForMode(mode))
button.setColorFilter(Color.WHITE)
}
}
private fun createAncModeOption(context: Context, mode: Int, isSelected: Boolean, mainButton: ImageButton): LinearLayout {
return LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
setMargins(0, 6, 0, 6)
}
gravity = Gravity.CENTER
setPadding(24, 16, 24, 16)
tag = "anc_mode_${mode}"
val icon = ImageView(context).apply {
layoutParams = LinearLayout.LayoutParams(60, 60).apply {
gravity = Gravity.CENTER
}
tag = "mode_icon_$mode"
try {
val packageContext = context.createPackageContext(
"me.kavishdevar.librepods",
Context.CONTEXT_IGNORE_SECURITY
)
val resourceName = when (mode) {
ANC_MODE_OFF -> "noise_cancellation"
ANC_MODE_TRANSPARENCY -> "transparency"
ANC_MODE_ADAPTIVE -> "adaptive"
ANC_MODE_NOISE_CANCELLATION -> "noise_cancellation"
else -> "noise_cancellation"
}
val resourceId = packageContext.resources.getIdentifier(
resourceName, "drawable", "me.kavishdevar.librepods"
)
if (resourceId != 0) {
val drawable = packageContext.resources.getDrawable(
resourceId, packageContext.theme
)
setImageDrawable(drawable)
} else {
setImageResource(getIconResourceForMode(mode))
}
} catch (e: Exception) {
setImageResource(getIconResourceForMode(mode))
Log.e(TAG, "Failed to load custom drawable for mode $mode: ${e.message}")
}
if (isSelected) {
setColorFilter(Color.BLACK)
} else {
setColorFilter(Color.WHITE)
}
}
addView(icon)
if (isSelected) {
background = createSelectedBackground(context)
} else {
background = null
}
setOnClickListener {
Log.d(TAG, "ANC mode selected: $mode (was: $currentANCMode)")
val container = findAirPodsContainer(this)
val drawerContainer = container?.findViewWithTag<View>("airpods_drawer_container")
if (currentANCMode == mode) {
if (drawerContainer != null && container != null) {
hideAirPodsDrawer(container, mainButton, drawerContainer)
}
return@setOnClickListener
}
currentANCMode = mode
val parentDrawer = parent as? ViewGroup
if (parentDrawer != null) {
for (i in 0 until parentDrawer.childCount) {
val child = parentDrawer.getChildAt(i) as? LinearLayout
if (child != null && child.tag.toString().startsWith("anc_mode_")) {
val childModeStr = child.tag.toString().substringAfter("anc_mode_")
val childMode = childModeStr.toIntOrNull() ?: -1
val childIcon = child.findViewWithTag<ImageView>("mode_icon_${childMode}")
if (childMode == mode) {
child.background = createSelectedBackground(context)
childIcon?.setColorFilter(Color.BLACK)
} else {
child.background = null
childIcon?.setColorFilter(Color.WHITE)
}
}
}
}
val intent = Intent(ACTION_SET_ANC_MODE).apply {
setPackage("me.kavishdevar.librepods")
putExtra(EXTRA_ANC_MODE, mode)
}
context.sendBroadcast(intent)
Log.d(TAG, "Sent broadcast to change ANC mode to: ${getLabelForMode(currentANCMode)}")
updateMainButtonIcon(context, mainButton, mode)
if (drawerContainer != null && container != null) {
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
hideAirPodsDrawer(container, mainButton, drawerContainer)
}, 50)
}
}
}
}
private fun createSelectedBackground(context: Context): GradientDrawable {
return GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
setColor(Color.WHITE)
cornerRadius = 50f
}
}
private fun findAirPodsContainer(view: View): ViewGroup? {
var current: View? = view
while (current != null) {
if (current is ViewGroup && current.tag == "airpods_container") {
return current
}
val parent = current.parent
if (parent is ViewGroup && parent.tag == "airpods_container") {
return parent
}
current = parent as? View
}
Log.w(TAG, "Could not find airpods_container ancestor")
return null
}
private fun showAirPodsDrawer(container: ViewGroup, mainButton: ImageButton, drawerContainer: View) {
Log.d(TAG, "Showing AirPods drawer")
val selectedModeView = drawerContainer.findViewWithTag<View>("anc_mode_$currentANCMode")
val selectedModeIcon = selectedModeView?.findViewWithTag<ImageView>("mode_icon_$currentANCMode")
val buttonContainer = container.findViewWithTag<View>("airpods_button_container")
if (selectedModeView == null || selectedModeIcon == null) {
Log.e(TAG, "Cannot find selected mode view or icon for show animation")
drawerContainer.alpha = 0f
drawerContainer.visibility = View.VISIBLE
drawerContainer.animate()
.alpha(1f)
.setDuration(ANIMATION_DURATION)
.start()
buttonContainer?.animate()
?.alpha(0f)
?.setDuration(ANIMATION_DURATION / 2)
?.setStartDelay(ANIMATION_DURATION / 2)
?.withEndAction {
buttonContainer.visibility = View.GONE
}
?.start()
return
}
drawerContainer.measure(
View.MeasureSpec.makeMeasureSpec(container.width, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
)
val drawerHeight = drawerContainer.measuredHeight
drawerContainer.alpha = 0f
drawerContainer.visibility = View.VISIBLE
drawerContainer.translationY = -drawerHeight.toFloat()
drawerContainer.animate()
.translationY(0f)
.alpha(1f)
.setDuration(ANIMATION_DURATION)
.setInterpolator(DecelerateInterpolator())
.start()
buttonContainer?.animate()
?.alpha(0f)
?.setDuration(ANIMATION_DURATION / 2)
?.setStartDelay(ANIMATION_DURATION / 3)
?.withEndAction {
buttonContainer.visibility = View.GONE
}
?.start()
}
private fun hideAirPodsDrawer(container: ViewGroup, mainButton: ImageButton, drawerContainer: View) {
Log.d(TAG, "Hiding AirPods drawer")
val buttonContainer = container.findViewWithTag<View>("airpods_button_container")
if (buttonContainer != null && buttonContainer.visibility != View.VISIBLE) {
buttonContainer.alpha = 0f
buttonContainer.visibility = View.VISIBLE
}
buttonContainer?.animate()
?.alpha(1f)
?.setDuration(ANIMATION_DURATION / 2)
?.start()
drawerContainer.animate()
.translationY(-drawerContainer.height.toFloat())
.alpha(0f)
.setDuration(ANIMATION_DURATION)
.setInterpolator(AccelerateInterpolator())
.setStartDelay(ANIMATION_DURATION / 4)
.withEndAction {
drawerContainer.visibility = View.GONE
drawerContainer.translationY = 0f
}
.start()
}
private fun getIconResourceForMode(mode: Int): Int {
return when (mode) {
ANC_MODE_OFF -> android.R.drawable.ic_lock_silent_mode
ANC_MODE_TRANSPARENCY -> android.R.drawable.ic_lock_silent_mode_off
ANC_MODE_ADAPTIVE -> android.R.drawable.ic_menu_compass
ANC_MODE_NOISE_CANCELLATION -> android.R.drawable.ic_lock_idle_charging
else -> android.R.drawable.ic_lock_silent_mode_off
}
}
private fun getLabelForMode(mode: Int): String {
return when (mode) {
ANC_MODE_OFF -> "Off"
ANC_MODE_TRANSPARENCY -> "Transparency"
ANC_MODE_ADAPTIVE -> "Adaptive"
ANC_MODE_NOISE_CANCELLATION -> "Noise Cancellation"
else -> "Unknown"
}
}
}
}

View File

@@ -0,0 +1,215 @@
/*
* LibrePods - AirPods liberated from Apple's 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.utils
import android.content.Context
import android.content.Intent
import android.net.Uri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.File
import java.io.InputStreamReader
class LogCollector(private val context: Context) {
private var isCollecting = false
private var logProcess: Process? = null
suspend fun openXposedSettings(context: Context) {
withContext(Dispatchers.IO) {
val command = if (android.os.Build.VERSION.SDK_INT >= 29) {
"am broadcast -a android.telephony.action.SECRET_CODE -d android_secret_code://5776733 android"
} else {
"am broadcast -a android.provider.Telephony.SECRET_CODE -d android_secret_code://5776733 android"
}
executeRootCommand(command)
}
}
suspend fun clearLogs() {
withContext(Dispatchers.IO) {
executeRootCommand("logcat -c")
}
}
suspend fun killBluetoothService() {
withContext(Dispatchers.IO) {
executeRootCommand("killall com.android.bluetooth")
}
}
private suspend fun getPackageUIDs(): Pair<String?, String?> {
return withContext(Dispatchers.IO) {
val btUid = executeRootCommand("dumpsys package com.android.bluetooth | grep -m 1 \"uid=\" | sed -E 's/.*uid=([0-9]+).*/\\1/'")
.trim()
.takeIf { it.isNotEmpty() }
val appUid = executeRootCommand("dumpsys package me.kavishdevar.librepods | grep -m 1 \"uid=\" | sed -E 's/.*uid=([0-9]+).*/\\1/'")
.trim()
.takeIf { it.isNotEmpty() }
Pair(btUid, appUid)
}
}
suspend fun startLogCollection(listener: (String) -> Unit, connectionDetectedCallback: () -> Unit): String {
return withContext(Dispatchers.IO) {
isCollecting = true
val (btUid, appUid) = getPackageUIDs()
val uidFilter = buildString {
if (!btUid.isNullOrEmpty() && !appUid.isNullOrEmpty()) {
append("$btUid,$appUid")
} else if (!btUid.isNullOrEmpty()) {
append(btUid)
} else if (!appUid.isNullOrEmpty()) {
append(appUid)
}
}
val command = if (uidFilter.isNotEmpty()) {
"su -c logcat --uid=$uidFilter -v threadtime"
} else {
"su -c logcat -v threadtime"
}
val logs = StringBuilder()
try {
logProcess = Runtime.getRuntime().exec(command)
val reader = BufferedReader(InputStreamReader(logProcess!!.inputStream))
var line: String? = null
var connectionDetected = false
while (isCollecting && reader.readLine().also { line = it } != null) {
line?.let {
if (it.contains("<LogCollector:")) {
logs.append("\n=============\n")
}
logs.append(it).append("\n")
listener(it)
if (it.contains("<LogCollector:")) {
logs.append("=============\n\n")
}
if (!connectionDetected) {
if (it.contains("<LogCollector:Complete:Success>")) {
connectionDetected = true
connectionDetectedCallback()
} else if (it.contains("<LogCollector:Complete:Failed>")) {
connectionDetected = true
connectionDetectedCallback()
} else if (it.contains("<LogCollector:Start>")) {
}
else if (it.contains("AirPodsService") && it.contains("Connected to device")) {
connectionDetected = true
connectionDetectedCallback()
} else if (it.contains("AirPodsService") && it.contains("Connection failed")) {
connectionDetected = true
connectionDetectedCallback()
} else if (it.contains("AirPodsService") && it.contains("Device disconnected")) {
}
else if (it.contains("BluetoothService") && it.contains("CONNECTION_STATE_CONNECTED")) {
connectionDetected = true
connectionDetectedCallback()
} else if (it.contains("BluetoothService") && it.contains("CONNECTION_STATE_DISCONNECTED")) {
}
}
}
}
} catch (e: Exception) {
logs.append("Error collecting logs: ${e.message}").append("\n")
e.printStackTrace()
}
logs.toString()
}
}
fun stopLogCollection() {
isCollecting = false
logProcess?.destroy()
logProcess = null
}
suspend fun saveLogToInternalStorage(fileName: String, content: String): File? {
return withContext(Dispatchers.IO) {
try {
val logsDir = File(context.filesDir, "logs")
if (!logsDir.exists()) {
logsDir.mkdir()
}
val file = File(logsDir, fileName)
file.writeText(content)
return@withContext file
} catch (e: Exception) {
e.printStackTrace()
return@withContext null
}
}
}
suspend fun addLogMarker(markerType: LogMarkerType, details: String = "") {
withContext(Dispatchers.IO) {
val timestamp = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", java.util.Locale.US)
.format(java.util.Date())
val marker = when (markerType) {
LogMarkerType.START -> "<LogCollector:Start> [$timestamp] Beginning connection test"
LogMarkerType.SUCCESS -> "<LogCollector:Complete:Success> [$timestamp] Connection test completed successfully"
LogMarkerType.FAILURE -> "<LogCollector:Complete:Failed> [$timestamp] Connection test failed"
LogMarkerType.CUSTOM -> "<LogCollector:Custom:$details> [$timestamp]"
}
val command = "log -t AirPodsService \"$marker\""
executeRootCommand(command)
}
}
enum class LogMarkerType {
START,
SUCCESS,
FAILURE,
CUSTOM
}
private suspend fun executeRootCommand(command: String): String {
return withContext(Dispatchers.IO) {
try {
val process = Runtime.getRuntime().exec("su -c $command")
val reader = BufferedReader(InputStreamReader(process.inputStream))
val output = StringBuilder()
var line: String?
while (reader.readLine().also { line = it } != null) {
output.append(line).append("\n")
}
process.waitFor()
output.toString()
} catch (e: Exception) {
e.printStackTrace()
""
}
}
}
}

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2024 Kavish Devar
* 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
@@ -16,16 +16,21 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.aln.utils
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.utils
import android.content.SharedPreferences
import android.media.AudioManager
import android.media.AudioPlaybackConfiguration
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.KeyEvent
import me.kavishdevar.aln.services.ServiceManager
import androidx.annotation.RequiresApi
import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi
object MediaController {
private var initialVolume: Int? = null
@@ -34,11 +39,12 @@ object MediaController {
var userPlayedTheMedia = false
private lateinit var sharedPreferences: SharedPreferences
private val handler = Handler(Looper.getMainLooper())
private lateinit var preferenceChangeListener: SharedPreferences.OnSharedPreferenceChangeListener
var pausedForCrossDevice = false
private var relativeVolume: Boolean = false
private var conversationalAwarenessVolume: Int = 1/12
private var conversationalAwarenessVolume: Int = 2
private var conversationalAwarenessPauseMusic: Boolean = false
fun initialize(audioManager: AudioManager, sharedPreferences: SharedPreferences) {
@@ -49,16 +55,16 @@ object MediaController {
this.sharedPreferences = sharedPreferences
Log.d("MediaController", "Initializing MediaController")
relativeVolume = sharedPreferences.getBoolean("relative_conversational_awareness_volume", false)
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 100/12)
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", (audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) / 0.4).toInt())
conversationalAwarenessPauseMusic = sharedPreferences.getBoolean("conversational_awareness_pause_music", false)
sharedPreferences.registerOnSharedPreferenceChangeListener { _, key ->
preferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
when (key) {
"relative_conversational_awareness_volume" -> {
relativeVolume = sharedPreferences.getBoolean("relative_conversational_awareness_volume", false)
}
"conversational_awareness_volume" -> {
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 100/12)
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", (audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * 0.4).toInt())
}
"conversational_awareness_pause_music" -> {
conversationalAwarenessPauseMusic = sharedPreferences.getBoolean("conversational_awareness_pause_music", false)
@@ -66,36 +72,84 @@ object MediaController {
}
}
sharedPreferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener)
audioManager.registerAudioPlaybackCallback(cb, null)
}
val cb = object : AudioManager.AudioPlaybackCallback() {
@RequiresApi(Build.VERSION_CODES.R)
override fun onPlaybackConfigChanged(configs: MutableList<AudioPlaybackConfiguration>?) {
super.onPlaybackConfigChanged(configs)
Log.d("MediaController", "Playback config changed, iPausedTheMedia: $iPausedTheMedia")
if (configs != null && !iPausedTheMedia) {
Log.d("MediaController", "Seems like the user changed the state of media themselves, now I won't `play` until the ear detection pauses it.")
Log.d("MediaController", "Seems like the user changed the state of media themselves, now I won't play until the ear detection pauses it.")
handler.postDelayed({
iPausedTheMedia = !audioManager.isMusicActive
userPlayedTheMedia = audioManager.isMusicActive
}, 7) // i have no idea why android sends an event a hundred times after the user does something.
}
Log.d("MediaController", "Ear detection status: ${ServiceManager.getService()?.earDetectionNotification?.status}, music active: ${audioManager.isMusicActive} and cross device available: ${CrossDevice.isAvailable}")
if (!pausedForCrossDevice && CrossDevice.isAvailable && ServiceManager.getService()?.earDetectionNotification?.status?.contains(0x00) == true && audioManager.isMusicActive) {
if (ServiceManager.getService()?.isConnectedLocally == false) {
sendPause(true)
pausedForCrossDevice = true
}
ServiceManager.getService()?.takeOver()
Log.d("MediaController", "pausedforcrossdevice: $pausedForCrossDevice")
if (!pausedForCrossDevice && audioManager.isMusicActive) {
ServiceManager.getService()?.takeOver("music")
}
}
}
@Synchronized
fun getMusicActive(): Boolean {
return audioManager.isMusicActive
}
@Synchronized
fun sendPlayPause() {
if (audioManager.isMusicActive) {
Log.d("MediaController", "Sending pause because music is active")
sendPause()
} else {
Log.d("MediaController", "Sending play because music is not active")
sendPlay()
}
}
@Synchronized
fun sendPreviousTrack() {
Log.d("MediaController", "Sending previous track")
audioManager.dispatchMediaKeyEvent(
KeyEvent(
KeyEvent.ACTION_DOWN,
KeyEvent.KEYCODE_MEDIA_PREVIOUS
)
)
audioManager.dispatchMediaKeyEvent(
KeyEvent(
KeyEvent.ACTION_UP,
KeyEvent.KEYCODE_MEDIA_PREVIOUS
)
)
}
@Synchronized
fun sendNextTrack() {
Log.d("MediaController", "Sending next track")
audioManager.dispatchMediaKeyEvent(
KeyEvent(
KeyEvent.ACTION_DOWN,
KeyEvent.KEYCODE_MEDIA_NEXT
)
)
audioManager.dispatchMediaKeyEvent(
KeyEvent(
KeyEvent.ACTION_UP,
KeyEvent.KEYCODE_MEDIA_NEXT
)
)
}
@Synchronized
fun sendPause(force: Boolean = false) {
Log.d("MediaController", "Sending pause with iPausedTheMedia: $iPausedTheMedia, userPlayedTheMedia: $userPlayedTheMedia")
if ((audioManager.isMusicActive && !userPlayedTheMedia) || force) {
iPausedTheMedia = true
Log.d("MediaController", "Sending pause with iPausedTheMedia: $iPausedTheMedia, userPlayedTheMedia: $userPlayedTheMedia, isMusicActive: ${audioManager.isMusicActive}, force: $force")
if ((audioManager.isMusicActive) && (!userPlayedTheMedia || force)) {
iPausedTheMedia = if (force) audioManager.isMusicActive else true
userPlayedTheMedia = false
audioManager.dispatchMediaKeyEvent(
KeyEvent(
@@ -131,15 +185,30 @@ object MediaController {
)
)
}
if (!audioManager.isMusicActive) {
Log.d("MediaController", "Setting iPausedTheMedia to false")
iPausedTheMedia = false
}
if (pausedForCrossDevice) {
Log.d("MediaController", "Setting pausedForCrossDevice to false")
pausedForCrossDevice = false
}
}
@Synchronized
fun startSpeaking() {
Log.d("MediaController", "Starting speaking")
Log.d("MediaController", "Starting speaking max vol: ${audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)}, current vol: ${audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)}, conversationalAwarenessVolume: $conversationalAwarenessVolume, relativeVolume: $relativeVolume")
if (initialVolume == null) {
initialVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
Log.d("MediaController", "Initial Volume Set: $initialVolume")
val targetVolume = if (relativeVolume) initialVolume!! * conversationalAwarenessVolume * 1/100 else if ( initialVolume!! > audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * conversationalAwarenessVolume * 1/100) audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * conversationalAwarenessVolume * 1/100 else initialVolume!!
Log.d("MediaController", "Initial Volume: $initialVolume")
val targetVolume = if (relativeVolume) {
(initialVolume!! * conversationalAwarenessVolume / 100)
} else if (initialVolume!! > (audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * conversationalAwarenessVolume / 100)) {
(audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * conversationalAwarenessVolume / 100)
} else {
initialVolume!!
}
smoothVolumeTransition(initialVolume!!, targetVolume.toInt())
if (conversationalAwarenessPauseMusic) {
sendPause(force = true)
@@ -161,6 +230,7 @@ object MediaController {
}
private fun smoothVolumeTransition(fromVolume: Int, toVolume: Int) {
Log.d("MediaController", "Smooth volume transition from $fromVolume to $toVolume")
val step = if (fromVolume < toVolume) 1 else -1
val delay = 50L
var currentVolume = fromVolume

View File

@@ -0,0 +1,278 @@
/*
* 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.utils
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ObjectAnimator
import android.animation.PropertyValuesHolder
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.PixelFormat
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Gravity
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.WindowManager
import android.view.animation.AccelerateInterpolator
import android.view.animation.DecelerateInterpolator
import android.widget.ImageButton
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.VideoView
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 kotlin.collections.find
@SuppressLint("InflateParams", "ClickableViewAccessibility")
class PopupWindow(
private val context: Context,
private val onCloseCallback: () -> Unit = {}
) {
private val mView: View
private var isClosing = false
private var autoCloseHandler = Handler(Looper.getMainLooper())
private var autoCloseRunnable: Runnable? = null
private var batteryUpdateReceiver: BroadcastReceiver? = null
@Suppress("DEPRECATION")
private val mParams: WindowManager.LayoutParams = WindowManager.LayoutParams().apply {
height = WindowManager.LayoutParams.WRAP_CONTENT
width = WindowManager.LayoutParams.MATCH_PARENT
type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
format = PixelFormat.TRANSLUCENT
gravity = Gravity.BOTTOM
dimAmount = 0.3f
flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
WindowManager.LayoutParams.FLAG_FULLSCREEN or
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_DIM_BEHIND or
WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
}
private val mWindowManager: WindowManager
init {
val layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
mView = layoutInflater.inflate(R.layout.popup_window, null)
mParams.x = 0
mParams.y = 0
mParams.gravity = Gravity.BOTTOM
mView.setOnClickListener {
close()
}
mView.findViewById<ImageButton>(R.id.close_button).setOnClickListener {
close()
}
val ll = mView.findViewById<LinearLayout>(R.id.linear_layout)
ll.setOnClickListener {
close()
}
@Suppress("DEPRECATION")
mView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
mView.setOnTouchListener { _, event ->
if (event.action == MotionEvent.ACTION_DOWN) {
val touchY = event.rawY
val popupTop = mView.top
if (touchY < popupTop) {
close()
true
} else {
false
}
} else {
false
}
}
mWindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
}
@SuppressLint("InlinedApi", "SetTextI18s")
fun open(name: String = "AirPods Pro", batteryNotification: AirPodsNotifications.BatteryNotification) {
try {
if (mView.windowToken == null && mView.parent == null && !isClosing) {
mView.findViewById<TextView>(R.id.name).text = name
updateBatteryStatus(batteryNotification)
val vid = mView.findViewById<VideoView>(R.id.video)
vid.setVideoPath("android.resource://me.kavishdevar.librepods/" + R.raw.connected)
vid.resolveAdjustedSize(vid.width, vid.height)
vid.start()
vid.setOnCompletionListener {
vid.start()
}
mWindowManager.addView(mView, mParams)
val displayMetrics = mView.context.resources.displayMetrics
val screenHeight = displayMetrics.heightPixels
mView.translationY = screenHeight.toFloat()
mView.alpha = 1f
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, screenHeight.toFloat(), 0f)
ObjectAnimator.ofPropertyValuesHolder(mView, translationY).apply {
duration = 500
interpolator = DecelerateInterpolator()
start()
}
registerBatteryUpdateReceiver()
autoCloseRunnable = Runnable { close() }
autoCloseHandler.postDelayed(autoCloseRunnable!!, 12000)
}
} catch (e: Exception) {
Log.e("PopupWindow", "Error opening popup: ${e.message}")
onCloseCallback()
}
}
@SuppressLint("UnspecifiedRegisterReceiverFlag")
private fun registerBatteryUpdateReceiver() {
batteryUpdateReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == AirPodsNotifications.BATTERY_DATA) {
val batteryList = intent.getParcelableArrayListExtra<Battery>("data")
if (batteryList != null) {
updateBatteryStatusFromList(batteryList)
}
}
}
}
val filter = IntentFilter(AirPodsNotifications.BATTERY_DATA)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(batteryUpdateReceiver, filter, Context.RECEIVER_EXPORTED)
} else {
context.registerReceiver(batteryUpdateReceiver, filter)
}
}
private fun unregisterBatteryUpdateReceiver() {
batteryUpdateReceiver?.let {
try {
context.unregisterReceiver(it)
batteryUpdateReceiver = null
} catch (e: Exception) {
Log.e("PopupWindow", "Error unregistering battery receiver: ${e.message}")
}
}
}
private fun updateBatteryStatusFromList(batteryList: List<Battery>) {
val batteryLeftText = mView.findViewById<TextView>(R.id.left_battery)
val batteryRightText = mView.findViewById<TextView>(R.id.right_battery)
val batteryCaseText = mView.findViewById<TextView>(R.id.case_battery)
batteryLeftText.text = batteryList.find { it.component == BatteryComponent.LEFT }?.let {
if (it.status != BatteryStatus.DISCONNECTED) {
"\uDBC3\uDC8E ${it.level}%"
} else {
""
}
} ?: ""
batteryRightText.text = batteryList.find { it.component == BatteryComponent.RIGHT }?.let {
if (it.status != BatteryStatus.DISCONNECTED) {
"\uDBC3\uDC8D ${it.level}%"
} else {
""
}
} ?: ""
batteryCaseText.text = batteryList.find { it.component == BatteryComponent.CASE }?.let {
if (it.status != BatteryStatus.DISCONNECTED) {
"\uDBC3\uDE6C ${it.level}%"
} else {
""
}
} ?: ""
}
@SuppressLint("SetTextI18s")
fun updateBatteryStatus(batteryNotification: AirPodsNotifications.BatteryNotification) {
val batteryStatus = batteryNotification.getBattery()
updateBatteryStatusFromList(batteryStatus)
}
fun close() {
try {
if (isClosing) return
isClosing = true
autoCloseRunnable?.let { autoCloseHandler.removeCallbacks(it) }
unregisterBatteryUpdateReceiver()
val vid = mView.findViewById<VideoView>(R.id.video)
vid.stopPlayback()
ObjectAnimator.ofFloat(mView, "translationY", mView.height.toFloat()).apply {
duration = 500
interpolator = AccelerateInterpolator()
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
try {
mView.visibility = View.GONE
if (mView.parent != null) {
mWindowManager.removeView(mView)
}
} catch (e: Exception) {
Log.e("PopupWindow", "Error removing view: ${e.message}")
} finally {
isClosing = false
onCloseCallback()
}
}
})
start()
}
} catch (e: Exception) {
Log.e("PopupWindow", "Error closing popup: ${e.message}")
isClosing = false
onCloseCallback()
}
}
val isShowing: Boolean
get() = mView.parent != null && !isClosing
}

View File

@@ -0,0 +1,616 @@
/*
* 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.utils
import android.content.Context
import android.util.Log
import androidx.compose.runtime.NoLiveLiterals
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
import me.kavishdevar.librepods.services.ServiceManager
import java.io.BufferedReader
import java.io.File
import java.io.FileOutputStream
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URL
import kotlin.io.encoding.ExperimentalEncodingApi
@NoLiveLiterals
class RadareOffsetFinder(context: Context) {
companion object {
private const val TAG = "RadareOffsetFinder"
private const val RADARE2_URL = "https://hc-cdn.hel1.your-objectstorage.com/s/v3/c9898243c42c0d3d1387de9a37d57ce9df77f9c9_radare2-5.9.9-android-aarch64.tar.gz"
private const val HOOK_OFFSET_PROP = "persist.librepods.hook_offset"
private const val CFG_REQ_OFFSET_PROP = "persist.librepods.cfg_req_offset"
private const val CSM_CONFIG_OFFSET_PROP = "persist.librepods.csm_config_offset"
private const val PEER_INFO_REQ_OFFSET_PROP = "persist.librepods.peer_info_req_offset"
private const val EXTRACT_DIR = "/"
private const val RADARE2_BIN_PATH = "$EXTRACT_DIR/data/local/tmp/aln_unzip/org.radare.radare2installer/radare2/bin"
private const val RADARE2_LIB_PATH = "$EXTRACT_DIR/data/local/tmp/aln_unzip/org.radare.radare2installer/radare2/lib"
private const val BUSYBOX_PATH = "$EXTRACT_DIR/data/local/tmp/aln_unzip/busybox"
private val LIBRARY_PATHS = listOf(
"/apex/com.android.bt/lib64/libbluetooth_jni.so",
"/apex/com.android.btservices/lib64/libbluetooth_jni.so",
"/system/lib64/libbluetooth_jni.so",
"/system/lib64/libbluetooth_qti.so",
"/system_ext/lib64/libbluetooth_qti.so"
)
fun findBluetoothLibraryPath(): String? {
for (path in LIBRARY_PATHS) {
if (File(path).exists()) {
Log.d(TAG, "Found Bluetooth library at $path")
return path
}
}
Log.e(TAG, "Could not find Bluetooth library")
return null
}
fun clearHookOffsets(): Boolean {
try {
val process = Runtime.getRuntime().exec(arrayOf(
"su", "-c",
"setprop $HOOK_OFFSET_PROP '' && " +
"setprop $CFG_REQ_OFFSET_PROP '' && " +
"setprop $CSM_CONFIG_OFFSET_PROP '' && " +
"setprop $PEER_INFO_REQ_OFFSET_PROP ''"
))
val exitCode = process.waitFor()
if (exitCode == 0) {
Log.d(TAG, "Successfully cleared hook offset properties")
return true
} else {
Log.e(TAG, "Failed to clear hook offset properties, exit code: $exitCode")
}
} catch (e: Exception) {
Log.e(TAG, "Error clearing hook offset properties", e)
}
return false
}
}
private val radare2TarballFile = File(context.cacheDir, "radare2.tar.gz")
private val _progressState = MutableStateFlow<ProgressState>(ProgressState.Idle)
val progressState: StateFlow<ProgressState> = _progressState
sealed class ProgressState {
object Idle : ProgressState()
object CheckingExisting : ProgressState()
object Downloading : ProgressState()
data class DownloadProgress(val progress: Float) : ProgressState()
object Extracting : ProgressState()
object MakingExecutable : ProgressState()
object FindingOffset : ProgressState()
object SavingOffset : ProgressState()
object Cleaning : ProgressState()
data class Error(val message: String) : ProgressState()
data class Success(val offset: Long) : ProgressState()
}
fun isHookOffsetAvailable(): Boolean {
Log.d(TAG, "Setup Skipped? " + ServiceManager.getService()?.applicationContext?.getSharedPreferences("settings", Context.MODE_PRIVATE)?.getBoolean("skip_setup", false).toString())
if (ServiceManager.getService()?.applicationContext?.getSharedPreferences("settings", Context.MODE_PRIVATE)?.getBoolean("skip_setup", false) == true) {
Log.d(TAG, "Setup skipped, returning true.")
return true
}
_progressState.value = ProgressState.CheckingExisting
try {
val process = Runtime.getRuntime().exec(arrayOf("getprop", HOOK_OFFSET_PROP))
val reader = BufferedReader(InputStreamReader(process.inputStream))
val propValue = reader.readLine()
process.waitFor()
if (propValue != null && propValue.isNotEmpty()) {
Log.d(TAG, "Hook offset property exists: $propValue")
_progressState.value = ProgressState.Idle
return true
}
} catch (e: Exception) {
Log.e(TAG, "Error checking if offset property exists", e)
_progressState.value = ProgressState.Error("Failed to check if offset property exists: ${e.message}")
}
Log.d(TAG, "No hook offset available")
_progressState.value = ProgressState.Idle
return false
}
suspend fun setupAndFindOffset(): Boolean {
val offset = findOffset()
return offset > 0
}
suspend fun findOffset(): Long = withContext(Dispatchers.IO) {
try {
_progressState.value = ProgressState.Downloading
if (!downloadRadare2TarballIfNeeded()) {
_progressState.value = ProgressState.Error("Failed to download radare2 tarball")
Log.e(TAG, "Failed to download radare2 tarball")
return@withContext 0L
}
_progressState.value = ProgressState.Extracting
if (!extractRadare2Tarball()) {
_progressState.value = ProgressState.Error("Failed to extract radare2 tarball")
Log.e(TAG, "Failed to extract radare2 tarball")
return@withContext 0L
}
_progressState.value = ProgressState.MakingExecutable
if (!makeExecutable()) {
_progressState.value = ProgressState.Error("Failed to make binaries executable")
Log.e(TAG, "Failed to make binaries executable")
return@withContext 0L
}
_progressState.value = ProgressState.FindingOffset
val offset = findFunctionOffset()
if (offset == 0L) {
_progressState.value = ProgressState.Error("Failed to find function offset")
Log.e(TAG, "Failed to find function offset")
return@withContext 0L
}
_progressState.value = ProgressState.SavingOffset
if (!saveOffset(offset)) {
_progressState.value = ProgressState.Error("Failed to save offset")
Log.e(TAG, "Failed to save offset")
return@withContext 0L
}
_progressState.value = ProgressState.Cleaning
cleanupExtractedFiles()
_progressState.value = ProgressState.Success(offset)
return@withContext offset
} catch (e: Exception) {
_progressState.value = ProgressState.Error("Error: ${e.message}")
Log.e(TAG, "Error in findOffset", e)
return@withContext 0L
}
}
private suspend fun downloadRadare2TarballIfNeeded(): Boolean = withContext(Dispatchers.IO) {
if (radare2TarballFile.exists() && radare2TarballFile.length() > 0) {
Log.d(TAG, "Radare2 tarball already downloaded to ${radare2TarballFile.absolutePath}")
return@withContext true
}
try {
val url = URL(RADARE2_URL)
val connection = url.openConnection() as HttpURLConnection
connection.connectTimeout = 60000
connection.readTimeout = 60000
val contentLength = connection.contentLength.toFloat()
val inputStream = connection.inputStream
val outputStream = FileOutputStream(radare2TarballFile)
val buffer = ByteArray(4096)
var bytesRead: Int
var totalBytesRead = 0L
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
outputStream.write(buffer, 0, bytesRead)
totalBytesRead += bytesRead
if (contentLength > 0) {
val progress = totalBytesRead.toFloat() / contentLength
_progressState.value = ProgressState.DownloadProgress(progress)
}
}
outputStream.close()
inputStream.close()
Log.d(TAG, "Download successful to ${radare2TarballFile.absolutePath}")
return@withContext true
} catch (e: Exception) {
Log.e(TAG, "Failed to download radare2 tarball", e)
return@withContext false
}
}
private suspend fun extractRadare2Tarball(): Boolean = withContext(Dispatchers.IO) {
try {
val isAlreadyExtracted = checkIfAlreadyExtracted()
if (isAlreadyExtracted) {
Log.d(TAG, "Radare2 files already extracted correctly, skipping extraction")
return@withContext true
}
Log.d(TAG, "Removing existing extract directory")
Runtime.getRuntime().exec(arrayOf("su", "-c", "rm -rf $EXTRACT_DIR/data/local/tmp/aln_unzip")).waitFor()
Runtime.getRuntime().exec(arrayOf("su", "-c", "mkdir -p $EXTRACT_DIR/data/local/tmp/aln_unzip")).waitFor()
Log.d(TAG, "Extracting ${radare2TarballFile.absolutePath} to $EXTRACT_DIR")
val process = Runtime.getRuntime().exec(
arrayOf("su", "-c", "tar xvf ${radare2TarballFile.absolutePath} -C $EXTRACT_DIR")
)
val reader = BufferedReader(InputStreamReader(process.inputStream))
val errorReader = BufferedReader(InputStreamReader(process.errorStream))
var line: String?
while (reader.readLine().also { line = it } != null) {
Log.d(TAG, "Extract output: $line")
}
while (errorReader.readLine().also { line = it } != null) {
Log.e(TAG, "Extract error: $line")
}
val exitCode = process.waitFor()
if (exitCode == 0) {
Log.d(TAG, "Extraction completed successfully")
return@withContext true
} else {
Log.e(TAG, "Extraction failed with exit code $exitCode")
return@withContext false
}
} catch (e: Exception) {
Log.e(TAG, "Failed to extract radare2", e)
return@withContext false
}
}
private suspend fun checkIfAlreadyExtracted(): Boolean = withContext(Dispatchers.IO) {
try {
val checkDirProcess = Runtime.getRuntime().exec(
arrayOf("su", "-c", "[ -d $EXTRACT_DIR/data/local/tmp/aln_unzip ] && echo 'exists'")
)
val dirExists = BufferedReader(InputStreamReader(checkDirProcess.inputStream)).readLine() == "exists"
checkDirProcess.waitFor()
if (!dirExists) {
Log.d(TAG, "Extract directory doesn't exist, need to extract")
return@withContext false
}
val tarProcess = Runtime.getRuntime().exec(
arrayOf("su", "-c", "tar tf ${radare2TarballFile.absolutePath}")
)
val tarFiles = BufferedReader(InputStreamReader(tarProcess.inputStream)).readLines()
.filter { it.isNotEmpty() }
.map { it.trim() }
.toSet()
tarProcess.waitFor()
if (tarFiles.isEmpty()) {
Log.e(TAG, "Failed to get file list from tarball")
return@withContext false
}
val findProcess = Runtime.getRuntime().exec(
arrayOf("su", "-c", "find $EXTRACT_DIR/data/local/tmp/aln_unzip -type f | sort")
)
val extractedFiles = BufferedReader(InputStreamReader(findProcess.inputStream)).readLines()
.filter { it.isNotEmpty() }
.map { it.trim() }
.toSet()
findProcess.waitFor()
if (extractedFiles.isEmpty()) {
Log.d(TAG, "No files found in extract directory, need to extract")
return@withContext false
}
for (tarFile in tarFiles) {
if (tarFile.endsWith("/")) continue
val filePathInExtractDir = "$EXTRACT_DIR/$tarFile"
val fileCheckProcess = Runtime.getRuntime().exec(
arrayOf("su", "-c", "[ -f $filePathInExtractDir ] && echo 'exists'")
)
val fileExists = BufferedReader(InputStreamReader(fileCheckProcess.inputStream)).readLine() == "exists"
fileCheckProcess.waitFor()
if (!fileExists) {
Log.d(TAG, "File $filePathInExtractDir from tarball missing in extract directory")
Runtime.getRuntime().exec(arrayOf("su", "-c", "rm -rf $EXTRACT_DIR/data/local/tmp/aln_unzip")).waitFor()
return@withContext false
}
}
Log.d(TAG, "All ${tarFiles.size} files from tarball exist in extract directory")
return@withContext true
} catch (e: Exception) {
Log.e(TAG, "Error checking extraction status", e)
return@withContext false
}
}
private suspend fun makeExecutable(): Boolean = withContext(Dispatchers.IO) {
try {
Log.d(TAG, "Making binaries executable in $RADARE2_BIN_PATH")
val chmod1Result = Runtime.getRuntime().exec(
arrayOf("su", "-c", "chmod -R 755 $RADARE2_BIN_PATH")
).waitFor()
Log.d(TAG, "Making binaries executable in $BUSYBOX_PATH")
val chmod2Result = Runtime.getRuntime().exec(
arrayOf("su", "-c", "chmod -R 755 $BUSYBOX_PATH")
).waitFor()
if (chmod1Result == 0 && chmod2Result == 0) {
Log.d(TAG, "Successfully made binaries executable")
return@withContext true
} else {
Log.e(TAG, "Failed to make binaries executable, exit codes: $chmod1Result, $chmod2Result")
return@withContext false
}
} catch (e: Exception) {
Log.e(TAG, "Error making binaries executable", e)
return@withContext false
}
}
private suspend fun findFunctionOffset(): Long = withContext(Dispatchers.IO) {
val libraryPath = findBluetoothLibraryPath() ?: return@withContext 0L
var offset = 0L
try {
@Suppress("LocalVariableName") val currentLD_LIBRARY_PATH = ProcessBuilder().command("su", "-c", "printenv LD_LIBRARY_PATH").start().inputStream.bufferedReader().readText().trim()
val currentPATH = ProcessBuilder().command("su", "-c", "printenv PATH").start().inputStream.bufferedReader().readText().trim()
val envSetup = """
export LD_LIBRARY_PATH="$RADARE2_LIB_PATH:$currentLD_LIBRARY_PATH"
export PATH="$BUSYBOX_PATH:$RADARE2_BIN_PATH:$currentPATH"
""".trimIndent()
val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep fcr_chk_chan"
Log.d(TAG, "Running command: $command")
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command))
val reader = BufferedReader(InputStreamReader(process.inputStream))
val errorReader = BufferedReader(InputStreamReader(process.errorStream))
var line: String?
while (reader.readLine().also { line = it } != null) {
Log.d(TAG, "rabin2 output: $line")
if (line?.contains("fcr_chk_chan") == true) {
val parts = line.split(" ")
if (parts.isNotEmpty() && parts[0].startsWith("0x")) {
offset = parts[0].substring(2).toLong(16)
Log.d(TAG, "Found offset at ${parts[0]}")
break
}
}
}
while (errorReader.readLine().also { line = it } != null) {
Log.d(TAG, "rabin2 error: $line")
}
val exitCode = process.waitFor()
if (exitCode != 0) {
Log.e(TAG, "rabin2 command failed with exit code $exitCode")
}
// findAndSaveL2cuProcessCfgReqOffset(libraryPath, envSetup)
// findAndSaveL2cCsmConfigOffset(libraryPath, envSetup)
// findAndSaveL2cuSendPeerInfoReqOffset(libraryPath, envSetup)
} catch (e: Exception) {
Log.e(TAG, "Failed to find function offset", e)
return@withContext 0L
}
if (offset == 0L) {
Log.e(TAG, "Failed to extract function offset from output, aborting")
return@withContext 0L
}
Log.d(TAG, "Successfully found offset: 0x${offset.toString(16)}")
return@withContext offset
}
private suspend fun findAndSaveL2cuProcessCfgReqOffset(libraryPath: String, envSetup: String) = withContext(Dispatchers.IO) {
try {
val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep l2cu_process_our_cfg_req"
Log.d(TAG, "Running command: $command")
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command))
val reader = BufferedReader(InputStreamReader(process.inputStream))
val errorReader = BufferedReader(InputStreamReader(process.errorStream))
var line: String?
var offset = 0L
while (reader.readLine().also { line = it } != null) {
Log.d(TAG, "rabin2 output: $line")
if (line?.contains("l2cu_process_our_cfg_req") == true) {
val parts = line.split(" ")
if (parts.isNotEmpty() && parts[0].startsWith("0x")) {
offset = parts[0].substring(2).toLong(16)
Log.d(TAG, "Found l2cu_process_our_cfg_req offset at ${parts[0]}")
break
}
}
}
while (errorReader.readLine().also { line = it } != null) {
Log.d(TAG, "rabin2 error: $line")
}
val exitCode = process.waitFor()
if (exitCode != 0) {
Log.e(TAG, "rabin2 command failed with exit code $exitCode")
}
if (offset > 0L) {
val hexString = "0x${offset.toString(16)}"
Runtime.getRuntime().exec(arrayOf(
"su", "-c", "setprop $CFG_REQ_OFFSET_PROP $hexString"
)).waitFor()
Log.d(TAG, "Saved l2cu_process_our_cfg_req offset: $hexString")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to find or save l2cu_process_our_cfg_req offset", e)
}
}
private suspend fun findAndSaveL2cCsmConfigOffset(libraryPath: String, envSetup: String) = withContext(Dispatchers.IO) {
try {
val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep l2c_csm_config"
Log.d(TAG, "Running command: $command")
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command))
val reader = BufferedReader(InputStreamReader(process.inputStream))
val errorReader = BufferedReader(InputStreamReader(process.errorStream))
var line: String?
var offset = 0L
while (reader.readLine().also { line = it } != null) {
Log.d(TAG, "rabin2 output: $line")
if (line?.contains("l2c_csm_config") == true) {
val parts = line.split(" ")
if (parts.isNotEmpty() && parts[0].startsWith("0x")) {
offset = parts[0].substring(2).toLong(16)
Log.d(TAG, "Found l2c_csm_config offset at ${parts[0]}")
break
}
}
}
while (errorReader.readLine().also { line = it } != null) {
Log.d(TAG, "rabin2 error: $line")
}
val exitCode = process.waitFor()
if (exitCode != 0) {
Log.e(TAG, "rabin2 command failed with exit code $exitCode")
}
if (offset > 0L) {
val hexString = "0x${offset.toString(16)}"
Runtime.getRuntime().exec(arrayOf(
"su", "-c", "setprop $CSM_CONFIG_OFFSET_PROP $hexString"
)).waitFor()
Log.d(TAG, "Saved l2c_csm_config offset: $hexString")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to find or save l2c_csm_config offset", e)
}
}
private suspend fun findAndSaveL2cuSendPeerInfoReqOffset(libraryPath: String, envSetup: String) = withContext(Dispatchers.IO) {
try {
val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep l2cu_send_peer_info_req"
Log.d(TAG, "Running command: $command")
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command))
val reader = BufferedReader(InputStreamReader(process.inputStream))
val errorReader = BufferedReader(InputStreamReader(process.errorStream))
var line: String?
var offset = 0L
while (reader.readLine().also { line = it } != null) {
Log.d(TAG, "rabin2 output: $line")
if (line?.contains("l2cu_send_peer_info_req") == true) {
val parts = line.split(" ")
if (parts.isNotEmpty() && parts[0].startsWith("0x")) {
offset = parts[0].substring(2).toLong(16)
Log.d(TAG, "Found l2cu_send_peer_info_req offset at ${parts[0]}")
break
}
}
}
while (errorReader.readLine().also { line = it } != null) {
Log.d(TAG, "rabin2 error: $line")
}
val exitCode = process.waitFor()
if (exitCode != 0) {
Log.e(TAG, "rabin2 command failed with exit code $exitCode")
}
if (offset > 0L) {
val hexString = "0x${offset.toString(16)}"
Runtime.getRuntime().exec(arrayOf(
"su", "-c", "setprop $PEER_INFO_REQ_OFFSET_PROP $hexString"
)).waitFor()
Log.d(TAG, "Saved l2cu_send_peer_info_req offset: $hexString")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to find or save l2cu_send_peer_info_req offset", e)
}
}
private suspend fun saveOffset(offset: Long): Boolean = withContext(Dispatchers.IO) {
try {
val hexString = "0x${offset.toString(16)}"
Log.d(TAG, "Saving offset to system property: $hexString")
val process = Runtime.getRuntime().exec(arrayOf(
"su", "-c", "setprop $HOOK_OFFSET_PROP $hexString"
))
val exitCode = process.waitFor()
if (exitCode == 0) {
val verifyProcess = Runtime.getRuntime().exec(arrayOf(
"getprop", HOOK_OFFSET_PROP
))
val propValue = BufferedReader(InputStreamReader(verifyProcess.inputStream)).readLine()
verifyProcess.waitFor()
if (propValue != null && propValue.isNotEmpty()) {
Log.d(TAG, "Successfully saved offset to system property: $propValue")
return@withContext true
} else {
Log.e(TAG, "Property was set but couldn't be verified")
}
} else {
Log.e(TAG, "Failed to set property, exit code: $exitCode")
}
return@withContext false
} catch (e: Exception) {
Log.e(TAG, "Failed to save offset", e)
return@withContext false
}
}
private fun cleanupExtractedFiles() {
try {
Runtime.getRuntime().exec(arrayOf("su", "-c", "rm -rf $EXTRACT_DIR/data/local/tmp/aln_unzip")).waitFor()
Log.d(TAG, "Cleaned up extracted files at $EXTRACT_DIR/data/local/tmp/aln_unzip")
} catch (e: Exception) {
Log.e(TAG, "Failed to cleanup extracted files", e)
}
}
}

View File

@@ -0,0 +1,306 @@
package me.kavishdevar.librepods.utils
import android.bluetooth.BluetoothDevice
import android.util.Log
import org.lsposed.hiddenapibypass.HiddenApiBypass
object SystemApisUtils {
/**
* Device type which is used in METADATA_DEVICE_TYPE
* Indicates this Bluetooth device is an untethered headset.
* @hide
*/
val BluetoothDevice.DEVICE_TYPE_UNTETHERED_HEADSET: String
get() = "Untethered Headset"
/**
* Maximum length of a metadata entry, this is to avoid exploding Bluetooth
* disk usage
* @hide
*/
val BluetoothDevice.METADATA_MAX_LENGTH: Int
get() = 2048
/**
* Manufacturer name of this Bluetooth device
* Data type should be {@String} as [Byte] array.
* @hide
*/
val BluetoothDevice.METADATA_MANUFACTURER_NAME: Int
get() = 0
/**
* Model name of this Bluetooth device
* Data type should be {@String} as [Byte] array.
* @hide
*/
val BluetoothDevice.METADATA_MODEL_NAME: Int
get() = 1
/**
* Software version of this Bluetooth device
* Data type should be {@String} as [Byte] array.
* @hide
*/
val BluetoothDevice.METADATA_SOFTWARE_VERSION: Int
get() = 2
/**
* Hardware version of this Bluetooth device
* Data type should be {@String} as [Byte] array.
* @hide
*/
val BluetoothDevice.METADATA_HARDWARE_VERSION: Int
get() = 3
/**
* Package name of the companion app, if any
* Data type should be {@String} as [Byte] array.
* @hide
*/
val BluetoothDevice.METADATA_COMPANION_APP: Int
get() = 4
/**
* URI to the main icon shown on the settings UI
* Data type should be [Byte] array.
* @hide
*/
val BluetoothDevice.METADATA_MAIN_ICON: Int
get() = 5
/**
* Whether this device is an untethered headset with left, right and case
* Data type should be {@String} as [Byte] array.
* @hide
*/
val BluetoothDevice.METADATA_IS_UNTETHERED_HEADSET: Int
get() = 6
/**
* URI to icon of the left headset
* Data type should be [Byte] array.
* @hide
*/
val BluetoothDevice.METADATA_UNTETHERED_LEFT_ICON: Int
get() = 7
/**
* URI to icon of the right headset
* Data type should be [Byte] array.
* @hide
*/
val BluetoothDevice.METADATA_UNTETHERED_RIGHT_ICON: Int
get() = 8
/**
* URI to icon of the headset charging case
* Data type should be [Byte] array.
* @hide
*/
val BluetoothDevice.METADATA_UNTETHERED_CASE_ICON: Int
get() = 9
/**
* Battery level of left headset
* Data type should be {@String} 0-100 as [Byte] array, otherwise
* as invalid.
* @hide
*/
val BluetoothDevice.METADATA_UNTETHERED_LEFT_BATTERY: Int
get() = 10
/**
* Battery level of rigth headset
* Data type should be {@String} 0-100 as [Byte] array, otherwise
* as invalid.
* @hide
*/
val BluetoothDevice.METADATA_UNTETHERED_RIGHT_BATTERY: Int
get() = 11
/**
* Battery level of the headset charging case
* Data type should be {@String} 0-100 as [Byte] array, otherwise
* as invalid.
* @hide
*/
val BluetoothDevice.METADATA_UNTETHERED_CASE_BATTERY: Int
get() = 12
/**
* Whether the left headset is charging
* Data type should be {@String} as [Byte] array.
* @hide
*/
val BluetoothDevice.METADATA_UNTETHERED_LEFT_CHARGING: Int
get() = 13
/**
* Whether the right headset is charging
* Data type should be {@String} as [Byte] array.
* @hide
*/
val BluetoothDevice.METADATA_UNTETHERED_RIGHT_CHARGING: Int
get() = 14
/**
* Whether the headset charging case is charging
* Data type should be {@String} as [Byte] array.
* @hide
*/
val BluetoothDevice.METADATA_UNTETHERED_CASE_CHARGING: Int
get() = 15
/**
* URI to the enhanced settings UI slice
* Data type should be {@String} as [Byte] array, null means
* the UI does not exist.
* @hide
*/
val BluetoothDevice.METADATA_ENHANCED_SETTINGS_UI_URI: Int
get() = 16
/**
* @hide
*/
val BluetoothDevice.COMPANION_TYPE_PRIMARY: String
get() = "COMPANION_PRIMARY"
/**
* @hide
*/
val BluetoothDevice.COMPANION_TYPE_SECONDARY: String
get() = "COMPANION_SECONDARY"
/**
* @hide
*/
val BluetoothDevice.COMPANION_TYPE_NONE: String
get() = "COMPANION_NONE"
/**
* Type of the Bluetooth device, must be within the list of
* BluetoothDevice.DEVICE_TYPE_*
* Data type should be {@String} as [Byte] array.
* @hide
*/
val BluetoothDevice.METADATA_DEVICE_TYPE: Int
get() = 17
/**
* Battery level of the Bluetooth device, use when the Bluetooth device
* does not support HFP battery indicator.
* Data type should be {@String} as [Byte] array.
* @hide
*/
val BluetoothDevice.METADATA_MAIN_BATTERY: Int
get() = 18
/**
* Whether the device is charging.
* Data type should be {@String} as [Byte] array.
* @hide
*/
val BluetoothDevice.METADATA_MAIN_CHARGING: Int
get() = 19
/**
* The battery threshold of the Bluetooth device to show low battery icon.
* Data type should be {@String} as [Byte] array.
* @hide
*/
val BluetoothDevice.METADATA_MAIN_LOW_BATTERY_THRESHOLD: Int
get() = 20
/**
* The battery threshold of the left headset to show low battery icon.
* Data type should be {@String} as [Byte] array.
* @hide
*/
val BluetoothDevice.METADATA_UNTETHERED_LEFT_LOW_BATTERY_THRESHOLD: Int
get() = 21
/**
* The battery threshold of the right headset to show low battery icon.
* Data type should be {@String} as [Byte] array.
* @hide
*/
val BluetoothDevice.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD: Int
get() = 22
/**
* The battery threshold of the case to show low battery icon.
* Data type should be {@String} as [Byte] array.
* @hide
*/
val BluetoothDevice.METADATA_UNTETHERED_CASE_LOW_BATTERY_THRESHOLD: Int
get() = 23
/**
* The metadata of the audio spatial data.
* Data type should be [Byte] array.
* @hide
*/
val BluetoothDevice.METADATA_SPATIAL_AUDIO: Int
get() = 24
/**
* The metadata of the Fast Pair for any custmized feature.
* Data type should be [Byte] array.
* @hide
*/
val BluetoothDevice.METADATA_FAST_PAIR_CUSTOMIZED_FIELDS: Int
get() = 25
/**
* The metadata of the Fast Pair for LE Audio capable devices.
* Data type should be [Byte] array.
* @hide
*/
val BluetoothDevice.METADATA_LE_AUDIO: Int
get() = 26
/**
* The UUIDs (16-bit) of registered to CCC characteristics from Media Control services.
* Data type should be [Byte] array.
* @hide
*/
val BluetoothDevice.METADATA_GMCS_CCCD: Int
get() = 27
/**
* The UUIDs (16-bit) of registered to CCC characteristics from Telephony Bearer service.
* Data type should be [Byte] array.
* @hide
*/
val BluetoothDevice.METADATA_GTBS_CCCD: Int
get() = 28
const val BATTERY_LEVEL_UNKNOWN: Int = -1
const val ACTION_BLUETOOTH_HANDSFREE_BATTERY_CHANGED = "android.intent.action.BLUETOOTH_HANDSFREE_BATTERY_CHANGED"
const val EXTRA_SHOW_BT_HANDSFREE_BATTERY = "android.intent.extra.show_bluetooth_handsfree_battery"
const val EXTRA_BT_HANDSFREE_BATTERY_LEVEL = "android.intent.extra.bluetooth_handsfree_battery_level"
/**
* Helper method to set metadata using HiddenApiBypass
*/
fun setMetadata(device: BluetoothDevice, key: Int, value: ByteArray): Boolean {
return try {
val result = HiddenApiBypass.invoke(
BluetoothDevice::class.java,
device,
"setMetadata",
key,
value
) as Boolean
result
} catch (e: Exception) {
Log.e("SystemApisUtils", "Failed to set metadata for key $key", e)
false
}
}
}

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2024 Kavish Devar
* 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
@@ -16,19 +16,15 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.aln.widgets
package me.kavishdevar.librepods.widgets
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
import android.widget.RemoteViews
import androidx.compose.material3.ExperimentalMaterial3Api
import me.kavishdevar.aln.MainActivity
import me.kavishdevar.aln.R
import me.kavishdevar.aln.services.ServiceManager
import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi
class BatteryWidget : AppWidgetProvider() {
override fun onUpdate(
@@ -36,6 +32,6 @@ class BatteryWidget : AppWidgetProvider() {
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
ServiceManager.getService()?.updateBatteryWidget()
ServiceManager.getService()?.updateBattery()
}
}

View File

@@ -1,7 +1,7 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2024 Kavish Devar
* 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
@@ -16,17 +16,21 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.aln.widgets
package me.kavishdevar.librepods.widgets
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
import android.util.Log
import android.widget.RemoteViews
import me.kavishdevar.aln.R
import me.kavishdevar.aln.services.ServiceManager
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
class NoiseControlWidget : AppWidgetProvider() {
override fun onUpdate(
@@ -77,7 +81,13 @@ class NoiseControlWidget : AppWidgetProvider() {
super.onReceive(context, intent)
if (intent.action == "ACTION_SET_ANC_MODE") {
val mode = intent.getIntExtra("ANC_MODE", 1)
ServiceManager.getService()?.setANCMode(mode)
Log.d("NoiseControlWidget", "Setting ANC mode to $mode")
ServiceManager.getService()!!
.aacpManager
.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
mode.toByte()
)
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="@color/colorBackground"
android:pathData="M0,0h108v108h-108z" />
</vector>

View File

@@ -4,27 +4,36 @@
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
<group
android:translateX="13.5"
android:translateY="13.5"
android:scaleX="0.75"
android:scaleY="0.75">
<path
android:pathData="M30.07 66.68l-1.73-1.32c-1.35-1.18-2.58-2.49-3.68-3.9l-0.12-0.17-1.28-1.92-0.72-1.24-1.02-2.3c-0.52-1.4-0.9-2.86-1.1-4.34l-0.06-0.45-0.08-1.13-0.07-1.28 0.03-0.79c0-0.37 0.04-0.75 0.1-1.12l0.02-0.14 0.14-0.52c0.2-0.72 0.8-1.27 1.54-1.4l0.3-0.01c0.2-0.01 0.4 0 0.6 0.07 0.22 0.06 0.43 0.18 0.62 0.33l0.62 0.52L26.1 47c1.61 1.26 3.3 2.4 5.09 3.37 0.6 0.33 1.23 0.66 1.77 0.93 0.87 0.43 1.77 0.82 2.68 1.18l0.9 0.36 0.45 0.14c0.7 0.2 1.42 0.36 2.15 0.46l0.56 0.04h0.14c0.12 0 0.18-0.14 0.1-0.23l-0.04-0.02L39.5 53l-0.76-0.41-1.24-0.64-3.39-1.47-2.78-1.62-1.44-0.87-1.69-1.35-1.96-1.58-1.92-1.89-1.36-1.73-0.1-0.13c-0.88-1.16-1.64-2.42-2.27-3.75l-0.71-2.22-0.57-1.89c-0.12-0.57-0.2-1.16-0.22-1.75l-0.05-1.14L19 25.88l0.01-0.11c0.07-0.78 0.2-1.54 0.4-2.3l0.3-1.01 0.38-1.1 0.4-0.87c0.06-0.15 0.14-0.28 0.23-0.4l0.05-0.07c0.1-0.13 0.22-0.25 0.35-0.34 0.22-0.16 0.47-0.25 0.73-0.29h0.04c0.1-0.01 0.22-0.02 0.33-0.01l0.21 0.01c0.12 0.01 0.25 0.03 0.36 0.06h0.03c0.26 0.07 0.5 0.2 0.72 0.36 0.14 0.1 0.26 0.23 0.36 0.37l0.41 0.54 1.54 1.77 1.81 2.08c0.88 0.9 1.82 1.73 2.83 2.5l0.8 0.6 2.16 1.23c0.15 0.09 0.3 0.15 0.47 0.2l1.66 0.53c0.34 0.11 0.53 0.47 0.44 0.81l-0.05 0.12-0.11 0.23c-0.24 0.47-0.36 0.98-0.36 1.5v2.3c0 1.27 0.18 2.52 0.54 3.73l0.5 1.68c0.63 1.66 1.48 3.23 2.54 4.66l0.48 0.66c0.96 1.3 2.04 2.51 3.22 3.62l3.22 3 1.69 1.27c0.2 0.15 0.44 0.27 0.69 0.34l0.17 0.04c0.3 0.09 0.55 0.25 0.75 0.49 0.22 0.27 0.34 0.6 0.34 0.95v33c0 0.18 0.13 0.33 0.31 0.36h0.07-0.07l-5.87-0.51-2.37-0.45C40 87.1 38.32 86.63 36.7 86l-2.44-1.3-1.01-0.73c-0.87-0.62-1.67-1.31-2.41-2.07-0.71-0.72-1.36-1.5-1.93-2.32l-0.66-0.94-0.98-1.39c-0.48-0.67-0.84-1.4-1.1-2.18-0.17-0.56-0.28-1.14-0.33-1.72l-0.01-0.08c-0.04-0.46-0.04-0.92 0-1.38l0.06-0.97V70.9c0-0.71 0.08-1.41 0.23-2.1l0.23-0.68c0.09-0.24 0.23-0.45 0.41-0.62 0.17-0.16 0.38-0.28 0.6-0.34l0.18-0.05c0.37-0.11 0.76-0.1 1.12 0.03 0.14 0.05 0.28 0.11 0.4 0.2l1.04 0.69 2.21 1.62 2.67 1.86 1.94 1.17 1.77 0.95c0.5 0.27 1.04 0.5 1.58 0.7l1.97 0.73 0.12 0.02h0.1c0.11 0 0.21-0.1 0.23-0.21 0-0.1-0.04-0.19-0.12-0.23l-0.94-0.57-1.68-1.05-3.72-2.05-2.88-2.13-3.28-2.16Z">
<aapt:attr name="android:fillColor">
<gradient
android:type="linear"
android:startX="34.51"
android:startY="19.37"
android:endX="34.51"
android:endY="88.4">
<item
android:color="#FF64AB5D"
android:offset="0"/>
<item
android:color="#FF21395B"
android:offset="1"/>
</gradient>
</aapt:attr>
</path>
<path
android:strokeColor="@color/popup_text"
android:strokeWidth="0.5"
android:pathData="M30.07 66.68l-1.73-1.32c-1.35-1.18-2.58-2.49-3.68-3.9l-0.12-0.17-1.28-1.92-0.72-1.24-1.02-2.3c-0.52-1.4-0.9-2.86-1.1-4.34l-0.06-0.45-0.08-1.13-0.07-1.28 0.03-0.79c0-0.37 0.04-0.75 0.1-1.12l0.02-0.14 0.14-0.52c0.2-0.72 0.8-1.27 1.54-1.4l0.3-0.01c0.2-0.01 0.4 0 0.6 0.07 0.22 0.06 0.43 0.18 0.62 0.33l0.62 0.52L26.1 47c1.61 1.26 3.3 2.4 5.09 3.37 0.6 0.33 1.23 0.66 1.77 0.93 0.87 0.43 1.77 0.82 2.68 1.18l0.9 0.36 0.45 0.14c0.7 0.2 1.42 0.36 2.15 0.46l0.56 0.04h0.14c0.12 0 0.18-0.14 0.1-0.23l-0.04-0.02L39.5 53l-0.76-0.41-1.24-0.64-3.39-1.47-2.78-1.62-1.44-0.87-1.69-1.35-1.96-1.58-1.92-1.89-1.36-1.73-0.1-0.13c-0.88-1.16-1.64-2.42-2.27-3.75l-0.71-2.22-0.57-1.89c-0.12-0.57-0.2-1.16-0.22-1.75l-0.05-1.14L19 25.88l0.01-0.11c0.07-0.78 0.2-1.54 0.4-2.3l0.3-1.01 0.38-1.1 0.4-0.87c0.06-0.15 0.14-0.28 0.23-0.4l0.05-0.07c0.1-0.13 0.22-0.25 0.35-0.34 0.22-0.16 0.47-0.25 0.73-0.29h0.04c0.1-0.01 0.22-0.02 0.33-0.01l0.21 0.01c0.12 0.01 0.25 0.03 0.36 0.06h0.03c0.26 0.07 0.5 0.2 0.72 0.36 0.14 0.1 0.26 0.23 0.36 0.37l0.41 0.54 1.54 1.77 1.81 2.08c0.88 0.9 1.82 1.73 2.83 2.5l0.8 0.6 2.16 1.23c0.15 0.09 0.3 0.15 0.47 0.2l1.66 0.53c0.34 0.11 0.53 0.47 0.44 0.81l-0.05 0.12-0.11 0.23c-0.24 0.47-0.36 0.98-0.36 1.5v2.3c0 1.27 0.18 2.52 0.54 3.73l0.5 1.68c0.63 1.66 1.48 3.23 2.54 4.66l0.48 0.66c0.96 1.3 2.04 2.51 3.22 3.62l3.22 3 1.69 1.27c0.2 0.15 0.44 0.27 0.69 0.34l0.17 0.04c0.3 0.09 0.55 0.25 0.75 0.49 0.22 0.27 0.34 0.6 0.34 0.95v33c0 0.18 0.13 0.33 0.31 0.36h0.07-0.07l-5.87-0.51-2.37-0.45C40 87.1 38.32 86.63 36.7 86l-2.44-1.3-1.01-0.73c-0.87-0.62-1.67-1.31-2.41-2.07-0.71-0.72-1.36-1.5-1.93-2.32l-0.66-0.94-0.98-1.39c-0.48-0.67-0.84-1.4-1.1-2.18-0.17-0.56-0.28-1.14-0.33-1.72l-0.01-0.08c-0.04-0.46-0.04-0.92 0-1.38l0.06-0.97V70.9c0-0.71 0.08-1.41 0.23-2.1l0.23-0.68c0.09-0.24 0.23-0.45 0.41-0.62 0.17-0.16 0.38-0.28 0.6-0.34l0.18-0.05c0.37-0.11 0.76-0.1 1.12 0.03 0.14 0.05 0.28 0.11 0.4 0.2l1.04 0.69 2.21 1.62 2.67 1.86 1.94 1.17 1.77 0.95c0.5 0.27 1.04 0.5 1.58 0.7l1.97 0.73 0.12 0.02h0.1c0.11 0 0.21-0.1 0.23-0.21 0-0.1-0.04-0.19-0.12-0.23l-0.94-0.57-1.68-1.05-3.72-2.05-2.88-2.13-3.28-2.16Z"/>
<path
android:strokeColor="@color/popup_text"
android:strokeWidth="2"
android:pathData="M49.59 54.33v33.15 0.04c0 0.62 0.14 1.23 0.42 1.78m-0.42-34.97l-2.1-1.4-0.29-0.2c-1.67-1.17-3.26-2.46-4.75-3.86l-0.35-0.35c-1.54-1.53-2.88-3.24-4-5.1l-0.86-1.57c-0.45-0.82-0.82-1.68-1.1-2.57-0.46-1.43-0.7-2.92-0.7-4.42v-0.54-0.74c0-1.27 0.16-2.53 0.47-3.77 0.25-1 0.6-1.97 1.04-2.9l0.74-1.54 0.4-0.67c0.85-1.41 1.84-2.73 2.96-3.94l0.84-0.78c0.56-0.5 1.17-0.95 1.82-1.32l0.98-0.56 1.1-0.56 1.28-0.56 1-0.36c0.63-0.23 1.3-0.4 1.97-0.5 0.54-0.08 1.08-0.12 1.63-0.12h1.7 0.61c0.62 0 1.23 0.04 1.85 0.13 0.69 0.1 1.37 0.25 2.04 0.46l1.24 0.39 2.24 0.56 2.1 0.84 1.54 0.84 1.96 1.12 1.82 1.26 1.68 1.25 0.23 0.2c0.87 0.7 1.68 1.48 2.43 2.32l1.12 1.12 1.26 1.54 1.12 1.54 0.84 1.4 0.59 1.3M49.59 54.34l0.06 0.04c0.33 0.25 0.68 0.47 1.06 0.66l1.54 0.7 1.68 0.7 1.68 0.56 1.54 0.42 1.54 0.42 1.54 0.28 0.84 0.14 1.12 0.04 0.56-0.04h0.56m14.73-25.97l-1.01-0.05h-1.54-0.06c-0.8 0-1.58 0.14-2.32 0.42l-1.12 0.42-0.55 0.3c-0.66 0.35-1.28 0.78-1.86 1.26-0.54 0.45-1.04 0.94-1.49 1.48l-0.72 0.87-0.16 0.19c-0.83 1-1.57 2.05-2.22 3.17l-0.84 1.4-0.84 1.4-0.84 1.82-0.7 1.95-0.42 1.96-0.28 2.1v0.11c0 0.95 0.1 1.9 0.28 2.83l0.42 1.4c0.28 0.92 0.7 1.8 1.28 2.58l0.26 0.36m14.73-25.97l1.65 0.37c0.84 0.18 1.65 0.46 2.43 0.82l0.14 0.07c0.52 0.24 1.03 0.53 1.52 0.85l0.11 0.08c0.56 0.37 1.08 0.8 1.55 1.26 0.37 0.37 0.7 0.76 1 1.18l1.09 1.47c0.65 0.93 1.17 1.94 1.55 3l0.13 0.36 0.02 0.06c0.36 1.17 0.62 2.37 0.77 3.58v0.8c0 0.78-0.06 1.55-0.18 2.32-0.22 1.45-0.65 2.87-1.27 4.2l-0.13 0.27-0.89 1.64-1.54 2.1-0.42 0.5c-0.56 0.69-1.17 1.33-1.84 1.92l-0.06 0.05c-0.88 0.77-1.84 1.44-2.86 2l-0.25 0.11c-1.14 0.5-2.32 0.88-3.53 1.15l-0.17 0.04c-0.72 0.16-1.47 0.24-2.21 0.24h-0.79c-1.15 0-2.3-0.14-3.41-0.42l-1.82-0.7-1.68-0.7-0.62-0.33c-0.61-0.34-1.17-0.76-1.67-1.25-0.34-0.34-0.71-0.65-1.12-0.92l-0.23-0.15m0 0v1.81 0.84 1.4l-0.42 24.9v0.43c0 0.55-0.1 1.1-0.28 1.61M50 89.3l0.3 0.52c0.17 0.3 0.38 0.58 0.63 0.83 0.4 0.4 0.88 0.71 1.4 0.9l0.33 0.13 0.1 0.04c0.29 0.11 0.58 0.2 0.88 0.26 0.37 0.08 0.75 0.12 1.13 0.12h0.55 0.84H57h0.7c0.37 0 0.75-0.05 1.11-0.14l0.3-0.07c0.56-0.14 1.1-0.35 1.62-0.6l0.05-0.03 0.42-0.28 0.28-0.18c0.18-0.12 0.35-0.26 0.5-0.42 0.22-0.25 0.4-0.54 0.51-0.86l0.1-0.28M50 89.3l12.6-0.06M48.9 31.81l-0.86-0.65c-0.17-0.13-0.32-0.28-0.45-0.47-0.06-0.1-0.12-0.2-0.16-0.3l-0.14-0.32c-0.14-0.33-0.21-0.69-0.21-1.05 0-0.28 0.04-0.57 0.13-0.84L47.26 28l0.2-0.5 0.16-0.3c0.1-0.2 0.23-0.38 0.39-0.54 0.12-0.12 0.25-0.22 0.4-0.31l0.29-0.17c0.12-0.08 0.26-0.15 0.4-0.2l0.11-0.05c0.25-0.1 0.51-0.15 0.78-0.15 0.2 0 0.4 0.03 0.58 0.08l0.26 0.08c0.3 0.08 0.57 0.2 0.83 0.34l1.15 0.62 1.4 0.84 1.12 0.84 1.4 0.98 1.17 0.89 0.3 0.28c0.14 0.15 0.25 0.32 0.34 0.5l0.02 0.03c0.18 0.35 0.27 0.74 0.27 1.13v0.25 0.19c0 0.42-0.1 0.84-0.3 1.22-0.08 0.18-0.18 0.34-0.3 0.5l-0.1 0.12c-0.19 0.23-0.42 0.41-0.68 0.54-0.1 0.06-0.22 0.1-0.34 0.14l-0.21 0.06c-0.4 0.1-0.8 0.16-1.2 0.16h-0.37c-0.28 0-0.55-0.05-0.81-0.15l-0.12-0.05c-0.13-0.05-0.25-0.11-0.37-0.18l-1.5-0.88-1.82-1.25-1.82-1.26Zm36.96 17.06l0.06-0.42c0.05-0.37 0.05-0.74-0.01-1.11l-0.01-0.07c-0.03-0.14-0.06-0.29-0.11-0.43l-0.1-0.28c-0.07-0.23-0.2-0.43-0.37-0.6l-0.07-0.07c-0.15-0.15-0.34-0.26-0.55-0.31-0.16-0.04-0.32-0.05-0.48-0.02l-0.25 0.04c-0.23 0.04-0.46 0.1-0.67 0.22l-0.14 0.07c-0.25 0.12-0.49 0.28-0.7 0.46l-0.26 0.22c-0.27 0.23-0.51 0.48-0.74 0.75l-0.36 0.43-0.56 0.84-0.84 1.26-0.14 0.21c-0.28 0.42-0.51 0.87-0.7 1.33l-0.56 1.54-0.1 0.36c-0.12 0.4-0.18 0.84-0.18 1.27v0.39c0 0.24 0.04 0.47 0.11 0.7l0.08 0.22c0.06 0.18 0.16 0.35 0.3 0.49l0.05 0.05c0.1 0.1 0.23 0.18 0.37 0.23 0.14 0.04 0.28 0.06 0.43 0.04l0.36-0.06c0.26-0.03 0.52-0.11 0.76-0.23l0.44-0.22c0.4-0.2 0.77-0.45 1.11-0.74l0.47-0.4 0.03-0.04c0.73-0.81 1.37-1.69 1.93-2.62 0.37-0.65 0.69-1.33 0.95-2.04l0.17-0.48 0.28-0.98Z"/>
</group>
</vector>

View File

@@ -0,0 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:translateX="21.6"
android:translateY="21.6"
android:scaleX="0.6"
android:scaleY="0.6">
<group>
<path
android:strokeColor="@color/popup_text"
android:strokeWidth="3.25"
android:pathData="M49.64 54.5v33.52c0 0.21 0.17 0.38 0.38 0.38l-5.94-0.52-2.37-0.45C40 87.1 38.32 86.63 36.7 86l-2.44-1.3-1.01-0.73c-0.87-0.62-1.67-1.31-2.41-2.07-0.71-0.72-1.36-1.5-1.93-2.32l-0.66-0.94-0.98-1.39c-0.48-0.67-0.84-1.4-1.1-2.18-0.17-0.56-0.28-1.14-0.33-1.72l-0.01-0.08c-0.04-0.46-0.04-0.92 0-1.38l0.06-0.97V70.9c0-0.71 0.08-1.41 0.23-2.1l0.23-0.68c0.09-0.24 0.23-0.45 0.41-0.62 0.17-0.16 0.38-0.28 0.6-0.34l0.18-0.05c0.37-0.11 0.76-0.1 1.12 0.03 0.14 0.05 0.28 0.11 0.4 0.2l1.04 0.69 2.21 1.62 2.67 1.86 1.94 1.17 1.77 0.95c0.5 0.27 1.04 0.5 1.58 0.7l1.97 0.73 0.12 0.02h0.1c0.11 0 0.21-0.1 0.23-0.21 0-0.1-0.04-0.19-0.12-0.23l-0.94-0.57-1.68-1.05-3.72-2.05-2.88-2.13-3.28-2.16-1.73-1.32c-1.35-1.18-2.58-2.49-3.68-3.9l-0.12-0.17-1.28-1.92-0.72-1.24-1.02-2.3c-0.52-1.4-0.9-2.86-1.1-4.34l-0.06-0.45-0.08-1.13-0.07-1.28 0.03-0.79c0-0.37 0.04-0.75 0.1-1.12l0.02-0.14 0.14-0.52c0.2-0.72 0.8-1.27 1.54-1.4l0.3-0.01c0.2-0.01 0.4 0 0.6 0.07 0.22 0.06 0.43 0.18 0.62 0.33l0.62 0.52L26.1 47c1.61 1.26 3.3 2.4 5.09 3.37 0.6 0.33 1.23 0.66 1.77 0.93 0.87 0.43 1.77 0.82 2.68 1.18l0.9 0.36 0.45 0.14c0.7 0.2 1.42 0.36 2.15 0.46l0.56 0.04h0.14c0.12 0 0.18-0.14 0.1-0.23l-0.04-0.02L39.5 53l-0.76-0.41-1.24-0.64-3.39-1.47-2.78-1.62-1.44-0.87-1.69-1.35-1.96-1.58-1.92-1.89-1.36-1.73-0.1-0.13c-0.88-1.16-1.64-2.42-2.27-3.75l-0.71-2.22-0.57-1.89c-0.12-0.57-0.2-1.16-0.22-1.75l-0.05-1.14L19 25.88l0.01-0.11c0.07-0.78 0.2-1.54 0.4-2.3l0.3-1.01 0.38-1.1 0.4-0.87c0.06-0.15 0.14-0.28 0.23-0.4l0.05-0.07c0.1-0.13 0.22-0.25 0.35-0.34 0.22-0.16 0.47-0.25 0.73-0.29h0.04c0.1-0.01 0.22-0.02 0.33-0.01l0.21 0.01c0.12 0.01 0.25 0.03 0.36 0.06h0.03c0.26 0.07 0.5 0.2 0.72 0.36 0.14 0.1 0.26 0.23 0.36 0.37l0.41 0.54 1.54 1.77 1.81 2.08c0.88 0.9 1.82 1.73 2.83 2.5l0.8 0.6 0.09 0.05c1.53 0.87 3.14 1.58 4.8 2.1"/>
</group>
<path
android:strokeColor="@color/popup_text"
android:strokeWidth="3.75"
android:pathData="M49.59 54.33v33.15 0.04c0 0.62 0.14 1.23 0.42 1.78m-0.42-34.97l-2.1-1.4-0.29-0.2c-1.67-1.17-3.26-2.46-4.75-3.86l-0.35-0.35c-1.54-1.53-2.88-3.24-4-5.1l-0.86-1.57c-0.45-0.82-0.82-1.68-1.1-2.57-0.46-1.43-0.7-2.92-0.7-4.42v-0.54-0.74c0-1.27 0.16-2.53 0.47-3.77 0.25-1 0.6-1.97 1.04-2.9l0.74-1.54 0.4-0.67c0.85-1.41 1.84-2.73 2.96-3.94l0.84-0.78c0.56-0.5 1.17-0.95 1.82-1.32l0.98-0.56 1.1-0.56 1.28-0.56 1-0.36c0.63-0.23 1.3-0.4 1.97-0.5 0.54-0.08 1.08-0.12 1.63-0.12h1.7 0.61c0.62 0 1.23 0.04 1.85 0.13 0.69 0.1 1.37 0.25 2.04 0.46l1.24 0.39 2.24 0.56 2.1 0.84 1.54 0.84 1.96 1.12 1.82 1.26 1.68 1.25 0.23 0.2c0.87 0.7 1.68 1.48 2.43 2.32l1.12 1.12 1.26 1.54 1.12 1.54 0.84 1.4 0.59 1.3M49.59 54.34l0.06 0.04c0.33 0.25 0.68 0.47 1.06 0.66l1.54 0.7 1.68 0.7 1.68 0.56 1.54 0.42 1.54 0.42 1.54 0.28 0.84 0.14 1.12 0.04 0.56-0.04h0.56m14.73-25.97l-1.01-0.05h-1.54-0.06c-0.8 0-1.58 0.14-2.32 0.42l-1.12 0.42-0.55 0.3c-0.66 0.35-1.28 0.78-1.86 1.26-0.54 0.45-1.04 0.94-1.49 1.48l-0.72 0.87-0.16 0.19c-0.83 1-1.57 2.05-2.22 3.17l-0.84 1.4-0.84 1.4-0.84 1.82-0.7 1.95-0.42 1.96-0.28 2.1v0.11c0 0.95 0.1 1.9 0.28 2.83l0.42 1.4c0.28 0.92 0.7 1.8 1.28 2.58l0.26 0.36m14.73-25.97l1.65 0.37c0.84 0.18 1.65 0.46 2.43 0.82l0.14 0.07c0.52 0.24 1.03 0.53 1.52 0.85l0.11 0.08c0.56 0.37 1.08 0.8 1.55 1.26 0.37 0.37 0.7 0.76 1 1.18l1.09 1.47c0.65 0.93 1.17 1.94 1.55 3l0.13 0.36 0.02 0.06c0.36 1.17 0.62 2.37 0.77 3.58v0.8c0 0.78-0.06 1.55-0.18 2.32-0.22 1.45-0.65 2.87-1.27 4.2l-0.13 0.27-0.89 1.64-1.54 2.1-0.42 0.5c-0.56 0.69-1.17 1.33-1.84 1.92l-0.06 0.05c-0.88 0.77-1.84 1.44-2.86 2l-0.25 0.11c-1.14 0.5-2.32 0.88-3.53 1.15l-0.17 0.04c-0.72 0.16-1.47 0.24-2.21 0.24h-0.79c-1.15 0-2.3-0.14-3.41-0.42l-1.82-0.7-1.68-0.7-0.62-0.33c-0.61-0.34-1.17-0.76-1.67-1.25-0.34-0.34-0.71-0.65-1.12-0.92l-0.23-0.15m0 0v1.81 0.84 1.4l-0.42 24.9v0.43c0 0.55-0.1 1.1-0.28 1.61M50 89.3l0.3 0.52c0.17 0.3 0.38 0.58 0.63 0.83 0.4 0.4 0.88 0.71 1.4 0.9l0.33 0.13 0.1 0.04c0.29 0.11 0.58 0.2 0.88 0.26 0.37 0.08 0.75 0.12 1.13 0.12h0.55 0.84H57h0.7c0.37 0 0.75-0.05 1.11-0.14l0.3-0.07c0.56-0.14 1.1-0.35 1.62-0.6l0.05-0.03 0.42-0.28 0.28-0.18c0.18-0.12 0.35-0.26 0.5-0.42 0.22-0.25 0.4-0.54 0.51-0.86l0.1-0.28M50 89.3l12.6-0.06M48.9 31.81l-0.86-0.65c-0.17-0.13-0.32-0.28-0.45-0.47-0.06-0.1-0.12-0.2-0.16-0.3l-0.14-0.32c-0.14-0.33-0.21-0.69-0.21-1.05 0-0.28 0.04-0.57 0.13-0.84L47.26 28l0.2-0.5 0.16-0.3c0.1-0.2 0.23-0.38 0.39-0.54 0.12-0.12 0.25-0.22 0.4-0.31l0.29-0.17c0.12-0.08 0.26-0.15 0.4-0.2l0.11-0.05c0.25-0.1 0.51-0.15 0.78-0.15 0.2 0 0.4 0.03 0.58 0.08l0.26 0.08c0.3 0.08 0.57 0.2 0.83 0.34l1.15 0.62 1.4 0.84 1.12 0.84 1.4 0.98 1.17 0.89 0.3 0.28c0.14 0.15 0.25 0.32 0.34 0.5l0.02 0.03c0.18 0.35 0.27 0.74 0.27 1.13v0.25 0.19c0 0.42-0.1 0.84-0.3 1.22-0.08 0.18-0.18 0.34-0.3 0.5l-0.1 0.12c-0.19 0.23-0.42 0.41-0.68 0.54-0.1 0.06-0.22 0.1-0.34 0.14l-0.21 0.06c-0.4 0.1-0.8 0.16-1.2 0.16h-0.37c-0.28 0-0.55-0.05-0.81-0.15l-0.12-0.05c-0.13-0.05-0.25-0.11-0.37-0.18l-1.5-0.88-1.82-1.25-1.82-1.26Zm36.96 17.06l0.06-0.42c0.05-0.37 0.05-0.74-0.01-1.11l-0.01-0.07c-0.03-0.14-0.06-0.29-0.11-0.43l-0.1-0.28c-0.07-0.23-0.2-0.43-0.37-0.6l-0.07-0.07c-0.15-0.15-0.34-0.26-0.55-0.31-0.16-0.04-0.32-0.05-0.48-0.02l-0.25 0.04c-0.23 0.04-0.46 0.1-0.67 0.22l-0.14 0.07c-0.25 0.12-0.49 0.28-0.7 0.46l-0.26 0.22c-0.27 0.23-0.51 0.48-0.74 0.75l-0.36 0.43-0.56 0.84-0.84 1.26-0.14 0.21c-0.28 0.42-0.51 0.87-0.7 1.33l-0.56 1.54-0.1 0.36c-0.12 0.4-0.18 0.84-0.18 1.27v0.39c0 0.24 0.04 0.47 0.11 0.7l0.08 0.22c0.06 0.18 0.16 0.35 0.3 0.49l0.05 0.05c0.1 0.1 0.23 0.18 0.37 0.23 0.14 0.04 0.28 0.06 0.43 0.04l0.36-0.06c0.26-0.03 0.52-0.11 0.76-0.23l0.44-0.22c0.4-0.2 0.77-0.45 1.11-0.74l0.47-0.4 0.03-0.04c0.73-0.81 1.37-1.69 1.93-2.62 0.37-0.65 0.69-1.33 0.95-2.04l0.17-0.48 0.28-0.98Z"/>
</group>
</vector>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:pathData="M440,880v-304L256,760l-56,-56 224,-224 -224,-224 56,-56 184,184v-304h40l228,228 -172,172 172,172L480,880h-40ZM520,384 L596,308 520,234v150ZM520,726 L596,652 520,576v150Z"
android:fillColor="#e8eaed"/>
</vector>

View File

@@ -1,170 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:pathData="M480,842 L120,562l66,-50 294,228 294,-228 66,50 -360,280ZM480,640L120,360l360,-280 360,280 -360,280ZM480,360ZM480,538 L710,360 480,182 250,360 480,538Z"
android:fillColor="#e8eaed"/>
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M17,3L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2L21,7l-4,-4zM19,19L5,19L5,5h11.17L19,7.83L19,19zM12,12c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3zM6,6h9v4L6,10z" />
</vector>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 752 KiB

After

Width:  |  Height:  |  Size: 605 KiB

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